@aivi/forge-github 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,45 @@
1
- # Temporary Holding Version
1
+ # @aivi/forge-github
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The GitHub forge: the remote a project came from, the pull requests over its
4
+ branches, and the review conversation on them. aivi speaks as **its own app** —
5
+ one installation, one token octokit owns the whole life of — never as the
6
+ person at the keyboard.
7
+
8
+ ## Exports
9
+
10
+ | Path | Contents |
11
+ | --- | --- |
12
+ | `@aivi/forge-github` | `GitHubApp`, `createGitHubForge`, `createForgeGithubModule` |
13
+ | `@aivi/forge-github/config` | the registry declaration: module id and `plugins.forge-github` schema |
14
+ | `@aivi/forge-github/setup` | the guide `aivi add` runs |
15
+ | `@aivi/forge-github/setupProject` | the `forge`-role contributor `aivi projects add` runs |
16
+
17
+ ## Model
18
+
19
+ - **Remote is the line.** Anything that reaches `origin` authenticates and so
20
+ belongs here: clone, fetch, the `source/` sync, push. Worktrees and commits
21
+ stay with whoever works locally, and this package never performs them.
22
+ - **One installation and only one.** An installation is the grant from an
23
+ account to the app; zero or several is a setup error naming what to fix,
24
+ never a mode.
25
+ - **The credential goes in the command's own environment**, and the URL is
26
+ named on the command line rather than read from the checkout: a token is left
27
+ in no file, and the person's stored credential is never offered.
28
+ - **Every post names its worker**, in the text a person reads — so the review
29
+ a wake is handed can tell aivi's own comments from a human's replies.
30
+
31
+ ## Status
32
+
33
+ The contract, the credential and the transfers are built and tested against a
34
+ scripted GitHub and a real local repository. Nothing asks a forge a question
35
+ yet: the host-side forge registry — the first thing with "who owns this
36
+ project's remote?" to ask — arrives with the orchestrator.
37
+
38
+ ## Dependencies
39
+
40
+ `octokit` and `@octokit/auth-app`; `@aivi/host` as a peer (the module runs
41
+ in-process with the host).
42
+
43
+ ## Docs
44
+
45
+ [plan (the contract, the boundary, what is left)](../../docs/plans/forge-github.md)
@@ -0,0 +1,35 @@
1
+ /** The GitHub forge's registry declaration, at its `./config` subpath: the
2
+ * module id and the block's own schema. The CLI imports this for every
3
+ * command, so nothing here pulls octokit — `createModule` reaches the module
4
+ * code lazily.
5
+ *
6
+ * **The block holds nothing** (ruled 2026-10-07): the app's identity moved to
7
+ * the environment — `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` in
8
+ * `<home>/.env` — because the forge and the tracker are one app and a plugin
9
+ * block cannot be the shared source for two plugins (`@aivi/github` reads
10
+ * both). The repositories come from each project's own `origin`; there is no
11
+ * per-project forge config. The empty block still marks the forge as
12
+ * configured: core offers the forge role to a project only when its block
13
+ * stands in `config.json`.
14
+ */
15
+ import type { AiviPlugin } from '@aivi/plugin';
16
+ import { z } from 'zod';
17
+ /**
18
+ * The `forge-github` module: the repository host a project's `origin` names,
19
+ * the pull requests over its branches, and the review conversation on them.
20
+ * It speaks as **its own app** — an installation token, minted and renewed by
21
+ * octokit — never as a human's credentials, exactly as the Linear app does.
22
+ * One installation and only one: the app grants aivi access to a repository
23
+ * set through it, and aivi has no vocabulary for more than one.
24
+ *
25
+ * The block lives at `plugins.forge-github` in config.json; the `aivi-plugins`
26
+ * list in `app/package.json` says whether the module runs.
27
+ */
28
+ export declare const forgeGithubSchema: z.ZodObject<{}, z.core.$strict>;
29
+ export type ForgeGithubConfig = z.infer<typeof forgeGithubSchema>;
30
+ /** The registry entry: the block holds no paths, so `home` finds nothing to
31
+ * resolve here. No project section is contributed: which repository a project
32
+ * works is a fact about its checkout, not about its configuration. The block
33
+ * itself is unused by the module — the credential is read from the
34
+ * environment where it is written. */
35
+ export declare const plugin: AiviPlugin<ForgeGithubConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * The `forge-github` module: the repository host a project's `origin` names,
4
+ * the pull requests over its branches, and the review conversation on them.
5
+ * It speaks as **its own app** — an installation token, minted and renewed by
6
+ * octokit — never as a human's credentials, exactly as the Linear app does.
7
+ * One installation and only one: the app grants aivi access to a repository
8
+ * set through it, and aivi has no vocabulary for more than one.
9
+ *
10
+ * The block lives at `plugins.forge-github` in config.json; the `aivi-plugins`
11
+ * list in `app/package.json` says whether the module runs.
12
+ */
13
+ export const forgeGithubSchema = z.strictObject({});
14
+ /** The registry entry: the block holds no paths, so `home` finds nothing to
15
+ * resolve here. No project section is contributed: which repository a project
16
+ * works is a fact about its checkout, not about its configuration. The block
17
+ * itself is unused by the module — the credential is read from the
18
+ * environment where it is written. */
19
+ export const plugin = {
20
+ id: 'forge-github',
21
+ configSchema: forgeGithubSchema,
22
+ // The lazy load is the design: the CLI reads this file's schema without
23
+ // pulling the host's world in, and the module arrives only when the host
24
+ // actually builds. The cycle back into this file (the module imports its
25
+ // own schema) is that design, not a tangle.
26
+ createModule: () => import('./module.js').then(m => m.createForgeGithubModule()),
27
+ };
@@ -0,0 +1,117 @@
1
+ import { type Logger } from '@aivi/core';
2
+ import { type GitHubApp, type GitRunner } from '@aivi/github';
3
+ import type { Forge, ForgeProject, ForgeSync, PrFacts, RepoRef, ReviewFacts } from '@aivi/plugin/forge';
4
+ export declare class GitHubForge implements Forge {
5
+ readonly app: GitHubApp;
6
+ private readonly log;
7
+ private readonly git;
8
+ constructor(app: GitHubApp, options?: {
9
+ log?: Logger;
10
+ git?: GitRunner;
11
+ });
12
+ /** The checkout's own `origin`, read locally: a project aivi has never
13
+ * cloned is not less a project for it. Anything that is not a GitHub
14
+ * repository — another host, a local path, no remote at all — is answered
15
+ * with silence, because the answer belongs to another forge or to nobody. */
16
+ repoFor(project: ForgeProject): Promise<RepoRef | undefined>;
17
+ /** The newest pull request over the branch: a branch whose first attempt was
18
+ * closed and reopened has a live one and a dead one, and aivi means the
19
+ * live one. */
20
+ prForBranch(repo: RepoRef, branch: string): Promise<PrFacts | undefined>;
21
+ /** Everything a review wake needs, in one read: the state of the approvals,
22
+ * the threads still open, and the plain conversation comments — context
23
+ * the worker reads but is never gated on. A pull request aivi cannot see
24
+ * is said, not answered empty — an empty review sends a worker away
25
+ * believing it is done. And a read that could not hold everything is
26
+ * **said too**: `hasNextPage` on either connection is a
27
+ * loud `review.facts.truncated`, the same word Linear keeps for its own
28
+ * wide board reads — silence here leaves a worker owing answers it
29
+ * believed were gathered. */
30
+ reviewFeedback(repo: RepoRef, pr: PrFacts): Promise<ReviewFacts>;
31
+ /** Answer the thread as aivi, then resolve it: the worker's words, signed
32
+ * with the role that wrote them, closing the person's question. */
33
+ resolveThread(repo: RepoRef, _pr: PrFacts, threadId: string, reply: {
34
+ author: string;
35
+ text: string;
36
+ }): Promise<void>;
37
+ /** A plain conversation comment, signed like every aivi post: what the
38
+ * worker says when there is no thread to answer. */
39
+ commentPr(repo: RepoRef, pr: PrFacts, comment: {
40
+ author: string;
41
+ text: string;
42
+ }): Promise<void>;
43
+ /** The review agent's teeth: findings on the lines themselves, which the
44
+ * platform shows as review threads — agent feedback that the next round's
45
+ * worker owes answers on. Every inline body carries the signature like
46
+ * every aivi post, so a wake can tell its own findings from a human's. */
47
+ submitReview(repo: RepoRef, pr: PrFacts, review: {
48
+ author: string;
49
+ body: string;
50
+ state: 'COMMENT' | 'REQUEST_CHANGES';
51
+ comments?: {
52
+ path: string;
53
+ line?: number;
54
+ body: string;
55
+ }[];
56
+ }): Promise<void>;
57
+ /** The URL and credential for one transfer. The URL is the one this forge
58
+ * named when it claimed the project — HTTPS, whatever the checkout's own
59
+ * `origin` says, because an installation token authenticates HTTPS and
60
+ * nothing else — and it is used as given: re-reading it here would let a
61
+ * checkout's config decide where aivi's credential is sent. */
62
+ private transfer;
63
+ /**
64
+ * Fetch, then fast-forward — and stop at that. Every case that would need a
65
+ * decision is reported with its reason: `source/` is the clean checkout
66
+ * aivi indexes, not a working directory, so aivi never merges a conflict,
67
+ * never discards a person's edit, and never rewrites history.
68
+ *
69
+ * The fetch names its refspec instead of trusting `origin`'s: the URL aivi
70
+ * authenticates is HTTPS, which for an ssh checkout is not the URL
71
+ * configured, and a fetch by URL updates only what its refspec names.
72
+ */
73
+ syncSource(repo: RepoRef, directory: string): Promise<ForgeSync>;
74
+ /**
75
+ * The one fetch the worktree machinery needs, and it is the forge's: the
76
+ * refspec names the branch, the URL is the one this forge named when it
77
+ * claimed the project, and the credential rides the environment — the
78
+ * checkout's config stays untouched, as with every other transfer. A
79
+ * remote without that branch answers "couldn't find remote ref", which is
80
+ * the ordinary news of a ticket branch never pushed: said quietly, no
81
+ * ref left behind. Anything else is a failed transfer, and it throws.
82
+ */
83
+ fetchBranch(repo: RepoRef, directory: string, branch: string): Promise<void>;
84
+ /**
85
+ * Move aivi's commits to the remote, as the app and never as the person at
86
+ * the keyboard. Plain when the remote fast-forwards; with a
87
+ * `--force-with-lease` keyed on the commit the orchestrator just fetched
88
+ * when the divergence is the worker's own rewrite — the force replaces
89
+ * exactly that commit and nothing a person added since.
90
+ */
91
+ push(repo: RepoRef, worktree: string, branch: string, options?: {
92
+ lease?: string;
93
+ }): Promise<void>;
94
+ /**
95
+ * Open the pull request that stands for the branch. The base is the
96
+ * repository's own default branch: that is where a pull request belongs
97
+ * unless a person said otherwise, and aivi has no place to have been
98
+ * told.
99
+ */
100
+ openPr(repo: RepoRef, branch: string, pr: {
101
+ author: string;
102
+ title: string;
103
+ body: string;
104
+ }): Promise<PrFacts>;
105
+ /** Every branch, pruned, refs only: the fresh view `aivi_sync` gives the
106
+ * worker and the push's lease starts from. Same transfer as every
107
+ * other — the URL this forge named, the credential in the environment,
108
+ * nothing left in the checkout's config. */
109
+ fetchRefs(repo: RepoRef, directory: string): Promise<void>;
110
+ }
111
+ /** The forge for one installed app. */
112
+ /** The forge over one authenticated app: the API reads and the git transfers.
113
+ * `git` is the disk crossing a test answers; production leaves it alone. */
114
+ export declare function createGitHubForge(app: GitHubApp, options?: {
115
+ log?: Logger;
116
+ git?: GitRunner;
117
+ }): GitHubForge;
package/dist/forge.js ADDED
@@ -0,0 +1,410 @@
1
+ /** The `Forge` contract, answered to GitHub. Reads and writes over the API —
2
+ * the facts git cannot see from a clone: which pull request stands for a
3
+ * branch, what its review threads and conversation say, whether the base
4
+ * would take the merge — and the posts aivi makes back: a thread answer, a
5
+ * plain comment, a review with inline findings — plus the transfers the
6
+ * remote boundary belongs to: fetching branches and checkouts, and moving
7
+ * aivi's own commits out.
8
+ *
9
+ * Every call here is made **as the app** through one installation token, and
10
+ * every transfer carries that token in the command's own environment: a push
11
+ * never runs with the human's keychain, and no credential is written into a
12
+ * repository. What aivi does *locally* — creating a worktree, committing in
13
+ * one — is not on this class and never was a forge's business.
14
+ */
15
+ import { stat } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { getLogger } from '@aivi/core';
18
+ import { gitCredential, parseWorker, runGit, signComment } from '@aivi/github';
19
+ import { httpsRemote, isGitHub, parseRemote } from './remote.js';
20
+ /** Who and what, out of the opaque `RepoRef` aivi hands back: the two parts of
21
+ * the forge's own name for a repository, which is all an API call needs. The
22
+ * remote URL is a separate matter — only a transfer reads it, and it reads it
23
+ * as the forge left it. */
24
+ function address(repo) {
25
+ const [owner, name] = repo.id.split('/');
26
+ if (!owner || !name)
27
+ throw new Error(`forge-github: ${repo.id} is not a repository this forge named`);
28
+ return { owner, repo: name };
29
+ }
30
+ /** A pull request, in aivi's words. `id` is the pull number as GitHub's own
31
+ * string: what aivi carries and hands back, never interprets. The REST list
32
+ * read does not compute whether the merge is clean, and this forge does not
33
+ * guess what GitHub has not said: `unknown`. */
34
+ function prFacts(pull) {
35
+ return {
36
+ id: String(pull.number),
37
+ url: pull.html_url,
38
+ title: pull.title,
39
+ state: pull.state,
40
+ branch: pull.head.ref,
41
+ mergeable: 'unknown',
42
+ };
43
+ }
44
+ /** The review conversation in one read. GitHub keeps thread *resolution* in
45
+ * GraphQL alone — the REST review-comment endpoints show the comments but not
46
+ * the thread that holds them — so this is the shape of the answer, not a
47
+ * preference for one API over another. */
48
+ const REVIEW_FACTS_QUERY = /* GraphQL */ `
49
+ query ReviewFacts($owner: String!, $repo: String!, $number: Int!, $threads: Int!, $comments: Int!) {
50
+ repository(owner: $owner, name: $repo) {
51
+ pullRequest(number: $number) {
52
+ number
53
+ url
54
+ title
55
+ state
56
+ headRefName
57
+ mergeable
58
+ reviews(last: 50) {
59
+ nodes {
60
+ state
61
+ author { login __typename }
62
+ }
63
+ }
64
+ reviewThreads(first: $threads) {
65
+ pageInfo { hasNextPage }
66
+ nodes {
67
+ id
68
+ isResolved
69
+ isOutdated
70
+ path
71
+ comments(first: $comments) {
72
+ nodes {
73
+ body
74
+ author { login __typename }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ comments(first: $comments) {
80
+ pageInfo { hasNextPage }
81
+ nodes {
82
+ body
83
+ createdAt
84
+ author { login __typename }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ `;
91
+ /** Post a reply into an open thread. */
92
+ const REPLY_MUTATION = /* GraphQL */ `
93
+ mutation ThreadReply($threadId: ID!, $body: String!) {
94
+ addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) {
95
+ comment {
96
+ id
97
+ }
98
+ }
99
+ }
100
+ `;
101
+ /** Close the thread the reply answered. GitHub offers no single mutation that
102
+ * does both, so an answer is two round trips: the words, then the resolution. */
103
+ const RESOLVE_MUTATION = /* GraphQL */ `
104
+ mutation ResolveThread($threadId: ID!) {
105
+ resolveReviewThread(input: { threadId: $threadId }) {
106
+ thread {
107
+ id
108
+ isResolved
109
+ }
110
+ }
111
+ }
112
+ `;
113
+ /** Who wrote it, in aivi's words. A GitHub bot's login ends in `[bot]` and its
114
+ * author node is a `Bot`; a deleted account answers `null` and leaves nothing
115
+ * to attribute. */
116
+ function authorOf(node) {
117
+ const login = node?.login ?? 'deleted';
118
+ return { login, bot: node?.__typename === 'Bot' || login.endsWith('[bot]') };
119
+ }
120
+ /** The same, with the worker role read back out of what the comment itself
121
+ * says — the signature is the only carrier, and it is one a human reads. */
122
+ function authorOfComment(comment) {
123
+ const author = authorOf(comment.author);
124
+ const worker = parseWorker(comment.body);
125
+ return worker ? { ...author, worker } : author;
126
+ }
127
+ /** One open thread, or nothing when it is settled: a wake takes what is open
128
+ * and owes nothing for what a person already resolved. */
129
+ function openThread(node) {
130
+ if (node.isResolved)
131
+ return undefined;
132
+ const [first, ...rest] = node.comments.nodes;
133
+ return {
134
+ id: node.id,
135
+ ...(node.path ? { path: node.path } : {}),
136
+ state: node.isOutdated ? 'outdated' : 'open',
137
+ question: first?.body ?? '',
138
+ replies: rest.map(comment => ({ author: authorOfComment(comment), body: comment.body })),
139
+ };
140
+ }
141
+ export class GitHubForge {
142
+ app;
143
+ log;
144
+ git;
145
+ constructor(app, options = {}) {
146
+ this.app = app;
147
+ this.log = options.log ?? getLogger(['aivi', 'forge-github']);
148
+ this.git = options.git ?? runGit;
149
+ }
150
+ /** The checkout's own `origin`, read locally: a project aivi has never
151
+ * cloned is not less a project for it. Anything that is not a GitHub
152
+ * repository — another host, a local path, no remote at all — is answered
153
+ * with silence, because the answer belongs to another forge or to nobody. */
154
+ async repoFor(project) {
155
+ const origin = await this.git(project.directory, ['remote', 'get-url', 'origin']);
156
+ if (!origin.ok)
157
+ return undefined;
158
+ const remote = parseRemote(origin.stdout);
159
+ if (!remote || !isGitHub(remote))
160
+ return undefined;
161
+ // An ssh origin is said, not fixed: the person chose that transport for
162
+ // their own reasons, and aivi's transfers go over HTTPS regardless.
163
+ if (remote.ssh)
164
+ this.log.debug('origin.ssh', { project: project.id, origin: origin.stdout, used: httpsRemote(remote) });
165
+ return { id: `${remote.owner}/${remote.repo}`, remote: httpsRemote(remote) };
166
+ }
167
+ /** The newest pull request over the branch: a branch whose first attempt was
168
+ * closed and reopened has a live one and a dead one, and aivi means the
169
+ * live one. */
170
+ async prForBranch(repo, branch) {
171
+ const { owner, repo: name } = address(repo);
172
+ const { data: found } = await this.app.octokit.rest.pulls.list({
173
+ owner,
174
+ repo: name,
175
+ state: 'all',
176
+ head: `${owner}:${branch}`,
177
+ per_page: 5,
178
+ direction: 'desc',
179
+ });
180
+ const newest = found[0];
181
+ return newest ? prFacts(newest) : undefined;
182
+ }
183
+ /** Everything a review wake needs, in one read: the state of the approvals,
184
+ * the threads still open, and the plain conversation comments — context
185
+ * the worker reads but is never gated on. A pull request aivi cannot see
186
+ * is said, not answered empty — an empty review sends a worker away
187
+ * believing it is done. And a read that could not hold everything is
188
+ * **said too**: `hasNextPage` on either connection is a
189
+ * loud `review.facts.truncated`, the same word Linear keeps for its own
190
+ * wide board reads — silence here leaves a worker owing answers it
191
+ * believed were gathered. */
192
+ async reviewFeedback(repo, pr) {
193
+ const { owner, repo: name } = address(repo);
194
+ const answer = await this.app.octokit.graphql(REVIEW_FACTS_QUERY, { owner, repo: name, number: Number(pr.id), threads: 100, comments: 100 });
195
+ const pull = answer.repository?.pullRequest;
196
+ if (!pull)
197
+ throw new Error(`forge-github: pull request #${pr.id} of ${repo.id} is not visible to installation #${this.app.installationId}`);
198
+ const cut = [
199
+ ...(pull.reviewThreads.pageInfo.hasNextPage ? ['review threads'] : []),
200
+ ...(pull.comments.pageInfo.hasNextPage ? ['conversation comments'] : []),
201
+ ];
202
+ if (cut.length)
203
+ this.log.warn('review.facts.truncated', { repo: repo.id, pr: pr.id, held: cut.join(' and ') });
204
+ return {
205
+ pr: {
206
+ id: String(pull.number),
207
+ url: pull.url,
208
+ title: pull.title,
209
+ state: pull.state,
210
+ branch: pull.headRefName,
211
+ // GitHub's own word, in aivi's three: MERGEABLE is clean,
212
+ // CONFLICTING is dirty, and a mergeability GitHub has not computed
213
+ // yet (it computes on demand) is said as unknown, not guessed.
214
+ mergeable: pull.mergeable === 'MERGEABLE' ? 'clean' : pull.mergeable === 'CONFLICTING' ? 'dirty' : 'unknown',
215
+ },
216
+ reviews: pull.reviews.nodes.map(review => ({ author: authorOf(review.author), state: review.state })),
217
+ threads: pull.reviewThreads.nodes.flatMap(node => {
218
+ const thread = openThread(node);
219
+ return thread ? [thread] : [];
220
+ }),
221
+ comments: pull.comments.nodes.map(comment => ({
222
+ author: authorOfComment(comment),
223
+ body: comment.body,
224
+ createdAt: comment.createdAt,
225
+ })),
226
+ };
227
+ }
228
+ /** Answer the thread as aivi, then resolve it: the worker's words, signed
229
+ * with the role that wrote them, closing the person's question. */
230
+ async resolveThread(repo, _pr, threadId, reply) {
231
+ await this.app.octokit.graphql(REPLY_MUTATION, { threadId, body: signComment(reply.text, reply.author) });
232
+ await this.app.octokit.graphql(RESOLVE_MUTATION, { threadId });
233
+ this.log.info('review.answered', { repo: repo.id, thread: threadId, worker: reply.author });
234
+ }
235
+ /** A plain conversation comment, signed like every aivi post: what the
236
+ * worker says when there is no thread to answer. */
237
+ async commentPr(repo, pr, comment) {
238
+ const { owner, repo: name } = address(repo);
239
+ await this.app.octokit.rest.issues.createComment({
240
+ owner,
241
+ repo: name,
242
+ issue_number: Number(pr.id),
243
+ body: signComment(comment.text, comment.author),
244
+ });
245
+ this.log.info('pull.commented', { repo: repo.id, pull: pr.id, worker: comment.author });
246
+ }
247
+ /** The review agent's teeth: findings on the lines themselves, which the
248
+ * platform shows as review threads — agent feedback that the next round's
249
+ * worker owes answers on. Every inline body carries the signature like
250
+ * every aivi post, so a wake can tell its own findings from a human's. */
251
+ async submitReview(repo, pr, review) {
252
+ const { owner, repo: name } = address(repo);
253
+ await this.app.octokit.rest.pulls.createReview({
254
+ owner,
255
+ repo: name,
256
+ pull_number: Number(pr.id),
257
+ event: review.state,
258
+ body: signComment(review.body, review.author),
259
+ ...(review.comments
260
+ ? {
261
+ comments: review.comments.map(finding => ({
262
+ path: finding.path,
263
+ ...(finding.line === undefined ? {} : { line: finding.line }),
264
+ body: signComment(finding.body, review.author),
265
+ })),
266
+ }
267
+ : {}),
268
+ });
269
+ this.log.info('review.submitted', {
270
+ repo: repo.id,
271
+ pull: pr.id,
272
+ state: review.state,
273
+ worker: review.author,
274
+ findings: review.comments?.length ?? 0,
275
+ });
276
+ }
277
+ /** The URL and credential for one transfer. The URL is the one this forge
278
+ * named when it claimed the project — HTTPS, whatever the checkout's own
279
+ * `origin` says, because an installation token authenticates HTTPS and
280
+ * nothing else — and it is used as given: re-reading it here would let a
281
+ * checkout's config decide where aivi's credential is sent. */
282
+ async transfer(repo) {
283
+ if (!repo.remote)
284
+ throw new Error(`forge-github: ${repo.id} came with no remote to reach`);
285
+ return { url: repo.remote, env: gitCredential(await this.app.gitToken()) };
286
+ }
287
+ /**
288
+ * Fetch, then fast-forward — and stop at that. Every case that would need a
289
+ * decision is reported with its reason: `source/` is the clean checkout
290
+ * aivi indexes, not a working directory, so aivi never merges a conflict,
291
+ * never discards a person's edit, and never rewrites history.
292
+ *
293
+ * The fetch names its refspec instead of trusting `origin`'s: the URL aivi
294
+ * authenticates is HTTPS, which for an ssh checkout is not the URL
295
+ * configured, and a fetch by URL updates only what its refspec names.
296
+ */
297
+ async syncSource(repo, directory) {
298
+ if (!(await stat(join(directory, '.git')).catch(() => null)))
299
+ return { state: 'held', reason: 'not a git checkout' };
300
+ const branch = await this.git(directory, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
301
+ if (!branch.ok || !branch.stdout)
302
+ return { state: 'held', reason: 'detached HEAD' };
303
+ const dirty = await this.git(directory, ['status', '--porcelain']);
304
+ if (!dirty.ok)
305
+ return { state: 'held', reason: dirty.message };
306
+ if (dirty.stdout)
307
+ return { state: 'held', reason: 'local changes in source/' };
308
+ const name = `refs/remotes/origin/${branch.stdout}`;
309
+ const transport = await this.transfer(repo);
310
+ const fetched = await this.git(directory, ['fetch', '--quiet', transport.url, `+${branch.stdout}:${name}`], {
311
+ env: transport.env,
312
+ });
313
+ if (!fetched.ok)
314
+ return { state: 'held', reason: `fetch failed: ${fetched.message}` };
315
+ const from = await this.git(directory, ['rev-parse', 'HEAD']);
316
+ if (!from.ok)
317
+ return { state: 'held', reason: from.message };
318
+ const to = await this.git(directory, ['rev-parse', name]);
319
+ if (!to.ok)
320
+ return { state: 'held', reason: `branch ${branch.stdout} has no upstream` };
321
+ if (from.stdout === to.stdout)
322
+ return { state: 'current', from: from.stdout, to: to.stdout };
323
+ const ahead = await this.git(directory, ['merge-base', '--is-ancestor', 'HEAD', name]);
324
+ if (!ahead.ok)
325
+ return { state: 'held', reason: `${branch.stdout} and ${name} have diverged; a person must decide` };
326
+ const merged = await this.git(directory, ['merge', '--ff-only', '--quiet', name]);
327
+ if (!merged.ok)
328
+ return { state: 'held', reason: merged.message };
329
+ this.log.info('source.synced', { repo: repo.id, from: from.stdout, to: to.stdout });
330
+ return { state: 'updated', from: from.stdout, to: to.stdout };
331
+ }
332
+ /**
333
+ * The one fetch the worktree machinery needs, and it is the forge's: the
334
+ * refspec names the branch, the URL is the one this forge named when it
335
+ * claimed the project, and the credential rides the environment — the
336
+ * checkout's config stays untouched, as with every other transfer. A
337
+ * remote without that branch answers "couldn't find remote ref", which is
338
+ * the ordinary news of a ticket branch never pushed: said quietly, no
339
+ * ref left behind. Anything else is a failed transfer, and it throws.
340
+ */
341
+ async fetchBranch(repo, directory, branch) {
342
+ const transport = await this.transfer(repo);
343
+ const fetched = await this.git(directory, ['fetch', '--quiet', transport.url, `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { env: transport.env });
344
+ if (fetched.ok)
345
+ return;
346
+ if (/couldn't find remote ref/i.test(fetched.message)) {
347
+ this.log.debug('branch.absent', { repo: repo.id, branch });
348
+ return;
349
+ }
350
+ throw new Error(`forge-github: fetching ${branch} from ${repo.id} failed: ${fetched.message}`);
351
+ }
352
+ /**
353
+ * Move aivi's commits to the remote, as the app and never as the person at
354
+ * the keyboard. Plain when the remote fast-forwards; with a
355
+ * `--force-with-lease` keyed on the commit the orchestrator just fetched
356
+ * when the divergence is the worker's own rewrite — the force replaces
357
+ * exactly that commit and nothing a person added since.
358
+ */
359
+ async push(repo, worktree, branch, options = {}) {
360
+ const transport = await this.transfer(repo);
361
+ const pushed = await this.git(worktree, [
362
+ 'push',
363
+ '--quiet',
364
+ ...(options.lease ? [`--force-with-lease=refs/heads/${branch}:${options.lease}`] : []),
365
+ transport.url,
366
+ `HEAD:refs/heads/${branch}`,
367
+ ], { env: transport.env });
368
+ if (!pushed.ok)
369
+ throw new Error(`forge-github: pushing ${branch} to ${repo.id} failed${options.lease ? ' (the lease no longer matches: the remote moved since the last fetch)' : ''}: ${pushed.message}`);
370
+ this.log.info('branch.pushed', { repo: repo.id, branch, ...(options.lease ? { forced: true } : {}) });
371
+ }
372
+ /**
373
+ * Open the pull request that stands for the branch. The base is the
374
+ * repository's own default branch: that is where a pull request belongs
375
+ * unless a person said otherwise, and aivi has no place to have been
376
+ * told.
377
+ */
378
+ async openPr(repo, branch, pr) {
379
+ const { owner, repo: name } = address(repo);
380
+ const target = await this.app.repository(owner, name);
381
+ if (!target)
382
+ throw new Error(`forge-github: ${repo.id} is not readable through installation #${this.app.installationId}, so a pull request has no base`);
383
+ const { data: created } = await this.app.octokit.rest.pulls.create({
384
+ owner,
385
+ repo: name,
386
+ head: branch,
387
+ base: target.defaultBranch,
388
+ title: pr.title,
389
+ body: signComment(pr.body, pr.author),
390
+ });
391
+ this.log.info('pull.opened', { repo: repo.id, pull: created.number, worker: pr.author });
392
+ return prFacts(created);
393
+ }
394
+ /** Every branch, pruned, refs only: the fresh view `aivi_sync` gives the
395
+ * worker and the push's lease starts from. Same transfer as every
396
+ * other — the URL this forge named, the credential in the environment,
397
+ * nothing left in the checkout's config. */
398
+ async fetchRefs(repo, directory) {
399
+ const transport = await this.transfer(repo);
400
+ const fetched = await this.git(directory, ['fetch', '--prune', '--quiet', transport.url, '+refs/heads/*:refs/remotes/origin/*'], { env: transport.env });
401
+ if (!fetched.ok)
402
+ throw new Error(`forge-github: fetching ${repo.id} failed: ${fetched.message}`);
403
+ }
404
+ }
405
+ /** The forge for one installed app. */
406
+ /** The forge over one authenticated app: the API reads and the git transfers.
407
+ * `git` is the disk crossing a test answers; production leaves it alone. */
408
+ export function createGitHubForge(app, options = {}) {
409
+ return new GitHubForge(app, options);
410
+ }
@@ -0,0 +1,7 @@
1
+ export type { ForgeGithubConfig } from './config.ts';
2
+ export { forgeGithubSchema, plugin } from './config.ts';
3
+ export { createGitHubForge, GitHubForge } from './forge.ts';
4
+ export { createForgeGithubModule } from './module.ts';
5
+ export type { GitHubRemote } from './remote.ts';
6
+ export { GITHUB_HOST, httpsRemote, isGitHub, parseRemote } from './remote.ts';
7
+ export { suggestedProjectId } from './setup-project.ts';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { forgeGithubSchema, plugin } from './config.js';
2
+ export { createGitHubForge, GitHubForge } from './forge.js';
3
+ export { createForgeGithubModule } from './module.js';
4
+ export { GITHUB_HOST, httpsRemote, isGitHub, parseRemote } from './remote.js';
5
+ export { suggestedProjectId } from './setup-project.js';
@@ -0,0 +1,2 @@
1
+ import type { AiviModule } from '@aivi/plugin';
2
+ export declare function createForgeGithubModule(): AiviModule;
package/dist/module.js ADDED
@@ -0,0 +1,41 @@
1
+ /** The `forge-github` module: the app, proven at boot, registered, standing by.
2
+ *
3
+ * The module's work is to **prove the credential at boot and register the
4
+ * answer**: an app whose key was rotated, an installation that was revoked
5
+ * or was never made, a grant that turned into two — each is said once,
6
+ * loudly, where `/status` shows it, rather than at the first push a worker
7
+ * is waiting on. The credential is the environment's (`GITHUB_APP_ID`,
8
+ * `GITHUB_APP_PRIVATE_KEY` in `<home>/.env`), shared with every other plugin
9
+ * that speaks GitHub (ruled 2026-10-07). The registry question — *"who owns
10
+ * this project's remote?"* — is asked now (built 2026-10-02): the projects-sync
11
+ * task was the first, and a checkout whose `origin` this app recognises syncs
12
+ * through this forge, authenticated as its own installation. The push asks
13
+ * it today — the worker's `aivi_pr` call ends here — and the review gather
14
+ * will ask it when it comes.
15
+ */
16
+ import { githubApp } from '@aivi/github';
17
+ import { createGitHubForge } from './forge.js';
18
+ async function startForge(services) {
19
+ const log = services.log.getChild('forge-github');
20
+ // Every failure here is a ConfigurationError naming what to fix on GitHub or
21
+ // in .env: the module goes to error, `/status` says so, and nothing retries.
22
+ // The client is the process's one app: a tracker module started beside this
23
+ // one shares the connection rather than paying the JWT and installation
24
+ // reads again.
25
+ const app = await githubApp({ log });
26
+ const unregister = services.forges.register(createGitHubForge(app, { log }));
27
+ log.info('ready', {
28
+ installation: app.installationId,
29
+ account: app.accountLogin,
30
+ note: 'registered on the forge registry; the host asks who owns each remote',
31
+ });
32
+ return {
33
+ // No routes, no tasks, no timers: only the registration to give back.
34
+ stop: async () => {
35
+ unregister();
36
+ },
37
+ };
38
+ }
39
+ export function createForgeGithubModule() {
40
+ return { id: 'forge-github', start: services => startForge(services) };
41
+ }
@@ -0,0 +1,45 @@
1
+ /** The facts aivi reads off a git remote's URL. Kept out of `github.ts`
2
+ * because none of it needs a credential, a network, or octokit: this is what
3
+ * a checkout says about itself, and what aivi answers with.
4
+ *
5
+ * What aivi answers with matters because an installation token authenticates
6
+ * **HTTPS and nothing else** — GitHub's ssh transport takes a key, which is a
7
+ * human's credential. So aivi never transfers through a checkout's own
8
+ * `origin`: it names the HTTPS URL of the same repository on the command line,
9
+ * beside the credential it supplies. A person who cloned with ssh keeps their
10
+ * `origin` exactly as they left it, and no byte of aivi's credential is
11
+ * written to the repository's config.
12
+ */
13
+ /** One repository, as its remote names it. `host` is lower-cased (DNS is);
14
+ * `owner` and `repo` keep their case, which GitHub treats as display and
15
+ * resolves either way. */
16
+ export interface GitHubRemote {
17
+ host: string;
18
+ owner: string;
19
+ repo: string;
20
+ /** True when the URL is an ssh form — the transport aivi cannot use. */
21
+ ssh: boolean;
22
+ }
23
+ /** The one host aivi speaks to as GitHub. GitHub Enterprise Server is a
24
+ * different host with a different install, not a detail of this parse. */
25
+ export declare const GITHUB_HOST = "github.com";
26
+ /**
27
+ * Read a remote URL for what aivi can use: `owner/repo` and which transport it
28
+ * names. Understands the scp-like ssh form (`git@github.com:owner/repo.git`),
29
+ * `ssh://`, `https://` and `http://`, each with or without the `.git` suffix,
30
+ * and the bare `owner/repo` shorthand a person types for a GitHub repository
31
+ * (no transport to speak of, so it reads as https). Undefined for anything
32
+ * that is not a two-segment repository path — a repository on another host,
33
+ * a URL with a port or a nested path, or a local path, which is a checkout
34
+ * with no remote to speak of.
35
+ */
36
+ export declare function parseRemote(url: string): GitHubRemote | undefined;
37
+ /** True when the remote is GitHub's own host — the only one this forge
38
+ * claims. Another host is another forge's turn, said silently: a project on
39
+ * GitLab is not an error here. */
40
+ export declare function isGitHub(remote: GitHubRemote): boolean;
41
+ /** The URL aivi authenticates: the same repository over HTTPS. Every transfer
42
+ * names this URL on the command line instead of a remote's name, so the
43
+ * credential aivi supplies is the one git uses — no rewrite of the checkout's
44
+ * own config, and no chance of a stored credential being preferred instead. */
45
+ export declare function httpsRemote(remote: GitHubRemote): string;
package/dist/remote.js ADDED
@@ -0,0 +1,92 @@
1
+ /** The facts aivi reads off a git remote's URL. Kept out of `github.ts`
2
+ * because none of it needs a credential, a network, or octokit: this is what
3
+ * a checkout says about itself, and what aivi answers with.
4
+ *
5
+ * What aivi answers with matters because an installation token authenticates
6
+ * **HTTPS and nothing else** — GitHub's ssh transport takes a key, which is a
7
+ * human's credential. So aivi never transfers through a checkout's own
8
+ * `origin`: it names the HTTPS URL of the same repository on the command line,
9
+ * beside the credential it supplies. A person who cloned with ssh keeps their
10
+ * `origin` exactly as they left it, and no byte of aivi's credential is
11
+ * written to the repository's config.
12
+ */
13
+ /** The `.git` transport suffix, which names nothing. */
14
+ const stripSuffix = (path) => (path.endsWith('.git') ? path.slice(0, -4) : path);
15
+ /** The one host aivi speaks to as GitHub. GitHub Enterprise Server is a
16
+ * different host with a different install, not a detail of this parse. */
17
+ export const GITHUB_HOST = 'github.com';
18
+ /** The shorthand a person types when they mean a GitHub repository: two
19
+ * path segments and nothing else. GitHub's own names are letters, digits,
20
+ * dots, dashes and underscores; a lone `.` or `..` is a relative path, so
21
+ * the segments may not be all-dots. */
22
+ const BARE = /^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/;
23
+ const bareRemote = (raw) => {
24
+ const bare = BARE.exec(raw);
25
+ if (!bare)
26
+ return undefined;
27
+ const [, owner, repo] = bare;
28
+ if (/^\.+$/.test(owner ?? '') || /^\.+$/.test(repo ?? ''))
29
+ return undefined;
30
+ return { host: GITHUB_HOST, owner: owner, repo: stripSuffix(repo), ssh: false };
31
+ };
32
+ /**
33
+ * Read a remote URL for what aivi can use: `owner/repo` and which transport it
34
+ * names. Understands the scp-like ssh form (`git@github.com:owner/repo.git`),
35
+ * `ssh://`, `https://` and `http://`, each with or without the `.git` suffix,
36
+ * and the bare `owner/repo` shorthand a person types for a GitHub repository
37
+ * (no transport to speak of, so it reads as https). Undefined for anything
38
+ * that is not a two-segment repository path — a repository on another host,
39
+ * a URL with a port or a nested path, or a local path, which is a checkout
40
+ * with no remote to speak of.
41
+ */
42
+ export function parseRemote(url) {
43
+ const raw = url.trim();
44
+ if (!raw || raw.startsWith('-'))
45
+ return undefined;
46
+ // `git@github.com:owner/repo.git` — scp syntax, no scheme to parse.
47
+ const scp = /^([^/@\s]+)@([^/:]+):(.+)$/.exec(raw);
48
+ if (scp && !raw.includes('://')) {
49
+ const [, , host, path] = scp;
50
+ const segments = stripSuffix(path ?? '')
51
+ .split('/')
52
+ .filter(Boolean);
53
+ if (!host || segments.length !== 2)
54
+ return undefined;
55
+ return { host: host.toLowerCase(), owner: segments[0], repo: segments[1], ssh: true };
56
+ }
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(raw);
60
+ }
61
+ catch {
62
+ // No scheme to parse: the only other thing that names a repository is the
63
+ // bare `owner/repo`, which can only mean the one host this forge speaks.
64
+ return bareRemote(raw);
65
+ }
66
+ if (!['https:', 'http:', 'ssh:'].includes(parsed.protocol))
67
+ return undefined;
68
+ if (parsed.port)
69
+ return undefined;
70
+ const segments = stripSuffix(parsed.pathname).split('/').filter(Boolean);
71
+ if (segments.length !== 2)
72
+ return undefined;
73
+ return {
74
+ host: parsed.hostname.toLowerCase(),
75
+ owner: segments[0],
76
+ repo: segments[1],
77
+ ssh: parsed.protocol === 'ssh:',
78
+ };
79
+ }
80
+ /** True when the remote is GitHub's own host — the only one this forge
81
+ * claims. Another host is another forge's turn, said silently: a project on
82
+ * GitLab is not an error here. */
83
+ export function isGitHub(remote) {
84
+ return remote.host === GITHUB_HOST;
85
+ }
86
+ /** The URL aivi authenticates: the same repository over HTTPS. Every transfer
87
+ * names this URL on the command line instead of a remote's name, so the
88
+ * credential aivi supplies is the one git uses — no rewrite of the checkout's
89
+ * own config, and no chance of a stored credential being preferred instead. */
90
+ export function httpsRemote(remote) {
91
+ return `https://${GITHUB_HOST}/${remote.owner}/${remote.repo}.git`;
92
+ }
@@ -0,0 +1,7 @@
1
+ import { type ProjectContributor } from '@aivi/plugin';
2
+ /** The project name a repository suggests: GitHub's `My-Repo` becomes aivi's
3
+ * `my-repo`, the shape a project directory takes. Anything the id rule does
4
+ * not allow becomes a dash, and a name that no longer starts with a letter
5
+ * keeps one. */
6
+ export declare function suggestedProjectId(repoName: string): string;
7
+ export declare const contributor: ProjectContributor;
@@ -0,0 +1,138 @@
1
+ /** The forge's part in `aivi projects add`: what repository this project is,
2
+ * and its checkout. The runner walks core's roles and calls this for the
3
+ * `forge` role; everything GitHub-specific lives in this file, and the
4
+ * questions are asked with the runner's own clack.
5
+ *
6
+ * The clone is the reason the role is the forge's and not aivi's: cloning
7
+ * reaches `origin`, and reaching `origin` authenticates. So aivi clones as
8
+ * **its own app** through the one installation token, in the command's own
9
+ * environment — the person's keychain is never offered, and no token is left
10
+ * in the checkout for someone to find later.
11
+ */
12
+ import { mkdir, stat } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+ import { getLogger, PROJECT_ID } from '@aivi/core';
15
+ import { gitCredential, githubApp, runGit } from '@aivi/github';
16
+ import { PluginSetupCancelled } from '@aivi/plugin';
17
+ import { httpsRemote, isGitHub, parseRemote } from './remote.js';
18
+ /** The project name a repository suggests: GitHub's `My-Repo` becomes aivi's
19
+ * `my-repo`, the shape a project directory takes. Anything the id rule does
20
+ * not allow becomes a dash, and a name that no longer starts with a letter
21
+ * keeps one. */
22
+ export function suggestedProjectId(repoName) {
23
+ const folded = repoName.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
24
+ return /^[a-z]/.test(folded) ? folded : `p-${folded}`;
25
+ }
26
+ /** A prompt the person cancelled is not an answer: nothing further is written
27
+ * and the runner says the one cancel line. */
28
+ async function settled(ctx, answer) {
29
+ const value = await answer;
30
+ if (ctx.prompts.isCancel(value))
31
+ throw new PluginSetupCancelled('a prompt was cancelled');
32
+ return value;
33
+ }
34
+ /** The repository asked for and read for what it is. Undefined when the answer
35
+ * names no GitHub repository at all: a non-GitHub host is said plainly, since
36
+ * a person typing a GitLab URL means it, and silence would lose the project.
37
+ *
38
+ * The app is asked what it was granted before the person is asked anything:
39
+ * a pick-list of the real grant beats typing a name from memory, and a
40
+ * mistake is impossible. A grant that cannot be listed is no failure — the
41
+ * typed answer's own probe referees it anyway — so the text prompt stays as
42
+ * the fallback, asking the very question the list would have. */
43
+ async function askRepository(ctx, app) {
44
+ const spinner = ctx.prompts.spinner();
45
+ spinner.start('Asking the app which repositories it can see');
46
+ let granted = [];
47
+ try {
48
+ granted = (await app.repositories()).map(repo => repo.id);
49
+ spinner.stop(`The app can see ${granted.length} repositor${granted.length === 1 ? 'y' : 'ies'}`);
50
+ }
51
+ catch (error) {
52
+ spinner.stop(`Could not list the app's repositories: ${error instanceof Error ? error.message : error}`);
53
+ }
54
+ const answer = granted.length
55
+ ? await settled(ctx, ctx.prompts.autocomplete({
56
+ message: 'Which GitHub repository is this project?',
57
+ options: granted.map(id => ({ value: id, label: id })),
58
+ maxItems: 12,
59
+ }))
60
+ : await settled(ctx, ctx.prompts.text({
61
+ message: 'Which GitHub repository is this project?',
62
+ placeholder: 'owner/repo, or its URL',
63
+ validate: value => (parseRemote(value ?? '') ? undefined : 'Give it as owner/repo, or as a github.com URL'),
64
+ }));
65
+ const remote = parseRemote(String(answer).trim());
66
+ if (!remote)
67
+ return undefined;
68
+ if (!isGitHub(remote))
69
+ throw new Error(`${remote.host} is not github.com, and this forge speaks github.com repositories only. Clone that project by hand, or point aivi at a different forge.`);
70
+ return remote;
71
+ }
72
+ export const contributor = {
73
+ role: 'forge',
74
+ async setup(ctx) {
75
+ const log = getLogger(['aivi', 'forge-github']);
76
+ // The app is the home's: the credential comes from <home>/.env, the same
77
+ // one the forge module and the tracker speak through (ruled 2026-10-07).
78
+ const app = await githubApp({ log });
79
+ const remote = await askRepository(ctx, app);
80
+ if (!remote)
81
+ throw new Error('no repository named');
82
+ const id = `${remote.owner}/${remote.repo}`;
83
+ // Proven under the grant before a directory is made: GitHub answers 404 for
84
+ // a repository the app was not given, so this is also the answer to "is
85
+ // this repository in the installation's set".
86
+ const seen = await app.repository(remote.owner, remote.repo);
87
+ if (!seen)
88
+ throw new Error(`the app ${app.appSlug} cannot see ${id} through installation #${app.installationId}. Grant it that repository on GitHub and run this again.`);
89
+ await ctx.prompts.log.message(`The app reads ${id} (default branch ${seen.defaultBranch}).`);
90
+ const suggestion = suggestedProjectId(remote.repo);
91
+ // Enter takes the suggested name. clack 1.x runs `validate` on the raw
92
+ // input **before** `finalize` applies `defaultValue`, so a validate
93
+ // that refuses '' makes the default dead code. '' passes validation
94
+ // here; the default is folded in below, where it takes effect.
95
+ let projectId = ctx.projectId ? String(ctx.projectId) : '';
96
+ if (!projectId) {
97
+ const answer = await settled(ctx, ctx.prompts.text({
98
+ message: 'Project name',
99
+ placeholder: suggestion,
100
+ defaultValue: suggestion,
101
+ validate: value => value === undefined || !value.trim() || PROJECT_ID.test(value.trim())
102
+ ? undefined
103
+ : 'lowercase letters, digits, dashes and underscores, starting with a letter',
104
+ }));
105
+ projectId = String(answer).trim() || suggestion;
106
+ }
107
+ const source = join(ctx.home, 'projects', projectId, 'source');
108
+ // A checkout already there belongs to its owner. The same repository means
109
+ // aivi found what it was looking for and takes it as it stands; a different
110
+ // one means aivi would be writing a stranger's remote into a person's
111
+ // working copy, which is a decision and never a default.
112
+ if (await stat(join(source, '.git')).catch(() => null)) {
113
+ const origin = await runGit(source, ['remote', 'get-url', 'origin']);
114
+ const found = origin.ok ? parseRemote(origin.stdout) : undefined;
115
+ if (found && `${found.owner}/${found.repo}` === id) {
116
+ await ctx.prompts.log.message(`projects/${projectId}/source is already a checkout of ${id}; left as it is.`);
117
+ return { id: projectId, cloned: true, repository: id };
118
+ }
119
+ throw new Error(`projects/${projectId}/source is a checkout of ${found ? `${found.owner}/${found.repo}` : 'something that is not a GitHub repository'}. Move it aside, or choose a project name that has no checkout yet.`);
120
+ }
121
+ const credential = gitCredential(await app.gitToken());
122
+ await mkdir(dirname(source), { recursive: true });
123
+ const spinner = ctx.prompts.spinner();
124
+ spinner.start(`cloning ${id}…`);
125
+ const cloned = await runGit(dirname(source), ['clone', '--quiet', httpsRemote(remote), source], {
126
+ env: credential,
127
+ });
128
+ // A clone that failed leaves a project with no checkout, and the runner
129
+ // would then write its "no source" note over the half-made directory: the
130
+ // flow stops here rather than reporting a project that is not one.
131
+ if (!cloned.ok) {
132
+ spinner.stop(`could not clone ${id}: ${cloned.message}`);
133
+ throw new Error(`clone of ${id} failed: ${cloned.message}`);
134
+ }
135
+ spinner.stop(`Cloned ${id} into projects/${projectId}/source.`);
136
+ return { id: projectId, cloned: true, repository: id };
137
+ },
138
+ };
@@ -0,0 +1,23 @@
1
+ /** The setup `aivi add @aivi/forge-github` runs. It guides what a person has
2
+ * to do on GitHub — create the app, give aivi its key, select the
3
+ * repositories — and then proves each fact at GitHub before writing a byte:
4
+ * that the id and the key belong to one app, that exactly one installation
5
+ * exists, and that the installation really reads a repository, by reading
6
+ * that repository's pull requests through it.
7
+ *
8
+ * The app's identity lands in `<home>/.env` — `GITHUB_APP_ID` beside
9
+ * `GITHUB_APP_PRIVATE_KEY` (ruled 2026-10-07): the forge and the tracker are
10
+ * one app, so the credential is a home fact, not a plugin block's. The
11
+ * guide and its two questions are therefore `@aivi/github`'s (`appIdentity`),
12
+ * shared with the tracker's installer; when the home already holds the
13
+ * identity, nothing is asked. The block written to `config.json` is empty:
14
+ * it only marks the forge as configured, which is what makes core offer the
15
+ * forge role to a new project.
16
+ *
17
+ * The installation is a human's act and aivi cannot do it: no throwaway
18
+ * ticket, no write, is asked of a person to prove the grant — one read is
19
+ * enough. Writes are proven once, at the live gate.
20
+ */
21
+ import type { PluginSetup } from '@aivi/plugin';
22
+ declare const setup: PluginSetup;
23
+ export default setup;
package/dist/setup.js ADDED
@@ -0,0 +1,62 @@
1
+ /** The setup `aivi add @aivi/forge-github` runs. It guides what a person has
2
+ * to do on GitHub — create the app, give aivi its key, select the
3
+ * repositories — and then proves each fact at GitHub before writing a byte:
4
+ * that the id and the key belong to one app, that exactly one installation
5
+ * exists, and that the installation really reads a repository, by reading
6
+ * that repository's pull requests through it.
7
+ *
8
+ * The app's identity lands in `<home>/.env` — `GITHUB_APP_ID` beside
9
+ * `GITHUB_APP_PRIVATE_KEY` (ruled 2026-10-07): the forge and the tracker are
10
+ * one app, so the credential is a home fact, not a plugin block's. The
11
+ * guide and its two questions are therefore `@aivi/github`'s (`appIdentity`),
12
+ * shared with the tracker's installer; when the home already holds the
13
+ * identity, nothing is asked. The block written to `config.json` is empty:
14
+ * it only marks the forge as configured, which is what makes core offer the
15
+ * forge role to a new project.
16
+ *
17
+ * The installation is a human's act and aivi cannot do it: no throwaway
18
+ * ticket, no write, is asked of a person to prove the grant — one read is
19
+ * enough. Writes are proven once, at the live gate.
20
+ */
21
+ import { getLogger } from '@aivi/core';
22
+ import { appIdentity, GitHubApp } from '@aivi/github';
23
+ const setup = async (ctx) => {
24
+ const log = getLogger(['aivi', 'forge-github']);
25
+ ctx.prompts.note([
26
+ 'aivi works on GitHub as its own app, never as you:',
27
+ 'it opens its pull requests and answers review comments as the bot,',
28
+ 'so the work in front of a reviewer is aivi’s and the credit is yours.',
29
+ '',
30
+ 'You create the app on GitHub and install it on the account that',
31
+ 'holds the repositories. aivi speaks through that one installation.',
32
+ 'It cannot do this part, and it will not write anything until it',
33
+ 'has read a real pull request through the grant.',
34
+ ].join('\n'), 'The GitHub app aivi works as');
35
+ // The guide, the App ID and the .pem are the library's (one app, one set of
36
+ // questions): when the home already holds the identity, nothing is asked.
37
+ const { appId, privateKey } = await appIdentity(ctx);
38
+ // The proof below runs in this process, which read `.env` before the key was
39
+ // in it: hand the credential to the connection directly, so what is tested
40
+ // here is the bytes that were just written.
41
+ const app = await GitHubApp.connect({ log, credentials: { appId, privateKey } });
42
+ await ctx.prompts.log.message(`The key is app ${app.appSlug}’s, installed on ${app.accountLogin} as installation #${app.installationId}.`);
43
+ // The proof that matters is a read the grant really allows. A repository the
44
+ // app was not given answers 404, so this is also the answer to "is it in the
45
+ // installation’s set" — and an installation that reaches no repository is a
46
+ // grant that has not been aimed at anything.
47
+ const { data: grant } = await app.octokit.rest.apps.listReposAccessibleToInstallation({ per_page: 100 });
48
+ if (!grant.repositories.length)
49
+ throw new Error(`installation #${app.installationId} reaches no repository: on the installation page, choose "Only select repositories" and select the repositories you want to use aivi in`);
50
+ const first = grant.repositories.find(r => !r.archived) ?? grant.repositories[0];
51
+ const [owner = '', name = ''] = first.full_name.split('/');
52
+ const { data: pulls } = await app.octokit.rest.pulls.list({ owner, repo: name, state: 'all', per_page: 3 });
53
+ await ctx.prompts.log.message(`It reaches ${grant.total_count >= 100 ? 'more than 100' : grant.total_count} repositories; ${first.full_name} answered with ${pulls.length} pull ${pulls.length === 1 ? 'request' : 'requests'}.`);
54
+ // The block is empty and says so in the summary: the identity lives in
55
+ // .env, and this byte in config.json only marks the forge as configured.
56
+ await ctx.writeConfigBlock(['plugins', 'forge-github'], {});
57
+ return {
58
+ module: 'forge-github',
59
+ summary: `The GitHub forge is set up: app ${app.appSlug} works as installation #${app.installationId} on ${app.accountLogin} (identity in .env), and read ${first.full_name} through it.`,
60
+ };
61
+ };
62
+ export default setup;
package/docs/github.md ADDED
@@ -0,0 +1,33 @@
1
+ # GitHub forge — the forge's configuration
2
+
3
+ `plugins.forge-github` is the GitHub forge's whole setup, and it is **empty**
4
+ (ruled 2026-10-07): `{ "plugins": { "forge-github": {} } }`. The block stands
5
+ as the mark that the forge is configured — core offers the forge role to a
6
+ project only when it is written — and holds no facts. The module runs when
7
+ `@aivi/forge-github` stands in the `aivi-plugins` list.
8
+
9
+ The app's identity is a **home** fact, not a block's: `GITHUB_APP_ID` (the
10
+ `App ID` number on the app's settings page) and `GITHUB_APP_PRIVATE_KEY` (the
11
+ PEM GitHub handed out when the app was created) both live in `<home>/.env`,
12
+ because the forge and the tracker are **one app** and a plugin block cannot be
13
+ the shared source for two plugins. `aivi add` asks for the id and the .pem
14
+ once: it copies the key into `.env`, and remembers no path.
15
+ The app must also be **installed** — an installation is the grant from the account
16
+ holding the repositories to the app — and aivi speaks through exactly one:
17
+ none is an error carrying the install link, several is an error naming the
18
+ grants and saying which to revoke.
19
+
20
+ Nothing in this block names a repository. Which repository a project is comes
21
+ from that project's own `origin`, and a checkout whose remote is not a GitHub
22
+ repository simply has no forge: no error, and no pull-request facts. Likewise
23
+ there is no per-project forge section — a forge is asked about a checkout it
24
+ did not create and reads the answer off the checkout. What asks it today is
25
+ the `projects-sync` job (a checkout this forge recognises syncs **through**
26
+ the forge, authenticated as its own installation; the rest stays plain git)
27
+ and the worker's git tools: `aivi_sync` brings the remote's refs in,
28
+ `aivi_push` moves commits (as the forge's own app, fast-forwarding, merging,
29
+ or forcing only a patch-equivalent rewrite), and `aivi_pr` opens the pull
30
+ request when the branch has none; `aivi_review`, `aivi_respond_feedback` and
31
+ `aivi_submit_review` carry the review conversation the same way. A project
32
+ with no forge gets none of these, and the tools say so plainly when they
33
+ are called anyway.
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@aivi/forge-github",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.0",
4
+ "description": "The GitHub forge for aivi: the remote a project came from, the pull requests over its branches, and the review conversation on them.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "development": "./src/index.ts",
9
+ "default": "./dist/index.js"
10
+ },
11
+ "./config": {
12
+ "development": "./src/config.ts",
13
+ "default": "./dist/config.js"
14
+ },
15
+ "./setup": {
16
+ "development": "./src/setup.ts",
17
+ "default": "./dist/setup.js"
18
+ },
19
+ "./setupProject": {
20
+ "development": "./src/setup-project.ts",
21
+ "default": "./dist/setup-project.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "docs"
27
+ ],
28
+ "engines": {
29
+ "node": ">=26.1.0 <27"
30
+ },
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/aivi-hq/aivi.git",
35
+ "directory": "packages/forge-github"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "prepack": "tsc -b tsconfig.build.json"
42
+ },
43
+ "dependencies": {
44
+ "@aivi/core": "^0.7.1",
45
+ "@aivi/github": "^0.0.0",
46
+ "@aivi/plugin": "^0.1.0",
47
+ "zod": "4.1.13"
48
+ },
49
+ "peerDependencies": {
50
+ "@aivi/host": "^0.8.1"
51
+ },
52
+ "devDependencies": {
53
+ "@aivi/host": "^0.8.1"
54
+ }
55
+ }