@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 +44 -2
- package/dist/config.d.ts +35 -0
- package/dist/config.js +27 -0
- package/dist/forge.d.ts +117 -0
- package/dist/forge.js +410 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/module.d.ts +2 -0
- package/dist/module.js +41 -0
- package/dist/remote.d.ts +45 -0
- package/dist/remote.js +92 -0
- package/dist/setup-project.d.ts +7 -0
- package/dist/setup-project.js +138 -0
- package/dist/setup.d.ts +23 -0
- package/dist/setup.js +62 -0
- package/docs/github.md +33 -0
- package/package.json +53 -4
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aivi/forge-github
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/forge.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|
package/dist/module.d.ts
ADDED
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
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/setup.d.ts
ADDED
|
@@ -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
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|