Merge pull request #1708 from paperclipai/pr/pap-817-onboarding-goal-context
Seed onboarding project and issue goal context
This commit is contained in:
commit
03f44d0089
8 changed files with 556 additions and 34 deletions
|
|
@ -20,16 +20,29 @@ describe("issue goal fallback", () => {
|
||||||
resolveIssueGoalId({
|
resolveIssueGoalId({
|
||||||
projectId: null,
|
projectId: null,
|
||||||
goalId: "goal-2",
|
goalId: "goal-2",
|
||||||
|
projectGoalId: "goal-3",
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBe("goal-2");
|
).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(
|
expect(
|
||||||
resolveIssueGoalId({
|
resolveIssueGoalId({
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
goalId: null,
|
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",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
@ -40,20 +53,47 @@ describe("issue goal fallback", () => {
|
||||||
resolveNextIssueGoalId({
|
resolveNextIssueGoalId({
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentGoalId: null,
|
currentGoalId: null,
|
||||||
|
currentProjectGoalId: null,
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBe("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(
|
expect(
|
||||||
resolveNextIssueGoalId({
|
resolveNextIssueGoalId({
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentGoalId: "goal-1",
|
currentGoalId: "goal-1",
|
||||||
|
currentProjectGoalId: null,
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
goalId: null,
|
goalId: null,
|
||||||
|
projectGoalId: "goal-2",
|
||||||
defaultGoalId: "goal-1",
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -171,6 +171,33 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
return rawId;
|
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
|
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
|
||||||
router.param("id", async (req, res, next, rawId) => {
|
router.param("id", async (req, res, next, rawId) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -311,14 +338,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
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),
|
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),
|
svc.findMentionedProjectIds(issue.id),
|
||||||
documentsSvc.getIssueDocumentPayload(issue),
|
documentsSvc.getIssueDocumentPayload(issue),
|
||||||
]);
|
]);
|
||||||
|
|
@ -356,14 +378,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
? req.query.wakeCommentId.trim()
|
? req.query.wakeCommentId.trim()
|
||||||
: null;
|
: 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),
|
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),
|
svc.getCommentCursor(issue.id),
|
||||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,54 @@ type MaybeId = string | null | undefined;
|
||||||
export function resolveIssueGoalId(input: {
|
export function resolveIssueGoalId(input: {
|
||||||
projectId: MaybeId;
|
projectId: MaybeId;
|
||||||
goalId: MaybeId;
|
goalId: MaybeId;
|
||||||
|
projectGoalId?: MaybeId;
|
||||||
defaultGoalId: MaybeId;
|
defaultGoalId: MaybeId;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (!input.projectId && !input.goalId) {
|
if (input.goalId) return input.goalId;
|
||||||
return input.defaultGoalId ?? null;
|
if (input.projectId) return input.projectGoalId ?? null;
|
||||||
}
|
return input.defaultGoalId ?? null;
|
||||||
return input.goalId ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveNextIssueGoalId(input: {
|
export function resolveNextIssueGoalId(input: {
|
||||||
currentProjectId: MaybeId;
|
currentProjectId: MaybeId;
|
||||||
currentGoalId: MaybeId;
|
currentGoalId: MaybeId;
|
||||||
|
currentProjectGoalId?: MaybeId;
|
||||||
projectId?: MaybeId;
|
projectId?: MaybeId;
|
||||||
goalId?: MaybeId;
|
goalId?: MaybeId;
|
||||||
|
projectGoalId?: MaybeId;
|
||||||
defaultGoalId: MaybeId;
|
defaultGoalId: MaybeId;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const projectId =
|
const projectId =
|
||||||
input.projectId !== undefined ? input.projectId : input.currentProjectId;
|
input.projectId !== undefined ? input.projectId : input.currentProjectId;
|
||||||
const goalId =
|
const projectGoalId =
|
||||||
input.goalId !== undefined ? input.goalId : input.currentGoalId;
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ type IssueUserContextInput = {
|
||||||
createdAt: Date | string;
|
createdAt: Date | string;
|
||||||
updatedAt: Date | string;
|
updatedAt: Date | string;
|
||||||
};
|
};
|
||||||
|
type ProjectGoalReader = Pick<Db, "select">;
|
||||||
|
|
||||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||||
if (actorRunId) return checkoutRunId === actorRunId;
|
if (actorRunId) return checkoutRunId === actorRunId;
|
||||||
|
|
@ -113,6 +114,20 @@ function escapeLikePattern(value: string): string {
|
||||||
return value.replace(/[\\%_]/g, "\\$&");
|
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) {
|
function touchedByUserCondition(companyId: string, userId: string) {
|
||||||
return sql<boolean>`
|
return sql<boolean>`
|
||||||
(
|
(
|
||||||
|
|
@ -744,6 +759,7 @@ export function issueService(db: Db) {
|
||||||
}
|
}
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
||||||
|
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
|
||||||
let executionWorkspaceSettings =
|
let executionWorkspaceSettings =
|
||||||
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
|
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
|
||||||
if (executionWorkspaceSettings == null && issueData.projectId) {
|
if (executionWorkspaceSettings == null && issueData.projectId) {
|
||||||
|
|
@ -795,6 +811,7 @@ export function issueService(db: Db) {
|
||||||
goalId: resolveIssueGoalId({
|
goalId: resolveIssueGoalId({
|
||||||
projectId: issueData.projectId,
|
projectId: issueData.projectId,
|
||||||
goalId: issueData.goalId,
|
goalId: issueData.goalId,
|
||||||
|
projectGoalId,
|
||||||
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||||
}),
|
}),
|
||||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||||
|
|
@ -895,11 +912,21 @@ export function issueService(db: Db) {
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
|
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({
|
patch.goalId = resolveNextIssueGoalId({
|
||||||
currentProjectId: existing.projectId,
|
currentProjectId: existing.projectId,
|
||||||
currentGoalId: existing.goalId,
|
currentGoalId: existing.goalId,
|
||||||
|
currentProjectGoalId,
|
||||||
projectId: issueData.projectId,
|
projectId: issueData.projectId,
|
||||||
goalId: issueData.goalId,
|
goalId: issueData.goalId,
|
||||||
|
projectGoalId: nextProjectGoalId,
|
||||||
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||||
});
|
});
|
||||||
const updated = await tx
|
const updated = await tx
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { companiesApi } from "../api/companies";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,6 +25,11 @@ import {
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
import { defaultCreateValues } from "./agent-config-defaults";
|
import { defaultCreateValues } from "./agent-config-defaults";
|
||||||
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
||||||
|
import {
|
||||||
|
buildOnboardingIssuePayload,
|
||||||
|
buildOnboardingProjectPayload,
|
||||||
|
selectDefaultCompanyGoalId
|
||||||
|
} from "../lib/onboarding-launch";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL
|
DEFAULT_CODEX_LOCAL_MODEL
|
||||||
|
|
@ -144,7 +150,11 @@ export function OnboardingWizard() {
|
||||||
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
|
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
||||||
|
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
|
||||||
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -160,6 +170,10 @@ export function OnboardingWizard() {
|
||||||
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
||||||
setCreatedCompanyId(cId);
|
setCreatedCompanyId(cId);
|
||||||
setCreatedCompanyPrefix(null);
|
setCreatedCompanyPrefix(null);
|
||||||
|
setCreatedCompanyGoalId(null);
|
||||||
|
setCreatedProjectId(null);
|
||||||
|
setCreatedAgentId(null);
|
||||||
|
setCreatedIssueRef(null);
|
||||||
}, [
|
}, [
|
||||||
effectiveOnboardingOpen,
|
effectiveOnboardingOpen,
|
||||||
effectiveOnboardingOptions.companyId,
|
effectiveOnboardingOptions.companyId,
|
||||||
|
|
@ -284,7 +298,9 @@ export function OnboardingWizard() {
|
||||||
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
|
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
|
||||||
setCreatedCompanyId(null);
|
setCreatedCompanyId(null);
|
||||||
setCreatedCompanyPrefix(null);
|
setCreatedCompanyPrefix(null);
|
||||||
|
setCreatedCompanyGoalId(null);
|
||||||
setCreatedAgentId(null);
|
setCreatedAgentId(null);
|
||||||
|
setCreatedProjectId(null);
|
||||||
setCreatedIssueRef(null);
|
setCreatedIssueRef(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +387,7 @@ export function OnboardingWizard() {
|
||||||
|
|
||||||
if (companyGoal.trim()) {
|
if (companyGoal.trim()) {
|
||||||
const parsedGoal = parseOnboardingGoalInput(companyGoal);
|
const parsedGoal = parseOnboardingGoalInput(companyGoal);
|
||||||
await goalsApi.create(company.id, {
|
const goal = await goalsApi.create(company.id, {
|
||||||
title: parsedGoal.title,
|
title: parsedGoal.title,
|
||||||
...(parsedGoal.description
|
...(parsedGoal.description
|
||||||
? { description: parsedGoal.description }
|
? { description: parsedGoal.description }
|
||||||
|
|
@ -379,9 +395,12 @@ export function OnboardingWizard() {
|
||||||
level: "company",
|
level: "company",
|
||||||
status: "active"
|
status: "active"
|
||||||
});
|
});
|
||||||
|
setCreatedCompanyGoalId(goal.id);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.goals.list(company.id)
|
queryKey: queryKeys.goals.list(company.id)
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setCreatedCompanyGoalId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStep(2);
|
setStep(2);
|
||||||
|
|
@ -522,16 +541,38 @@ export function OnboardingWizard() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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;
|
let issueRef = createdIssueRef;
|
||||||
if (!issueRef) {
|
if (!issueRef) {
|
||||||
const issue = await issuesApi.create(createdCompanyId, {
|
const issue = await issuesApi.create(
|
||||||
title: taskTitle.trim(),
|
createdCompanyId,
|
||||||
...(taskDescription.trim()
|
buildOnboardingIssuePayload({
|
||||||
? { description: taskDescription.trim() }
|
title: taskTitle,
|
||||||
: {}),
|
description: taskDescription,
|
||||||
assigneeAgentId: createdAgentId,
|
assigneeAgentId: createdAgentId,
|
||||||
status: "todo"
|
projectId,
|
||||||
});
|
goalId
|
||||||
|
})
|
||||||
|
);
|
||||||
issueRef = issue.identifier ?? issue.id;
|
issueRef = issue.identifier ?? issue.id;
|
||||||
setCreatedIssueRef(issueRef);
|
setCreatedIssueRef(issueRef);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
|
|
|
||||||
131
ui/src/lib/onboarding-launch.test.ts
Normal file
131
ui/src/lib/onboarding-launch.test.ts
Normal file
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
53
ui/src/lib/onboarding-launch.ts
Normal file
53
ui/src/lib/onboarding-launch.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue