Seed onboarding project and issue goal context

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-24 08:11:09 -05:00
parent 59e29afab5
commit eb73fc747a
8 changed files with 556 additions and 34 deletions

View file

@ -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");
});
});

View 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();
});
});

View file

@ -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,
]);

View file

@ -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;
}

View file

@ -101,6 +101,7 @@ type IssueUserContextInput = {
createdAt: Date | string;
updatedAt: Date | string;
};
type ProjectGoalReader = Pick<Db, "select">;
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<boolean>`
(
@ -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<string, unknown> | 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