Improve onboarding defaults and issue goal fallback

This commit is contained in:
Dotta
2026-03-12 08:50:31 -05:00
parent 5f3f354b3a
commit 448e9c192b
9 changed files with 378 additions and 77 deletions

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
resolveIssueGoalId,
resolveNextIssueGoalId,
} from "../services/issue-goal-fallback.ts";
describe("issue goal fallback", () => {
it("assigns the company goal when creating an issue without project or goal", () => {
expect(
resolveIssueGoalId({
projectId: null,
goalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("keeps an explicit goal when creating an issue", () => {
expect(
resolveIssueGoalId({
projectId: null,
goalId: "goal-2",
defaultGoalId: "goal-1",
}),
).toBe("goal-2");
});
it("does not force a company goal when the issue belongs to a project", () => {
expect(
resolveIssueGoalId({
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
it("backfills the company goal on update for legacy no-project issues", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: null,
defaultGoalId: "goal-1",
}),
).toBe("goal-1");
});
it("clears the fallback when a project is added later", () => {
expect(
resolveNextIssueGoalId({
currentProjectId: null,
currentGoalId: "goal-1",
projectId: "project-1",
goalId: null,
defaultGoalId: "goal-1",
}),
).toBeNull();
});
});

View File

@@ -294,13 +294,24 @@ export function issueRoutes(db: Db, storage: StorageService) {
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
res.json({
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
});
});
router.post("/issues/:id/read", async (req, res) => {

View File

@@ -1,7 +1,47 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { goals } from "@paperclipai/db";
type GoalReader = Pick<Db, "select">;
export async function getDefaultCompanyGoal(db: GoalReader, companyId: string) {
const activeRootGoal = await db
.select()
.from(goals)
.where(
and(
eq(goals.companyId, companyId),
eq(goals.level, "company"),
eq(goals.status, "active"),
isNull(goals.parentId),
),
)
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
if (activeRootGoal) return activeRootGoal;
const anyRootGoal = await db
.select()
.from(goals)
.where(
and(
eq(goals.companyId, companyId),
eq(goals.level, "company"),
isNull(goals.parentId),
),
)
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
if (anyRootGoal) return anyRootGoal;
return db
.select()
.from(goals)
.where(and(eq(goals.companyId, companyId), eq(goals.level, "company")))
.orderBy(asc(goals.createdAt))
.then((rows) => rows[0] ?? null);
}
export function goalService(db: Db) {
return {
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
@@ -13,6 +53,8 @@ export function goalService(db: Db) {
.where(eq(goals.id, id))
.then((rows) => rows[0] ?? null),
getDefaultCompanyGoal: (companyId: string) => getDefaultCompanyGoal(db, companyId),
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
db
.insert(goals)

View File

@@ -0,0 +1,30 @@
type MaybeId = string | null | undefined;
export function resolveIssueGoalId(input: {
projectId: MaybeId;
goalId: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
if (!input.projectId && !input.goalId) {
return input.defaultGoalId ?? null;
}
return input.goalId ?? null;
}
export function resolveNextIssueGoalId(input: {
currentProjectId: MaybeId;
currentGoalId: MaybeId;
projectId?: MaybeId;
goalId?: MaybeId;
defaultGoalId: MaybeId;
}): string | null {
const projectId =
input.projectId !== undefined ? input.projectId : input.currentProjectId;
const goalId =
input.goalId !== undefined ? input.goalId : input.currentGoalId;
if (!projectId && !goalId) {
return input.defaultGoalId ?? null;
}
return goalId ?? null;
}

View File

@@ -23,6 +23,8 @@ import {
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
import { getDefaultCompanyGoal } from "./goals.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -649,6 +651,7 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
@@ -673,6 +676,11 @@ export function issueService(db: Db) {
const values = {
...issueData,
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
@@ -752,6 +760,14 @@ export function issueService(db: Db) {
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
patch.goalId = resolveNextIssueGoalId({
currentProjectId: existing.projectId,
currentGoalId: existing.goalId,
projectId: issueData.projectId,
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
});
const updated = await tx
.update(issues)
.set(patch)