Vectrum
Sign In
312 lines · 15.3 KB · TypeScript
import "server-only";
import { AGENT_BRANCH_PREFIX } from "../branch-rules";
import { defaultBranch } from "../git";
import { getIssue } from "../issues";
import {
  addPullComment,
  createPull,
  getPull,
  listPullComments,
  listPullEvents,
  listPulls,
  listReviews,
  headLabel,
  MAX_PULL_BODY,
  mergePull,
  pullCommits,
  pullDiff,
  pullStatus,
  replyToComment,
  submitReview,
  threadsOf,
  updatePull,
  type PullRequest,
  type PullStatus,
} from "../pulls";
import { canWrite } from "../repos";
import { actorOf, iso, openRepo, owner, renderDiff, repo, tool, truncate, type Need, type ToolContext } from "./kit";
import { array, boolean, integer, object, oneOf, string, ToolError } from "./params";

// Pull requests for agents: open one from a branch you pushed, read the review it gets, answer the
// comments on its lines, and (where the rules let agents) merge it. Approvals come from people.

const prNumber = integer("The pull request's number.", { min: 1 });

async function openPull(ctx: ToolContext, ownerName: string, name: string, number: number, need: Need) {
  const opened = await openRepo(ctx, ownerName, name, need);
  const pr = await getPull(opened.repo.id, number);
  if (!pr) {
    const issue = await getIssue(opened.repo.id, number);
    throw new ToolError(issue ? `#${number} is an issue, not a pull request. Use get_issue.` : `There's no pull request #${number}.`);
  }
  return { ...opened, pr };
}

export const pullSummary = (url: string, pr: PullRequest, ctx: ToolContext) => ({
  number: pr.number,
  title: pr.title,
  state: pr.state,
  draft: pr.draft,
  head: pr.head,
  ...(pr.headRepo ? { head_repo: pr.headRepo.fullName, head_label: headLabel(pr) } : {}),
  base: pr.base,
  author: pr.author,
  ...(pr.agent ? { opened_by_agent: pr.agent.name, opened_by_you: pr.agent.key === ctx.agent.key } : {}),
  linked_issues: pr.linkedIssues,
  comments: pr.commentCount,
  created_at: iso(pr.createdAt),
  updated_at: iso(pr.updatedAt),
  ...(pr.state === "merged" ? { merged_at: iso(pr.mergedAt), merged_by: pr.mergedByAgent ? `${pr.mergedByAgent} (agent of ${pr.mergedBy})` : pr.mergedBy, merge_commit: pr.mergeSha } : {}),
  url: `${url}/pulls/${pr.number}`,
});

/** What blocks a merge, and the checks, in a form that says what to do next. */
export function statusSummary(url: string, status: PullStatus) {
  return {
    mergeable: status.mergeable,
    head_sha: status.headSha,
    commits_ahead: status.ahead,
    commits_behind_base: status.behind,
    conflicts: status.conflicts,
    requirements: status.requirements.map((r) => ({ ok: r.ok, requirement: r.label, ...(r.detail ? { detail: r.detail } : {}) })),
    checks: {
      state: status.checks,
      runs: status.runs.map((r) => ({ number: r.number, workflow: r.workflowName, status: r.status, url: `${url}/actions/runs/${r.number}` })),
    },
    approved_by: status.approvals,
    changes_requested_by: status.changesRequestedBy,
  };
}

const lineComment = object("A comment on one line of the changes.", {
  path: string("The file's path (its new path, or its old one with side old).", { min: 1, max: 1024 }),
  line: integer("The line's number in the file: after the change (side new) or before it (side old).", { min: 1 }),
  side: oneOf("new: a line as it is after the change (added or unchanged); old: a deleted line.", ["new", "old"] as const).default("new"),
  body: string("The comment, in Markdown.", { min: 1, max: MAX_PULL_BODY }),
});

export const pullTools = [
  tool({
    name: "list_pull_requests",
    title: "List pull requests",
    description: "A repository's pull requests, newest first.",
    access: "read",
    params: {
      owner,
      repo,
      state: oneOf("Which ones.", ["open", "closed", "merged", "all"] as const).default("open"),
      base: string("Only those into this branch.", { max: 255 }).optional(),
      head: string("Only those from this branch.", { max: 255 }).optional(),
      limit: integer("At most this many.", { min: 1, max: 100 }).default(30),
    },
    run: async (args, ctx) => {
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, "read");
      const { pulls, total } = await listPulls(repo.id, { state: args.state, base: args.base, head: args.head, perPage: args.limit });
      return { total, pull_requests: pulls.map((p) => pullSummary(url, p, ctx)) };
    },
  }),

  tool({
    name: "get_pull_request",
    title: "Get a pull request",
    description: [
      "A pull request with its description, commits, the files it changes, whether it can be merged and what still blocks it,",
      "its checks, reviews, conversation, and the review threads on lines of its changes (unresolved first; outdated ones were made on an earlier commit).",
      "Read the full changes with get_pull_request_diff.",
    ].join(" "),
    access: "read",
    params: { owner, repo, number: prNumber },
    run: async (args, ctx) => {
      const { repo, url, pr } = await openPull(ctx, args.owner, args.repo, args.number, "read");
      const [status, commits, files, reviews, comments, events] = await Promise.all([
        pullStatus(repo, pr, actorOf(ctx)),
        pullCommits(repo, pr),
        pullDiff(repo, pr),
        listReviews(pr.id),
        listPullComments(pr.id),
        listPullEvents(pr.id),
      ]);
      const threads = threadsOf(comments, status.headSha).sort((a, b) => Number(a.root.resolved) - Number(b.root.resolved));
      const who = (c: { author: string; agent: string | null }) => (c.agent ? `${c.agent} (agent of ${c.author})` : c.author);
      return {
        ...pullSummary(url, pr, ctx),
        body: pr.body,
        status: statusSummary(url, status),
        commits: commits.map((c) => ({ sha: c.sha, subject: c.subject, author: c.authorName, committer: c.committerName, date: iso(c.date) })),
        files: files.map((f) => ({ path: f.newPath, ...(f.status === "renamed" ? { from: f.oldPath } : {}), status: f.status, additions: f.additions, deletions: f.deletions })),
        reviews: reviews.map((r) => ({ author: who(r), state: r.state, body: r.body || null, commit: r.commitSha, outdated: r.commitSha !== status.headSha, created_at: iso(r.createdAt) })),
        review_threads: threads.map((t) => ({
          comment_id: t.root.id,
          path: t.root.path,
          line: t.root.line,
          side: t.root.side,
          resolved: t.root.resolved,
          outdated: t.outdated,
          code: t.root.snippet,
          comments: [t.root, ...t.replies].map((c) => ({ id: c.id, author: who(c), body: c.body, created_at: iso(c.createdAt) })),
        })),
        conversation: comments.filter((c) => !c.path).map((c) => ({ id: c.id, author: who(c), body: c.body, created_at: iso(c.createdAt) })),
        timeline: events.map((e) => ({ event: e.kind, by: e.agent ? `${e.agent} (agent of ${e.actor})` : e.actor, ...e.data, at: iso(e.createdAt) })),
      };
    },
  }),

  tool({
    name: "get_pull_request_diff",
    title: "Get a pull request's changes",
    description: "The changes a pull request brings, as a unified diff (from where its branch left the base to its latest commit). Optionally only some files.",
    access: "read",
    params: {
      owner,
      repo,
      number: prNumber,
      paths: array("Only these files or directories.", string("A path.", { max: 1024 }), { max: 100 }).default([]),
    },
    run: async (args, ctx) => {
      const { repo, pr } = await openPull(ctx, args.owner, args.repo, args.number, "read");
      const files = await pullDiff(repo, pr, args.paths);
      if (files.length === 0) return "No changes.";
      return truncate(renderDiff(files), "Ask for fewer paths, or read the files with read_file at the head branch.");
    },
  }),

  tool({
    name: "create_pull_request",
    title: "Open a pull request",
    description: [
      `Proposes merging a branch you pushed (head, e.g. ${AGENT_BRANCH_PREFIX}fix-login) into another (base, by default the default branch).`,
      "To propose changes to a repository you can't push to, fork it (fork_repository), push to the fork, and pass the fork as head_repo.",
      'Write "Fixes #12" in the description to link an issue: it moves to In Review now and to Done when the pull request is merged into the default branch.',
      "With draft, it's marked as work in progress until update_pull_request sets draft to false.",
    ].join(" "),
    access: "write",
    params: {
      owner,
      repo,
      head: string("The branch with the changes.", { min: 1, max: 255 }),
      head_repo: string("The fork the head branch is on (owner/name), when it isn't this repository.", { max: 200 }).optional(),
      base: string("The branch to merge them into. Defaults to the default branch.", { max: 255 }).optional(),
      title: string("A short summary of the change.", { min: 1, max: 256 }),
      body: string("What changed and why, in Markdown. Mention issues it fixes as Fixes #12.", { max: MAX_PULL_BODY }).default(""),
      draft: boolean("Open it as a draft.").default(false),
    },
    run: async (args, ctx) => {
      const [forkOwner, forkName] = (args.head_repo ?? "").split("/");
      if (args.head_repo && !(forkOwner && forkName)) throw new ToolError("head_repo is owner/name, like alice/app.");
      const fork = forkOwner ? (await openRepo(ctx, forkOwner, forkName, "read")).repo : null;
      const { repo, url } = await openRepo(ctx, args.owner, args.repo, fork ? "comment" : "write");
      const base = args.base ?? (await defaultBranch(repo.dir));
      if (!base) throw new ToolError("The repository is empty: it has no branches yet.");
      const pr = await createPull(repo, actorOf(ctx), { head: args.head, headRepo: fork, base, title: args.title, body: args.body, draft: args.draft });
      return { ...pullSummary(url, pr, ctx), status: statusSummary(url, await pullStatus(repo, pr, actorOf(ctx))) };
    },
  }),

  tool({
    name: "update_pull_request",
    title: "Update a pull request",
    description: "Changes a pull request's title, description, base branch or draft mark, or closes or reopens it. Only the fields you pass change.",
    access: "write",
    idempotent: true,
    params: {
      owner,
      repo,
      number: prNumber,
      title: string("A new title.", { min: 1, max: 256 }).optional(),
      body: string("A new description, in Markdown. Replaces the old one.", { max: MAX_PULL_BODY }).optional(),
      base: string("A different branch to merge into.", { max: 255 }).optional(),
      draft: boolean("true marks it as a draft, false as ready for review.").optional(),
      state: oneOf("closed closes it without merging; open reopens it.", ["open", "closed"] as const).optional(),
    },
    run: async ({ owner: o, repo: r, number, ...patch }, ctx) => {
      // Its author may change it too: a pull request from their fork.
      const { repo, url, pr, access } = await openPull(ctx, o, r, number, "comment");
      if (!canWrite(access) && pr.authorId !== ctx.user.id) throw new ToolError(`Only collaborators on ${o}/${r} and its author can change #${number}.`);
      const updated = await updatePull(repo, pr, actorOf(ctx), patch);
      return pullSummary(url, updated, ctx);
    },
  }),

  tool({
    name: "comment_on_pull_request",
    title: "Comment on a pull request",
    description: "Adds a comment, in Markdown, to a pull request's conversation. To comment on lines of its changes, use create_review.",
    access: "write",
    params: { owner, repo, number: prNumber, body: string("The comment, in Markdown.", { min: 1, max: MAX_PULL_BODY }) },
    run: async (args, ctx) => {
      const { repo, url, pr } = await openPull(ctx, args.owner, args.repo, args.number, "comment");
      const comment = await addPullComment(repo, pr, actorOf(ctx), args.body);
      return { id: comment.id, url: `${url}/pulls/${pr.number}` };
    },
  }),

  tool({
    name: "create_review",
    title: "Review a pull request",
    description: [
      "Submits a review of a pull request's latest commit: comment, request_changes or approve, with an overall comment and comments on lines of its changes.",
      "Agents can comment and request changes; approvals have to come from people (and only theirs count towards a branch's required approvals).",
    ].join(" "),
    access: "write",
    params: {
      owner,
      repo,
      number: prNumber,
      event: oneOf("What the review says.", ["comment", "request_changes", "approve"] as const),
      body: string("The overall comment, in Markdown.", { max: MAX_PULL_BODY }).default(""),
      comments: array("Comments on lines of the changes.", lineComment, { max: 100 }).default([]),
    },
    run: async (args, ctx) => {
      const { repo, access, url, pr } = await openPull(ctx, args.owner, args.repo, args.number, "comment");
      if (args.event !== "comment" && !canWrite(access)) throw new ToolError("Only people with push access can approve or request changes. Comment instead.");
      const state = args.event === "approve" ? "approved" : args.event === "request_changes" ? "changes_requested" : "commented";
      const review = await submitReview(repo, pr, actorOf(ctx), { state, body: args.body, comments: args.comments });
      return { id: review.id, state: review.state, commit: review.commitSha, comments: args.comments.length, url: `${url}/pulls/${pr.number}` };
    },
  }),

  tool({
    name: "reply_to_review_comment",
    title: "Reply to a review comment",
    description: "Replies in a review thread on a pull request's changes (comment_id is any comment in the thread, from get_pull_request), and can resolve it once it's addressed.",
    access: "write",
    params: {
      owner,
      repo,
      number: prNumber,
      comment_id: string("A comment in the thread.", { min: 1, max: 30 }),
      body: string("The reply, in Markdown. May be empty if you only resolve.", { max: MAX_PULL_BODY }).default(""),
      resolve: boolean("true resolves the thread, false reopens it.").optional(),
    },
    run: async (args, ctx) => {
      const { repo, url, pr } = await openPull(ctx, args.owner, args.repo, args.number, "comment");
      const result = await replyToComment(repo, pr, actorOf(ctx), args.comment_id, args.body, args.resolve);
      return { thread: result.threadId, reply: result.replyId, ...(args.resolve !== undefined ? { resolved: args.resolve } : {}), url: `${url}/pulls/${pr.number}` };
    },
  }),

  tool({
    name: "merge_pull_request",
    title: "Merge a pull request",
    description: [
      "Merges a pull request whose requirements are met (get_pull_request lists them): a merge commit, or squash for one commit.",
      "Agents can only merge into branches they may push to, or that a branch rule lets agents merge into; otherwise a person merges.",
      "Pass expected_head_sha (the commit you reviewed) to refuse if the branch moved since.",
    ].join(" "),
    access: "write",
    destructive: true,
    params: {
      owner,
      repo,
      number: prNumber,
      method: oneOf("merge: a merge commit; squash: all changes as one commit.", ["merge", "squash"] as const).default("merge"),
      delete_branch: boolean("Delete the head branch afterwards.").default(false),
      expected_head_sha: string("The head commit the merge is for.", { max: 64 }).optional(),
    },
    run: async (args, ctx) => {
      const { repo, url, pr } = await openPull(ctx, args.owner, args.repo, args.number, "write");
      const { sha, pull } = await mergePull(repo, pr, actorOf(ctx), { method: args.method, deleteBranch: args.delete_branch, expectedHeadSha: args.expected_head_sha });
      return { ...pullSummary(url, pull, ctx), merge_commit: sha };
    },
  }),
];