dsh-github-router 0.1.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/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +156 -0
- package/SECURITY.md +112 -0
- package/cordis.patch.yml +8 -0
- package/docs/design.md +198 -0
- package/lib/cache.js +91 -0
- package/lib/client.js +553 -0
- package/lib/config.js +109 -0
- package/lib/core/file.js +166 -0
- package/lib/core/issue.js +202 -0
- package/lib/core/pr.js +429 -0
- package/lib/core/probe.js +121 -0
- package/lib/core/runtime.js +89 -0
- package/lib/guidance.js +24 -0
- package/lib/index.js +52 -0
- package/lib/net.js +213 -0
- package/lib/remote.js +169 -0
- package/lib/render.js +154 -0
- package/lib/routes/api.js +136 -0
- package/lib/routes/gh.js +109 -0
- package/lib/routes/git.js +429 -0
- package/lib/routes/html.js +250 -0
- package/lib/routes/mirror.js +55 -0
- package/lib/skill.js +44 -0
- package/lib/tools/api.js +101 -0
- package/lib/tools/file.js +48 -0
- package/lib/tools/issue.js +46 -0
- package/lib/tools/pr.js +56 -0
- package/lib/tools/probe.js +42 -0
- package/lib/tunnel.js +247 -0
- package/lib/util.js +235 -0
- package/package.json +79 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api.github.com REST client — GET-only by construction. There is no method
|
|
3
|
+
* field, no body, and no verb other than GET anywhere in this module. The
|
|
4
|
+
* token is attached only to api.github.com requests and is never logged.
|
|
5
|
+
* @module dsh-github-router/routes/api
|
|
6
|
+
*/
|
|
7
|
+
import { RouteError } from '../net.js'
|
|
8
|
+
|
|
9
|
+
export const API_BASE = 'https://api.github.com'
|
|
10
|
+
export const USER_AGENT = 'dsh-github-router/0.1.0'
|
|
11
|
+
const ACCEPT = 'application/vnd.github+json'
|
|
12
|
+
const API_VERSION = '2022-11-28'
|
|
13
|
+
|
|
14
|
+
/** Classify a non-2xx api.github.com response into a stable failure code. */
|
|
15
|
+
export function apiErrorCode(status, headers) {
|
|
16
|
+
if (status === 401) return 'AUTH_REQUIRED'
|
|
17
|
+
if (status === 404) return 'NOT_FOUND'
|
|
18
|
+
if (status === 403 && headers !== undefined && headers['x-ratelimit-remaining'] === '0') return 'RATE_LIMITED'
|
|
19
|
+
if (status === 429) return 'RATE_LIMITED'
|
|
20
|
+
if (status >= 500) return 'HTTP'
|
|
21
|
+
return 'HTTP'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build one api.github.com URL from a validated path and query values. */
|
|
25
|
+
export function apiUrl(path, query) {
|
|
26
|
+
const url = new URL(API_BASE + path)
|
|
27
|
+
if (query !== undefined && query !== null) {
|
|
28
|
+
for (const [key, value] of Object.entries(query)) {
|
|
29
|
+
if (value === undefined || value === null || value === '') continue
|
|
30
|
+
const v = String(value)
|
|
31
|
+
if (/[\u0000-\u001f\u007f]/.test(v) || v.length > 1024) continue
|
|
32
|
+
url.searchParams.set(key, v)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return url.toString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A GitHub API client bound to the router's transport and options.
|
|
40
|
+
* Token resolution happens per call so settings changes apply immediately.
|
|
41
|
+
*/
|
|
42
|
+
export class GithubApi {
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.options = options // { retries, directTimeoutMs, proxyTimeoutMs, maxBytes }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
headers(token) {
|
|
48
|
+
const headers = {
|
|
49
|
+
accept: ACCEPT,
|
|
50
|
+
'user-agent': USER_AGENT,
|
|
51
|
+
'x-github-api-version': API_VERSION,
|
|
52
|
+
}
|
|
53
|
+
if (token !== undefined && token !== null && token !== '') {
|
|
54
|
+
headers.authorization = `Bearer ${token}`
|
|
55
|
+
}
|
|
56
|
+
return headers
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* GET a JSON endpoint with cache and retry handling.
|
|
61
|
+
* @param path - validated api.github.com relative path
|
|
62
|
+
* @param call - { query, token, signal, proxy, timeoutMs, cache, ttlMs, forceRefresh }
|
|
63
|
+
*/
|
|
64
|
+
async getJson(path, call = {}) {
|
|
65
|
+
const url = apiUrl(path, call.query)
|
|
66
|
+
const { cache, ttlMs, forceRefresh } = call
|
|
67
|
+
const fetcher = async () => {
|
|
68
|
+
const raw = await call.fetchImpl(url, {
|
|
69
|
+
headers: this.headers(call.token),
|
|
70
|
+
timeoutMs: call.timeoutMs !== undefined ? call.timeoutMs : call.proxy !== undefined ? this.options.proxyTimeoutMs : this.options.directTimeoutMs,
|
|
71
|
+
proxy: call.proxy,
|
|
72
|
+
signal: call.signal,
|
|
73
|
+
maxBytes: this.options.maxBytes,
|
|
74
|
+
retries: this.options.retries,
|
|
75
|
+
})
|
|
76
|
+
if (raw.ok) {
|
|
77
|
+
let json
|
|
78
|
+
try {
|
|
79
|
+
json = JSON.parse(raw.body.toString('utf8'))
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new RouteError('TRANSPORT', `unprocessable JSON from ${url}: ${String(error && error.message ? error.message : error)}`, { cause: error })
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, status: raw.status, json, headers: raw.headers, truncated: raw.truncated }
|
|
84
|
+
}
|
|
85
|
+
const code = apiErrorCode(raw.status, raw.headers)
|
|
86
|
+
let detail = `GitHub API error (HTTP ${raw.status})`
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(raw.body.toString('utf8'))
|
|
89
|
+
const message = parsed && (parsed.message || (parsed.error && parsed.error.message))
|
|
90
|
+
if (typeof message === 'string' && message.length > 0) detail = message
|
|
91
|
+
} catch { /* keep generic detail */ }
|
|
92
|
+
throw new RouteError(code, detail, { status: raw.status })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const withMeta = (value, hit) => {
|
|
96
|
+
const out = Array.isArray(value) ? [...value] : { ...value }
|
|
97
|
+
// Arrays serialize indices only — enumerable side props would violate
|
|
98
|
+
// the lossless-JSON tool contract, so they are attached only to
|
|
99
|
+
// object endpoints (where rate-limit notes actually read them).
|
|
100
|
+
if (!Array.isArray(value)) {
|
|
101
|
+
Object.defineProperties(out, {
|
|
102
|
+
_headers: { value: hit.headers, enumerable: true },
|
|
103
|
+
_status: { value: hit.status, enumerable: true },
|
|
104
|
+
cached: { value: true, enumerable: true },
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
if (cache !== undefined && ttlMs > 0 && !forceRefresh) {
|
|
110
|
+
const hit = cache.get(url, ttlMs)
|
|
111
|
+
if (hit !== undefined) return withMeta(hit.json, hit)
|
|
112
|
+
}
|
|
113
|
+
const fresh = await fetcher()
|
|
114
|
+
if (cache !== undefined && ttlMs > 0 && fresh.ok) {
|
|
115
|
+
cache.set(url, { json: fresh.json, headers: fresh.headers, status: fresh.status }, ttlMs)
|
|
116
|
+
}
|
|
117
|
+
return withMeta(fresh.json, fresh)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* GET a text/diff endpoint (Accept overridden). Used for PR diffs.
|
|
122
|
+
*/
|
|
123
|
+
async getText(path, accept, call = {}) {
|
|
124
|
+
const url = apiUrl(path, call.query)
|
|
125
|
+
const raw = await call.fetchImpl(url, {
|
|
126
|
+
headers: { ...this.headers(call.token), accept },
|
|
127
|
+
timeoutMs: call.timeoutMs !== undefined ? call.timeoutMs : call.proxy !== undefined ? this.options.proxyTimeoutMs : this.options.directTimeoutMs,
|
|
128
|
+
proxy: call.proxy,
|
|
129
|
+
signal: call.signal,
|
|
130
|
+
maxBytes: this.options.maxBytes,
|
|
131
|
+
retries: this.options.retries,
|
|
132
|
+
})
|
|
133
|
+
if (raw.ok) return { ok: true, status: raw.status, text: raw.body.toString('utf8'), truncated: raw.truncated, headers: raw.headers }
|
|
134
|
+
throw new RouteError(apiErrorCode(raw.status, raw.headers), `GitHub API error (HTTP ${raw.status})`, { status: raw.status })
|
|
135
|
+
}
|
|
136
|
+
}
|
package/lib/routes/gh.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gh CLI route. Only read-only subcommands are ever constructed here:
|
|
3
|
+
* `gh --version`, `gh auth status`, `gh api` (GET by default), and the
|
|
4
|
+
* `--json` viewers (`pr view`, `issue view`, `repo view`). JSON field lists
|
|
5
|
+
* are fixed constants — user input never reaches argv beyond validated
|
|
6
|
+
* owner/repo/number/path values, and nothing is ever passed through a shell.
|
|
7
|
+
* @module dsh-github-router/routes/gh
|
|
8
|
+
*/
|
|
9
|
+
import { childEnv, runCaptured } from '../util.js'
|
|
10
|
+
|
|
11
|
+
const PR_FIELDS =
|
|
12
|
+
'author,title,body,state,isDraft,mergedAt,additions,deletions,changedFiles,' +
|
|
13
|
+
'createdAt,updatedAt,baseRefName,headRefName,headRefOid,baseRefOid,' +
|
|
14
|
+
'mergeable,reviewDecision,reviews,comments,commits,files'
|
|
15
|
+
const ISSUE_FIELDS = 'author,title,body,state,stateReason,createdAt,updatedAt,closedAt,comments,labels,assignees'
|
|
16
|
+
const REPO_FIELDS = 'nameWithOwner,description,defaultBranchRef,stargazerCount,forkCount,updatedAt,licenseInfo,isPrivate'
|
|
17
|
+
|
|
18
|
+
export async function ghVersion(subprocess, signal) {
|
|
19
|
+
const out = await runCaptured(subprocess, { argv: ['gh', '--version'], graceMs: 8_000, signal, env: childEnv(), maxStdout: 4096, maxStderr: 4096 })
|
|
20
|
+
return out.ok
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function ghAuthed(subprocess, signal) {
|
|
24
|
+
const out = await runCaptured(subprocess, { argv: ['gh', 'auth', 'status'], graceMs: 8_000, signal, env: childEnv(), maxStdout: 8192, maxStderr: 8192 })
|
|
25
|
+
return out.ok
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Run a fixed gh invocation and parse its JSON stdout. */
|
|
29
|
+
async function ghJson(subprocess, argv, spec = {}) {
|
|
30
|
+
const out = await runCaptured(subprocess, {
|
|
31
|
+
argv,
|
|
32
|
+
graceMs: spec.graceMs ?? 30_000,
|
|
33
|
+
signal: spec.signal,
|
|
34
|
+
env: childEnv(),
|
|
35
|
+
maxStdout: spec.maxStdout ?? 1048576,
|
|
36
|
+
maxStderr: 262144,
|
|
37
|
+
})
|
|
38
|
+
if (!out.ok) {
|
|
39
|
+
const err = new Error(`gh exited with code ${out.exitCode}: ${out.stderr.text.slice(0, 400)}`)
|
|
40
|
+
err.code = 'GH_FAILED'
|
|
41
|
+
throw err
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(out.stdout.text)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const err = new Error(`gh produced unprocessable JSON: ${String(error && error.message ? error.message : error)}`)
|
|
47
|
+
err.code = 'GH_FAILED'
|
|
48
|
+
throw err
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `gh pr view <owner/repo/N> --json ...` */
|
|
53
|
+
export async function ghPrView(subprocess, owner, repo, number, spec = {}) {
|
|
54
|
+
return ghJson(subprocess, ['gh', 'pr', 'view', `${owner}/${repo}/${number}`, '--json', PR_FIELDS], spec)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `gh pr diff <owner/repo/N>` — capped text of the PR diff. */
|
|
58
|
+
export async function ghPrDiff(subprocess, owner, repo, number, spec = {}) {
|
|
59
|
+
const out = await runCaptured(subprocess, {
|
|
60
|
+
argv: ['gh', 'pr', 'diff', `${owner}/${repo}/${number}`],
|
|
61
|
+
graceMs: 30_000,
|
|
62
|
+
signal: spec.signal,
|
|
63
|
+
env: childEnv(),
|
|
64
|
+
maxStdout: spec.maxStdout ?? 1048576,
|
|
65
|
+
maxStderr: 262144,
|
|
66
|
+
})
|
|
67
|
+
if (!out.ok) {
|
|
68
|
+
const err = new Error(`gh pr diff exited with code ${out.exitCode}: ${out.stderr.text.slice(0, 400)}`)
|
|
69
|
+
err.code = 'GH_FAILED'
|
|
70
|
+
throw err
|
|
71
|
+
}
|
|
72
|
+
return { text: out.stdout.text, truncated: out.stdout.truncated }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `gh issue view <owner/repo/N> --json ...` */
|
|
76
|
+
export async function ghIssueView(subprocess, owner, repo, number, spec = {}) {
|
|
77
|
+
return ghJson(subprocess, ['gh', 'issue', 'view', `${owner}/${repo}/${number}`, '--json', ISSUE_FIELDS], spec)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `gh repo view <owner/repo> --json ...` */
|
|
81
|
+
export async function ghRepoView(subprocess, owner, repo, spec = {}) {
|
|
82
|
+
return ghJson(subprocess, ['gh', 'repo', 'view', `${owner}/${repo}`, '--json', REPO_FIELDS], spec)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `gh api <path>` — GET only, with the validated api path as a single argv
|
|
87
|
+
* element. Output is parsed as JSON when possible, else returned as text.
|
|
88
|
+
*/
|
|
89
|
+
export async function ghApi(subprocess, path, spec = {}) {
|
|
90
|
+
const out = await runCaptured(subprocess, {
|
|
91
|
+
argv: ['gh', 'api', path],
|
|
92
|
+
graceMs: 30_000,
|
|
93
|
+
signal: spec.signal,
|
|
94
|
+
env: childEnv(),
|
|
95
|
+
maxStdout: spec.maxStdout ?? 1048576,
|
|
96
|
+
maxStderr: 262144,
|
|
97
|
+
})
|
|
98
|
+
if (!out.ok) {
|
|
99
|
+
const err = new Error(`gh api exited with code ${out.exitCode}: ${out.stderr.text.slice(0, 400)}`)
|
|
100
|
+
err.code = 'GH_FAILED'
|
|
101
|
+
throw err
|
|
102
|
+
}
|
|
103
|
+
const text = out.stdout.text
|
|
104
|
+
try {
|
|
105
|
+
return { json: JSON.parse(text), truncated: out.stdout.truncated }
|
|
106
|
+
} catch {
|
|
107
|
+
return { json: null, text, truncated: out.stdout.truncated }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git protocol route — the empirically surviving path on machines where
|
|
3
|
+
* HTTPS to github.com/api.github.com is reset.
|
|
4
|
+
*
|
|
5
|
+
* Two strictly separated modes:
|
|
6
|
+
* 1. Plugin fetch cache: a plugin-owned bare-less repo under the host
|
|
7
|
+
* storage dir. `git init` + `git fetch` of pull refs write ONLY there.
|
|
8
|
+
* 2. Local repo reads: user-granted repos (settings `repos`, the session
|
|
9
|
+
* cwd repo when its origin matches, or an explicit per-call path) are
|
|
10
|
+
* read with log/diff/show/rev-parse ONLY. The plugin never fetches into,
|
|
11
|
+
* checks out, resets, or pushes to a user repository.
|
|
12
|
+
*
|
|
13
|
+
* All subprocess calls are argv arrays; nothing is shell-interpolated.
|
|
14
|
+
* @module dsh-github-router/routes/git
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { GIT_CACHE_DIR, childEnv, ownerRepoFromUrl, runCaptured } from '../util.js'
|
|
19
|
+
|
|
20
|
+
const FETCH_GRACE_MS = 90_000
|
|
21
|
+
const READ_GRACE_MS = 20_000
|
|
22
|
+
|
|
23
|
+
export async function gitVersion(subprocess, signal) {
|
|
24
|
+
const out = await runCaptured(subprocess, { argv: ['git', '--version'], graceMs: 8_000, signal, env: childEnv(), maxStdout: 4096, maxStderr: 4096 })
|
|
25
|
+
return out.ok
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function proxyEnv(proxy) {
|
|
29
|
+
if (proxy === undefined || proxy === null || proxy === '') return {}
|
|
30
|
+
return { http_proxy: proxy, https_proxy: proxy, HTTP_PROXY: proxy, HTTPS_PROXY: proxy }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `git ls-remote <url> <ref>` — a pure connectivity + sha probe. */
|
|
34
|
+
export async function gitLsRemote(subprocess, remoteUrl, ref, spec = {}) {
|
|
35
|
+
const out = await runCaptured(subprocess, {
|
|
36
|
+
argv: ['git', 'ls-remote', remoteUrl, ref],
|
|
37
|
+
graceMs: spec.graceMs ?? 20_000,
|
|
38
|
+
signal: spec.signal,
|
|
39
|
+
env: childEnv(proxyEnv(spec.proxy)),
|
|
40
|
+
maxStdout: 8192,
|
|
41
|
+
maxStderr: 8192,
|
|
42
|
+
})
|
|
43
|
+
if (!out.ok) return { ok: false, sha: null, error: (out.stderr.text || out.spawnError || `exit ${out.exitCode}`).slice(0, 300) }
|
|
44
|
+
const match = /^([0-9a-f]{40})\s+/.exec(out.stdout.text)
|
|
45
|
+
if (match === null) return { ok: true, sha: null, error: 'no sha in ls-remote output' }
|
|
46
|
+
return { ok: true, sha: match[1], error: null }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** In-process mutex per cache repo: fetches to one repo are serialized. */
|
|
50
|
+
const repoLocks = new Map()
|
|
51
|
+
async function withRepoLock(key, fn) {
|
|
52
|
+
const prev = repoLocks.get(key) ?? Promise.resolve()
|
|
53
|
+
const next = prev.then(fn, fn)
|
|
54
|
+
repoLocks.set(key, next.catch(() => {}))
|
|
55
|
+
try {
|
|
56
|
+
return await next
|
|
57
|
+
} finally {
|
|
58
|
+
if (repoLocks.get(key) === next) repoLocks.delete(key)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The plugin-owned fetch cache. Every write stays under the host storage
|
|
64
|
+
* dir; nothing outside it is ever created.
|
|
65
|
+
*/
|
|
66
|
+
export class GitFetchCache {
|
|
67
|
+
constructor(subprocess, gitCacheDir = GIT_CACHE_DIR) {
|
|
68
|
+
this.subprocess = subprocess
|
|
69
|
+
this.gitDir = gitCacheDir
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
repoDir(owner, repo) {
|
|
73
|
+
return join(this.gitDir, `${owner}__${repo}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async ensure(owner, repo) {
|
|
77
|
+
const dir = this.repoDir(owner, repo)
|
|
78
|
+
if (existsSync(join(dir, '.git'))) return dir
|
|
79
|
+
try { mkdirSync(this.gitDir, { recursive: true }) } catch { /* exists */ }
|
|
80
|
+
const init = await runCaptured(this.subprocess, {
|
|
81
|
+
argv: ['git', 'init', '--quiet', dir],
|
|
82
|
+
graceMs: 15_000,
|
|
83
|
+
env: childEnv(),
|
|
84
|
+
maxStdout: 8192,
|
|
85
|
+
maxStderr: 8192,
|
|
86
|
+
})
|
|
87
|
+
if (!init.ok) throw new Error(`git init failed: ${init.stderr.text.slice(0, 300)}`)
|
|
88
|
+
const add = await runCaptured(this.subprocess, {
|
|
89
|
+
argv: ['git', '-C', dir, 'remote', 'add', 'origin', `https://github.com/${owner}/${repo}.git`],
|
|
90
|
+
graceMs: 15_000,
|
|
91
|
+
env: childEnv(),
|
|
92
|
+
maxStdout: 8192,
|
|
93
|
+
maxStderr: 8192,
|
|
94
|
+
})
|
|
95
|
+
if (!add.ok) {
|
|
96
|
+
// A race with another process: recover with set-url.
|
|
97
|
+
const set = await runCaptured(this.subprocess, {
|
|
98
|
+
argv: ['git', '-C', dir, 'remote', 'set-url', 'origin', `https://github.com/${owner}/${repo}.git`],
|
|
99
|
+
graceMs: 15_000,
|
|
100
|
+
env: childEnv(),
|
|
101
|
+
maxStdout: 8192,
|
|
102
|
+
maxStderr: 8192,
|
|
103
|
+
})
|
|
104
|
+
if (!set.ok) throw new Error(`git remote setup failed: ${set.stderr.text.slice(0, 300)}`)
|
|
105
|
+
}
|
|
106
|
+
return dir
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch refs/pull/N/{head,merge} into refs/pr/N/* in the cache repo.
|
|
111
|
+
* A missing merge ref (conflicted PR) degrades, not fails.
|
|
112
|
+
*/
|
|
113
|
+
async fetchPr(owner, repo, number, spec = {}) {
|
|
114
|
+
return withRepoLock(`${owner}/${repo}#${number}`, async () => {
|
|
115
|
+
const dir = await this.ensure(owner, repo)
|
|
116
|
+
const env = childEnv(proxyEnv(spec.proxy))
|
|
117
|
+
const head = await runCaptured(this.subprocess, {
|
|
118
|
+
argv: ['git', '-C', dir, 'fetch', '--quiet', '--no-tags', '--depth=64', 'origin', `refs/pull/${number}/head:refs/pr/${number}/head`],
|
|
119
|
+
graceMs: spec.graceMs ?? FETCH_GRACE_MS,
|
|
120
|
+
signal: spec.signal,
|
|
121
|
+
env,
|
|
122
|
+
maxStdout: 8192,
|
|
123
|
+
maxStderr: 16384,
|
|
124
|
+
})
|
|
125
|
+
const merge = await runCaptured(this.subprocess, {
|
|
126
|
+
argv: ['git', '-C', dir, 'fetch', '--quiet', '--no-tags', '--depth=64', 'origin', `refs/pull/${number}/merge:refs/pr/${number}/merge`],
|
|
127
|
+
graceMs: spec.graceMs ?? FETCH_GRACE_MS,
|
|
128
|
+
signal: spec.signal,
|
|
129
|
+
env,
|
|
130
|
+
maxStdout: 8192,
|
|
131
|
+
maxStderr: 16384,
|
|
132
|
+
})
|
|
133
|
+
if (!head.ok) {
|
|
134
|
+
throw new Error(`git fetch failed: ${(head.stderr.text || head.spawnError || `exit ${head.exitCode}`).slice(0, 400)}`)
|
|
135
|
+
}
|
|
136
|
+
return { dir, mergeFetched: merge.ok, mergeError: merge.ok ? null : merge.stderr.text.slice(0, 300) }
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async fetchRef(owner, repo, ref, spec = {}) {
|
|
141
|
+
return withRepoLock(`${owner}/${repo}@${ref}`, async () => {
|
|
142
|
+
const dir = await this.ensure(owner, repo)
|
|
143
|
+
const out = await runCaptured(this.subprocess, {
|
|
144
|
+
argv: ['git', '-C', dir, 'fetch', '--quiet', '--no-tags', '--depth=1', 'origin', `+refs/heads/${ref}:refs/gh-router/${ref}`],
|
|
145
|
+
graceMs: spec.graceMs ?? FETCH_GRACE_MS,
|
|
146
|
+
signal: spec.signal,
|
|
147
|
+
env: childEnv(proxyEnv(spec.proxy)),
|
|
148
|
+
maxStdout: 8192,
|
|
149
|
+
maxStderr: 16384,
|
|
150
|
+
})
|
|
151
|
+
if (!out.ok) {
|
|
152
|
+
throw new Error(`git fetch failed: ${(out.stderr.text || out.spawnError || `exit ${out.exitCode}`).slice(0, 400)}`)
|
|
153
|
+
}
|
|
154
|
+
return { dir }
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Fetch a full commit sha (GitHub allows reachable-sha wants). */
|
|
159
|
+
async fetchSha(owner, repo, sha, spec = {}) {
|
|
160
|
+
return withRepoLock(`${owner}/${repo}@${sha}`, async () => {
|
|
161
|
+
const dir = await this.ensure(owner, repo)
|
|
162
|
+
const out = await runCaptured(this.subprocess, {
|
|
163
|
+
argv: ['git', '-C', dir, 'fetch', '--quiet', '--no-tags', 'origin', sha],
|
|
164
|
+
graceMs: spec.graceMs ?? FETCH_GRACE_MS,
|
|
165
|
+
signal: spec.signal,
|
|
166
|
+
env: childEnv(proxyEnv(spec.proxy)),
|
|
167
|
+
maxStdout: 8192,
|
|
168
|
+
maxStderr: 16384,
|
|
169
|
+
})
|
|
170
|
+
if (!out.ok) {
|
|
171
|
+
throw new Error(`git fetch sha failed: ${(out.stderr.text || out.spawnError || `exit ${out.exitCode}`).slice(0, 400)}`)
|
|
172
|
+
}
|
|
173
|
+
return { dir }
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Fetch the default branch head into FETCH_HEAD. */
|
|
178
|
+
async fetchHead(owner, repo, spec = {}) {
|
|
179
|
+
return withRepoLock(`${owner}/${repo}@HEAD`, async () => {
|
|
180
|
+
const dir = await this.ensure(owner, repo)
|
|
181
|
+
const out = await runCaptured(this.subprocess, {
|
|
182
|
+
argv: ['git', '-C', dir, 'fetch', '--quiet', '--no-tags', '--depth=1', 'origin', 'HEAD'],
|
|
183
|
+
graceMs: spec.graceMs ?? FETCH_GRACE_MS,
|
|
184
|
+
signal: spec.signal,
|
|
185
|
+
env: childEnv(proxyEnv(spec.proxy)),
|
|
186
|
+
maxStdout: 8192,
|
|
187
|
+
maxStderr: 16384,
|
|
188
|
+
})
|
|
189
|
+
if (!out.ok) {
|
|
190
|
+
throw new Error(`git fetch HEAD failed: ${(out.stderr.text || out.spawnError || `exit ${out.exitCode}`).slice(0, 400)}`)
|
|
191
|
+
}
|
|
192
|
+
return { dir }
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --------------------------------------------------------- local repo reads
|
|
198
|
+
|
|
199
|
+
/** The session/local repo the git route may READ, or null. */
|
|
200
|
+
export async function findLocalRepo(subprocess, cwd, owner, repo, extraRepos, localRepo) {
|
|
201
|
+
const candidates = []
|
|
202
|
+
if (typeof localRepo === 'string' && localRepo.trim().length > 0) candidates.push(localRepo.trim())
|
|
203
|
+
for (const r of extraRepos ?? []) candidates.push(r)
|
|
204
|
+
if (typeof cwd === 'string' && cwd.length > 0) candidates.push(cwd)
|
|
205
|
+
for (const candidate of candidates) {
|
|
206
|
+
if (candidate === undefined || candidate === null) continue
|
|
207
|
+
const rootOut = await runCaptured(subprocess, {
|
|
208
|
+
argv: ['git', '-C', candidate, 'rev-parse', '--show-toplevel'],
|
|
209
|
+
graceMs: 10_000,
|
|
210
|
+
env: childEnv(),
|
|
211
|
+
maxStdout: 8192,
|
|
212
|
+
maxStderr: 8192,
|
|
213
|
+
})
|
|
214
|
+
if (!rootOut.ok) continue
|
|
215
|
+
const root = rootOut.stdout.text.trim().replace(/\/+$/, '')
|
|
216
|
+
if (root.length === 0) continue
|
|
217
|
+
const urlOut = await runCaptured(subprocess, {
|
|
218
|
+
argv: ['git', '-C', root, 'config', '--get', 'remote.origin.url'],
|
|
219
|
+
graceMs: 10_000,
|
|
220
|
+
env: childEnv(),
|
|
221
|
+
maxStdout: 8192,
|
|
222
|
+
maxStderr: 8192,
|
|
223
|
+
})
|
|
224
|
+
if (!urlOut.ok) continue
|
|
225
|
+
const parsed = ownerRepoFromUrl(urlOut.stdout.text)
|
|
226
|
+
if (parsed !== null && parsed.owner.toLowerCase() === owner.toLowerCase() && parsed.repo.toLowerCase() === repo.toLowerCase()) {
|
|
227
|
+
return root
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Ref names the local repo may hold for a PR, in lookup order. */
|
|
234
|
+
function prRefCandidates(number) {
|
|
235
|
+
return [
|
|
236
|
+
`refs/pull/${number}/merge`,
|
|
237
|
+
`refs/pull/${number}/head`,
|
|
238
|
+
`refs/remotes/origin/pull/${number}/merge`,
|
|
239
|
+
`refs/remotes/origin/pull/${number}/head`,
|
|
240
|
+
`refs/remotes/origin/pr/${number}`,
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function resolveLocalRef(subprocess, repoDir, candidates) {
|
|
245
|
+
for (const ref of candidates) {
|
|
246
|
+
const out = await runCaptured(subprocess, {
|
|
247
|
+
argv: ['git', '-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
|
|
248
|
+
graceMs: 10_000,
|
|
249
|
+
env: childEnv(),
|
|
250
|
+
maxStdout: 8192,
|
|
251
|
+
maxStderr: 8192,
|
|
252
|
+
})
|
|
253
|
+
if (out.ok && out.stdout.text.trim().length > 0) return ref
|
|
254
|
+
}
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Read PR commits/diff from a LOCAL repo without touching its network or
|
|
260
|
+
* working tree: rev-parse / log / merge-base / diff / show only.
|
|
261
|
+
*/
|
|
262
|
+
export async function readLocalPr(subprocess, repoDir, number, spec = {}) {
|
|
263
|
+
const found = await resolveLocalRef(subprocess, repoDir, prRefCandidates(number))
|
|
264
|
+
if (found === null) return null
|
|
265
|
+
const headRef = found
|
|
266
|
+
const mergeRef = found.endsWith('/merge') ? found : null
|
|
267
|
+
|
|
268
|
+
let base = null
|
|
269
|
+
if (mergeRef !== null) {
|
|
270
|
+
base = `${mergeRef}^1`
|
|
271
|
+
} else {
|
|
272
|
+
// Fall back to the merge base with origin/HEAD when available.
|
|
273
|
+
const headOut = await runCaptured(subprocess, {
|
|
274
|
+
argv: ['git', '-C', repoDir, 'symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
|
|
275
|
+
graceMs: 10_000,
|
|
276
|
+
env: childEnv(),
|
|
277
|
+
maxStdout: 8192,
|
|
278
|
+
maxStderr: 8192,
|
|
279
|
+
})
|
|
280
|
+
if (headOut.ok) {
|
|
281
|
+
const defaultRef = headOut.stdout.text.trim()
|
|
282
|
+
const baseOut = await runCaptured(subprocess, {
|
|
283
|
+
argv: ['git', '-C', repoDir, 'merge-base', defaultRef, headRef],
|
|
284
|
+
graceMs: 10_000,
|
|
285
|
+
env: childEnv(),
|
|
286
|
+
maxStdout: 8192,
|
|
287
|
+
maxStderr: 8192,
|
|
288
|
+
})
|
|
289
|
+
if (baseOut.ok && baseOut.stdout.text.trim().length > 0) base = baseOut.stdout.text.trim()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const commitsOut = await runCaptured(subprocess, {
|
|
294
|
+
argv: ['git', '-C', repoDir, 'log', '--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b%x1e', base !== null ? `${base}..${headRef}` : `-50 ${headRef}`, '--no-merges'],
|
|
295
|
+
graceMs: spec.graceMs ?? READ_GRACE_MS,
|
|
296
|
+
signal: spec.signal,
|
|
297
|
+
env: childEnv(),
|
|
298
|
+
maxStdout: spec.maxStdout ?? 1048576,
|
|
299
|
+
maxStderr: 16384,
|
|
300
|
+
})
|
|
301
|
+
let diff = null
|
|
302
|
+
let diffTruncated = false
|
|
303
|
+
if (base !== null) {
|
|
304
|
+
const diffOut = await runCaptured(subprocess, {
|
|
305
|
+
argv: ['git', '-C', repoDir, 'diff', '--find-renames', base, headRef],
|
|
306
|
+
graceMs: spec.graceMs ?? READ_GRACE_MS,
|
|
307
|
+
signal: spec.signal,
|
|
308
|
+
env: childEnv(),
|
|
309
|
+
maxStdout: spec.maxDiffBytes ?? 1048576,
|
|
310
|
+
maxStderr: 16384,
|
|
311
|
+
})
|
|
312
|
+
if (diffOut.ok) {
|
|
313
|
+
diff = diffOut.stdout.text
|
|
314
|
+
diffTruncated = diffOut.stdout.truncated
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
commits: parseCommitLog(commitsOut.ok ? commitsOut.stdout.text : ''),
|
|
319
|
+
commitsTruncated: commitsOut.stdout.truncated,
|
|
320
|
+
diff,
|
|
321
|
+
diffTruncated,
|
|
322
|
+
headRef,
|
|
323
|
+
baseRef: base,
|
|
324
|
+
notes: commitsOut.ok ? [] : [commitsOut.stderr.text.slice(0, 300)],
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Read one file at a local ref with `git show` — read-only. */
|
|
329
|
+
export async function readLocalFile(subprocess, repoDir, ref, path, spec = {}) {
|
|
330
|
+
const out = await runCaptured(subprocess, {
|
|
331
|
+
argv: ['git', '-C', repoDir, 'show', `${ref}:${path}`],
|
|
332
|
+
graceMs: spec.graceMs ?? READ_GRACE_MS,
|
|
333
|
+
signal: spec.signal,
|
|
334
|
+
env: childEnv(),
|
|
335
|
+
maxStdout: spec.maxStdout ?? 1048576,
|
|
336
|
+
maxStderr: 16384,
|
|
337
|
+
})
|
|
338
|
+
if (!out.ok) {
|
|
339
|
+
const err = new Error(`git show failed: ${out.stderr.text.slice(0, 300)}`)
|
|
340
|
+
err.code = 'GIT_FAILED'
|
|
341
|
+
throw err
|
|
342
|
+
}
|
|
343
|
+
return { content: out.stdout.text, truncated: out.stdout.truncated }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Read PR commits/diff from the plugin fetch cache (refs/pr/N/*). */
|
|
347
|
+
export async function readCachePr(subprocess, dir, number, spec = {}) {
|
|
348
|
+
const mergeCheck = await runCaptured(subprocess, {
|
|
349
|
+
argv: ['git', '-C', dir, 'rev-parse', '--verify', '--quiet', `refs/pr/${number}/merge^{commit}`],
|
|
350
|
+
graceMs: 10_000,
|
|
351
|
+
env: childEnv(),
|
|
352
|
+
maxStdout: 4096,
|
|
353
|
+
maxStderr: 4096,
|
|
354
|
+
})
|
|
355
|
+
const hasMerge = mergeCheck.ok && mergeCheck.stdout.text.trim().length > 0
|
|
356
|
+
const headRef = `refs/pr/${number}/head`
|
|
357
|
+
const base = hasMerge ? `refs/pr/${number}/merge^1` : null
|
|
358
|
+
|
|
359
|
+
const commitsOut = await runCaptured(subprocess, {
|
|
360
|
+
argv: ['git', '-C', dir, 'log', '--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b%x1e', base !== null ? `${base}..${headRef}` : `-50 ${headRef}`, '--no-merges'],
|
|
361
|
+
graceMs: spec.graceMs ?? READ_GRACE_MS,
|
|
362
|
+
signal: spec.signal,
|
|
363
|
+
env: childEnv(),
|
|
364
|
+
maxStdout: spec.maxStdout ?? 1048576,
|
|
365
|
+
maxStderr: 16384,
|
|
366
|
+
})
|
|
367
|
+
let diff = null
|
|
368
|
+
let diffTruncated = false
|
|
369
|
+
if (base !== null) {
|
|
370
|
+
const diffOut = await runCaptured(subprocess, {
|
|
371
|
+
argv: ['git', '-C', dir, 'diff', '--find-renames', base, headRef],
|
|
372
|
+
graceMs: spec.graceMs ?? READ_GRACE_MS,
|
|
373
|
+
signal: spec.signal,
|
|
374
|
+
env: childEnv(),
|
|
375
|
+
maxStdout: spec.maxDiffBytes ?? 1048576,
|
|
376
|
+
maxStderr: 16384,
|
|
377
|
+
})
|
|
378
|
+
if (diffOut.ok) {
|
|
379
|
+
diff = diffOut.stdout.text
|
|
380
|
+
diffTruncated = diffOut.stdout.truncated
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
commits: parseCommitLog(commitsOut.ok ? commitsOut.stdout.text : ''),
|
|
385
|
+
commitsTruncated: commitsOut.stdout.truncated,
|
|
386
|
+
diff,
|
|
387
|
+
diffTruncated,
|
|
388
|
+
headRef,
|
|
389
|
+
baseRef: base,
|
|
390
|
+
notes: commitsOut.ok ? [] : [commitsOut.stderr.text.slice(0, 300)],
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Read one file at a ref in the plugin cache with `git show`. */
|
|
395
|
+
export async function readCacheFile(subprocess, dir, ref, path, spec = {}) {
|
|
396
|
+
const out = await runCaptured(subprocess, {
|
|
397
|
+
argv: ['git', '-C', dir, 'show', `${ref}:${path}`],
|
|
398
|
+
graceMs: spec.graceMs ?? READ_GRACE_MS,
|
|
399
|
+
signal: spec.signal,
|
|
400
|
+
env: childEnv(),
|
|
401
|
+
maxStdout: spec.maxStdout ?? 1048576,
|
|
402
|
+
maxStderr: 16384,
|
|
403
|
+
})
|
|
404
|
+
if (!out.ok) {
|
|
405
|
+
const err = new Error(`git show failed: ${out.stderr.text.slice(0, 300)}`)
|
|
406
|
+
err.code = 'GIT_FAILED'
|
|
407
|
+
throw err
|
|
408
|
+
}
|
|
409
|
+
return { content: out.stdout.text, truncated: out.stdout.truncated }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Parse the %x1f/%x1e-delimited commit log into structured entries. */
|
|
413
|
+
export function parseCommitLog(text) {
|
|
414
|
+
const entries = String(text ?? '').split('\x1e')
|
|
415
|
+
const out = []
|
|
416
|
+
for (const entry of entries) {
|
|
417
|
+
if (entry.trim().length === 0) continue
|
|
418
|
+
const parts = entry.split('\x1f')
|
|
419
|
+
if (parts.length < 5) continue
|
|
420
|
+
out.push({
|
|
421
|
+
sha: parts[0].trim().slice(0, 40),
|
|
422
|
+
author: parts[1].trim(),
|
|
423
|
+
email: parts[2].trim(),
|
|
424
|
+
date: parts[3].trim(),
|
|
425
|
+
message: parts.slice(4).join('\x1f').replace(/^\n+/, '').slice(0, 2000),
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
return out
|
|
429
|
+
}
|