pr_01m47d24b0e6n91zwymwxg0vpx/services/runner/src/repo-instructions.ts

188 lines7,205 bytesCodeBlame
1/**
2 * Reading a repository's instructions for agents through the repos
3 * service: for a run's prompt, and for the project's Agents page.
4 */
5import {
6 type RepoInstructions,
7 type RepoPath,
8 type Result,
9 type ServiceBinding,
10 type User,
11 type Viewer,
12 ok,
13 reposClient,
14 workClient,
15} from "@g1t/contracts";
16
17import {
18 INSTRUCTION_FILES,
19 type InstructionTask,
20 MAX_FILE_CHARS,
21 MAX_TOTAL_CHARS,
22 REVIEW_FILE,
23 type TreeReader,
24 cachedReader,
25 describeRead,
26 loadInstructions,
27 pathsIn,
28 remember,
29 renderInstructions,
30} from "./instructions";
31
32/** How many top-level directories the Agents page looks in. */
33const MAX_LISTED_DIRECTORIES = 30;
34
35/** A repository at its commits, read as `viewer`, cached by commit. */
36export function treeReader(repos: ServiceBinding, path: RepoPath, viewer: Viewer, repoId: string): TreeReader {
37 const client = reposClient(repos);
38 return cachedReader(
39 {
40 resolve: async (ref) => {
41 const tree = await client.tree(path, viewer, ref, "");
42 return tree.ok ? (tree.value.head?.hash ?? null) : null;
43 },
44 list: async (commit, dir) => {
45 const tree = await client.tree(path, viewer, commit, dir);
46 return tree.ok ? tree.value.entries.filter((entry) => entry.kind === "blob" || entry.kind === "exec").map((entry) => entry.name) : null;
47 },
48 read: async (commit, file) => {
49 const blob = await client.blob(path, viewer, commit, file);
50 return blob.ok ? blob.value.text : null;
51 },
52 },
53 repoId,
54 );
55}
56
57/**
58 * `actor` as a viewer who can read the repository: someone g1t works for
59 * there is a member of its workspace, though a stored author carries no
60 * memberships.
61 */
62export function asMember(actor: User, repo: RepoPath): User {
63 const slug = repo.namespace.toLowerCase();
64 const workspaces = actor.workspaces ?? [];
65 if (workspaces.some((membership) => membership.slug.toLowerCase() === slug)) return actor;
66 return { ...actor, workspaces: [...workspaces, { slug, role: "member" }] };
67}
68
69/**
70 * The repository's instructions for one agent run, as its prompt gives
71 * them, and noted in the pull request's session when there is one. For a
72 * run on a pull request, read at its head too: followed only from a branch
73 * of the repository itself, never from a fork. Never holds up a run.
74 */
75export async function instructionsFor(
76 env: { REPOS: ServiceBinding; WORK: ServiceBinding },
77 input: {
78 task: InstructionTask;
79 actor: User;
80 repo: RepoPath;
81 /** The pull request the run is on, if it is on one. */
82 pull?: number | null;
83 /** What the task is about, for the paths it names. */
84 about?: string;
85 /** Note what was read in the pull request's session. */
86 note?: boolean;
87 },
88): Promise<string | null> {
89 try {
90 const viewer = asMember(input.actor, input.repo);
91 const found = await reposClient(env.REPOS).get(input.repo, viewer);
92 if (!found.ok) return null;
93 const base = treeReader(env.REPOS, input.repo, viewer, found.value.id);
94 const touched = pathsIn(input.about ?? "");
95 let head: { reader: TreeReader; ref: string; inRepo: boolean } | null = null;
96 if (input.pull) {
97 const detail = await workClient(env.WORK).getPull(input.repo, input.pull, viewer);
98 if (detail.ok) {
99 const { pull } = detail.value;
100 touched.push(...pull.files.map((file) => file.path));
101 if (pull.fork) {
102 const fork = await reposClient(env.REPOS).get(pull.fork, viewer);
103 if (fork.ok && pull.headCommit) {
104 head = { reader: treeReader(env.REPOS, pull.fork, viewer, fork.value.id), ref: pull.headCommit, inRepo: false };
105 }
106 } else if (pull.branch) {
107 head = { reader: base, ref: pull.headCommit ?? pull.branch, inRepo: true };
108 }
109 }
110 }
111 const instructions = await loadInstructions({
112 base,
113 baseRef: found.value.defaultBranch,
114 head,
115 touched,
116 task: input.task,
117 });
118 const read = describeRead(instructions);
119 if (read && input.note && input.pull) {
120 await workClient(env.WORK)
121 .appendSession(input.actor, input.repo, input.pull, [{ kind: "note", text: read }])
122 .catch(() => undefined);
123 }
124 return renderInstructions(instructions);
125 } catch (error) {
126 console.log("instructions not read", input.repo.namespace, input.repo.name, String(error));
127 return null;
128 }
129}
130
131/** `prompt` with `block` added, when there is one. */
132export function withBlock(prompt: string, block: string | null): string {
133 return block ? `${prompt}\n\n${block}` : prompt;
134}
135
136/** What the project's Agents page shows: every instructions file on the default branch. */
137export async function repoInstructions(
138 repos: ServiceBinding,
139 viewer: Viewer,
140 path: RepoPath,
141): Promise<Result<RepoInstructions>> {
142 const client = reposClient(repos);
143 const found = await client.get(path, viewer);
144 if (!found.ok) return found;
145 const branch = found.value.defaultBranch;
146 const limits = { fileChars: MAX_FILE_CHARS, totalChars: MAX_TOTAL_CHARS };
147 const root = await client.tree(path, viewer, branch, "");
148 if (!root.ok || !root.value.head) return ok({ branch, commit: null, files: [], limits });
149 const commit = root.value.head.hash;
150 const reader = treeReader(repos, path, viewer, found.value.id);
151 const names = (entries: string[] | null) => INSTRUCTION_FILES.filter((name) => (entries ?? []).includes(name));
152 const rootFiles = root.value.entries.filter((entry) => entry.kind === "blob" || entry.kind === "exec").map((entry) => entry.name);
153 const dirs = root.value.entries
154 .filter((entry) => entry.kind === "tree" && entry.name !== ".git")
155 .map((entry) => entry.name)
156 .slice(0, MAX_LISTED_DIRECTORIES);
157 const listed = await Promise.all(dirs.map((dir) => reader.list(commit, dir).catch(() => null)));
158 const candidates: { path: string; role: RepoInstructions["files"][number]["role"] }[] = [
159 ...names(rootFiles).map((name) => ({ path: name, role: "root" as const })),
160 ...(dirs.includes(".g1t") && (listed[dirs.indexOf(".g1t")] ?? []).includes("review.md")
161 ? [{ path: REVIEW_FILE, role: "review" as const }]
162 : []),
163 ...dirs.flatMap((dir, at) =>
164 dir === ".g1t" ? [] : names(listed[at]).map((name) => ({ path: `${dir}/${name}`, role: "directory" as const })),
165 ),
166 ];
167 const files = await Promise.all(
168 candidates.map(async ({ path: file, role }) => {
169 const [text, blame] = await Promise.all([
170 reader.read(commit, file).catch(() => null),
171 remember(`${found.value.id}@${commit}:blame:${file}`, () => client.blame(path, viewer, commit, file)).catch(() => null),
172 ]);
173 const latest = blame?.ok
174 ? [...blame.value.commits].sort((a, b) => b.authoredAt.localeCompare(a.authoredAt))[0]
175 : undefined;
176 return {
177 path: file,
178 role,
179 text: text ?? "",
180 truncated: (text ?? "").trim().length > MAX_FILE_CHARS,
181 lastChanged: latest
182 ? { commit: latest.hash, message: latest.message.split("\n")[0], author: latest.author.name, at: latest.authoredAt }
183 : null,
184 };
185 }),
186 );
187 return ok({ branch, commit, files, limits });
188}