feat: dispatch authenticated project issue requests

This commit is contained in:
-Puter
2026-07-24 02:00:02 +05:30
parent 7974bd5f3e
commit 38c73847e0
11 changed files with 806 additions and 65 deletions

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

View File

@@ -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", {