@nanonets/graft 0.16.0 → 0.18.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.
Files changed (143) hide show
  1. package/README.md +9 -2
  2. package/dist/app/brain-build.d.ts +66 -0
  3. package/dist/app/brain-build.d.ts.map +1 -0
  4. package/dist/app/brain-build.js +169 -0
  5. package/dist/app/brain-build.js.map +1 -0
  6. package/dist/app/checkout.d.ts +53 -3
  7. package/dist/app/checkout.d.ts.map +1 -1
  8. package/dist/app/checkout.js +195 -27
  9. package/dist/app/checkout.js.map +1 -1
  10. package/dist/app/events.d.ts +7 -0
  11. package/dist/app/events.d.ts.map +1 -1
  12. package/dist/app/events.js +7 -2
  13. package/dist/app/events.js.map +1 -1
  14. package/dist/app/history.d.ts +128 -0
  15. package/dist/app/history.d.ts.map +1 -0
  16. package/dist/app/history.js +269 -0
  17. package/dist/app/history.js.map +1 -0
  18. package/dist/app/identity.d.ts +33 -0
  19. package/dist/app/identity.d.ts.map +1 -1
  20. package/dist/app/identity.js +72 -0
  21. package/dist/app/identity.js.map +1 -1
  22. package/dist/app/main.js +12 -0
  23. package/dist/app/main.js.map +1 -1
  24. package/dist/app/pages.d.ts +17 -0
  25. package/dist/app/pages.d.ts.map +1 -1
  26. package/dist/app/pages.js +112 -5
  27. package/dist/app/pages.js.map +1 -1
  28. package/dist/app/review-process.d.ts +66 -0
  29. package/dist/app/review-process.d.ts.map +1 -0
  30. package/dist/app/review-process.js +199 -0
  31. package/dist/app/review-process.js.map +1 -0
  32. package/dist/app/review-worker.d.ts +2 -0
  33. package/dist/app/review-worker.d.ts.map +1 -0
  34. package/dist/app/review-worker.js +108 -0
  35. package/dist/app/review-worker.js.map +1 -0
  36. package/dist/app/review.d.ts.map +1 -1
  37. package/dist/app/review.js +4 -1
  38. package/dist/app/review.js.map +1 -1
  39. package/dist/app/server.d.ts +22 -2
  40. package/dist/app/server.d.ts.map +1 -1
  41. package/dist/app/server.js +77 -3
  42. package/dist/app/server.js.map +1 -1
  43. package/dist/app/sources.d.ts +68 -0
  44. package/dist/app/sources.d.ts.map +1 -0
  45. package/dist/app/sources.js +341 -0
  46. package/dist/app/sources.js.map +1 -0
  47. package/dist/ask/ask.d.ts +6 -0
  48. package/dist/ask/ask.d.ts.map +1 -1
  49. package/dist/ask/ask.js +30 -2
  50. package/dist/ask/ask.js.map +1 -1
  51. package/dist/brain/attach.d.ts +44 -0
  52. package/dist/brain/attach.d.ts.map +1 -0
  53. package/dist/brain/attach.js +63 -0
  54. package/dist/brain/attach.js.map +1 -0
  55. package/dist/brain/connect.d.ts +59 -0
  56. package/dist/brain/connect.d.ts.map +1 -0
  57. package/dist/brain/connect.js +80 -0
  58. package/dist/brain/connect.js.map +1 -0
  59. package/dist/brain/link.d.ts +82 -0
  60. package/dist/brain/link.d.ts.map +1 -0
  61. package/dist/brain/link.js +136 -0
  62. package/dist/brain/link.js.map +1 -0
  63. package/dist/brain/push.d.ts +72 -0
  64. package/dist/brain/push.d.ts.map +1 -0
  65. package/dist/brain/push.js +191 -0
  66. package/dist/brain/push.js.map +1 -0
  67. package/dist/brain/wire.d.ts +38 -0
  68. package/dist/brain/wire.d.ts.map +1 -0
  69. package/dist/brain/wire.js +133 -0
  70. package/dist/brain/wire.js.map +1 -0
  71. package/dist/claude/format.d.ts.map +1 -1
  72. package/dist/claude/format.js +10 -3
  73. package/dist/claude/format.js.map +1 -1
  74. package/dist/claude/hooks.d.ts.map +1 -1
  75. package/dist/claude/hooks.js +32 -1
  76. package/dist/claude/hooks.js.map +1 -1
  77. package/dist/claude/init.d.ts +5 -0
  78. package/dist/claude/init.d.ts.map +1 -1
  79. package/dist/claude/init.js +9 -1
  80. package/dist/claude/init.js.map +1 -1
  81. package/dist/claude/session-metrics.d.ts +10 -0
  82. package/dist/claude/session-metrics.d.ts.map +1 -1
  83. package/dist/claude/session-metrics.js +25 -0
  84. package/dist/claude/session-metrics.js.map +1 -1
  85. package/dist/claude/settings-merge.d.ts +18 -0
  86. package/dist/claude/settings-merge.d.ts.map +1 -1
  87. package/dist/claude/settings-merge.js +42 -8
  88. package/dist/claude/settings-merge.js.map +1 -1
  89. package/dist/claude/skill-template.d.ts.map +1 -1
  90. package/dist/claude/skill-template.js +6 -0
  91. package/dist/claude/skill-template.js.map +1 -1
  92. package/dist/claude/state.d.ts +14 -0
  93. package/dist/claude/state.d.ts.map +1 -1
  94. package/dist/claude/state.js.map +1 -1
  95. package/dist/claude/tally.d.ts +27 -0
  96. package/dist/claude/tally.d.ts.map +1 -1
  97. package/dist/claude/tally.js +74 -1
  98. package/dist/claude/tally.js.map +1 -1
  99. package/dist/cli.js +210 -3
  100. package/dist/cli.js.map +1 -1
  101. package/dist/context/price.d.ts +49 -0
  102. package/dist/context/price.d.ts.map +1 -0
  103. package/dist/context/price.js +83 -0
  104. package/dist/context/price.js.map +1 -0
  105. package/dist/context/savings.d.ts +13 -3
  106. package/dist/context/savings.d.ts.map +1 -1
  107. package/dist/context/savings.js +44 -5
  108. package/dist/context/savings.js.map +1 -1
  109. package/dist/hosts/claude-global.d.ts +33 -0
  110. package/dist/hosts/claude-global.d.ts.map +1 -0
  111. package/dist/hosts/claude-global.js +118 -0
  112. package/dist/hosts/claude-global.js.map +1 -0
  113. package/dist/hosts/plan.d.ts.map +1 -1
  114. package/dist/hosts/plan.js +5 -1
  115. package/dist/hosts/plan.js.map +1 -1
  116. package/dist/hosts/retract.d.ts.map +1 -1
  117. package/dist/hosts/retract.js +33 -13
  118. package/dist/hosts/retract.js.map +1 -1
  119. package/dist/hosts/sections.d.ts +23 -2
  120. package/dist/hosts/sections.d.ts.map +1 -1
  121. package/dist/hosts/sections.js +25 -9
  122. package/dist/hosts/sections.js.map +1 -1
  123. package/dist/mcp/server.d.ts.map +1 -1
  124. package/dist/mcp/server.js +33 -4
  125. package/dist/mcp/server.js.map +1 -1
  126. package/dist/mcp/tools.d.ts.map +1 -1
  127. package/dist/mcp/tools.js +5 -1
  128. package/dist/mcp/tools.js.map +1 -1
  129. package/dist/telemetry/gate.d.ts +14 -0
  130. package/dist/telemetry/gate.d.ts.map +1 -1
  131. package/dist/telemetry/gate.js +18 -2
  132. package/dist/telemetry/gate.js.map +1 -1
  133. package/dist/upkeep-run.d.ts.map +1 -1
  134. package/dist/upkeep-run.js +12 -2
  135. package/dist/upkeep-run.js.map +1 -1
  136. package/dist/upkeep.d.ts +18 -0
  137. package/dist/upkeep.d.ts.map +1 -1
  138. package/dist/upkeep.js +38 -0
  139. package/dist/upkeep.js.map +1 -1
  140. package/dist/util/state.d.ts +9 -0
  141. package/dist/util/state.d.ts.map +1 -1
  142. package/dist/util/state.js.map +1 -1
  143. package/package.json +1 -1
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  <img src="https://img.shields.io/badge/License-MIT-20C997?style=for-the-badge" />
18
18
  <a href="TELEMETRY.md"><img src="https://img.shields.io/badge/telemetry-anonymous%2C%20opt--out-546FFF?style=for-the-badge" /></a>
19
19
  <a href="https://scorecard.dev/viewer/?uri=github.com/NanoNets/Graft"><img src="https://img.shields.io/ossf-scorecard/github.com/NanoNets/Graft?style=for-the-badge&label=openssf%20scorecard" /></a>
20
- <a href="https://github.com/apps/trailhq-graft"><img src="https://img.shields.io/badge/PR%20reviews-install%20GitHub%20App-546FFF?style=for-the-badge&logo=github&logoColor=white" /></a>
20
+ <a href="https://app.trailhq.com/get-started?step=pick"><img src="https://img.shields.io/badge/Trail%20Brain-try%20it-E5484D?style=for-the-badge&logoColor=white" /></a>
21
21
  </p>
22
22
 
23
23
  ### Up to **4× cheaper** and **3× faster**, with better or no loss of correctness.
@@ -31,7 +31,14 @@
31
31
 
32
32
  </div>
33
33
 
34
- <p align="center"><b>Reviewing pull requests?</b> <a href="https://github.com/apps/trailhq-graft">Add the graft GitHub App</a> to get an automatic blast-radius review on every PR.</p>
34
+ <p align="center">
35
+ <b>This works beyond code too.</b><br/>
36
+ A living skill file that learns from every task and gets sharper the more your team works.
37
+ </p>
38
+
39
+ <p align="center">
40
+ <a href="https://app.trailhq.com/get-started?step=pick"><img src="https://img.shields.io/badge/Try%20Trail%20Brain%20%E2%86%92-E5484D?style=for-the-badge" alt="Try Trail Brain" height="34"/></a>
41
+ </p>
35
42
 
36
43
  <p align="center">
37
44
  <img src="assets/graft-comparison-demo.gif" alt="Side-by-side comparison of a coding agent working with and without graft" width="820"/>
@@ -0,0 +1,66 @@
1
+ import { type AppCredentials, type Fetch, type RepoAccessGap } from "./identity.js";
2
+ import { type RepoDigest } from "./history.js";
3
+ /** One request to build a repository into a brain. */
4
+ export interface BrainBuildJob {
5
+ owner: string;
6
+ repo: string;
7
+ /** Branch to read; empty means the repository's default. */
8
+ ref?: string;
9
+ /**
10
+ * The brain the rules land in, and the workspace key to write it with.
11
+ *
12
+ * Both optional, and omitting them changes the mode: with them, the digest is
13
+ * posted straight to the brain and only a job id comes back; without them the
14
+ * digest is RETURNED and the caller ingests it itself.
15
+ *
16
+ * The second mode is what the platform's own proxy uses. It already holds the
17
+ * user's session and can write to the brain directly, so handing graft a
18
+ * workspace key just to have it call back would mean minting a credential per
19
+ * build for no gain.
20
+ */
21
+ brainId?: string;
22
+ brainToken?: string;
23
+ /** Platform base URL. Defaults to production. */
24
+ brainBaseUrl?: string;
25
+ /** File the rules as approved rather than as drafts. Onboarding sets it:
26
+ * a brain whose every rule is an invisible draft answers nothing. */
27
+ autoApprove?: boolean;
28
+ }
29
+ export interface BrainBuildResult {
30
+ /** Set only when the digest was posted; empty in return-the-digest mode. */
31
+ jobId: string;
32
+ headSha: string;
33
+ commits: number;
34
+ threads: number;
35
+ symbols: number;
36
+ sources: number;
37
+ /** Set only in return-the-digest mode. */
38
+ digest?: RepoDigest;
39
+ }
40
+ export interface BrainBuildDeps {
41
+ creds: AppCredentials;
42
+ fetch: Fetch;
43
+ api?: string;
44
+ githubHost?: string;
45
+ log?: (msg: string) => void;
46
+ now?: () => number;
47
+ }
48
+ /** Raised when the App cannot see the repository. Distinct because the caller
49
+ * turns it into a specific answer — "install the app, or run it locally" — and
50
+ * not into a 500. */
51
+ export declare class RepoNotAccessibleError extends Error {
52
+ /** Which gap it is, so the caller can send the user to the right place. */
53
+ readonly gap: RepoAccessGap;
54
+ constructor(message: string,
55
+ /** Which gap it is, so the caller can send the user to the right place. */
56
+ gap?: RepoAccessGap);
57
+ }
58
+ /**
59
+ * Read the repository and hand its history to the brain.
60
+ *
61
+ * Public repositories still go through the installation lookup, because the App
62
+ * needs a token to clone at any useful rate limit, and a repository the App is
63
+ * not installed on is exactly the case the UI has to distinguish.
64
+ */
65
+ export declare function buildRepoIntoBrain(job: BrainBuildJob, deps: BrainBuildDeps): Promise<BrainBuildResult>;
66
+ //# sourceMappingURL=brain-build.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brain-build.d.ts","sourceRoot":"","sources":["../../src/app/brain-build.ts"],"names":[],"mappings":"AAiBA,OAAO,EAA0C,KAAK,cAAc,EAAE,KAAK,KAAK,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAC5H,OAAO,EAAkE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAc/G,sDAAsD;AACtD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;yEACqE;IACrE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,EAAE,KAAK,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;qBAEqB;AACrB,qBAAa,sBAAuB,SAAQ,KAAK;IAG7C,2EAA2E;IAC3E,QAAQ,CAAC,GAAG,EAAE,aAAa;gBAF3B,OAAO,EAAE,MAAM;IACf,2EAA2E;IAClE,GAAG,GAAE,aAAgF;CAIjG;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,aAAa,EAClB,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,gBAAgB,CAAC,CA4H3B"}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Building one repository into a brain, on demand.
3
+ *
4
+ * The whole point of the onboarding flow this serves: someone pastes a
5
+ * repository URL, and a minute later they are looking at rules mined from their
6
+ * own history. Nothing is installed and nobody has signed up yet, so this has to
7
+ * work from the repository name alone.
8
+ *
9
+ * Runs in the app rather than in the platform because only the app has the
10
+ * GitHub App credentials, only it can clone, and only graft can build a symbol
11
+ * graph. What crosses back to the platform is a digest of messages and symbol
12
+ * ids — never source.
13
+ */
14
+ import { buildGraph } from "../graph/build.js";
15
+ import { contextDirFor } from "../context/node-file.js";
16
+ import { loadGraphCached } from "../graph/load.js";
17
+ import { checkoutRepository } from "./checkout.js";
18
+ import { appJwt, installationFor, repoAccessGap } from "./identity.js";
19
+ import { buildDigest, postDigest, readCommits, readSymbols, readThreads } from "./history.js";
20
+ import { budgetSources, readAgentInstructions, readBranchProtection, readCodeowners, readCodifiedRules, readDecisionDocs, readDeclinedIssues, readReverts, readTestNames, } from "./sources.js";
21
+ /** Raised when the App cannot see the repository. Distinct because the caller
22
+ * turns it into a specific answer — "install the app, or run it locally" — and
23
+ * not into a 500. */
24
+ export class RepoNotAccessibleError extends Error {
25
+ gap;
26
+ constructor(message,
27
+ /** Which gap it is, so the caller can send the user to the right place. */
28
+ gap = { reason: "not_installed", ownerId: null, installationId: null }) {
29
+ super(message);
30
+ this.gap = gap;
31
+ }
32
+ }
33
+ /**
34
+ * Read the repository and hand its history to the brain.
35
+ *
36
+ * Public repositories still go through the installation lookup, because the App
37
+ * needs a token to clone at any useful rate limit, and a repository the App is
38
+ * not installed on is exactly the case the UI has to distinguish.
39
+ */
40
+ export async function buildRepoIntoBrain(job, deps) {
41
+ const log = deps.log ?? (() => { });
42
+ const api = deps.api ?? "https://api.github.com";
43
+ const tag = `${job.owner}/${job.repo}`;
44
+ const installationId = await installationFor(deps.creds, job.owner, job.repo, deps.fetch, (deps.now ?? Date.now)(), api);
45
+ if (installationId === null) {
46
+ // Which of the two gaps it is decides what the UI can offer, so it is
47
+ // resolved here rather than guessed there.
48
+ const gap = await repoAccessGap(deps.creds, job.owner, deps.fetch, (deps.now ?? Date.now)(), api);
49
+ throw new RepoNotAccessibleError(gap.reason === "repo_not_selected"
50
+ ? `graft is installed on ${job.owner} but ${tag} is not in the list of repositories it can see`
51
+ : `graft is not installed on ${job.owner}`, gap);
52
+ }
53
+ const tokenRes = await deps.fetch(`${api}/app/installations/${installationId}/access_tokens`, {
54
+ method: "POST",
55
+ headers: {
56
+ authorization: `Bearer ${appJwt(deps.creds, (deps.now ?? Date.now)())}`,
57
+ accept: "application/vnd.github+json",
58
+ "user-agent": "graft-app",
59
+ },
60
+ });
61
+ if (!tokenRes.ok) {
62
+ throw new Error(`installation token for ${tag} failed: ${tokenRes.status}`);
63
+ }
64
+ const token = JSON.parse(await tokenRes.text()).token;
65
+ // Whether the repository is private decides what the UI may show before
66
+ // signup, and the default branch is not discoverable from a shallow
67
+ // single-ref fetch (there is no origin/HEAD to resolve), so both are read from
68
+ // the API in one call rather than guessed.
69
+ const meta = await repoMeta(job.owner, job.repo, token, deps.fetch, api);
70
+ const checkout = checkoutRepository({
71
+ owner: job.owner,
72
+ repo: job.repo,
73
+ ref: job.ref || meta.defaultBranch,
74
+ token,
75
+ api: deps.githubHost,
76
+ log,
77
+ });
78
+ try {
79
+ // `graphOnly`: the symbol ids and their hashes are all the digest needs, and
80
+ // the markdown projections cost real time on a large repo.
81
+ await buildGraph(checkout.dir, { graphOnly: true });
82
+ const graph = loadGraphCached(contextDirFor(checkout.dir));
83
+ const commits = readCommits(checkout.dir);
84
+ const symbols = readSymbols(graph);
85
+ // The threads are the slow part (two API calls per pull request), and the
86
+ // one most likely to fail on a rate limit. A failure here degrades to a
87
+ // commits-only ingest rather than losing the whole build.
88
+ let threads = [];
89
+ try {
90
+ threads = await readThreads(job.owner, job.repo, token, deps.fetch, api);
91
+ }
92
+ catch (e) {
93
+ log(`${tag}: pull-request discussion unavailable (${e instanceof Error ? e.message : e}); mining commits only`);
94
+ }
95
+ // Everything the repo already states as a rule. All best-effort: the file
96
+ // readers cannot fail the build, and the two API readers swallow their own
97
+ // errors, so a repo with none of this still gets a brain from its history.
98
+ const sources = budgetSources([
99
+ ...readAgentInstructions(checkout.dir),
100
+ ...readDecisionDocs(checkout.dir),
101
+ ...readCodeowners(checkout.dir),
102
+ ...readCodifiedRules(checkout.dir),
103
+ ...readReverts(checkout.dir),
104
+ ...readTestNames(symbols.map((s) => ({ name: s.name, path: s.path }))),
105
+ ...(await readBranchProtection(job.owner, job.repo, meta.defaultBranch, token, deps.fetch, api)),
106
+ ...(await readDeclinedIssues(job.owner, job.repo, token, deps.fetch, api)),
107
+ ]);
108
+ log(`${tag}: read ${commits.length} commits, ${threads.length} threads, ${symbols.length} symbols, ${sources.length} stated sources`);
109
+ const digest = buildDigest({
110
+ owner: job.owner,
111
+ name: job.repo,
112
+ headSha: checkout.headSha,
113
+ defaultBranch: job.ref || meta.defaultBranch,
114
+ isPrivate: meta.isPrivate,
115
+ commits,
116
+ threads,
117
+ symbols,
118
+ sources,
119
+ autoApprove: job.autoApprove ?? false,
120
+ });
121
+ const counts = {
122
+ headSha: checkout.headSha,
123
+ commits: commits.length,
124
+ threads: threads.length,
125
+ symbols: symbols.length,
126
+ sources: sources.length,
127
+ };
128
+ if (!job.brainId || !job.brainToken) {
129
+ return { jobId: "", ...counts, digest };
130
+ }
131
+ const { jobId } = await postDigest(job.brainBaseUrl ?? "https://agents.nanonets.com", job.brainId, job.brainToken, digest, deps.fetch);
132
+ return { jobId, ...counts };
133
+ }
134
+ finally {
135
+ // Always: the clone was made with an installation token and is not something
136
+ // to leave in /tmp.
137
+ checkout.cleanup();
138
+ }
139
+ }
140
+ /**
141
+ * The repository's visibility and default branch, in one call.
142
+ *
143
+ * Unknown counts as private: saying a repo is public when it is not would leak
144
+ * its name into a pre-signup screen. An unknown default branch is left empty,
145
+ * and the checkout then falls back to whatever the remote's HEAD points at,
146
+ * which is the same thing by another route.
147
+ */
148
+ async function repoMeta(owner, repo, token, fetchImpl, api) {
149
+ try {
150
+ const res = await fetchImpl(`${api}/repos/${owner}/${repo}`, {
151
+ headers: {
152
+ authorization: `Bearer ${token}`,
153
+ accept: "application/vnd.github+json",
154
+ "user-agent": "graft-app",
155
+ },
156
+ });
157
+ if (!res.ok)
158
+ return { isPrivate: true, defaultBranch: "" };
159
+ const parsed = JSON.parse(await res.text());
160
+ return {
161
+ isPrivate: parsed.private !== false,
162
+ defaultBranch: (parsed.default_branch ?? "").trim(),
163
+ };
164
+ }
165
+ catch {
166
+ return { isPrivate: true, defaultBranch: "" };
167
+ }
168
+ }
169
+ //# sourceMappingURL=brain-build.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brain-build.js","sourceRoot":"","sources":["../../src/app/brain-build.ts"],"names":[],"mappings":"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"}
@@ -5,24 +5,74 @@ export interface CheckoutRequest {
5
5
  baseRef: string;
6
6
  token: string;
7
7
  api?: string;
8
+ /** Where to say which of GitHub's two PR refs the review is actually using —
9
+ * the App's own log callback, so the two cases are told apart in the container
10
+ * logs rather than guessed at from the comment. */
11
+ log?: (msg: string) => void;
8
12
  }
9
13
  export interface Checkout {
10
- /** Working tree at the pull request's merge commit. */
14
+ /** Working tree at the pull request's merge commit, or at its head commit when
15
+ * the merge ref is gone (see {@link ref}). */
11
16
  dir: string;
12
17
  /** What `blast --base` should diff against. */
13
18
  base: string;
19
+ /** Which ref the tree came from. `merge` is the normal case; `head` means the
20
+ * pull request is closed and GitHub has deleted its merge preview. */
21
+ ref: "merge" | "head";
14
22
  /** Removes the tree. Always call it — the token's clone is not something to
15
23
  * leave in /tmp. */
16
24
  cleanup: () => void;
17
25
  }
18
26
  /**
19
- * Fetch the PR's merge ref and its base, shallow.
27
+ * Fetch the PR's code and its base, shallow, and land on it.
20
28
  *
21
29
  * `refs/pull/N/merge` is GitHub's own merge of the PR into its base — the same
22
30
  * thing the checks see, and the right thing to review: it is what will land, not
23
- * what the contributor's branch says in isolation.
31
+ * what the contributor's branch says in isolation. It is preferred whenever it
32
+ * exists.
33
+ *
34
+ * But GitHub DELETES that ref the moment a pull request is closed or merged: it
35
+ * is only a preview of a merge, and there is nothing left to preview afterwards.
36
+ * Re-reviewing any merged PR therefore died at the fetch with `couldn't find
37
+ * remote ref refs/pull/N/merge` — 16 in a row on one installation — which is why
38
+ * `refs/pull/N/head` (the branch tip as the author pushed it, kept indefinitely
39
+ * in the BASE repository, so it survives even the fork being deleted) is fetched
40
+ * as a fallback. What changes with it is the diff basis, which
41
+ * {@link headDiffBase} is entirely about.
24
42
  */
25
43
  export declare function checkoutPullRequest(req: CheckoutRequest): Checkout;
44
+ /** What a plain-repository checkout needs: no pull request, just a ref. */
45
+ export interface RepoCheckoutRequest {
46
+ owner: string;
47
+ repo: string;
48
+ /** Branch to read. Empty means "whatever HEAD points at" (the default branch). */
49
+ ref?: string;
50
+ token: string;
51
+ /** How much history to fetch. The default is deeper than a PR review needs
52
+ * because commit MESSAGES are the payload here, not a diff — 50 commits is a
53
+ * fortnight on an active repo and would mine almost nothing. */
54
+ depth?: number;
55
+ api?: string;
56
+ log?: (msg: string) => void;
57
+ }
58
+ /** A plain checkout: the tree, the commit it landed on, and the cleanup. */
59
+ export interface RepoCheckout {
60
+ dir: string;
61
+ headSha: string;
62
+ /** The branch actually checked out, resolved when the caller named none. */
63
+ branch: string;
64
+ cleanup: () => void;
65
+ }
66
+ /**
67
+ * Clone one repository at a ref, shallow, for reading rather than reviewing.
68
+ *
69
+ * Separate from {@link checkoutPullRequest} rather than a flag on it: that
70
+ * function's whole shape — two refspecs, the merge/head fallback, the diff-base
71
+ * resolution — exists to answer "what does this pull request change", and none
72
+ * of it applies to "read this repository's history". Sharing the hardened `git`
73
+ * runner is the part worth reusing.
74
+ */
75
+ export declare function checkoutRepository(req: RepoCheckoutRequest): RepoCheckout;
26
76
  /** Never let a token reach a log line, even inside git's own error text. */
27
77
  export declare function redact(text: string, token: string): string;
28
78
  //# sourceMappingURL=checkout.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"checkout.d.ts","sourceRoot":"","sources":["../../src/app/checkout.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,QAAQ;IACvB,uDAAuD;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb;wBACoB;IACpB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAkCD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,eAAe,GAAG,QAAQ,CAoClE;AAED,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAI1D"}
1
+ {"version":3,"file":"checkout.d.ts","sourceRoot":"","sources":["../../src/app/checkout.ts"],"names":[],"mappings":"AAoCA,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;uDAEmD;IACnD,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,QAAQ;IACvB;kDAC8C;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb;0EACsE;IACtE,GAAG,EAAE,OAAO,GAAG,MAAM,CAAC;IACtB;wBACoB;IACpB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AA+CD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,eAAe,GAAG,QAAQ,CA2DlE;AAED,2EAA2E;AAC3E,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,kFAAkF;IAClF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd;;oEAEgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,YAAY,CAqCzE;AAyED,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAI1D"}
@@ -21,6 +21,16 @@ import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  /** Enough history for a merge base against the pull request's base branch. */
23
23
  const FETCH_DEPTH = 50;
24
+ /**
25
+ * One extra reach back, used only on the `/head` fallback below.
26
+ *
27
+ * An open PR's base tip is a commit or two from the merge base, so 50 is plenty.
28
+ * A pull request being re-reviewed weeks after it merged is the opposite case:
29
+ * the base branch has moved on by however many commits the repo lands in that
30
+ * time, and the commit the branch was actually merged on top of sits behind all
31
+ * of them. Deepening once is cheaper than raising `FETCH_DEPTH` for every review.
32
+ */
33
+ const DEEPEN_DEPTH = 500;
24
34
  const GIT_TIMEOUT_MS = 120_000;
25
35
  /** A git invocation with the dangerous parts of the environment removed. */
26
36
  function git(dir, args, token) {
@@ -47,48 +57,206 @@ function git(dir, args, token) {
47
57
  HOME: dir,
48
58
  },
49
59
  });
50
- return { ok: res.status === 0, err: `${res.stderr ?? ""}${res.error ? ` ${res.error.message}` : ""}`.trim() };
60
+ return {
61
+ ok: res.status === 0,
62
+ out: res.stdout ?? "",
63
+ err: `${res.stderr ?? ""}${res.error ? ` ${res.error.message}` : ""}`.trim(),
64
+ };
51
65
  }
66
+ /** First line of a git command's output, or null when it said nothing useful. */
67
+ function line(dir, args) {
68
+ const { ok, out } = git(dir, args);
69
+ const first = out.split("\n")[0]?.trim() ?? "";
70
+ return ok && first !== "" ? first : null;
71
+ }
72
+ const short = (sha) => sha.slice(0, 7);
52
73
  /**
53
- * Fetch the PR's merge ref and its base, shallow.
74
+ * Fetch the PR's code and its base, shallow, and land on it.
54
75
  *
55
76
  * `refs/pull/N/merge` is GitHub's own merge of the PR into its base — the same
56
77
  * thing the checks see, and the right thing to review: it is what will land, not
57
- * what the contributor's branch says in isolation.
78
+ * what the contributor's branch says in isolation. It is preferred whenever it
79
+ * exists.
80
+ *
81
+ * But GitHub DELETES that ref the moment a pull request is closed or merged: it
82
+ * is only a preview of a merge, and there is nothing left to preview afterwards.
83
+ * Re-reviewing any merged PR therefore died at the fetch with `couldn't find
84
+ * remote ref refs/pull/N/merge` — 16 in a row on one installation — which is why
85
+ * `refs/pull/N/head` (the branch tip as the author pushed it, kept indefinitely
86
+ * in the BASE repository, so it survives even the fork being deleted) is fetched
87
+ * as a fallback. What changes with it is the diff basis, which
88
+ * {@link headDiffBase} is entirely about.
58
89
  */
59
90
  export function checkoutPullRequest(req) {
60
91
  const host = (req.api ?? "https://github.com").replace(/\/$/, "");
61
92
  const dir = mkdtempSync(join(tmpdir(), `graft-app-${req.owner}-${req.repo}-`));
62
93
  const cleanup = () => rmSync(dir, { recursive: true, force: true });
63
- const steps = [
94
+ const log = req.log ?? (() => { });
95
+ const tag = `${req.owner}/${req.repo}#${req.number}`;
96
+ const fail = (step, err) => {
97
+ cleanup();
98
+ // A merge ref is absent while GitHub is still computing mergeability, and
99
+ // present-but-stale right after a push — worth saying which step failed so
100
+ // that case is distinguishable from a permissions problem.
101
+ throw new Error(`checkout ${tag} failed at ${step}: ${redact(err, req.token)}`);
102
+ };
103
+ const setup = [
64
104
  ["init", ["init", "--quiet", "-b", "__graft_base"]],
65
105
  ["remote", ["remote", "add", "origin", `${host}/${req.owner}/${req.repo}.git`]],
66
- // Both refs in one fetch: the merge ref to review, the base to diff against.
67
- [
68
- "fetch",
69
- [
70
- "fetch",
71
- "--quiet",
72
- `--depth=${FETCH_DEPTH}`,
73
- "--no-recurse-submodules",
74
- "origin",
75
- `+refs/pull/${req.number}/merge:refs/graft/merge`,
76
- `+refs/heads/${req.baseRef}:refs/graft/base`,
77
- ],
78
- ],
79
- ["checkout", ["checkout", "--quiet", "--detach", "refs/graft/merge"]],
80
106
  ];
81
- for (const [name, args] of steps) {
82
- const { ok, err } = git(dir, args, name === "fetch" ? req.token : undefined);
83
- if (!ok) {
84
- cleanup();
85
- // A merge ref is absent while GitHub is still computing mergeability, and
86
- // present-but-stale right after a push — worth saying which step failed so
87
- // that case is distinguishable from a permissions problem.
88
- throw new Error(`checkout ${req.owner}/${req.repo}#${req.number} failed at ${name}: ${redact(err, req.token)}`);
107
+ for (const [name, args] of setup) {
108
+ const { ok, err } = git(dir, args);
109
+ if (!ok)
110
+ fail(name, err);
111
+ }
112
+ // Both refs in one fetch: the ref to review, the base to diff against.
113
+ const merge = git(dir, fetchArgs(req, "merge"), req.token);
114
+ const ref = merge.ok ? "merge" : "head";
115
+ if (!merge.ok) {
116
+ const head = git(dir, fetchArgs(req, "head"), req.token);
117
+ // Neither ref: a base branch that has since been deleted, an installation
118
+ // that lost access, a repository that is gone. Both errors go out, because a
119
+ // bare "couldn't find refs/pull/N/merge" now means only "this PR is closed"
120
+ // and says nothing about why the fallback did not save it either.
121
+ if (!head.ok) {
122
+ fail("fetch", `${merge.err} | falling back to refs/pull/${req.number}/head: ${head.err}`);
89
123
  }
90
124
  }
91
- return { dir, base: "refs/graft/base", cleanup };
125
+ const co = git(dir, ["checkout", "--quiet", "--detach", `refs/graft/${ref}`]);
126
+ if (!co.ok)
127
+ fail("checkout", co.err);
128
+ if (ref === "merge") {
129
+ log(`${tag}: reviewing refs/pull/${req.number}/merge against ${req.baseRef}`);
130
+ return { dir, base: "refs/graft/base", ref, cleanup };
131
+ }
132
+ const against = headDiffBase(dir, req);
133
+ if (against === null) {
134
+ fail("diff base", `refs/pull/${req.number}/merge is gone and refs/pull/${req.number}/head gives no diff against ` +
135
+ `${req.baseRef}: within ${FETCH_DEPTH + DEEPEN_DEPTH} commits they share no history, or the branch ` +
136
+ `is already in ${req.baseRef} with no merge commit to diff against (a fast-forward merge)`);
137
+ }
138
+ // Not "the PR is closed": that is only the usual reason the merge ref is
139
+ // missing, and stating it as fact would send an operator chasing the wrong
140
+ // thing when the fetch failed for some other reason.
141
+ log(`${tag}: no refs/pull/${req.number}/merge (GitHub deletes it when a pull request closes), reviewing refs/pull/${req.number}/head against ${against}`);
142
+ return { dir, base: "refs/graft/base", ref, cleanup };
143
+ }
144
+ /**
145
+ * Clone one repository at a ref, shallow, for reading rather than reviewing.
146
+ *
147
+ * Separate from {@link checkoutPullRequest} rather than a flag on it: that
148
+ * function's whole shape — two refspecs, the merge/head fallback, the diff-base
149
+ * resolution — exists to answer "what does this pull request change", and none
150
+ * of it applies to "read this repository's history". Sharing the hardened `git`
151
+ * runner is the part worth reusing.
152
+ */
153
+ export function checkoutRepository(req) {
154
+ const host = (req.api ?? "https://github.com").replace(/\/$/, "");
155
+ const dir = mkdtempSync(join(tmpdir(), `graft-repo-${req.owner}-${req.repo}-`));
156
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
157
+ const log = req.log ?? (() => { });
158
+ const tag = `${req.owner}/${req.repo}`;
159
+ const fail = (step, err) => {
160
+ cleanup();
161
+ throw new Error(`${tag}: ${step} failed: ${redact(err, req.token)}`);
162
+ };
163
+ const init = git(dir, ["init", "--quiet", "--initial-branch=graft-tmp"]);
164
+ if (!init.ok)
165
+ fail("init", init.err);
166
+ const remote = git(dir, ["remote", "add", "origin", `${host}/${req.owner}/${req.repo}.git`]);
167
+ if (!remote.ok)
168
+ fail("remote", remote.err);
169
+ const depth = req.depth ?? REPO_FETCH_DEPTH;
170
+ // A named branch is fetched by name; with none, `HEAD` resolves to whatever
171
+ // the remote's default branch is, which is what the caller means by "the
172
+ // repository" and saves an API round trip to look the name up.
173
+ const refspec = req.ref
174
+ ? `+refs/heads/${req.ref}:refs/graft/head`
175
+ : `+HEAD:refs/graft/head`;
176
+ const fetched = git(dir, ["fetch", "--quiet", `--depth=${depth}`, "--no-recurse-submodules", "origin", refspec], req.token);
177
+ if (!fetched.ok)
178
+ fail("fetch", fetched.err);
179
+ const co = git(dir, ["checkout", "--quiet", "--detach", "refs/graft/head"]);
180
+ if (!co.ok)
181
+ fail("checkout", co.err);
182
+ const headSha = line(dir, ["rev-parse", "HEAD"]) ?? "";
183
+ const branch = req.ref ?? line(dir, ["rev-parse", "--abbrev-ref", "origin/HEAD"]) ?? "";
184
+ log(`${tag}: read ${req.ref || "HEAD"} at ${short(headSha)} (${depth} commits deep)`);
185
+ return { dir, headSha, branch: branch.replace(/^origin\//, ""), cleanup };
186
+ }
187
+ /** Default history depth for a repository read. See RepoCheckoutRequest.depth. */
188
+ const REPO_FETCH_DEPTH = 500;
189
+ /** The one fetch each attempt makes: the PR ref to review plus the base branch. */
190
+ function fetchArgs(req, pull, deepen = false) {
191
+ return [
192
+ "fetch",
193
+ "--quiet",
194
+ deepen ? `--deepen=${DEEPEN_DEPTH}` : `--depth=${FETCH_DEPTH}`,
195
+ "--no-recurse-submodules",
196
+ "origin",
197
+ `+refs/pull/${req.number}/${pull}:refs/graft/${pull}`,
198
+ `+refs/heads/${req.baseRef}:refs/graft/base`,
199
+ ];
200
+ }
201
+ /**
202
+ * Point `refs/graft/base` at the commit the head ref should be diffed against,
203
+ * and describe it for the log — or null when this history cannot answer.
204
+ *
205
+ * The two refs do NOT give the same diff, and the difference is not a detail:
206
+ *
207
+ * - `/merge` is a commit whose parents are the base tip and the head, so
208
+ * `refs/graft/base...HEAD` resolves its merge base to the base tip and the
209
+ * diff is exactly the pull request.
210
+ * - `/head` is the author's branch tip. While the branch is *not* in the base
211
+ * branch's history — an open PR, a closed-unmerged one, and also a squash or
212
+ * rebase merge, which land new commits with new shas — the three-dot diff
213
+ * still resolves to the fork point and still reports exactly the author's
214
+ * changes (that is what three dots is for; see `rangeArgs` in blast/diff.ts).
215
+ * - After a MERGE COMMIT, though, the head commit is an ancestor of the base
216
+ * branch, so the merge base of the two IS the head commit and
217
+ * `refs/graft/base...HEAD` reports that nothing changed. An empty blast
218
+ * radius posted with total confidence is worse than the fetch error it
219
+ * replaced, so that case is detected and given the basis `/merge` would have
220
+ * had: the first parent of the merge commit that brought the branch in, which
221
+ * is the base branch as it stood at the merge.
222
+ */
223
+ function headDiffBase(dir, req) {
224
+ for (const deepen of [false, true]) {
225
+ // The shallow window is measured from each tip, so a base branch that moved
226
+ // on since the merge can leave the shared history out of it entirely: git
227
+ // then has no merge base at all and `git diff a...b` fails outright. One
228
+ // deepening is the difference between reviewing a months-old PR and not.
229
+ if (deepen && !git(dir, fetchArgs(req, "head", true), req.token).ok)
230
+ return null;
231
+ const against = resolveHeadBase(dir, req);
232
+ if (against !== null)
233
+ return against;
234
+ }
235
+ return null;
236
+ }
237
+ function resolveHeadBase(dir, req) {
238
+ const head = line(dir, ["rev-parse", "refs/graft/head"]);
239
+ const shared = line(dir, ["merge-base", "refs/graft/base", "refs/graft/head"]);
240
+ if (head === null || shared === null)
241
+ return null;
242
+ if (shared !== head)
243
+ return `${req.baseRef} (merge base ${short(shared)})`;
244
+ // The head commit is in the base branch: find the merge that put it there. The
245
+ // EARLIEST merge on the ancestry path is the one — a later one is some other
246
+ // branch's merge that happens to sit above it.
247
+ const merge = line(dir, ["rev-list", "--reverse", "--ancestry-path", "--merges", "refs/graft/head..refs/graft/base"]);
248
+ if (merge === null)
249
+ return null;
250
+ const parent = line(dir, ["rev-parse", `${merge}^1`]);
251
+ if (parent === null)
252
+ return null;
253
+ // Only worth using if it is diffable: a shallow boundary can hold the merge
254
+ // commit while leaving the fork point below it out of the repository.
255
+ if (line(dir, ["merge-base", parent, "refs/graft/head"]) === null)
256
+ return null;
257
+ if (!git(dir, ["update-ref", "refs/graft/base", parent]).ok)
258
+ return null;
259
+ return `${req.baseRef} as it stood at the merge (${short(parent)})`;
92
260
  }
93
261
  /** Never let a token reach a log line, even inside git's own error text. */
94
262
  export function redact(text, token) {