From eb73fc747a60f4b1eed5721b903ef5b48d027137 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 24 Mar 2026 08:11:09 -0500 Subject: [PATCH] Seed onboarding project and issue goal context Co-Authored-By: Paperclip --- .../src/__tests__/issue-goal-fallback.test.ts | 46 ++++- .../issues-goal-context-routes.test.ts | 187 ++++++++++++++++++ server/src/routes/issues.ts | 45 +++-- server/src/services/issue-goal-fallback.ts | 42 +++- server/src/services/issues.ts | 27 +++ ui/src/components/OnboardingWizard.tsx | 59 +++++- ui/src/lib/onboarding-launch.test.ts | 131 ++++++++++++ ui/src/lib/onboarding-launch.ts | 53 +++++ 8 files changed, 556 insertions(+), 34 deletions(-) create mode 100644 server/src/__tests__/issues-goal-context-routes.test.ts create mode 100644 ui/src/lib/onboarding-launch.test.ts create mode 100644 ui/src/lib/onboarding-launch.ts diff --git a/server/src/__tests__/issue-goal-fallback.test.ts b/server/src/__tests__/issue-goal-fallback.test.ts index cae1b8ab..43ccb5f7 100644 --- a/server/src/__tests__/issue-goal-fallback.test.ts +++ b/server/src/__tests__/issue-goal-fallback.test.ts @@ -20,16 +20,29 @@ describe("issue goal fallback", () => { resolveIssueGoalId({ projectId: null, goalId: "goal-2", + projectGoalId: "goal-3", defaultGoalId: "goal-1", }), ).toBe("goal-2"); }); - it("does not force a company goal when the issue belongs to a project", () => { + it("inherits the project goal when creating a project-linked issue", () => { expect( resolveIssueGoalId({ projectId: "project-1", goalId: null, + projectGoalId: "goal-2", + defaultGoalId: "goal-1", + }), + ).toBe("goal-2"); + }); + + it("does not force a company goal when the project has no goal", () => { + expect( + resolveIssueGoalId({ + projectId: "project-1", + goalId: null, + projectGoalId: null, defaultGoalId: "goal-1", }), ).toBeNull(); @@ -40,20 +53,47 @@ describe("issue goal fallback", () => { resolveNextIssueGoalId({ currentProjectId: null, currentGoalId: null, + currentProjectGoalId: null, defaultGoalId: "goal-1", }), ).toBe("goal-1"); }); - it("clears the fallback when a project is added later", () => { + it("switches from the company fallback to the project goal when a project is added later", () => { expect( resolveNextIssueGoalId({ currentProjectId: null, currentGoalId: "goal-1", + currentProjectGoalId: null, projectId: "project-1", goalId: null, + projectGoalId: "goal-2", defaultGoalId: "goal-1", }), - ).toBeNull(); + ).toBe("goal-2"); + }); + + it("backfills the project goal for legacy project-linked issues on update", () => { + expect( + resolveNextIssueGoalId({ + currentProjectId: "project-1", + currentGoalId: null, + currentProjectGoalId: "goal-2", + defaultGoalId: "goal-1", + }), + ).toBe("goal-2"); + }); + + it("preserves an explicit goal across project fallback changes", () => { + expect( + resolveNextIssueGoalId({ + currentProjectId: "project-1", + currentGoalId: "goal-explicit", + currentProjectGoalId: "goal-2", + projectId: "project-2", + projectGoalId: "goal-3", + defaultGoalId: "goal-1", + }), + ).toBe("goal-explicit"); }); }); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts new file mode 100644 index 00000000..b4611d39 --- /dev/null +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -0,0 +1,187 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getAncestors: vi.fn(), + findMentionedProjectIds: vi.fn(), + getCommentCursor: vi.fn(), + getComment: vi.fn(), +})); + +const mockProjectService = vi.hoisted(() => ({ + getById: vi.fn(), + listByIds: vi.fn(), +})); + +const mockGoalService = vi.hoisted(() => ({ + getById: vi.fn(), + getDefaultCompanyGoal: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + documentService: () => ({ + getIssueDocumentPayload: vi.fn(async () => ({})), + }), + executionWorkspaceService: () => ({ + getById: vi.fn(), + }), + goalService: () => mockGoalService, + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => mockProjectService, + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({ + listForIssue: vi.fn(async () => []), + }), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +const legacyProjectLinkedIssue = { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-581", + title: "Legacy onboarding task", + description: "Seed the first CEO task", + status: "todo", + priority: "medium", + projectId: "22222222-2222-4222-8222-222222222222", + goalId: null, + parentId: null, + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + assigneeUserId: null, + updatedAt: new Date("2026-03-24T12:00:00Z"), + executionWorkspaceId: null, + labels: [], + labelIds: [], +}; + +const projectGoal = { + id: "44444444-4444-4444-8444-444444444444", + companyId: "company-1", + title: "Launch the company", + description: null, + level: "company", + status: "active", + parentId: null, + ownerAgentId: null, + createdAt: new Date("2026-03-20T00:00:00Z"), + updatedAt: new Date("2026-03-20T00:00:00Z"), +}; + +describe("issue goal context routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue); + mockIssueService.getAncestors.mockResolvedValue([]); + mockIssueService.findMentionedProjectIds.mockResolvedValue([]); + mockIssueService.getCommentCursor.mockResolvedValue({ + totalComments: 0, + latestCommentId: null, + latestCommentAt: null, + }); + mockIssueService.getComment.mockResolvedValue(null); + mockProjectService.getById.mockResolvedValue({ + id: legacyProjectLinkedIssue.projectId, + companyId: "company-1", + urlKey: "onboarding", + goalId: projectGoal.id, + goalIds: [projectGoal.id], + goals: [{ id: projectGoal.id, title: projectGoal.title }], + name: "Onboarding", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/company-1/project-1", + effectiveLocalFolder: "/tmp/company-1/project-1", + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date("2026-03-20T00:00:00Z"), + updatedAt: new Date("2026-03-20T00:00:00Z"), + }); + mockProjectService.listByIds.mockResolvedValue([]); + mockGoalService.getById.mockImplementation(async (id: string) => + id === projectGoal.id ? projectGoal : null, + ); + mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null); + }); + + it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => { + const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); + + expect(res.status).toBe(200); + expect(res.body.goalId).toBe(projectGoal.id); + expect(res.body.goal).toEqual( + expect.objectContaining({ + id: projectGoal.id, + title: projectGoal.title, + }), + ); + expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); + }); + + it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => { + const res = await request(createApp()).get( + "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", + ); + + expect(res.status).toBe(200); + expect(res.body.issue.goalId).toBe(projectGoal.id); + expect(res.body.goal).toEqual( + expect.objectContaining({ + id: projectGoal.id, + title: projectGoal.title, + }), + ); + expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 073b32f7..794cda33 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -171,6 +171,33 @@ export function issueRoutes(db: Db, storage: StorageService) { return rawId; } + async function resolveIssueProjectAndGoal(issue: { + companyId: string; + projectId: string | null; + goalId: string | null; + }) { + const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null); + const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null); + const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]); + + if (directGoal) { + return { project, goal: directGoal }; + } + + const projectGoalId = project?.goalId ?? project?.goalIds[0] ?? null; + if (projectGoalId) { + const projectGoal = await goalsSvc.getById(projectGoalId); + return { project, goal: projectGoal }; + } + + if (!issue.projectId) { + const defaultGoal = await goalsSvc.getDefaultCompanyGoal(issue.companyId); + return { project, goal: defaultGoal }; + } + + return { project, goal: null }; + } + // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes router.param("id", async (req, res, next, rawId) => { try { @@ -311,14 +338,9 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } assertCompanyAccess(req, issue.companyId); - const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([ + const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([ + resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), - issue.projectId ? projectsSvc.getById(issue.projectId) : null, - issue.goalId - ? goalsSvc.getById(issue.goalId) - : !issue.projectId - ? goalsSvc.getDefaultCompanyGoal(issue.companyId) - : null, svc.findMentionedProjectIds(issue.id), documentsSvc.getIssueDocumentPayload(issue), ]); @@ -356,14 +378,9 @@ export function issueRoutes(db: Db, storage: StorageService) { ? req.query.wakeCommentId.trim() : null; - const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([ + const [{ project, goal }, ancestors, commentCursor, wakeComment] = await Promise.all([ + resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), - issue.projectId ? projectsSvc.getById(issue.projectId) : null, - issue.goalId - ? goalsSvc.getById(issue.goalId) - : !issue.projectId - ? goalsSvc.getDefaultCompanyGoal(issue.companyId) - : null, svc.getCommentCursor(issue.id), wakeCommentId ? svc.getComment(wakeCommentId) : null, ]); diff --git a/server/src/services/issue-goal-fallback.ts b/server/src/services/issue-goal-fallback.ts index fe48f0a1..91693d54 100644 --- a/server/src/services/issue-goal-fallback.ts +++ b/server/src/services/issue-goal-fallback.ts @@ -3,28 +3,54 @@ type MaybeId = string | null | undefined; export function resolveIssueGoalId(input: { projectId: MaybeId; goalId: MaybeId; + projectGoalId?: MaybeId; defaultGoalId: MaybeId; }): string | null { - if (!input.projectId && !input.goalId) { - return input.defaultGoalId ?? null; - } - return input.goalId ?? null; + if (input.goalId) return input.goalId; + if (input.projectId) return input.projectGoalId ?? null; + return input.defaultGoalId ?? null; } export function resolveNextIssueGoalId(input: { currentProjectId: MaybeId; currentGoalId: MaybeId; + currentProjectGoalId?: MaybeId; projectId?: MaybeId; goalId?: MaybeId; + projectGoalId?: MaybeId; defaultGoalId: MaybeId; }): string | null { const projectId = input.projectId !== undefined ? input.projectId : input.currentProjectId; - const goalId = - input.goalId !== undefined ? input.goalId : input.currentGoalId; + const projectGoalId = + input.projectGoalId !== undefined + ? input.projectGoalId + : projectId + ? input.currentProjectGoalId + : null; - if (!projectId && !goalId) { + const resolveFallbackGoalId = (targetProjectId: MaybeId, targetProjectGoalId: MaybeId) => { + if (targetProjectId) return targetProjectGoalId ?? null; return input.defaultGoalId ?? null; + }; + + if (input.goalId !== undefined) { + return input.goalId ?? resolveFallbackGoalId(projectId, projectGoalId); } - return goalId ?? null; + + const currentFallbackGoalId = resolveFallbackGoalId( + input.currentProjectId, + input.currentProjectGoalId, + ); + const nextFallbackGoalId = resolveFallbackGoalId(projectId, projectGoalId); + + if (!input.currentGoalId) { + return nextFallbackGoalId; + } + + if (input.currentGoalId === currentFallbackGoalId) { + return nextFallbackGoalId; + } + + return input.currentGoalId; } diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index d453fd3a..5ae7c0c3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -101,6 +101,7 @@ type IssueUserContextInput = { createdAt: Date | string; updatedAt: Date | string; }; +type ProjectGoalReader = Pick; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -113,6 +114,20 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); } +async function getProjectDefaultGoalId( + db: ProjectGoalReader, + companyId: string, + projectId: string | null | undefined, +) { + if (!projectId) return null; + const row = await db + .select({ goalId: projects.goalId }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return row?.goalId ?? null; +} + function touchedByUserCondition(companyId: string, userId: string) { return sql` ( @@ -744,6 +759,7 @@ export function issueService(db: Db) { } return db.transaction(async (tx) => { const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); + const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; if (executionWorkspaceSettings == null && issueData.projectId) { @@ -795,6 +811,7 @@ export function issueService(db: Db) { goalId: resolveIssueGoalId({ projectId: issueData.projectId, goalId: issueData.goalId, + projectGoalId, defaultGoalId: defaultCompanyGoal?.id ?? null, }), ...(projectWorkspaceId ? { projectWorkspaceId } : {}), @@ -895,11 +912,21 @@ export function issueService(db: Db) { return db.transaction(async (tx) => { const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId); + const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([ + getProjectDefaultGoalId(tx, existing.companyId, existing.projectId), + getProjectDefaultGoalId( + tx, + existing.companyId, + issueData.projectId !== undefined ? issueData.projectId : existing.projectId, + ), + ]); patch.goalId = resolveNextIssueGoalId({ currentProjectId: existing.projectId, currentGoalId: existing.goalId, + currentProjectGoalId, projectId: issueData.projectId, goalId: issueData.goalId, + projectGoalId: nextProjectGoalId, defaultGoalId: defaultCompanyGoal?.id ?? null, }); const updated = await tx diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 8d4c3a17..06a053db 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -8,6 +8,7 @@ import { companiesApi } from "../api/companies"; import { goalsApi } from "../api/goals"; import { agentsApi } from "../api/agents"; import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; import { queryKeys } from "../lib/queryKeys"; import { Dialog, DialogPortal } from "@/components/ui/dialog"; import { @@ -24,6 +25,11 @@ import { import { getUIAdapter } from "../adapters"; import { defaultCreateValues } from "./agent-config-defaults"; import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; +import { + buildOnboardingIssuePayload, + buildOnboardingProjectPayload, + selectDefaultCompanyGoalId +} from "../lib/onboarding-launch"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL @@ -144,7 +150,11 @@ export function OnboardingWizard() { const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState< string | null >(null); + const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState( + null + ); const [createdAgentId, setCreatedAgentId] = useState(null); + const [createdProjectId, setCreatedProjectId] = useState(null); const [createdIssueRef, setCreatedIssueRef] = useState(null); useEffect(() => { @@ -160,6 +170,10 @@ export function OnboardingWizard() { setStep(effectiveOnboardingOptions.initialStep ?? 1); setCreatedCompanyId(cId); setCreatedCompanyPrefix(null); + setCreatedCompanyGoalId(null); + setCreatedProjectId(null); + setCreatedAgentId(null); + setCreatedIssueRef(null); }, [ effectiveOnboardingOpen, effectiveOnboardingOptions.companyId, @@ -284,7 +298,9 @@ export function OnboardingWizard() { setTaskDescription(DEFAULT_TASK_DESCRIPTION); setCreatedCompanyId(null); setCreatedCompanyPrefix(null); + setCreatedCompanyGoalId(null); setCreatedAgentId(null); + setCreatedProjectId(null); setCreatedIssueRef(null); } @@ -371,7 +387,7 @@ export function OnboardingWizard() { if (companyGoal.trim()) { const parsedGoal = parseOnboardingGoalInput(companyGoal); - await goalsApi.create(company.id, { + const goal = await goalsApi.create(company.id, { title: parsedGoal.title, ...(parsedGoal.description ? { description: parsedGoal.description } @@ -379,9 +395,12 @@ export function OnboardingWizard() { level: "company", status: "active" }); + setCreatedCompanyGoalId(goal.id); queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(company.id) }); + } else { + setCreatedCompanyGoalId(null); } setStep(2); @@ -522,16 +541,38 @@ export function OnboardingWizard() { setLoading(true); setError(null); try { + let goalId = createdCompanyGoalId; + if (!goalId) { + const goals = await goalsApi.list(createdCompanyId); + goalId = selectDefaultCompanyGoalId(goals); + setCreatedCompanyGoalId(goalId); + } + + let projectId = createdProjectId; + if (!projectId) { + const project = await projectsApi.create( + createdCompanyId, + buildOnboardingProjectPayload(goalId) + ); + projectId = project.id; + setCreatedProjectId(projectId); + queryClient.invalidateQueries({ + queryKey: queryKeys.projects.list(createdCompanyId) + }); + } + let issueRef = createdIssueRef; if (!issueRef) { - const issue = await issuesApi.create(createdCompanyId, { - title: taskTitle.trim(), - ...(taskDescription.trim() - ? { description: taskDescription.trim() } - : {}), - assigneeAgentId: createdAgentId, - status: "todo" - }); + const issue = await issuesApi.create( + createdCompanyId, + buildOnboardingIssuePayload({ + title: taskTitle, + description: taskDescription, + assigneeAgentId: createdAgentId, + projectId, + goalId + }) + ); issueRef = issue.identifier ?? issue.id; setCreatedIssueRef(issueRef); queryClient.invalidateQueries({ diff --git a/ui/src/lib/onboarding-launch.test.ts b/ui/src/lib/onboarding-launch.test.ts new file mode 100644 index 00000000..0fb758d7 --- /dev/null +++ b/ui/src/lib/onboarding-launch.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + buildOnboardingIssuePayload, + buildOnboardingProjectPayload, + selectDefaultCompanyGoalId, +} from "./onboarding-launch"; + +describe("selectDefaultCompanyGoalId", () => { + it("prefers the earliest active root company goal", () => { + expect( + selectDefaultCompanyGoalId([ + { + id: "team-goal", + companyId: "company-1", + title: "Nested", + description: null, + level: "team", + status: "active", + parentId: null, + ownerAgentId: null, + createdAt: new Date("2026-03-04T00:00:00Z"), + updatedAt: new Date("2026-03-04T00:00:00Z"), + }, + { + id: "goal-2", + companyId: "company-1", + title: "Later active root", + description: null, + level: "company", + status: "active", + parentId: null, + ownerAgentId: null, + createdAt: new Date("2026-03-03T00:00:00Z"), + updatedAt: new Date("2026-03-03T00:00:00Z"), + }, + { + id: "goal-1", + companyId: "company-1", + title: "Earliest active root", + description: null, + level: "company", + status: "active", + parentId: null, + ownerAgentId: null, + createdAt: new Date("2026-03-02T00:00:00Z"), + updatedAt: new Date("2026-03-02T00:00:00Z"), + }, + ]), + ).toBe("goal-1"); + }); + + it("falls back to the earliest root company goal when none are active", () => { + expect( + selectDefaultCompanyGoalId([ + { + id: "goal-2", + companyId: "company-1", + title: "Cancelled root", + description: null, + level: "company", + status: "cancelled", + parentId: null, + ownerAgentId: null, + createdAt: new Date("2026-03-03T00:00:00Z"), + updatedAt: new Date("2026-03-03T00:00:00Z"), + }, + { + id: "goal-1", + companyId: "company-1", + title: "Earliest root", + description: null, + level: "company", + status: "planned", + parentId: null, + ownerAgentId: null, + createdAt: new Date("2026-03-02T00:00:00Z"), + updatedAt: new Date("2026-03-02T00:00:00Z"), + }, + ]), + ).toBe("goal-1"); + }); +}); + +describe("onboarding launch payloads", () => { + it("links the onboarding project and first issue to the selected goal", () => { + expect(buildOnboardingProjectPayload("goal-1")).toEqual({ + name: "Onboarding", + status: "in_progress", + goalIds: ["goal-1"], + }); + + expect( + buildOnboardingIssuePayload({ + title: " Hire your first engineer ", + description: " Kick off the hiring plan ", + assigneeAgentId: "agent-1", + projectId: "project-1", + goalId: "goal-1", + }), + ).toEqual({ + title: "Hire your first engineer", + description: "Kick off the hiring plan", + assigneeAgentId: "agent-1", + projectId: "project-1", + goalId: "goal-1", + status: "todo", + }); + }); + + it("omits goal links when no default company goal exists", () => { + expect(buildOnboardingProjectPayload(null)).toEqual({ + name: "Onboarding", + status: "in_progress", + }); + + expect( + buildOnboardingIssuePayload({ + title: "Task", + description: "", + assigneeAgentId: "agent-1", + projectId: "project-1", + goalId: null, + }), + ).toEqual({ + title: "Task", + assigneeAgentId: "agent-1", + projectId: "project-1", + status: "todo", + }); + }); +}); diff --git a/ui/src/lib/onboarding-launch.ts b/ui/src/lib/onboarding-launch.ts new file mode 100644 index 00000000..c5f69fd5 --- /dev/null +++ b/ui/src/lib/onboarding-launch.ts @@ -0,0 +1,53 @@ +import type { Goal } from "@paperclipai/shared"; + +export const ONBOARDING_PROJECT_NAME = "Onboarding"; + +function goalCreatedAt(goal: Goal) { + const createdAt = goal.createdAt instanceof Date ? goal.createdAt : new Date(goal.createdAt); + return Number.isNaN(createdAt.getTime()) ? 0 : createdAt.getTime(); +} + +function pickEarliestGoal(goals: Goal[]) { + return [...goals].sort((a, b) => goalCreatedAt(a) - goalCreatedAt(b))[0] ?? null; +} + +export function selectDefaultCompanyGoalId(goals: Goal[]): string | null { + const companyGoals = goals.filter((goal) => goal.level === "company"); + const rootGoals = companyGoals.filter((goal) => !goal.parentId); + const activeRootGoals = rootGoals.filter((goal) => goal.status === "active"); + + return ( + pickEarliestGoal(activeRootGoals)?.id ?? + pickEarliestGoal(rootGoals)?.id ?? + pickEarliestGoal(companyGoals)?.id ?? + null + ); +} + +export function buildOnboardingProjectPayload(goalId: string | null) { + return { + name: ONBOARDING_PROJECT_NAME, + status: "in_progress" as const, + ...(goalId ? { goalIds: [goalId] } : {}), + }; +} + +export function buildOnboardingIssuePayload(input: { + title: string; + description: string; + assigneeAgentId: string; + projectId: string; + goalId: string | null; +}) { + const title = input.title.trim(); + const description = input.description.trim(); + + return { + title, + ...(description ? { description } : {}), + assigneeAgentId: input.assigneeAgentId, + projectId: input.projectId, + ...(input.goalId ? { goalId: input.goalId } : {}), + status: "todo" as const, + }; +}