| | @@ -59,6 +59,8 @@ |
| 59 | 59 | }; |
| 60 | 60 | // A transferred or renamed repository is served under its old owner/name too. |
| 61 | 61 | const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min, { follow: true }); |
| 62 | +/** A page of the repository on the web, for the `html_url` results carry so an agent can link people to what it made or read. */ |
| 63 | +const web = (c: AppContext, repo: { owner_login: string; name: string }, path = "") => `${c.env.PUBLIC_URL}/${repo.owner_login}/${repo.name}${path}`; |
| 62 | 64 | const OWNER_REPO = { |
| 63 | 65 | owner: { type: "string", description: "Repository owner (user or org login)" }, |
| 64 | 66 | repo: { type: "string", description: "Repository name" }, |
| | @@ -125,11 +127,11 @@ |
| 125 | 127 | input: { ...OWNER_REPO, branch: { type: "string" }, from: { type: "string", description: "Default: the default branch" } }, |
| 126 | 128 | required: ["owner", "repo", "branch"], |
| 127 | 129 | run: async (c, a) => { |
| 128 | | - const { stub, caller } = await repoOf(c, a, "write"); |
| 130 | + const { stub, caller, repo } = await repoOf(c, a, "write"); |
| 129 | 131 | const sha = await stub.resolve(optStr(a, "from") ?? "HEAD"); |
| 130 | 132 | if (!sha) throw new HttpError(404, "source ref not found"); |
| 131 | 133 | await stub.createRef(`refs/heads/${str(a, "branch")}`, sha, caller); |
| 132 | | - return { branch: str(a, "branch"), sha }; |
| 134 | + return { branch: str(a, "branch"), sha, html_url: web(c, repo, `/tree/${encodeURI(str(a, "branch"))}`) }; |
| 133 | 135 | }, |
| 134 | 136 | }, |
| 135 | 137 | commit_files: { |
| | @@ -146,7 +148,7 @@ |
| 146 | 148 | }, |
| 147 | 149 | required: ["owner", "repo", "branch", "message"], |
| 148 | 150 | run: async (c, a) => { |
| 149 | | - const { stub, caller } = await repoOf(c, a, "write"); |
| 151 | + const { stub, caller, repo } = await repoOf(c, a, "write"); |
| 150 | 152 | const files = Array.isArray(a.files) ? (a.files as Array<{ path: string; content?: string; encoding?: string; executable?: boolean }>) : []; |
| 151 | 153 | const deletes = Array.isArray(a.deletes) ? (a.deletes as string[]) : []; |
| 152 | 154 | if (!files.length && !deletes.length) throw new HttpError(422, "nothing to commit"); |
| | @@ -158,7 +160,8 @@ |
| 158 | 160 | })), |
| 159 | 161 | ...deletes.map((p) => ({ path: String(p), delete: true })), |
| 160 | 162 | ]; |
| 161 | | - return stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller); |
| 163 | + const out = await stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller); |
| 164 | + return { ...out, html_url: web(c, repo, `/commit/${out.sha}`) }; |
| 162 | 165 | }, |
| 163 | 166 | }, |
| 164 | 167 | compare: { |
| | @@ -174,9 +177,9 @@ |
| 174 | 177 | required: ["owner", "repo"], |
| 175 | 178 | readOnly: true, |
| 176 | 179 | run: async (c, a) => { |
| 177 | | - const { stub } = await repoOf(c, a); |
| 180 | + const { stub, repo } = await repoOf(c, a); |
| 178 | 181 | const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100, hide: await hiddenAuthors(c) }); |
| 179 | | - return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt })) }; |
| 182 | + return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt, html_url: web(c, repo, `/pull/${i.number}`) })) }; |
| 180 | 183 | }, |
| 181 | 184 | }, |
| 182 | 185 | get_pull_request: { |
| | @@ -186,12 +189,12 @@ |
| 186 | 189 | required: ["owner", "repo", "number"], |
| 187 | 190 | readOnly: true, |
| 188 | 191 | run: async (c, a) => { |
| 189 | | - const { stub, caller } = await repoOf(c, a); |
| 192 | + const { stub, caller, repo } = await repoOf(c, a); |
| 190 | 193 | const n = int(a, "number"); |
| 191 | 194 | // An agent may not merge into a protected branch itself, but auto-merge may land it for the agent: say what each needs. |
| 192 | 195 | const [detail, mergeability, automatic] = await Promise.all([stub.issue(n, await hiddenAuthors(c)), stub.mergeability(n, caller).catch(() => null), isPerson(caller) ? null : stub.mergeability(n, caller, { automatic: true }).catch(() => null)]); |
| 193 | 196 | const files = a.include_diff ? await stub.pullFiles(n) : undefined; |
| 194 | | - return { ...detail, mergeability, ...(automatic ? { auto_merge_blockers: automatic.blockers } : {}), files }; |
| 197 | + return { ...detail, html_url: web(c, repo, `/pull/${n}`), mergeability, ...(automatic ? { auto_merge_blockers: automatic.blockers } : {}), files }; |
| 195 | 198 | }, |
| 196 | 199 | }, |
| 197 | 200 | create_pull_request: { |
| | @@ -200,8 +203,8 @@ |
| 200 | 203 | input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, head: { type: "string" }, base: { type: "string" }, draft: { type: "boolean" }, intent: { type: "string" }, reviewers: { type: "array", items: { type: "string" } }, auto_merge: { type: "boolean" } }, |
| 201 | 204 | required: ["owner", "repo", "title", "head"], |
| 202 | 205 | run: async (c, a) => { |
| 203 | | - const { stub, caller } = await repoOf(c, a, "write"); |
| 204 | | - return stub.createPull( |
| 206 | + const { stub, caller, repo } = await repoOf(c, a, "write"); |
| 207 | + const pull = await stub.createPull( |
| 205 | 208 | { |
| 206 | 209 | title: str(a, "title"), |
| 207 | 210 | body: optStr(a, "body"), |
| | @@ -214,6 +217,7 @@ |
| 214 | 217 | }, |
| 215 | 218 | caller, |
| 216 | 219 | ); |
| 220 | + return { ...pull, html_url: web(c, repo, `/pull/${pull.number}`) }; |
| 217 | 221 | }, |
| 218 | 222 | }, |
| 219 | 223 | comment: { |
| | @@ -221,8 +225,9 @@ |
| 221 | 225 | input: { ...OWNER_REPO, number: { type: "integer" }, body: { type: "string" } }, |
| 222 | 226 | required: ["owner", "repo", "number", "body"], |
| 223 | 227 | run: async (c, a) => { |
| 224 | | - const { stub, caller } = await repoOf(c, a, "triage"); |
| 225 | | - return stub.addComment(int(a, "number"), str(a, "body"), caller); |
| 228 | + const { stub, caller, repo } = await repoOf(c, a, "triage"); |
| 229 | + const comment = await stub.addComment(int(a, "number"), str(a, "body"), caller); |
| 230 | + return { ...comment, html_url: web(c, repo, `/issues/${int(a, "number")}#c-${comment.id}`) }; |
| 226 | 231 | }, |
| 227 | 232 | }, |
| 228 | 233 | review_pull_request: { |
| | @@ -294,9 +299,9 @@ |
| 294 | 299 | required: ["owner", "repo"], |
| 295 | 300 | readOnly: true, |
| 296 | 301 | run: async (c, a) => { |
| 297 | | - const { stub } = await repoOf(c, a); |
| 302 | + const { stub, repo } = await repoOf(c, a); |
| 298 | 303 | const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100, hide: await hiddenAuthors(c) }); |
| 299 | | - return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) }; |
| 304 | + return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels, html_url: web(c, repo, `/issues/${i.number}`) })) }; |
| 300 | 305 | }, |
| 301 | 306 | }, |
| 302 | 307 | get_issue: { |
| | @@ -304,15 +309,19 @@ |
| 304 | 309 | input: { ...OWNER_REPO, number: { type: "integer" } }, |
| 305 | 310 | required: ["owner", "repo", "number"], |
| 306 | 311 | readOnly: true, |
| 307 | | - run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number"), await hiddenAuthors(c)), |
| 312 | + run: async (c, a) => { |
| 313 | + const { stub, repo } = await repoOf(c, a); |
| 314 | + return { ...(await stub.issue(int(a, "number"), await hiddenAuthors(c))), html_url: web(c, repo, `/issues/${int(a, "number")}`) }; |
| 315 | + }, |
| 308 | 316 | }, |
| 309 | 317 | create_issue: { |
| 310 | 318 | description: "Open an issue.", |
| 311 | 319 | input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, labels: { type: "array", items: { type: "string" } } }, |
| 312 | 320 | required: ["owner", "repo", "title"], |
| 313 | 321 | run: async (c, a) => { |
| 314 | | - const { stub, caller } = await repoOf(c, a, "read"); |
| 315 | | - return stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); |
| 322 | + const { stub, caller, repo } = await repoOf(c, a, "read"); |
| 323 | + const issue = await stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); |
| 324 | + return { ...issue, html_url: web(c, repo, `/issues/${issue.number}`) }; |
| 316 | 325 | }, |
| 317 | 326 | }, |
| 318 | 327 | update_issue: { |
| | @@ -320,8 +329,9 @@ |
| 320 | 329 | input: { ...OWNER_REPO, number: { type: "integer" }, title: { type: "string" }, body: { type: "string" }, state: { type: "string", enum: ["open", "closed"] }, labels: { type: "array", items: { type: "string" } } }, |
| 321 | 330 | required: ["owner", "repo", "number"], |
| 322 | 331 | run: async (c, a) => { |
| 323 | | - const { stub, caller } = await repoOf(c, a, "triage"); |
| 324 | | - return stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); |
| 332 | + const { stub, caller, repo } = await repoOf(c, a, "triage"); |
| 333 | + const issue = await stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); |
| 334 | + return { ...issue, html_url: web(c, repo, `/issues/${int(a, "number")}`) }; |
| 325 | 335 | }, |
| 326 | 336 | }, |
| 327 | 337 | list_workflow_runs: { |
| | @@ -330,8 +340,9 @@ |
| 330 | 340 | required: ["owner", "repo"], |
| 331 | 341 | readOnly: true, |
| 332 | 342 | run: async (c, a) => { |
| 333 | | - const { stub } = await repoOf(c, a); |
| 334 | | - return stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 }); |
| 343 | + const { stub, repo } = await repoOf(c, a); |
| 344 | + const res = await stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 }); |
| 345 | + return { ...res, runs: res.runs.map((r) => ({ ...r, html_url: web(c, repo, `/actions/runs/${r.id}`) })) }; |
| 335 | 346 | }, |
| 336 | 347 | }, |
| 337 | 348 | get_workflow_run: { |
| | @@ -339,7 +350,12 @@ |
| 339 | 350 | input: { ...OWNER_REPO, run_id: { type: "string" } }, |
| 340 | 351 | required: ["owner", "repo", "run_id"], |
| 341 | 352 | readOnly: true, |
| 342 | | - run: async (c, a) => (await repoOf(c, a)).stub.run(str(a, "run_id")), |
| 353 | + run: async (c, a) => { |
| 354 | + const { stub, repo } = await repoOf(c, a); |
| 355 | + // The RPC result's type is too deep to map over; only the ids are needed here. |
| 356 | + const run = (await stub.run(str(a, "run_id"))) as unknown as { id: string; jobs: Array<{ id: string }> }; |
| 357 | + return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`), jobs: run.jobs.map((j) => ({ ...j, html_url: web(c, repo, `/actions/runs/${run.id}/job/${j.id}`) })) }; |
| 358 | + }, |
| 343 | 359 | }, |
| 344 | 360 | get_job_logs: { |
| 345 | 361 | description: "Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400).", |
| | @@ -358,8 +374,9 @@ |
| 358 | 374 | input: { ...OWNER_REPO, run_id: { type: "string" }, failed_only: { type: "boolean" } }, |
| 359 | 375 | required: ["owner", "repo", "run_id"], |
| 360 | 376 | run: async (c, a) => { |
| 361 | | - const { stub, caller } = await repoOf(c, a, "write"); |
| 362 | | - return stub.rerun(str(a, "run_id"), caller, a.failed_only === true ? "failed" : "all"); |
| 377 | + const { stub, caller, repo } = await repoOf(c, a, "write"); |
| 378 | + const run = await stub.rerun(str(a, "run_id"), caller, a.failed_only === true ? "failed" : "all"); |
| 379 | + return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`) }; |
| 363 | 380 | }, |
| 364 | 381 | }, |
| 365 | 382 | rerun_job: { |
| | @@ -367,8 +384,9 @@ |
| 367 | 384 | input: { ...OWNER_REPO, job_id: { type: "string" } }, |
| 368 | 385 | required: ["owner", "repo", "job_id"], |
| 369 | 386 | run: async (c, a) => { |
| 370 | | - const { stub, caller } = await repoOf(c, a, "write"); |
| 371 | | - return stub.rerunJob(str(a, "job_id"), caller); |
| 387 | + const { stub, caller, repo } = await repoOf(c, a, "write"); |
| 388 | + const run = await stub.rerunJob(str(a, "job_id"), caller); |
| 389 | + return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`) }; |
| 372 | 390 | }, |
| 373 | 391 | }, |
| 374 | 392 | cancel_workflow: { |
| | @@ -387,7 +405,8 @@ |
| 387 | 405 | required: ["owner", "repo", "workflow"], |
| 388 | 406 | run: async (c, a) => { |
| 389 | 407 | const { stub, caller, repo } = await repoOf(c, a, "write"); |
| 390 | | - return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller); |
| 408 | + const run = await stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller); |
| 409 | + return { ...run, html_url: web(c, repo, `/actions/runs/${run.id}`) }; |
| 391 | 410 | }, |
| 392 | 411 | }, |
| 393 | 412 | get_organization: { |