Public: anyone can read this; the crew can change it.

Commenting through …/issues/{number}/comments on a pull's number comments on the pull, as the OpenAPI document says #3306

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3298-commenting-through-issues-number-comment → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3298-commenting-through-issues-number-comment was merged into main as ebbf042.

Review

Sign in to review this change.
2 files changed +113 −2
src/http/api.ts +7 −2
@@ -3517,14 +3517,19 @@ export function apiRoutes(router: Router<Handler>): void {
35173517 async (ctx) => {
35183518 const denied = requireScope(ctx, "rw");
35193519 if (denied) return denied;
3520 const found = loadIssue(ctx);
3520 // A pull's number answers here too, as the OpenAPI document says.
3521 const issueFound = loadIssue(ctx);

Nit, not blocking: loadConversation(ctx) already loads either kind with the same mayView gate. Using it here would drop the thread/pull union and the ternary on line 3532, and give you const found = loadConversation(ctx) with found.issue passed through unchanged.

3522 const found =
3523 "error" in issueFound
3524 ? loadPull(ctx)
3525 : { repo: issueFound.repo, thread: issueFound.issue };
35213526 if ("error" in found) return found.error;
35223527 const input = await body(ctx.request);
35233528 const added = await addCommentThrough(
35243529 ctx.app,
35253530 ctx.auth.user,
35263531 found.repo,
3527 found.issue,
3532 "thread" in found ? found.thread : found.pull,
35283533 str(input.body),
35293534 );
35303535 if (!added.ok) return originAware(added.error);
test/issues-comment-on-pull.test.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * The issues comment route answers for a pull's number too, as the OpenAPI
3 * document says (#3298): the comment lands on the pull, through the same path
4 * as `…/pulls/{number}/comments`; a number that is neither stays not_found.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { getIssue, listComments } from "../src/core/issues.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let token = "";
25 let pullNumber = 0;
26 let repoId = "";
27
28 const ok = <T>(
29 r: { ok: true; value: T } | { ok: false; error: unknown },
30 ): T => {
31 if (!r.ok) throw new Error(JSON.stringify(r.error));
32 return r.value;
33 };
34
35 const comment = (number: number, text: string, auth = token) =>
36 fetch(`${servers.url}/api/v1/repos/alice/sq/issues/${number}/comments`, {
37 method: "POST",
38 headers: { authorization: `Bearer ${auth}`, "content-type": "application/json" },
39 body: JSON.stringify({ body: text }),
40 });
41
42 beforeAll(async () => {
43 dataDir = await mkdtemp(join(tmpdir(), "forge-issue-pull-comment-"));
44 app = createApp({ dataDir, port: 0 });
45 servers = await serve(app);
46 const alice = ok(
47 await createUser(app.db, {
48 origin: "seed",
49 trusted: true,
50 username: "alice",
51 email: "[email protected]",
52 password: "a-good-passphrase",
53 adminIfFirst: true,
54 }),
55 );
56 const repo = ok(await createRepo(app, alice, { name: "sq", visibility: "public" }));
57 repoId = repo.id;
58 ok(
59 await commitToBranch(app, alice, repo, {
60 message: "Start",
61 files: [{ path: "README.md", content: "# sq\n" }],
62 }),
63 );
64 ok(
65 await commitToBranch(app, alice, repo, {
66 branch: "topic",
67 base: "main",
68 message: "Topic",
69 files: [{ path: "README.md", content: "# sq\nmore\n" }],
70 }),
71 );
72 const pull = ok(
73 await openPull(app, alice, repo, {
74 title: "Topic",
75 body: "",
76 baseBranch: "main",
77 headBranch: "topic",
78 }),
79 );
80 pullNumber = pull.number;
81 token = createToken(app.db, alice.id, "rw", "rw").token;
82 });
83
84 afterAll(async () => {
85 await servers.stop();
86 app.db.close();
87 await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("commenting through the issues route on a pull's number comments on the pull", async () => {
91 const res = await comment(pullNumber, "Seen from the issues route.");
92 expect(res.status).toBe(201);
93 const pull = getIssue(app.db, repoId, pullNumber);
94 expect(listComments(app.db, pull!.id).map((c) => c.body)).toContain(
95 "Seen from the issues route.",
96 );
97 });
98
99 test("a number that is neither an issue nor a pull stays not_found", async () => {
100 expect((await comment(999, "nobody home")).status).toBe(404);
101 });
102
103 test("a read-only token is still refused on a pull's number", async () => {
104 const ro = createToken(app.db, getIssue(app.db, repoId, pullNumber)!.authorId, "ro", "ro");
105 expect((await comment(pullNumber, "no", ro.token)).status).toBe(403);
106 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs