📦 EqualifyEverything / equalify-iris

📄 pr.ts · 118 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118import { Octokit } from "@octokit/rest";
import { createHash } from "node:crypto";

export interface RepoRef {
  owner: string;
  repo: string;
}

export function parseRepo(url: string): RepoRef {
  const m = url.replace(/\.git$/, "").match(/github\.com[/:]([^/]+)\/([^/]+)/);
  if (!m) throw new Error(`cannot parse GitHub repo from "${url}"`);
  return { owner: m[1], repo: m[2] };
}

function shortHash(...parts: string[]): string {
  return createHash("sha1").update(parts.join("|")).digest("hex").slice(0, 4);
}

async function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

// Ensure the user has a fork of the upstream repo (PRD §7.13: created lazily on
// first close). Returns the fork's html_url.
export async function ensureFork(octokit: Octokit, upstream: RepoRef): Promise<string> {
  const me = (await octokit.users.getAuthenticated()).data.login;
  try {
    const existing = await octokit.repos.get({ owner: me, repo: upstream.repo });
    if (existing.data.fork) return existing.data.html_url;
  } catch {
    // not found -> create below
  }
  const created = await octokit.repos.createFork({ owner: upstream.owner, repo: upstream.repo });
  // Forking is asynchronous on GitHub; wait until the repo is queryable.
  for (let i = 0; i < 10; i++) {
    try {
      await octokit.repos.get({ owner: me, repo: upstream.repo });
      break;
    } catch {
      await sleep(2000);
    }
  }
  return created.data.html_url;
}

// A file to commit to the PR branch. Text content is given as a string; binary
// content (e.g. an image asset) as a Buffer.
export interface PrFile {
  path: string; // path within the repo, e.g. agents/foo.md
  content: string | Buffer;
}

interface OpenPrArgs {
  upstream: RepoRef;
  forkOwner: string;
  branchPrefix: string;
  nameForBranch: string; // used in branch name + hashing
  files: PrFile[]; // files to commit on the branch (e.g. the agent file)
  title: string;
  body: string;
}

export interface OpenedPr {
  pr_url: string;
  branch: string;
}

function toBase64(content: string | Buffer): string {
  return Buffer.isBuffer(content) ? content.toString("base64") : Buffer.from(content, "utf8").toString("base64");
}

// Create a branch on the user's fork, commit the files, and open a PR upstream.
export async function openPr(octokit: Octokit, args: OpenPrArgs): Promise<OpenedPr> {
  const { upstream, forkOwner } = args;
  const hashSeed = args.files.map((f) => (Buffer.isBuffer(f.content) ? f.content.toString("base64") : f.content)).join("|");
  const branch = `${args.branchPrefix}/${args.nameForBranch}-${shortHash(args.nameForBranch, hashSeed)}`;

  // Base off upstream's default branch HEAD.
  const upstreamRepo = await octokit.repos.get({ owner: upstream.owner, repo: upstream.repo });
  const base = upstreamRepo.data.default_branch;
  const baseRef = await octokit.git.getRef({ owner: upstream.owner, repo: upstream.repo, ref: `heads/${base}` });
  const baseSha = baseRef.data.object.sha;

  // Create the branch on the fork.
  await octokit.git.createRef({ owner: forkOwner, repo: upstream.repo, ref: `refs/heads/${branch}`, sha: baseSha });

  // Commit each file to the branch on the fork (create or update).
  for (const file of args.files) {
    let existingSha: string | undefined;
    try {
      const existing = await octokit.repos.getContent({ owner: forkOwner, repo: upstream.repo, path: file.path, ref: branch });
      if (!Array.isArray(existing.data) && "sha" in existing.data) existingSha = existing.data.sha;
    } catch {
      // file does not exist on this branch yet
    }
    await octokit.repos.createOrUpdateFileContents({
      owner: forkOwner,
      repo: upstream.repo,
      path: file.path,
      message: `${args.title}: ${file.path}`,
      content: toBase64(file.content),
      branch,
      sha: existingSha,
    });
  }

  // Open the PR upstream from fork:branch.
  const pr = await octokit.pulls.create({
    owner: upstream.owner,
    repo: upstream.repo,
    title: args.title,
    body: args.body,
    head: `${forkOwner}:${branch}`,
    base,
  });
  return { pr_url: pr.data.html_url, branch };
}