feat: dispatch authenticated project issue requests
This commit is contained in:
177
packages/backend/convex/projectIssueRequests.smoke.test.ts
Normal file
177
packages/backend/convex/projectIssueRequests.smoke.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|project-request-a";
|
||||
const ID_B = "https://convex.test|project-request-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>
|
||||
): Promise<Id<"projects">> => {
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const outcome = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ID_A,
|
||||
source: {
|
||||
host: "github.com",
|
||||
normalizedUrl: "https://github.com/test/project-request",
|
||||
projectName: "project-request",
|
||||
repositoryPath: "test/project-request",
|
||||
url: "https://github.com/test/project-request",
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
content: "# Project Request\n",
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
describe("project issue request smoke", () => {
|
||||
test("project Signal becomes a queued project issue with durable evidence", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createProject(t);
|
||||
const organization = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "project-request-1",
|
||||
organizationId: organization._id,
|
||||
rawText: "Staging deploys fail during DNS resolution.",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "project-request-1",
|
||||
organizationId: organization._id,
|
||||
submissionId: "submission-project-request-1",
|
||||
});
|
||||
const signal = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: organization._id,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: ["Do not change production"],
|
||||
desiredOutcome: "Staging deploys successfully",
|
||||
summary: "The staging deploy fails during DNS resolution.",
|
||||
title: "Fix the staging deploy DNS failure",
|
||||
},
|
||||
processedBy: {
|
||||
agentInstanceId: String(organization._id),
|
||||
agentName: "zopu",
|
||||
},
|
||||
projectId,
|
||||
});
|
||||
|
||||
const created = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.createFromSignal, {
|
||||
signalId: signal.signalId,
|
||||
});
|
||||
const issue = await t.run(async (ctx) => ctx.db.get(created.issueId));
|
||||
|
||||
expect(issue).toMatchObject({
|
||||
body: expect.stringContaining("Source Signal"),
|
||||
projectId,
|
||||
status: "open",
|
||||
title: "Fix the staging deploy DNS failure",
|
||||
});
|
||||
|
||||
const status = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.begin, { issueId: created.issueId });
|
||||
expect(status).toBe("queued");
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", projectId)
|
||||
)
|
||||
.collect()
|
||||
);
|
||||
expect(events.map((event) => event.kind)).toEqual([
|
||||
"project.connected",
|
||||
"issue.created",
|
||||
"issue.queued",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a different organization cannot consume the project Signal", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createProject(t);
|
||||
await t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const organization = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "project-request-cross-org",
|
||||
organizationId: organization._id,
|
||||
rawText: "Private project request",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "project-request-cross-org",
|
||||
organizationId: organization._id,
|
||||
submissionId: "submission-project-request-cross-org",
|
||||
});
|
||||
const signal = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: organization._id,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: [],
|
||||
desiredOutcome: "Keep the request private",
|
||||
summary: "This request belongs to the first organization.",
|
||||
title: "Private project request",
|
||||
},
|
||||
processedBy: {
|
||||
agentInstanceId: String(organization._id),
|
||||
agentName: "zopu",
|
||||
},
|
||||
projectId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityB).mutation(api.projectIssues.createFromSignal, {
|
||||
signalId: signal.signalId,
|
||||
})
|
||||
).rejects.toThrow(/membership required/u);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,82 @@
|
||||
import {
|
||||
projectIssueDraftFromSignal,
|
||||
queueProjectIssue,
|
||||
validateProjectIssueDraft,
|
||||
type ProjectIssueDraft,
|
||||
} from "@code/primitives/project-issue";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, type MutationCtx, query } from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
const toDomainError = (error: unknown) => {
|
||||
return new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid project issue"
|
||||
);
|
||||
};
|
||||
|
||||
const decodeDraft = async (input: unknown): Promise<ProjectIssueDraft> => {
|
||||
try {
|
||||
return await Effect.runPromise(validateProjectIssueDraft(input));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const draftFromSignal = async (input: unknown): Promise<ProjectIssueDraft> => {
|
||||
try {
|
||||
return await Effect.runPromise(projectIssueDraftFromSignal(input));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const insertIssue = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
draft: ProjectIssueDraft
|
||||
): Promise<Id<"projectIssues">> => {
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
body: draft.body,
|
||||
createdAt: timestamp,
|
||||
number,
|
||||
projectId,
|
||||
status: "open",
|
||||
title: draft.title,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const workArtifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", "work.md")
|
||||
)
|
||||
.unique();
|
||||
if (workArtifact) {
|
||||
await ctx.db.patch("projectArtifacts", workArtifact._id, {
|
||||
content: `${workArtifact.content}\n## Issue ${number}: ${draft.title}\n\nStatus: open\n\n${draft.body}\n`,
|
||||
revision: workArtifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number, title: draft.title },
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
projectId,
|
||||
});
|
||||
return issueId;
|
||||
};
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
body: v.string(),
|
||||
@@ -11,55 +85,36 @@ export const create = mutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
const title = args.title.trim();
|
||||
const body = args.body.trim();
|
||||
if (title.length < 3 || title.length > 160) {
|
||||
throw new ConvexError("Issue title must be between 3 and 160 characters");
|
||||
const draft = await decodeDraft({ body: args.body, title: args.title });
|
||||
return insertIssue(ctx, args.projectId, draft);
|
||||
},
|
||||
});
|
||||
|
||||
export const createFromSignal = mutation({
|
||||
args: { signalId: v.id("signals") },
|
||||
handler: async (ctx, args) => {
|
||||
const signal = await ctx.db.get("signals", args.signalId);
|
||||
if (!signal) {
|
||||
throw new ConvexError("Signal not found");
|
||||
}
|
||||
if (body.length < 10 || body.length > 10_000) {
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal is not project-scoped");
|
||||
}
|
||||
const { organizationId } = await requireProjectMember(
|
||||
ctx,
|
||||
signal.projectId
|
||||
);
|
||||
if (signal.organizationId !== organizationId) {
|
||||
throw new ConvexError(
|
||||
"Issue description must be between 10 and 10000 characters"
|
||||
"Signal does not belong to the project organization"
|
||||
);
|
||||
}
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
body,
|
||||
createdAt: timestamp,
|
||||
number,
|
||||
projectId: args.projectId,
|
||||
status: "open",
|
||||
title,
|
||||
updatedAt: timestamp,
|
||||
const draft = await draftFromSignal({
|
||||
problemStatement: signal.problemStatement,
|
||||
signalId: String(signal._id),
|
||||
});
|
||||
const workArtifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", args.projectId).eq("path", "work.md")
|
||||
)
|
||||
.unique();
|
||||
if (workArtifact) {
|
||||
await ctx.db.patch("projectArtifacts", workArtifact._id, {
|
||||
content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`,
|
||||
revision: workArtifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number, title },
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
projectId: args.projectId,
|
||||
});
|
||||
return issueId;
|
||||
const issueId = await insertIssue(ctx, signal.projectId, draft);
|
||||
return { issueId, projectId: signal.projectId };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,12 +126,18 @@ export const begin = mutation({
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
if (issue.status === "queued" || issue.status === "working") {
|
||||
return issue.status;
|
||||
let status: "queued" | "working";
|
||||
try {
|
||||
status = await Effect.runPromise(queueProjectIssue(issue.status));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
if (status === issue.status) {
|
||||
return status;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "queued",
|
||||
status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
|
||||
Reference in New Issue
Block a user