Public: anyone can read this; the crew can change it.
MCP `pull` carries the pull's looks: each picture with its width, line and URL #8438
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8425-mcp-pull-carries-the-pull-s-looks-each-p → main 1 commit fork
2 files
changed +66
−1
src/http/mcp/tools/pull.ts +22 −1
| @@ -11,11 +11,14 @@ import { appView } from "../../../actions/apps.ts"; | ||
| 11 | 11 | import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts"; |
| 12 | 12 | import { isCrew } from "../../../core/agents.ts"; |
| 13 | 13 | import { markReaders } from "../../../crew/direct.ts"; |
| 14 | import { getAppByRepo } from "../../../core/apps.ts"; | |
| 15 | import { looksForEnv, lookUrl } from "../../../core/looks.ts"; | |
| 16 | import { canWrite } from "../../../core/repos.ts"; | |
| 14 | 17 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 15 | 18 | |
| 16 | 19 | export const tool: Tool = { |
| 17 | 20 | name: "pull", |
| 18 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 21 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; `looks` (newest first, for writers: `[{ id, width, says, by, commit, url, at }]`, the pictures taken or attached for the pull's preview; `[]` when there are none), draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 19 | 22 | description: |
| 20 | 23 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.", |
| 21 | 24 | inputSchema: { |
| @@ -50,7 +53,25 @@ export const tool: Tool = { | ||
| 50 | 53 | const rows = listComments(db, pull.id, { newest: true, limit: EMBEDDED_COMMENTS }); |
| 51 | 54 | const comments = marking ? markReaders(db, repo, rows) : rows; |
| 52 | 55 | const total = countThread(db, pull.id); |
| 56 | // The pictures are the writers' (as `mayFetchLook` keeps them), so | |
| 57 | // anyone else sees none. | |
| 58 | const record = canWrite(db, repo, user) ? getAppByRepo(db, repo.id) : undefined; | |
| 59 | const looks = | |
| 60 | record === undefined | |
| 61 | ? [] | |
| 62 | : looksForEnv(db, record.id, previewEnv(pull.number)) | |
| 63 | .filter((l) => l.error === null) | |
| 64 | .map((l) => ({ | |
| 65 | id: l.id, | |
| 66 | width: l.kind, | |
| 67 | says: l.note ?? "", | |
| 68 | by: l.attachedBy, | |
| 69 | commit: l.commitOid, | |
| 70 | url: lookUrl(`/${repo.ownerName}/${repo.name}`, l), | |
| 71 | at: l.createdAt, | |
| 72 | })); | |
| 53 | 73 | const answer = good({ |
| 74 | looks, | |
| 54 | 75 | comments: comments.map((c) => ({ |
| 55 | 76 | author: c.authorName, |
| 56 | 77 | body: c.body, |
test/attach-look.test.ts +44
| @@ -262,4 +262,48 @@ describe("attaching a look to a pull", () => { | ||
| 262 | 262 | expect(done.value.url).toContain("/alice/garden/looks/"); |
| 263 | 263 | expect(kept().length).toBe(before + 1); |
| 264 | 264 | }); |
| 265 | ||
| 266 | test("MCP pull carries the looks of its pull, newest first; a pull with none has []", async () => { | |
| 267 | const call = async (token: string, number: number) => { | |
| 268 | const res = await fetch(`${base}/mcp`, { | |
| 269 | method: "POST", | |
| 270 | headers: { | |
| 271 | "content-type": "application/json", | |
| 272 | accept: "application/json, text/event-stream", | |
| 273 | authorization: `Bearer ${token}`, | |
| 274 | }, | |
| 275 | body: JSON.stringify({ | |
| 276 | jsonrpc: "2.0", | |
| 277 | id: 1, | |
| 278 | method: "tools/call", | |
| 279 | params: { name: "pull", arguments: { repo: "alice/garden", number } }, | |
| 280 | }), | |
| 281 | }); | |
| 282 | const body = (await res.json()) as any; | |
| 283 | return JSON.parse(body.result.content[0].text); | |
| 284 | }; | |
| 285 | const done = await mcp(tokens["alice"]!, { ...good, says: "seen over MCP", dryRun: false }); | |
| 286 | const seen = await call(tokens["alice"]!, pull.number); | |
| 287 | expect(seen.looks[0]).toMatchObject({ | |
| 288 | width: "phone", | |
| 289 | says: "seen over MCP", | |
| 290 | by: "alice", | |
| 291 | commit: pull.headOid, | |
| 292 | url: done.value.url, | |
| 293 | }); | |
| 294 | expect(seen.looks.length).toBe(kept().length); | |
| 295 | const ats = seen.looks.map((l: any) => l.at); | |
| 296 | expect(ats).toEqual([...ats].sort().reverse()); | |
| 297 | // Not a writer: none. | |
| 298 | expect((await call(tokens["bob"]!, pull.number)).looks).toEqual([]); | |
| 299 | // A pull with no looks. | |
| 300 | await commitToBranch(app, alice, repo, { | |
| 301 | branch: "two", base: "main", message: "two", files: [{ path: "two.md", content: "x\n" }], | |
| 302 | }); | |
| 303 | const second = await openPull(app, alice, repo, { | |
| 304 | title: "Change two", body: "", baseBranch: "main", headBranch: "two", | |
| 305 | }); | |
| 306 | if (!second.ok) throw new Error(String(second.error)); | |
| 307 | expect((await call(tokens["alice"]!, second.value.number)).looks).toEqual([]); | |
| 308 | }); | |
| 265 | 309 | }); |
new above
offline