pi-odd-worktree 0.0.0-stage → 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/README.md +223 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
package/lib/gh-issues.js
ADDED
|
@@ -0,0 +1,1555 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `gh` client migrated whole from the source package's issues panel (T2
|
|
3
|
+
// S1,S3): `/issues` reads through it once that command is wired.
|
|
4
|
+
//
|
|
5
|
+
// This module is the ONLY place that runs `gh`. Every spawn uses
|
|
6
|
+
// `child_process.execFile` with an argv array and NO shell string, so no
|
|
7
|
+
// argument (a search query, a comment body, a label name) can ever be
|
|
8
|
+
// interpreted by a shell. Nothing here prints or persists a token and
|
|
9
|
+
// nothing writes user config: authentication stays entirely inside `gh`.
|
|
10
|
+
//
|
|
11
|
+
// Injectable runner seam: `setGhRunner(fn)` replaces the default
|
|
12
|
+
// `execFile` runner with `fn(bin, argv, {cwd, timeoutMs})` resolving to
|
|
13
|
+
// `{status, stdout, stderr, error}`. Tests inject captured REAL `gh` JSON
|
|
14
|
+
// through it and never invoke the real binary. Pass `null` to restore the
|
|
15
|
+
// default runner.
|
|
16
|
+
//
|
|
17
|
+
// Every exported call returns `{ok:true, ...}` or `{ok:false, error}` and
|
|
18
|
+
// never throws — including a missing binary (ENOENT), a non-zero exit,
|
|
19
|
+
// an empty body and malformed JSON.
|
|
20
|
+
//
|
|
21
|
+
// Structured data always comes from `gh --json` output. Verified against
|
|
22
|
+
// `gh <cmd> --help` in the source repository:
|
|
23
|
+
// - `gh issue list` supports `--state {open|closed|all}`, `--search`,
|
|
24
|
+
// `--limit` and `--json`, but NO offset/page flag — so `listIssues`
|
|
25
|
+
// fetches up to a bounded limit and pages the result in memory.
|
|
26
|
+
// - `gh issue view` accepts `--json` with fields including
|
|
27
|
+
// number,title,state,stateReason,author,labels,createdAt,updatedAt,
|
|
28
|
+
// comments,reactionGroups,url,body, but REJECTS `--comments` together
|
|
29
|
+
// with `--json` (`specify only one of --comments or --json`) — so
|
|
30
|
+
// `getIssue` reads comments from the single `--json` call, which embeds
|
|
31
|
+
// them. `authorAssociation`, `reactions` and `isPullRequest` are NOT
|
|
32
|
+
// valid `--json` fields (gh errors `Unknown JSON field`), so the
|
|
33
|
+
// normalized shapes carry honest defaults for those instead of asking
|
|
34
|
+
// `gh` for fields it cannot return.
|
|
35
|
+
// - `gh label list --json name,color,description` and
|
|
36
|
+
// `gh label create <name> [--color … --description …]` as documented.
|
|
37
|
+
// - `gh issue edit --add-label/--remove-label`, `gh issue comment --body`,
|
|
38
|
+
// `gh issue close` / `gh issue reopen` as documented.
|
|
39
|
+
// - Identity via `gh api user` (JSON with `login`) and the repository via
|
|
40
|
+
// `gh repo view --json nameWithOwner`; `gh auth status` failures surface
|
|
41
|
+
// as unauthenticated errors from the same calls.
|
|
42
|
+
//
|
|
43
|
+
// FORK SCOPE: `gh` resolves a fork checkout's BASE repository. With both
|
|
44
|
+
// `origin` (the fork) and `upstream` (the original) configured, `gh repo view`
|
|
45
|
+
// answers the ORIGINAL, so unscoped `gh issue` reads and every
|
|
46
|
+
// `repository(owner, name)` query listed the original's issues on every reader
|
|
47
|
+
// surface. The checkout's OWN repository is an unambiguous NON-BASE remote:
|
|
48
|
+
// `branch.<branch>.pushRemote`, `remote.pushDefault`, `origin` or the tracking
|
|
49
|
+
// remote when one of them is not named like a base, otherwise the only non-base
|
|
50
|
+
// remote. A branch that merely TRACKS or pushes to `upstream` never makes the
|
|
51
|
+
// base the scope, and two or more non-base remotes with no attach point is
|
|
52
|
+
// AMBIGUOUS: the whole resolution fails closed instead of guessing a fork or
|
|
53
|
+
// silently reading the base. `checkoutRepoScope` reads that from git (never the
|
|
54
|
+
// network) and every gh call here carries it as a HOST-QUALIFIED `GH_REPO`
|
|
55
|
+
// (`<host>/<owner>/<repo>`, the form gh accepts), while repository resolution
|
|
56
|
+
// reports it instead of `gh`'s base answer. A checkout with a single remote has
|
|
57
|
+
// nothing to disambiguate, so it keeps `gh`'s resolution exactly; a
|
|
58
|
+
// caller-supplied `GH_REPO` always wins at resolution time; and a call that
|
|
59
|
+
// names no checkout (`cwd`) keeps `gh`'s default too.
|
|
60
|
+
//
|
|
61
|
+
// FAIL CLOSED: the selected head remote must PROVE it names a GitHub repository
|
|
62
|
+
// — the GitHub host gh will actually target and a valid `owner/name`. A
|
|
63
|
+
// local/`file://` remote, a remote on another host, an unreadable URL, a
|
|
64
|
+
// resolution error or an invalid identifier fails the whole resolution: no
|
|
65
|
+
// GH_REPO is injected and no gh command runs, so an issue read or write can
|
|
66
|
+
// never be redirected to a different repository. The same remote is never
|
|
67
|
+
// swapped for another one to make the check pass, and a self-hosted GitHub
|
|
68
|
+
// Enterprise host is accepted only when `GH_HOST` names it — in which case the
|
|
69
|
+
// remote must be on that EXACT server, because gh targets GH_HOST whatever the
|
|
70
|
+
// remote says. Any non-github.com server requires GH_HOST for the same reason
|
|
71
|
+
// from the other side: `gh api`/GraphQL use the default host. A resolved scope is reused for a two-second read window and then
|
|
72
|
+
// revalidated against the live git configuration hash, so a changed remote is
|
|
73
|
+
// followed quickly; the write path resolves it fresh at the moment it runs.
|
|
74
|
+
//
|
|
75
|
+
// PINNED IDENTITY: a caller may hand back the identity it already
|
|
76
|
+
// displayed. It is always normalized to `<host>/<owner>/<repo>`, injected
|
|
77
|
+
// verbatim as GH_REPO (so an ambient GH_REPO cannot override it) and validated
|
|
78
|
+
// against the caller's live environment: a pin naming one server while GH_HOST
|
|
79
|
+
// selects another fails closed instead of being redirected to that host.
|
|
80
|
+
|
|
81
|
+
const { execFile } = require('node:child_process');
|
|
82
|
+
const crypto = require('node:crypto');
|
|
83
|
+
const path = require('node:path');
|
|
84
|
+
|
|
85
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
86
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
87
|
+
const LIST_FETCH_MAX = 500;
|
|
88
|
+
const LABEL_FETCH_MAX = 500;
|
|
89
|
+
const DEFAULT_PAGE_SIZE = 30;
|
|
90
|
+
const MAX_PAGE_SIZE = 100;
|
|
91
|
+
// `gh issue list` has no offset flag and the SEARCH API caps its own result
|
|
92
|
+
// set, so a complete open-issue snapshot is read through the GraphQL
|
|
93
|
+
// connection: `first` is the server page size and `after` walks it to the end.
|
|
94
|
+
const GRAPHQL_PAGE_SIZE = 100;
|
|
95
|
+
// Runaway guard, never a data cutoff: 100 issues per page, so only a
|
|
96
|
+
// pathological repository (250k open issues) can reach it. Every page also
|
|
97
|
+
// runs under DEFAULT_TIMEOUT_MS, so a hung request cannot hold the loop.
|
|
98
|
+
const SNAPSHOT_MAX_PAGES = 2500;
|
|
99
|
+
// Search-syntax qualifier: this client asks `gh` for last-update ordering so
|
|
100
|
+
// every consumer inherits it.
|
|
101
|
+
const LAST_UPDATED_SORT = 'sort:updated-desc';
|
|
102
|
+
|
|
103
|
+
const ISSUE_LIST_JSON_FIELDS = [
|
|
104
|
+
'number', 'title', 'state', 'stateReason', 'author', 'labels',
|
|
105
|
+
'createdAt', 'updatedAt', 'comments', 'reactionGroups', 'url',
|
|
106
|
+
].join(',');
|
|
107
|
+
// The detail pane needs the issue `body`, so the single-issue fetch keeps
|
|
108
|
+
// the full field set; the list never asks for it (getIssue owns bodies).
|
|
109
|
+
const ISSUE_DETAIL_JSON_FIELDS = [
|
|
110
|
+
'number', 'title', 'state', 'stateReason', 'author', 'assignees', 'labels',
|
|
111
|
+
'createdAt', 'updatedAt', 'comments', 'reactionGroups', 'url', 'body',
|
|
112
|
+
].join(',');
|
|
113
|
+
const LABEL_JSON_FIELDS = 'name,color,description';
|
|
114
|
+
|
|
115
|
+
// The whole open set as a cursor-paged connection. The row fields are the ones
|
|
116
|
+
// `normalizeIssue` reads, in the GraphQL shapes (`labels`/`comments` arrive as
|
|
117
|
+
// connections); `graphqlIssueRow` reshapes them.
|
|
118
|
+
const OPEN_ISSUES_QUERY = `query($owner: String!, $name: String!, $first: Int!, $cursor: String) {
|
|
119
|
+
repository(owner: $owner, name: $name) {
|
|
120
|
+
issues(states: OPEN, first: $first, after: $cursor, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
121
|
+
totalCount
|
|
122
|
+
pageInfo { hasNextPage endCursor }
|
|
123
|
+
nodes {
|
|
124
|
+
number
|
|
125
|
+
title
|
|
126
|
+
state
|
|
127
|
+
stateReason
|
|
128
|
+
url
|
|
129
|
+
createdAt
|
|
130
|
+
updatedAt
|
|
131
|
+
author { login }
|
|
132
|
+
labels(first: 50) { nodes { name color description } }
|
|
133
|
+
comments { totalCount }
|
|
134
|
+
reactionGroups { content users { totalCount } }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}`;
|
|
139
|
+
|
|
140
|
+
let runnerOverride = null;
|
|
141
|
+
|
|
142
|
+
// Test seam: inject `fn(bin, argv, runOpts)` (may be sync or async), or
|
|
143
|
+
// `null`/`undefined` to restore the default `execFile` runner.
|
|
144
|
+
function setGhRunner(fn) {
|
|
145
|
+
if (fn === null || fn === undefined) {
|
|
146
|
+
runnerOverride = null;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (typeof fn !== 'function') throw new TypeError('setGhRunner requires a function or null');
|
|
150
|
+
runnerOverride = fn;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ghBinary(opts = {}) {
|
|
154
|
+
if (typeof opts.ghBin === 'string' && opts.ghBin.length > 0) return opts.ghBin;
|
|
155
|
+
const env = opts.env || process.env;
|
|
156
|
+
const fromEnv = env && env.GH_BIN_PATH;
|
|
157
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
|
|
158
|
+
return 'gh';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Checkout scope: which repository this checkout belongs to.
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
// A remote named like a base is never the fork's own repository.
|
|
166
|
+
const BASE_REMOTE_PATTERN = /^(upstream|parent|base)$/i;
|
|
167
|
+
// Hosts this module can prove are GitHub without asking the network. With
|
|
168
|
+
// GH_HOST set, gh targets THAT server whatever the remote says, so the remote
|
|
169
|
+
// must name the same server; without it, gh derives the host from the remote,
|
|
170
|
+
// and only a proven GitHub host is accepted.
|
|
171
|
+
const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']);
|
|
172
|
+
// GitHub's own identifier rules. An owner is 1-39 alphanumerics and hyphens
|
|
173
|
+
// (no leading or trailing hyphen); a repository name is 1-100 of alphanumerics,
|
|
174
|
+
// `.`, `_` and `-`. Anything else is not a repository this client may target.
|
|
175
|
+
const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
176
|
+
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
|
|
177
|
+
// A resolved scope is cached per checkout. Reads reuse it for a very short
|
|
178
|
+
// window (so one screenful is not a git probe per gh call) and every reuse
|
|
179
|
+
// after that window revalidates it against the hash of the checkout's live
|
|
180
|
+
// local git configuration, so `git remote set-url/add/remove` invalidates it
|
|
181
|
+
// quickly. The longer max age is the backstop for configuration living
|
|
182
|
+
// OUTSIDE `--local` (a global `url.<base>.insteadOf`, for example). The write
|
|
183
|
+
// path never reuses the cache at all: it resolves fresh.
|
|
184
|
+
const REPO_SCOPE_CACHE_MAX = 64;
|
|
185
|
+
const REPO_SCOPE_FRESH_WINDOW_MS = 2000;
|
|
186
|
+
const REPO_SCOPE_MAX_AGE_MS = 60000;
|
|
187
|
+
|
|
188
|
+
let gitRunnerOverride = null;
|
|
189
|
+
|
|
190
|
+
// Test seam: inject `fn(argv, {cwd, timeoutMs})` returning
|
|
191
|
+
// `{status, stdout, stderr, error}` (sync or async), or `null`/`undefined` to
|
|
192
|
+
// restore the real git runner. A throw or rejection propagates to the resolver,
|
|
193
|
+
// whose caller fails closed rather than trusting another repository.
|
|
194
|
+
function setGitRunner(fn) {
|
|
195
|
+
if (fn === null || fn === undefined) {
|
|
196
|
+
gitRunnerOverride = null;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (typeof fn !== 'function') throw new TypeError('setGitRunner requires a function or null');
|
|
200
|
+
gitRunnerOverride = fn;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function gitResult(raw) {
|
|
204
|
+
const outcome = raw && typeof raw === 'object' ? raw : {};
|
|
205
|
+
return {
|
|
206
|
+
status: typeof outcome.status === 'number' ? outcome.status : null,
|
|
207
|
+
stdout: typeof outcome.stdout === 'string' ? outcome.stdout : '',
|
|
208
|
+
stderr: typeof outcome.stderr === 'string' ? outcome.stderr : '',
|
|
209
|
+
error: outcome.error === null || outcome.error === undefined ? null : String(outcome.error),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Real git, fixed argv, no shell string, bounded timeout. Any spawn failure is
|
|
214
|
+
// reported as `error` (never thrown), so a resolution can fail closed.
|
|
215
|
+
function defaultGitRunner(argv, opts = {}) {
|
|
216
|
+
const timeout = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
217
|
+
? Math.floor(opts.timeoutMs)
|
|
218
|
+
: GIT_TIMEOUT_MS;
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
try {
|
|
221
|
+
execFile('git', argv, {
|
|
222
|
+
cwd: opts.cwd,
|
|
223
|
+
timeout,
|
|
224
|
+
encoding: 'utf8',
|
|
225
|
+
windowsHide: true,
|
|
226
|
+
maxBuffer: 1024 * 1024,
|
|
227
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
228
|
+
}, (err, stdout, stderr) => {
|
|
229
|
+
if (!err) {
|
|
230
|
+
resolve({ status: 0, stdout: String(stdout || ''), stderr: String(stderr || ''), error: null });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
resolve({
|
|
234
|
+
status: typeof err.code === 'number' ? err.code : null,
|
|
235
|
+
stdout: String(stdout || ''),
|
|
236
|
+
stderr: String(stderr || ''),
|
|
237
|
+
error: err.killed ? `git timed out after ${timeout}ms` : null,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
resolve({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function runGit(argv, opts = {}) {
|
|
247
|
+
const timeoutMs = opts.timeoutMs;
|
|
248
|
+
if (gitRunnerOverride) {
|
|
249
|
+
return Promise.resolve()
|
|
250
|
+
.then(() => gitRunnerOverride(argv, { cwd: opts.cwd, timeoutMs }))
|
|
251
|
+
.then(gitResult);
|
|
252
|
+
}
|
|
253
|
+
return defaultGitRunner(argv, { cwd: opts.cwd, timeoutMs }).then(gitResult);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function configFingerprint(stdout) {
|
|
257
|
+
return crypto.createHash('sha256').update(String(stdout || '')).digest('hex');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// `git config --local --null --list` records as `key\nvalue\0`; the value may
|
|
261
|
+
// contain newlines, so only the FIRST newline separates key from value. The
|
|
262
|
+
// raw configuration is never stored or printed — only parsed in memory and
|
|
263
|
+
// hashed, because it can carry credentials.
|
|
264
|
+
function parseConfigList(stdout) {
|
|
265
|
+
const config = new Map();
|
|
266
|
+
for (const record of String(stdout || '').split('\0')) {
|
|
267
|
+
if (record === '') continue;
|
|
268
|
+
const cut = record.indexOf('\n');
|
|
269
|
+
if (cut < 0) continue;
|
|
270
|
+
config.set(record.slice(0, cut), record.slice(cut + 1));
|
|
271
|
+
}
|
|
272
|
+
return config;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A remote exists when its configuration does. Names keep the case git stored
|
|
276
|
+
// them with (a subsection is case-sensitive).
|
|
277
|
+
function configRemotes(config) {
|
|
278
|
+
const names = [];
|
|
279
|
+
for (const key of config.keys()) {
|
|
280
|
+
const match = key.match(/^remote\.(.+)\.(?:url|pushurl)$/);
|
|
281
|
+
if (match && !names.includes(match[1])) names.push(match[1]);
|
|
282
|
+
}
|
|
283
|
+
return names;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Exactly ONE head remote is selected. A checkout with a single remote has
|
|
287
|
+
// nothing to disambiguate, so it keeps gh's own resolution (the caller then
|
|
288
|
+
// pins gh's answer, host included). With several remotes, the checkout's own
|
|
289
|
+
// repository is an unambiguous NON-BASE remote:
|
|
290
|
+
// 1. an explicit attach point wins when it is NOT named like a base:
|
|
291
|
+
// branch.<branch>.pushRemote, remote.pushDefault, `origin`, then the
|
|
292
|
+
// branch's tracking remote. A branch that merely TRACKS or pushes to
|
|
293
|
+
// `upstream` therefore never makes the base the scope;
|
|
294
|
+
// 2. otherwise exactly one non-base remote is the checkout's own repository;
|
|
295
|
+
// 3. otherwise (two or more non-base remotes and no attach point) the
|
|
296
|
+
// selection is AMBIGUOUS and the whole resolution fails closed: a base
|
|
297
|
+
// remote is never silently chosen and no other remote is guessed.
|
|
298
|
+
// The verdict is final — consumers never slide to another remote when it fails.
|
|
299
|
+
function selectHeadRemote(config, branch) {
|
|
300
|
+
const remotes = configRemotes(config);
|
|
301
|
+
if (remotes.length < 2) return { ok: true, remote: null };
|
|
302
|
+
const isBase = (name) => BASE_REMOTE_PATTERN.test(name);
|
|
303
|
+
const pushDefault = config.get('remote.pushdefault') || null;
|
|
304
|
+
const pushRemote = branch ? (config.get(`branch.${branch}.pushremote`) || null) : null;
|
|
305
|
+
const branchRemote = branch ? (config.get(`branch.${branch}.remote`) || null) : null;
|
|
306
|
+
const candidates = [];
|
|
307
|
+
if (pushRemote) candidates.push(pushRemote);
|
|
308
|
+
if (pushDefault) candidates.push(pushDefault);
|
|
309
|
+
candidates.push('origin');
|
|
310
|
+
if (branchRemote && branchRemote !== '.') candidates.push(branchRemote);
|
|
311
|
+
for (const name of candidates) {
|
|
312
|
+
if (remotes.includes(name) && !isBase(name)) return { ok: true, remote: name };
|
|
313
|
+
}
|
|
314
|
+
const nonBase = remotes.filter((name) => !isBase(name));
|
|
315
|
+
if (nonBase.length === 0) return { ok: true, remote: null };
|
|
316
|
+
if (nonBase.length === 1) return { ok: true, remote: nonBase[0] };
|
|
317
|
+
const names = nonBase.map((name) => `"${name}"`).join(', ');
|
|
318
|
+
const hint = branch
|
|
319
|
+
? `set remote.pushDefault or branch.${branch}.pushRemote to the fork`
|
|
320
|
+
: 'set remote.pushDefault or branch.<branch>.pushRemote to the fork';
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: `cannot tell which checkout remote is this repository's own: ${names} are all non-base remotes; ${hint}`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function scopeFailure(remote, reason) {
|
|
328
|
+
return { ok: false, error: `checkout remote "${remote}" ${reason}` };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function ghHostOf(env) {
|
|
332
|
+
const raw = env && typeof env.GH_HOST === 'string' ? env.GH_HOST.trim().toLowerCase() : '';
|
|
333
|
+
return raw.replace(/\/+$/, '');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The canonical host an identity string carries: lowercase, no trailing slash,
|
|
337
|
+
// and `www.github.com` folded into `github.com`, so the same server always
|
|
338
|
+
// yields the same identity string.
|
|
339
|
+
function canonicalHost(raw) {
|
|
340
|
+
const host = String(raw || '').trim().toLowerCase().replace(/\/+$/, '');
|
|
341
|
+
return host === 'www.github.com' ? 'github.com' : host;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The server a scope may target. github.com is gh's default. Any other server
|
|
345
|
+
// — GitHub Enterprise Server or `*.ghe.com` — is only reachable CONSISTENTLY
|
|
346
|
+
// when GH_HOST names it: repository-scoped commands take a host from GH_REPO,
|
|
347
|
+
// but `gh api`/GraphQL (every search and connection read) use the default host,
|
|
348
|
+
// which is github.com without GH_HOST. Accepting an enterprise remote without
|
|
349
|
+
// GH_HOST would send the same `owner/name` to github.com for those reads, so it
|
|
350
|
+
// is refused. With GH_HOST set, the remote must name that EXACT server; a
|
|
351
|
+
// different host is the same `owner/name` somewhere else, so it is refused too.
|
|
352
|
+
function hostVerdict(authority, env) {
|
|
353
|
+
const required = ghHostOf(env);
|
|
354
|
+
if (required !== '') {
|
|
355
|
+
if (authority === required) return { ok: true };
|
|
356
|
+
return { ok: false, error: `is on ${authority}, but GH_HOST selects ${required}` };
|
|
357
|
+
}
|
|
358
|
+
if (GITHUB_HOSTS.has(authority)) return { ok: true };
|
|
359
|
+
if (authority.endsWith('.ghe.com') || authority.startsWith('github.') || authority.includes('.github.')) {
|
|
360
|
+
return { ok: false, error: `is on ${authority}, which gh reaches only when GH_HOST selects it` };
|
|
361
|
+
}
|
|
362
|
+
return { ok: false, error: `is on ${authority}, which is not a GitHub host` };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// A remote URL as a GitHub `owner/name`, or a fail-closed reason. Scheme URLs
|
|
366
|
+
// are read through `URL`; the scp-like form (`git@host:owner/name.git`) is
|
|
367
|
+
// parsed by its own shape. `file://`, absolute/relative paths and Windows drive
|
|
368
|
+
// paths are LOCAL remotes and are never GitHub names; a percent-encoded path is
|
|
369
|
+
// rejected outright because no GitHub identifier contains `%`.
|
|
370
|
+
function parseRemoteRepo(raw, env, remote) {
|
|
371
|
+
const url = String(raw || '').trim();
|
|
372
|
+
if (url === '') return scopeFailure(remote, 'has no URL');
|
|
373
|
+
let authority = '';
|
|
374
|
+
let pathPart = '';
|
|
375
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
376
|
+
let parsed;
|
|
377
|
+
try {
|
|
378
|
+
parsed = new URL(url);
|
|
379
|
+
} catch {
|
|
380
|
+
return scopeFailure(remote, 'is not a usable URL');
|
|
381
|
+
}
|
|
382
|
+
if (parsed.protocol === 'file:') return scopeFailure(remote, 'is a local path, not a GitHub repository');
|
|
383
|
+
authority = parsed.host.toLowerCase();
|
|
384
|
+
pathPart = parsed.pathname;
|
|
385
|
+
} else if (/^[A-Za-z]:[\\/]/.test(url) || url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) {
|
|
386
|
+
return scopeFailure(remote, 'is a local path, not a GitHub repository');
|
|
387
|
+
} else {
|
|
388
|
+
const scp = url.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/);
|
|
389
|
+
if (!scp) return scopeFailure(remote, 'is not a GitHub repository URL');
|
|
390
|
+
authority = scp[1].toLowerCase();
|
|
391
|
+
pathPart = scp[2];
|
|
392
|
+
}
|
|
393
|
+
if (authority === '') return scopeFailure(remote, 'is not a GitHub repository URL');
|
|
394
|
+
const verdict = hostVerdict(authority, env);
|
|
395
|
+
if (verdict.ok !== true) return scopeFailure(remote, verdict.error);
|
|
396
|
+
if (pathPart.includes('%')) return scopeFailure(remote, 'names an invalid GitHub repository');
|
|
397
|
+
const segments = pathPart.split('/').map((segment) => segment.trim()).filter(Boolean);
|
|
398
|
+
if (segments.length !== 2) return scopeFailure(remote, 'names an invalid GitHub repository');
|
|
399
|
+
const owner = segments[0];
|
|
400
|
+
const name = segments[1].replace(/\.git$/i, '');
|
|
401
|
+
if (!GITHUB_OWNER_PATTERN.test(owner) || !GITHUB_REPO_PATTERN.test(name)
|
|
402
|
+
|| name === '.' || name === '..') {
|
|
403
|
+
return scopeFailure(remote, 'names an invalid GitHub repository');
|
|
404
|
+
}
|
|
405
|
+
const host = canonicalHost(authority);
|
|
406
|
+
const nameWithOwner = `${owner}/${name}`;
|
|
407
|
+
// The identity gh accepts for both GH_REPO and `-R`: `[HOST/]OWNER/REPO`.
|
|
408
|
+
// Carrying the host is what keeps the same owner/name on two servers apart.
|
|
409
|
+
return { ok: true, host, nameWithOwner, identity: `${host}/${nameWithOwner}` };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A caller-supplied identity (`GH_REPO`, or the identity a caller already
|
|
413
|
+
// displayed) normalized to the HOST-QUALIFIED `<host>/<owner>/<repo>` form.
|
|
414
|
+
// A bare `owner/name` is qualified with the host gh would use (GH_HOST, else
|
|
415
|
+
// github.com). A value that names one server while GH_HOST selects another, or
|
|
416
|
+
// a host that is not provably GitHub, fails closed: a pin is never redirected.
|
|
417
|
+
function normalizeScopeIdentity(value, env = process.env) {
|
|
418
|
+
const text = String(value === undefined || value === null ? '' : value).trim();
|
|
419
|
+
if (text === '') return { ok: false, error: 'no repository identity was given' };
|
|
420
|
+
const segments = text.split('/');
|
|
421
|
+
let host;
|
|
422
|
+
let owner;
|
|
423
|
+
let name;
|
|
424
|
+
if (segments.length === 2) {
|
|
425
|
+
host = ghHostOf(env) || 'github.com';
|
|
426
|
+
owner = segments[0];
|
|
427
|
+
name = segments[1];
|
|
428
|
+
} else if (segments.length === 3) {
|
|
429
|
+
host = canonicalHost(segments[0]);
|
|
430
|
+
owner = segments[1];
|
|
431
|
+
name = segments[2];
|
|
432
|
+
} else {
|
|
433
|
+
return { ok: false, error: `repository identity "${text}" is not in [HOST/]OWNER/REPO form` };
|
|
434
|
+
}
|
|
435
|
+
const verdict = hostVerdict(host, env);
|
|
436
|
+
if (verdict.ok !== true) return { ok: false, error: verdict.error, host };
|
|
437
|
+
if (!GITHUB_OWNER_PATTERN.test(owner) || !GITHUB_REPO_PATTERN.test(name)
|
|
438
|
+
|| name === '.' || name === '..' || owner === '.' || owner === '..') {
|
|
439
|
+
return { ok: false, error: 'names an invalid GitHub repository', host };
|
|
440
|
+
}
|
|
441
|
+
const nameWithOwner = `${owner}/${name}`;
|
|
442
|
+
return { ok: true, host, nameWithOwner, identity: `${host}/${nameWithOwner}` };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Read the checkout's own facts in a fixed number of commands: the local
|
|
446
|
+
// configuration (which is also the invalidation hash), the current branch, and
|
|
447
|
+
// — once a head remote is selected — that remote's push URL (git expands
|
|
448
|
+
// `insteadOf`, so an alias remote still resolves to its real URL).
|
|
449
|
+
// A nonzero `git config --local` exit is NOT proof that this is no repository.
|
|
450
|
+
// Only a checkout git itself confirms is neither a repository ("not a git
|
|
451
|
+
// repository", with the child locale pinned) keeps the unchanged baseline;
|
|
452
|
+
// every other failure — a corrupt or unreadable configuration, dubious
|
|
453
|
+
// ownership, a broken `.git` — fails closed instead of falling back to gh's
|
|
454
|
+
// base resolution. The probe runs only after the config read already failed.
|
|
455
|
+
const NOT_A_REPOSITORY_PATTERN = /not a git repository/i;
|
|
456
|
+
const CONFIG_UNREADABLE = "cannot read this checkout's git configuration";
|
|
457
|
+
|
|
458
|
+
async function repositoryStatusAfterConfigFailure(cwd) {
|
|
459
|
+
const probe = await runGit(['rev-parse', '--git-dir'], { cwd });
|
|
460
|
+
if (probe.error) return { ok: false, error: CONFIG_UNREADABLE };
|
|
461
|
+
if (probe.status === 0) return { ok: false, error: CONFIG_UNREADABLE };
|
|
462
|
+
if (NOT_A_REPOSITORY_PATTERN.test(probe.stderr || '')) return { ok: true, repository: false };
|
|
463
|
+
return { ok: false, error: CONFIG_UNREADABLE };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function readCheckoutFacts(cwd) {
|
|
467
|
+
const config = await runGit(['config', '--local', '--null', '--list'], { cwd });
|
|
468
|
+
if (config.error) return { ok: false, error: CONFIG_UNREADABLE };
|
|
469
|
+
if (config.status !== 0) {
|
|
470
|
+
const repository = await repositoryStatusAfterConfigFailure(cwd);
|
|
471
|
+
if (repository.ok !== true) return repository;
|
|
472
|
+
return { ok: true, facts: null };
|
|
473
|
+
}
|
|
474
|
+
const branch = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd });
|
|
475
|
+
const branchName = branch && branch.status === 0 ? branch.stdout.trim() : '';
|
|
476
|
+
return {
|
|
477
|
+
ok: true,
|
|
478
|
+
facts: {
|
|
479
|
+
config: parseConfigList(config.stdout),
|
|
480
|
+
fingerprint: configFingerprint(config.stdout),
|
|
481
|
+
branch: branchName || null,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function resolveCheckoutRepo(cwd, env) {
|
|
487
|
+
const read = await readCheckoutFacts(cwd);
|
|
488
|
+
if (read.ok !== true) return read;
|
|
489
|
+
const facts = read.facts;
|
|
490
|
+
if (!facts) return { ok: true, nameWithOwner: null, fingerprint: null };
|
|
491
|
+
const selection = selectHeadRemote(facts.config, facts.branch);
|
|
492
|
+
if (selection.ok !== true) return { ...selection, fingerprint: facts.fingerprint };
|
|
493
|
+
if (!selection.remote) {
|
|
494
|
+
return { ok: true, identity: null, host: null, nameWithOwner: null, fingerprint: facts.fingerprint };
|
|
495
|
+
}
|
|
496
|
+
const url = await runGit(['remote', 'get-url', '--push', selection.remote], { cwd });
|
|
497
|
+
if (url.error || url.status !== 0) {
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
error: `cannot read the URL of checkout remote "${selection.remote}"`,
|
|
501
|
+
fingerprint: facts.fingerprint,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const repo = parseRemoteRepo(url.stdout, env, selection.remote);
|
|
505
|
+
if (repo.ok !== true) return { ...repo, fingerprint: facts.fingerprint };
|
|
506
|
+
return {
|
|
507
|
+
ok: true,
|
|
508
|
+
identity: repo.identity,
|
|
509
|
+
host: repo.host,
|
|
510
|
+
nameWithOwner: repo.nameWithOwner,
|
|
511
|
+
remote: selection.remote,
|
|
512
|
+
fingerprint: facts.fingerprint,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// A caller that names its checkout (`cwd`) is the only case the fork scope is
|
|
517
|
+
// read for: a call that names none keeps gh's own default resolution exactly as
|
|
518
|
+
// before, so the override never invents a checkout nobody asked about.
|
|
519
|
+
function namesCheckout(opts) {
|
|
520
|
+
return typeof opts.cwd === 'string' && opts.cwd.length > 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Cache key: the checkout AND the host gh would target (GH_HOST), so a scope
|
|
524
|
+
// resolved for one server is never reused for another.
|
|
525
|
+
function repoScopeKey(cwd, env) {
|
|
526
|
+
return `${cwd}\n${ghHostOf(env)}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const repoScopes = new Map();
|
|
530
|
+
|
|
531
|
+
async function currentFingerprint(cwd) {
|
|
532
|
+
const res = await runGit(['config', '--local', '--null', '--list'], { cwd });
|
|
533
|
+
if (res.error || res.status !== 0) return null;
|
|
534
|
+
return configFingerprint(res.stdout);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The repository this checkout belongs to (its fork, never the upstream `gh`
|
|
538
|
+
// resolves as a base): `{ok:true, identity, host, nameWithOwner, remote}` with a
|
|
539
|
+
// HOST-QUALIFIED identity, `{ok:true, identity:null}` when there is nothing to
|
|
540
|
+
// override (a single-remote checkout keeps gh's resolution), or
|
|
541
|
+
// `{ok:false, error}` when the checkout's own remote cannot be proven to be a
|
|
542
|
+
// GitHub repository or the selection is ambiguous. A read may reuse it for at
|
|
543
|
+
// most the short fresh window; after that every reuse is VALIDATED against the
|
|
544
|
+
// checkout's live git configuration hash, so a changed remote is followed
|
|
545
|
+
// quickly. `opts.fresh` bypasses the cache entirely, and the write path always
|
|
546
|
+
// asks fresh, so no action ever runs on a cached scope.
|
|
547
|
+
async function checkoutRepoScope(opts = {}) {
|
|
548
|
+
const env = opts.env || process.env;
|
|
549
|
+
// An explicit GH_REPO is the caller's scope and is never second-guessed
|
|
550
|
+
// (a caller that already displayed a repository pins it itself, host
|
|
551
|
+
// included).
|
|
552
|
+
if (env && typeof env.GH_REPO === 'string' && env.GH_REPO.trim() !== '') {
|
|
553
|
+
return { ok: true, identity: null, host: null, nameWithOwner: null };
|
|
554
|
+
}
|
|
555
|
+
let cwd;
|
|
556
|
+
try {
|
|
557
|
+
cwd = path.resolve(typeof opts.cwd === 'string' && opts.cwd.length > 0 ? opts.cwd : process.cwd());
|
|
558
|
+
} catch {
|
|
559
|
+
return { ok: true, identity: null, host: null, nameWithOwner: null };
|
|
560
|
+
}
|
|
561
|
+
const key = repoScopeKey(cwd, env);
|
|
562
|
+
const entry = repoScopes.get(key);
|
|
563
|
+
if (opts.fresh !== true && entry) {
|
|
564
|
+
if (entry.settled !== true) return entry.run;
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const sinceUse = typeof entry.at === 'number' ? now - entry.at : Number.POSITIVE_INFINITY;
|
|
567
|
+
const sinceResolve = typeof entry.resolvedAt === 'number' ? now - entry.resolvedAt : Number.POSITIVE_INFINITY;
|
|
568
|
+
if (sinceUse < REPO_SCOPE_FRESH_WINDOW_MS) return entry.result;
|
|
569
|
+
const current = await currentFingerprint(cwd);
|
|
570
|
+
// The max age counts from the RESOLUTION, never from the last use: local
|
|
571
|
+
// configuration outside `--local` (a global `insteadOf`) cannot move the
|
|
572
|
+
// hash, so a sliding window would mask it forever.
|
|
573
|
+
if (sinceResolve < REPO_SCOPE_MAX_AGE_MS && current !== null && current === entry.fingerprint) {
|
|
574
|
+
entry.at = Date.now();
|
|
575
|
+
return entry.result;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const next = { settled: false, fingerprint: null, result: null, at: Date.now(), resolvedAt: null, run: null };
|
|
579
|
+
next.run = resolveCheckoutRepo(cwd, env)
|
|
580
|
+
.catch(() => ({ ok: false, error: "cannot resolve the checkout's own repository" }))
|
|
581
|
+
.then((result) => {
|
|
582
|
+
next.settled = true;
|
|
583
|
+
next.result = result;
|
|
584
|
+
next.fingerprint = result && typeof result.fingerprint === 'string' ? result.fingerprint : null;
|
|
585
|
+
next.at = Date.now();
|
|
586
|
+
next.resolvedAt = next.at;
|
|
587
|
+
return result;
|
|
588
|
+
});
|
|
589
|
+
if (repoScopes.size >= REPO_SCOPE_CACHE_MAX && !repoScopes.has(key)) {
|
|
590
|
+
repoScopes.delete(repoScopes.keys().next().value);
|
|
591
|
+
}
|
|
592
|
+
repoScopes.set(key, next);
|
|
593
|
+
return next.run;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// The env a gh child runs with: the caller's own environment plus the scope as
|
|
597
|
+
// GH_REPO, which every repo-resolving gh command honours (`gh repo view`
|
|
598
|
+
// ignores it and is overridden in resolution instead). A scope ALWAYS wins over
|
|
599
|
+
// an ambient GH_REPO: an identity a reader already displayed is the pin, and an
|
|
600
|
+
// environment variable must never move a read or a write to another repository
|
|
601
|
+
// behind its back.
|
|
602
|
+
function scopedEnv(opts, scope) {
|
|
603
|
+
if (!scope) return opts.env;
|
|
604
|
+
const base = opts.env || process.env || {};
|
|
605
|
+
return { ...base, GH_REPO: scope };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Default runner over execFile: argv array, no shell, bounded timeout.
|
|
609
|
+
// Always resolves to {status, stdout, stderr, error}; never throws.
|
|
610
|
+
function defaultRunner(bin, argv, runOpts = {}) {
|
|
611
|
+
const timeout = Number.isFinite(runOpts.timeoutMs) && runOpts.timeoutMs > 0
|
|
612
|
+
? Math.floor(runOpts.timeoutMs)
|
|
613
|
+
: DEFAULT_TIMEOUT_MS;
|
|
614
|
+
return new Promise((resolve) => {
|
|
615
|
+
let child;
|
|
616
|
+
const spawnOpts = {
|
|
617
|
+
cwd: runOpts.cwd,
|
|
618
|
+
timeout,
|
|
619
|
+
encoding: 'utf8',
|
|
620
|
+
windowsHide: true,
|
|
621
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
622
|
+
};
|
|
623
|
+
if (runOpts.env) spawnOpts.env = runOpts.env;
|
|
624
|
+
try {
|
|
625
|
+
child = execFile(bin, argv, spawnOpts, (err, stdout, stderr) => {
|
|
626
|
+
if (!err) {
|
|
627
|
+
resolve({ status: 0, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
// ENOENT / spawn failures carry no status: keep the signal text so
|
|
631
|
+
// callers can name the missing binary.
|
|
632
|
+
if (err.code === 'ENOENT' || /ENOENT/i.test(err.message || '')) {
|
|
633
|
+
resolve({
|
|
634
|
+
status: null, stdout: String(stdout || ''), stderr: String(stderr || ''),
|
|
635
|
+
error: `spawn ${bin} ENOENT: gh CLI is not available`,
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (err.killed) {
|
|
640
|
+
resolve({
|
|
641
|
+
status: null, stdout: String(stdout || ''), stderr: String(stderr || ''),
|
|
642
|
+
error: `gh timed out after ${timeout}ms`,
|
|
643
|
+
});
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Non-zero exit: status plus whatever the command printed.
|
|
647
|
+
const status = typeof err.code === 'number' ? err.code : null;
|
|
648
|
+
resolve({ status, stdout: String(stdout || ''), stderr: String(stderr || ''), error: null });
|
|
649
|
+
});
|
|
650
|
+
} catch (err) {
|
|
651
|
+
resolve({
|
|
652
|
+
status: null, stdout: '', stderr: '',
|
|
653
|
+
error: (err && err.message) || String(err),
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (!child) {
|
|
658
|
+
resolve({ status: null, stdout: '', stderr: '', error: 'gh runner produced no child process' });
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// The scope a call runs with, as a host-qualified `GH_REPO` value or null.
|
|
664
|
+
// An explicit scope is validated against the caller's live environment first
|
|
665
|
+
// (host mismatch or an invalid identity fails closed before any spawn); a call
|
|
666
|
+
// that names a checkout resolves the checkout's own repository; anything else
|
|
667
|
+
// keeps gh's default resolution.
|
|
668
|
+
async function scopeForCall(opts) {
|
|
669
|
+
if (opts.scope !== undefined) {
|
|
670
|
+
if (opts.scope === null || opts.scope === '') return { ok: true, identity: null };
|
|
671
|
+
const pinned = normalizeScopeIdentity(opts.scope, opts.env || process.env);
|
|
672
|
+
if (pinned.ok !== true) return { ok: false, error: `the pinned repository ${pinned.error}` };
|
|
673
|
+
return { ok: true, identity: pinned.identity };
|
|
674
|
+
}
|
|
675
|
+
if (namesCheckout(opts)) return checkoutRepoScope({ ...opts, fresh: opts.freshScope === true });
|
|
676
|
+
return { ok: true, identity: null };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function runGh(argv, opts = {}) {
|
|
680
|
+
const bin = ghBinary(opts);
|
|
681
|
+
const scopeResult = await scopeForCall(opts);
|
|
682
|
+
// Fail closed: when the checkout's own repository cannot be proven, no gh
|
|
683
|
+
// command runs at all. A read or a write must never fall back to a guessed
|
|
684
|
+
// repository (gh's base answer included).
|
|
685
|
+
if (scopeResult.ok !== true) {
|
|
686
|
+
return { bin, argv, status: null, stdout: '', stderr: '', error: scopeResult.error };
|
|
687
|
+
}
|
|
688
|
+
const env = scopedEnv(opts, scopeResult.identity || null);
|
|
689
|
+
const runOpts = { cwd: opts.cwd, timeoutMs: opts.timeoutMs || DEFAULT_TIMEOUT_MS };
|
|
690
|
+
if (env) runOpts.env = env;
|
|
691
|
+
const run = runnerOverride || defaultRunner;
|
|
692
|
+
let raw;
|
|
693
|
+
try {
|
|
694
|
+
raw = await run(bin, argv, runOpts);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
return {
|
|
697
|
+
bin, argv, status: null, stdout: '', stderr: '',
|
|
698
|
+
error: (err && err.message) || String(err),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
const outcome = raw && typeof raw === 'object' ? raw : {};
|
|
702
|
+
return {
|
|
703
|
+
bin,
|
|
704
|
+
argv,
|
|
705
|
+
status: typeof outcome.status === 'number' ? outcome.status : null,
|
|
706
|
+
stdout: typeof outcome.stdout === 'string' ? outcome.stdout : '',
|
|
707
|
+
stderr: typeof outcome.stderr === 'string' ? outcome.stderr : '',
|
|
708
|
+
error: outcome.error === null || outcome.error === undefined ? null : String(outcome.error),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Failure text for a non-zero `gh` exit: prefer stderr (where gh explains
|
|
713
|
+
// auth and resolution problems), fall back to the bare status.
|
|
714
|
+
function exitError(outcome, what) {
|
|
715
|
+
const stderr = (outcome.stderr || '').trim();
|
|
716
|
+
if (stderr) return `${what}: ${stderr.split('\n')[0].slice(0, 500)}`;
|
|
717
|
+
if (outcome.status !== null) return `${what}: gh exited with status ${outcome.status}`;
|
|
718
|
+
return `${what}: gh failed`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function parseJsonBody(stdout, what) {
|
|
722
|
+
const text = (stdout || '').trim();
|
|
723
|
+
if (!text) return { ok: false, error: `${what}: gh returned an empty body` };
|
|
724
|
+
try {
|
|
725
|
+
return { ok: true, doc: JSON.parse(text) };
|
|
726
|
+
} catch {
|
|
727
|
+
return { ok: false, error: `${what}: gh returned unparseable output` };
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function asLogin(author) {
|
|
732
|
+
if (typeof author === 'string') return author;
|
|
733
|
+
if (author && typeof author === 'object' && typeof author.login === 'string') return author.login;
|
|
734
|
+
return '';
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// `reactionGroups` (the field gh actually returns) sums to a total with
|
|
738
|
+
// the THUMBS_UP share kept as plusOne. Unknown shapes sum to zero rather
|
|
739
|
+
// than failing the whole call.
|
|
740
|
+
function sumReactions(reactionGroups) {
|
|
741
|
+
let total = 0;
|
|
742
|
+
let plusOne = 0;
|
|
743
|
+
if (!Array.isArray(reactionGroups)) return { total: 0, plusOne: 0 };
|
|
744
|
+
for (const group of reactionGroups) {
|
|
745
|
+
if (!group || typeof group !== 'object') continue;
|
|
746
|
+
const count = group && typeof group === 'object'
|
|
747
|
+
? (group.users && typeof group.users.totalCount === 'number'
|
|
748
|
+
? group.users.totalCount
|
|
749
|
+
: (typeof group.reactors === 'object' && group.reactors !== null
|
|
750
|
+
&& typeof group.reactors.totalCount === 'number'
|
|
751
|
+
? group.reactors.totalCount
|
|
752
|
+
: (typeof group.totalCount === 'number' ? group.totalCount : 0)))
|
|
753
|
+
: 0;
|
|
754
|
+
total += count;
|
|
755
|
+
if (group.content === 'THUMBS_UP') plusOne += count;
|
|
756
|
+
}
|
|
757
|
+
return { total, plusOne };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function asLabel(raw) {
|
|
761
|
+
const entry = raw && typeof raw === 'object' ? raw : {};
|
|
762
|
+
return {
|
|
763
|
+
name: typeof entry.name === 'string' ? entry.name : '',
|
|
764
|
+
color: typeof entry.color === 'string' ? entry.color : '',
|
|
765
|
+
description: typeof entry.description === 'string' ? entry.description : '',
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function normalizeIssue(raw) {
|
|
770
|
+
const entry = raw && typeof raw === 'object' ? raw : {};
|
|
771
|
+
const comments = Array.isArray(entry.comments) ? entry.comments : [];
|
|
772
|
+
return {
|
|
773
|
+
number: typeof entry.number === 'number' ? entry.number : -1,
|
|
774
|
+
title: typeof entry.title === 'string' ? entry.title : '',
|
|
775
|
+
state: typeof entry.state === 'string' ? entry.state.toLowerCase() : '',
|
|
776
|
+
stateReason: typeof entry.stateReason === 'string' ? entry.stateReason : '',
|
|
777
|
+
author: asLogin(entry.author),
|
|
778
|
+
// `assignees` arrives only on the detail fetch (the list field set
|
|
779
|
+
// omits it, so list rows keep their shape); entries are {login}
|
|
780
|
+
// objects like `author`, normalized through the same helper.
|
|
781
|
+
assignees: Array.isArray(entry.assignees)
|
|
782
|
+
? entry.assignees.map(asLogin).filter((name) => name !== '')
|
|
783
|
+
: [],
|
|
784
|
+
// `gh issue list/view --json` has no authorAssociation field
|
|
785
|
+
// (verified: `Unknown JSON field`); the honest default is ''.
|
|
786
|
+
authorAssociation: typeof entry.authorAssociation === 'string' ? entry.authorAssociation : '',
|
|
787
|
+
labels: Array.isArray(entry.labels) ? entry.labels.map(asLabel) : [],
|
|
788
|
+
createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : '',
|
|
789
|
+
updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : '',
|
|
790
|
+
commentsCount: Number.isInteger(entry.commentsTotal) && entry.commentsTotal >= 0
|
|
791
|
+
? entry.commentsTotal
|
|
792
|
+
: comments.length,
|
|
793
|
+
reactions: sumReactions(entry.reactionGroups || entry.reactions),
|
|
794
|
+
url: typeof entry.url === 'string' ? entry.url : '',
|
|
795
|
+
body: typeof entry.body === 'string' ? entry.body : '',
|
|
796
|
+
// `gh` exposes no isPullRequest JSON field for issues; issues listed
|
|
797
|
+
// here are issues, so the honest default is false.
|
|
798
|
+
isPullRequest: entry.isPullRequest === true,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function normalizeComment(raw, fallbackId) {
|
|
803
|
+
const entry = raw && typeof raw === 'object' ? raw : {};
|
|
804
|
+
const id = typeof entry.id === 'string' || typeof entry.id === 'number'
|
|
805
|
+
? entry.id
|
|
806
|
+
: (typeof entry.databaseId === 'number' ? entry.databaseId : fallbackId);
|
|
807
|
+
return {
|
|
808
|
+
id,
|
|
809
|
+
author: asLogin(entry.author),
|
|
810
|
+
createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : '',
|
|
811
|
+
body: typeof entry.body === 'string' ? entry.body : '',
|
|
812
|
+
reactions: sumReactions(entry.reactionGroups || entry.reactions),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function normalizeStateFilter(state) {
|
|
817
|
+
if (state === undefined || state === null || state === '') return 'open';
|
|
818
|
+
const lowered = String(state).toLowerCase();
|
|
819
|
+
if (lowered === 'open' || lowered === 'closed' || lowered === 'all') return lowered;
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function normalizePaging(page, pageSize) {
|
|
824
|
+
let p = Number(page);
|
|
825
|
+
if (!Number.isFinite(p) || p < 1) p = 1;
|
|
826
|
+
p = Math.floor(p);
|
|
827
|
+
let s = Number(pageSize);
|
|
828
|
+
if (!Number.isFinite(s) || s < 1) s = DEFAULT_PAGE_SIZE;
|
|
829
|
+
s = Math.min(Math.floor(s), MAX_PAGE_SIZE);
|
|
830
|
+
return { page: p, pageSize: s };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Authenticated identity plus the repository in scope.
|
|
834
|
+
// `{ok:true, login, nameWithOwner}`; a repository that cannot be resolved
|
|
835
|
+
// still reports the login with `nameWithOwner: null`.
|
|
836
|
+
async function ghStatus(opts = {}) {
|
|
837
|
+
const user = await runGh(['api', 'user'], opts);
|
|
838
|
+
if (user.error) return { ok: false, error: user.error };
|
|
839
|
+
if (user.status !== 0) return { ok: false, error: exitError(user, 'gh is not authenticated (run `gh auth login`)') };
|
|
840
|
+
const parsedUser = parseJsonBody(user.stdout, 'gh api user');
|
|
841
|
+
if (!parsedUser.ok) return parsedUser;
|
|
842
|
+
const login = parsedUser.doc && typeof parsedUser.doc.login === 'string' ? parsedUser.doc.login : '';
|
|
843
|
+
if (!login) return { ok: false, error: 'gh api user returned an unexpected shape' };
|
|
844
|
+
const repo = await runGh(['repo', 'view', '--json', 'nameWithOwner'], opts);
|
|
845
|
+
if (repo.error || repo.status !== 0) return { ok: true, login, nameWithOwner: null };
|
|
846
|
+
const parsedRepo = parseJsonBody(repo.stdout, 'gh repo view');
|
|
847
|
+
if (!parsedRepo.ok) return { ok: true, login, nameWithOwner: null };
|
|
848
|
+
const reported = parsedRepo.doc && typeof parsedRepo.doc.nameWithOwner === 'string'
|
|
849
|
+
? parsedRepo.doc.nameWithOwner
|
|
850
|
+
: null;
|
|
851
|
+
// An explicit scope is the identity a caller already showed; `null` means
|
|
852
|
+
// gh's own resolution. It is normalized so a host-qualified pin answers the
|
|
853
|
+
// `owner/name` every consumer expects, and an invalid pin fails closed.
|
|
854
|
+
if (opts.scope !== undefined) {
|
|
855
|
+
if (!opts.scope) return { ok: true, login, nameWithOwner: reported };
|
|
856
|
+
const pinned = normalizeScopeIdentity(opts.scope, opts.env || process.env);
|
|
857
|
+
if (pinned.ok !== true) return { ok: false, error: pinned.error };
|
|
858
|
+
return { ok: true, login, nameWithOwner: pinned.nameWithOwner };
|
|
859
|
+
}
|
|
860
|
+
const scope = namesCheckout(opts) ? await checkoutRepoScope(opts) : { ok: true, identity: null };
|
|
861
|
+
if (scope.ok !== true) return { ok: false, error: scope.error };
|
|
862
|
+
return { ok: true, login, nameWithOwner: (scope.ok === true && scope.nameWithOwner) || reported };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function repoInfo(opts = {}) {
|
|
866
|
+
const resolved = await repoNameWithOwner(opts);
|
|
867
|
+
if (!resolved.ok) return resolved;
|
|
868
|
+
const user = await runGh(['api', 'user'], opts);
|
|
869
|
+
let viewer = null;
|
|
870
|
+
if (!user.error && user.status === 0) {
|
|
871
|
+
const parsedUser = parseJsonBody(user.stdout, 'gh api user');
|
|
872
|
+
if (parsedUser.ok && parsedUser.doc && typeof parsedUser.doc.login === 'string') {
|
|
873
|
+
viewer = parsedUser.doc.login;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return { ok: true, nameWithOwner: resolved.nameWithOwner, viewer };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// gh's OWN answer for the checkout, with no scope override applied. It is the
|
|
880
|
+
// effective repository of a checkout that has no fork remote of its own (a
|
|
881
|
+
// caller pins this identity for its lifetime) and the fallback of
|
|
882
|
+
// `repoNameWithOwner`. `gh repo view` ignores GH_REPO, so a caller-supplied
|
|
883
|
+
// GH_REPO is handled by the caller, never guessed here.
|
|
884
|
+
async function reportedRepoName(opts = {}) {
|
|
885
|
+
// A caller that wants gh's OWN answer with no checkout scope passes
|
|
886
|
+
// `scope: null` explicitly; any other `opts` behaves exactly like every
|
|
887
|
+
// other client call (the checkout scope is resolved first, so a resolution
|
|
888
|
+
// failure still prevents this gh call).
|
|
889
|
+
const repo = await runGh(['repo', 'view', '--json', 'nameWithOwner'], opts);
|
|
890
|
+
if (repo.error) return { ok: false, error: repo.error };
|
|
891
|
+
if (repo.status !== 0) return { ok: false, error: exitError(repo, 'cannot resolve the repository') };
|
|
892
|
+
const parsedRepo = parseJsonBody(repo.stdout, 'gh repo view');
|
|
893
|
+
if (!parsedRepo.ok) return parsedRepo;
|
|
894
|
+
const reported = parsedRepo.doc && typeof parsedRepo.doc.nameWithOwner === 'string'
|
|
895
|
+
? parsedRepo.doc.nameWithOwner
|
|
896
|
+
: '';
|
|
897
|
+
if (!reported) return { ok: false, error: 'gh repo view returned an unexpected shape' };
|
|
898
|
+
return { ok: true, nameWithOwner: reported };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// The repository in scope, in one call. Split out of `repoInfo` so a GraphQL
|
|
902
|
+
// read does not also pay for the identity lookup.
|
|
903
|
+
async function repoNameWithOwner(opts = {}) {
|
|
904
|
+
// An explicit scope is the identity a caller already showed: reads and
|
|
905
|
+
// writes bound to it stay on that repository even after the checkout's remote
|
|
906
|
+
// moves, instead of silently following it. It is normalized to `owner/name`
|
|
907
|
+
// for the GraphQL consumers and validated before anything runs; `null` means
|
|
908
|
+
// gh's own resolution.
|
|
909
|
+
if (opts.scope !== undefined) {
|
|
910
|
+
if (opts.scope === null || opts.scope === '') {
|
|
911
|
+
const reported = await reportedRepoName(opts);
|
|
912
|
+
if (reported.ok !== true) return reported;
|
|
913
|
+
return { ok: true, nameWithOwner: reported.nameWithOwner };
|
|
914
|
+
}
|
|
915
|
+
const pinned = normalizeScopeIdentity(opts.scope, opts.env || process.env);
|
|
916
|
+
if (pinned.ok !== true) return { ok: false, error: pinned.error };
|
|
917
|
+
return { ok: true, nameWithOwner: pinned.nameWithOwner };
|
|
918
|
+
}
|
|
919
|
+
const reportedRead = await reportedRepoName(opts);
|
|
920
|
+
if (reportedRead.ok !== true) return reportedRead;
|
|
921
|
+
// Otherwise the checkout's own repository wins over gh's base answer (fork
|
|
922
|
+
// scope); a checkout that resolves to one repository is untouched. An
|
|
923
|
+
// unresolvable checkout remote fails here instead of being replaced by gh's
|
|
924
|
+
// base answer.
|
|
925
|
+
const scope = namesCheckout(opts) ? await checkoutRepoScope(opts) : { ok: true, identity: null };
|
|
926
|
+
if (scope.ok !== true) return { ok: false, error: scope.error };
|
|
927
|
+
return { ok: true, nameWithOwner: scope.nameWithOwner || reportedRead.nameWithOwner };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// The list order belongs to the QUERY, never to a JavaScript re-sort:
|
|
931
|
+
// `gh issue list --search` takes the search syntax, so the
|
|
932
|
+
// most-recently-updated ordering is one qualifier on the caller's query.
|
|
933
|
+
// A caller that already passed an explicit `sort:` keeps it — a second
|
|
934
|
+
// qualifier would be ambiguous, so theirs wins.
|
|
935
|
+
function withLastUpdatedSort(search) {
|
|
936
|
+
if (/\bsort:/i.test(search)) return search;
|
|
937
|
+
return search ? `${search} ${LAST_UPDATED_SORT}` : LAST_UPDATED_SORT;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Paged listing. `gh issue list` has no offset flag, so the call fetches
|
|
941
|
+
// ONCE up to the bound and slices the requested page in memory; `total`
|
|
942
|
+
// is the fetched count and `truncated` is true exactly when it reached
|
|
943
|
+
// the bound (a lower bound, never the exact total). `opts.fetchMax`
|
|
944
|
+
// overrides the bound (default LIST_FETCH_MAX) so tests can exercise
|
|
945
|
+
// truncation without 500 fixtures. `wholeResult` returns the ENTIRE fetched
|
|
946
|
+
// set instead of one page, for a caller whose bound is its own completeness
|
|
947
|
+
// proof (a caller's buckets, bounded by the open snapshot's count):
|
|
948
|
+
// slicing a page there would throw away rows already downloaded. The list
|
|
949
|
+
// fetch omits `body` (the detail pane reads it through `getIssue`); every
|
|
950
|
+
// other row field the view's list uses still arrives, so list rows render
|
|
951
|
+
// unchanged. A caller that needs the whole open set uses `listAllOpenIssues`
|
|
952
|
+
// instead: a page sliced from one bounded fetch is not every page.
|
|
953
|
+
async function listIssues({ query = '', state = 'open', page = 1, pageSize = DEFAULT_PAGE_SIZE, wholeResult = false } = {}, opts = {}) {
|
|
954
|
+
const stateFilter = normalizeStateFilter(state);
|
|
955
|
+
if (stateFilter === null) return { ok: false, error: `invalid state ${JSON.stringify(state)}: want open, closed or all` };
|
|
956
|
+
const paging = normalizePaging(page, pageSize);
|
|
957
|
+
const search = withLastUpdatedSort(typeof query === 'string' ? query.trim() : '');
|
|
958
|
+
const rawMax = opts && opts.fetchMax != null ? Number(opts.fetchMax) : LIST_FETCH_MAX;
|
|
959
|
+
const bound = Number.isFinite(rawMax) && rawMax > 0 ? Math.floor(rawMax) : LIST_FETCH_MAX;
|
|
960
|
+
const fetchLimit = bound;
|
|
961
|
+
const argv = ['issue', 'list', '--state', stateFilter, '--limit', String(fetchLimit), '--json', ISSUE_LIST_JSON_FIELDS];
|
|
962
|
+
if (search) argv.splice(4, 0, '--search', search);
|
|
963
|
+
const res = await runGh(argv, opts);
|
|
964
|
+
if (res.error) return { ok: false, error: res.error };
|
|
965
|
+
if (res.status !== 0) return { ok: false, error: exitError(res, 'gh issue list failed') };
|
|
966
|
+
const parsed = parseJsonBody(res.stdout, 'gh issue list');
|
|
967
|
+
if (!parsed.ok) return parsed;
|
|
968
|
+
if (!Array.isArray(parsed.doc)) return { ok: false, error: 'gh issue list returned an unexpected shape' };
|
|
969
|
+
const normalized = parsed.doc.map(normalizeIssue);
|
|
970
|
+
// The list never asked for `assignees`: drop the normalized default so
|
|
971
|
+
// list rows keep their exact shape (only getIssue carries assignees).
|
|
972
|
+
for (const item of normalized) delete item.assignees;
|
|
973
|
+
const total = normalized.length;
|
|
974
|
+
const truncated = total >= bound;
|
|
975
|
+
if (wholeResult === true) {
|
|
976
|
+
return { ok: true, issues: normalized, total, truncated, page: paging.page, pageSize: paging.pageSize };
|
|
977
|
+
}
|
|
978
|
+
const start = (paging.page - 1) * paging.pageSize;
|
|
979
|
+
return {
|
|
980
|
+
ok: true,
|
|
981
|
+
issues: normalized.slice(start, start + paging.pageSize),
|
|
982
|
+
total,
|
|
983
|
+
truncated,
|
|
984
|
+
page: paging.page,
|
|
985
|
+
pageSize: paging.pageSize,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// The GraphQL connection carries the same row facts in different shapes:
|
|
990
|
+
// `labels` and `comments` are connections. Reshape them into the payload
|
|
991
|
+
// `normalizeIssue` understands, handing the comment count over as a number so
|
|
992
|
+
// the snapshot never materializes comment arrays it does not need.
|
|
993
|
+
function graphqlIssueRow(node) {
|
|
994
|
+
const entry = node && typeof node === 'object' ? node : {};
|
|
995
|
+
const labelNodes = entry.labels && typeof entry.labels === 'object' && Array.isArray(entry.labels.nodes)
|
|
996
|
+
? entry.labels.nodes
|
|
997
|
+
: [];
|
|
998
|
+
const commentsTotal = entry.comments && typeof entry.comments === 'object'
|
|
999
|
+
&& Number.isInteger(entry.comments.totalCount)
|
|
1000
|
+
? entry.comments.totalCount
|
|
1001
|
+
: 0;
|
|
1002
|
+
return { ...entry, labels: labelNodes, comments: [], commentsTotal };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// EVERY open issue of the repository, read through the GraphQL connection.
|
|
1006
|
+
// Each page is validated before it is trusted: GraphQL errors fail the whole
|
|
1007
|
+
// snapshot, `hasNextPage` must come with a non-empty cursor this loop has not
|
|
1008
|
+
// used yet, and the terminal page must have delivered exactly `totalCount`
|
|
1009
|
+
// distinct issues. Anything short of that is a failed snapshot — never a
|
|
1010
|
+
// partial set a caller could read as "these issues are gone". `opts` reaches
|
|
1011
|
+
// `runGh`, so each page also runs under its bounded timeout.
|
|
1012
|
+
async function listAllOpenIssues(opts = {}) {
|
|
1013
|
+
const resolved = await repoNameWithOwner(opts);
|
|
1014
|
+
if (!resolved.ok) return resolved;
|
|
1015
|
+
const slash = resolved.nameWithOwner.indexOf('/');
|
|
1016
|
+
if (slash <= 0 || slash === resolved.nameWithOwner.length - 1) {
|
|
1017
|
+
return { ok: false, error: `gh repo view returned an unexpected shape: ${JSON.stringify(resolved.nameWithOwner)}` };
|
|
1018
|
+
}
|
|
1019
|
+
const owner = resolved.nameWithOwner.slice(0, slash);
|
|
1020
|
+
const name = resolved.nameWithOwner.slice(slash + 1);
|
|
1021
|
+
const issues = [];
|
|
1022
|
+
const seen = new Set();
|
|
1023
|
+
const cursors = new Set();
|
|
1024
|
+
let cursor = null;
|
|
1025
|
+
for (let page = 1; page <= SNAPSHOT_MAX_PAGES; page += 1) {
|
|
1026
|
+
const argv = [
|
|
1027
|
+
'api', 'graphql',
|
|
1028
|
+
'-f', `query=${OPEN_ISSUES_QUERY}`,
|
|
1029
|
+
'-F', `owner=${owner}`,
|
|
1030
|
+
'-F', `name=${name}`,
|
|
1031
|
+
'-F', `first=${GRAPHQL_PAGE_SIZE}`,
|
|
1032
|
+
];
|
|
1033
|
+
if (cursor !== null) argv.push('-F', `cursor=${cursor}`);
|
|
1034
|
+
const res = await runGh(argv, opts);
|
|
1035
|
+
if (res.error) return { ok: false, error: res.error };
|
|
1036
|
+
if (res.status !== 0) return { ok: false, error: exitError(res, 'gh api graphql failed') };
|
|
1037
|
+
const parsed = parseJsonBody(res.stdout, 'gh api graphql');
|
|
1038
|
+
if (!parsed.ok) return parsed;
|
|
1039
|
+
const doc = parsed.doc;
|
|
1040
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
1041
|
+
return { ok: false, error: 'gh api graphql returned an unexpected shape' };
|
|
1042
|
+
}
|
|
1043
|
+
if (Array.isArray(doc.errors) && doc.errors.length > 0) {
|
|
1044
|
+
const message = doc.errors[0] && typeof doc.errors[0].message === 'string'
|
|
1045
|
+
? doc.errors[0].message
|
|
1046
|
+
: 'unknown GraphQL error';
|
|
1047
|
+
return { ok: false, error: `gh api graphql failed: ${message}` };
|
|
1048
|
+
}
|
|
1049
|
+
const repository = doc.data && typeof doc.data === 'object' ? doc.data.repository : null;
|
|
1050
|
+
const connection = repository && typeof repository === 'object' ? repository.issues : null;
|
|
1051
|
+
if (!connection || typeof connection !== 'object' || !Array.isArray(connection.nodes)) {
|
|
1052
|
+
return { ok: false, error: 'gh api graphql returned an unexpected shape' };
|
|
1053
|
+
}
|
|
1054
|
+
const total = connection.totalCount;
|
|
1055
|
+
if (!Number.isInteger(total) || total < 0) {
|
|
1056
|
+
return { ok: false, error: 'gh api graphql returned no open-issue total' };
|
|
1057
|
+
}
|
|
1058
|
+
for (const node of connection.nodes) {
|
|
1059
|
+
const row = graphqlIssueRow(node);
|
|
1060
|
+
if (!Number.isInteger(row.number) || row.number <= 0) {
|
|
1061
|
+
return { ok: false, error: 'gh api graphql returned an issue without a number' };
|
|
1062
|
+
}
|
|
1063
|
+
// A duplicate adds nothing and would make the terminal count a lie; the
|
|
1064
|
+
// count check below catches the shortfall it causes.
|
|
1065
|
+
if (seen.has(row.number)) continue;
|
|
1066
|
+
seen.add(row.number);
|
|
1067
|
+
issues.push(normalizeIssue(row));
|
|
1068
|
+
}
|
|
1069
|
+
const pageInfo = connection.pageInfo && typeof connection.pageInfo === 'object' ? connection.pageInfo : {};
|
|
1070
|
+
if (pageInfo.hasNextPage !== true) {
|
|
1071
|
+
if (issues.length !== total) {
|
|
1072
|
+
return { ok: false, error: `gh api graphql returned ${issues.length} of ${total} open issues; the snapshot is incomplete` };
|
|
1073
|
+
}
|
|
1074
|
+
return { ok: true, issues, total, pages: page };
|
|
1075
|
+
}
|
|
1076
|
+
const endCursor = typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : '';
|
|
1077
|
+
if (endCursor === '') return { ok: false, error: 'gh api graphql reported another page without a cursor' };
|
|
1078
|
+
if (cursors.has(endCursor)) {
|
|
1079
|
+
return { ok: false, error: `gh api graphql repeated the cursor ${endCursor}; the snapshot cannot advance` };
|
|
1080
|
+
}
|
|
1081
|
+
cursors.add(endCursor);
|
|
1082
|
+
cursor = endCursor;
|
|
1083
|
+
}
|
|
1084
|
+
return { ok: false, error: `could not page through every open issue: stopped after ${SNAPSHOT_MAX_PAGES} pages` };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// A non-zero `gh` exit caused by the issue NOT EXISTING is the one failure a
|
|
1088
|
+
// caller may act on destructively (drop a stored association); every other
|
|
1089
|
+
// failure leaves the question open. Authentication, permission and transport
|
|
1090
|
+
// wording always wins over the not-found patterns, because a private
|
|
1091
|
+
// repository answers 404 for an issue the reader merely cannot see. Only
|
|
1092
|
+
// ISSUE-specific wording is proof: a repository-level `Not Found (HTTP 404)`
|
|
1093
|
+
// names no issue and is the answer for an inaccessible or renamed repository,
|
|
1094
|
+
// so it never justifies dropping a stored number.
|
|
1095
|
+
const NOT_FOUND_PATTERN = /could not resolve to an? (?:issue|pull request)|no (?:such|matching) issue/i;
|
|
1096
|
+
const UNVERIFIABLE_PATTERN = /401|403|bad credentials|authenticat|permission|forbidden|saml|enoent|timed out|timeout|dial tcp|no such host|connection refused|network/i;
|
|
1097
|
+
|
|
1098
|
+
function issueViewFailure(res, number) {
|
|
1099
|
+
const text = `${res.stderr || ''}\n${res.stdout || ''}`;
|
|
1100
|
+
const notFound = typeof res.status === 'number' && res.status !== 0
|
|
1101
|
+
&& !UNVERIFIABLE_PATTERN.test(text)
|
|
1102
|
+
&& NOT_FOUND_PATTERN.test(text);
|
|
1103
|
+
return {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
error: exitError(res, `cannot view issue #${number}`),
|
|
1106
|
+
status: res.status,
|
|
1107
|
+
notFound,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function getIssue(number, opts = {}) {
|
|
1112
|
+
if (!Number.isInteger(number)) return { ok: false, error: `invalid issue number ${JSON.stringify(number)}: want an integer`, status: null, notFound: false };
|
|
1113
|
+
const res = await runGh(['issue', 'view', String(number), '--json', ISSUE_DETAIL_JSON_FIELDS], opts);
|
|
1114
|
+
if (res.error) return { ok: false, error: res.error, status: null, notFound: false };
|
|
1115
|
+
if (res.status !== 0) return issueViewFailure(res, number);
|
|
1116
|
+
const parsed = parseJsonBody(res.stdout, `gh issue view ${number}`);
|
|
1117
|
+
if (!parsed.ok) return parsed;
|
|
1118
|
+
if (!parsed.doc || typeof parsed.doc !== 'object' || typeof parsed.doc.number !== 'number') {
|
|
1119
|
+
return { ok: false, error: `gh issue view ${number} returned an unexpected shape` };
|
|
1120
|
+
}
|
|
1121
|
+
const rawComments = Array.isArray(parsed.doc.comments) ? parsed.doc.comments : [];
|
|
1122
|
+
const issue = normalizeIssue(parsed.doc);
|
|
1123
|
+
if (opts.includeMetadata === true) {
|
|
1124
|
+
const metadata = await getIssueMetadata(number, opts);
|
|
1125
|
+
if (metadata.ok) {
|
|
1126
|
+
const { ok: _ok, ...fields } = metadata;
|
|
1127
|
+
Object.assign(issue, fields);
|
|
1128
|
+
} else {
|
|
1129
|
+
issue.permissions = repositoryEditPermissions(null);
|
|
1130
|
+
issue.metadataError = metadata.error;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
ok: true,
|
|
1135
|
+
issue,
|
|
1136
|
+
comments: rawComments.map((entry, index) => normalizeComment(entry, index)),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
// These page helpers retain server search semantics instead of inferring
|
|
1142
|
+
// author/assignee/involvement buckets from incomplete local metadata.
|
|
1143
|
+
const PAGE_ISSUE_FIELDS = `number title state stateReason url createdAt updatedAt
|
|
1144
|
+
author { login } labels(first: 50) { nodes { name color description } }
|
|
1145
|
+
comments { totalCount } reactionGroups { content users { totalCount } }`;
|
|
1146
|
+
const ISSUE_PAGE_QUERY = `query($search: String!, $first: Int!, $cursor: String) {
|
|
1147
|
+
search(query: $search, type: ISSUE, first: $first, after: $cursor) {
|
|
1148
|
+
issueCount pageInfo { hasNextPage endCursor }
|
|
1149
|
+
edges { cursor node { ... on Issue { ${PAGE_ISSUE_FIELDS} } } }
|
|
1150
|
+
}
|
|
1151
|
+
}`;
|
|
1152
|
+
|
|
1153
|
+
async function feedbackGraphql(query, fields, opts) {
|
|
1154
|
+
const argv = ['api', 'graphql', '-f', `query=${query}`];
|
|
1155
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
1156
|
+
if (value !== null && value !== undefined) argv.push('-F', `${name}=${value}`);
|
|
1157
|
+
}
|
|
1158
|
+
const result = await runGh(argv, opts);
|
|
1159
|
+
if (result.error) return { ok: false, error: result.error };
|
|
1160
|
+
if (result.status !== 0) return { ok: false, error: exitError(result, 'GitHub query failed') };
|
|
1161
|
+
const parsed = parseJsonBody(result.stdout, 'GitHub query');
|
|
1162
|
+
if (!parsed.ok) return parsed;
|
|
1163
|
+
if (Array.isArray(parsed.doc?.errors) && parsed.doc.errors.length > 0) {
|
|
1164
|
+
return { ok: false, error: parsed.doc.errors[0]?.message || 'GitHub query failed' };
|
|
1165
|
+
}
|
|
1166
|
+
if (!parsed.doc?.data || typeof parsed.doc.data !== 'object') {
|
|
1167
|
+
return { ok: false, error: 'GitHub query returned no data' };
|
|
1168
|
+
}
|
|
1169
|
+
return { ok: true, data: parsed.doc.data };
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
async function feedbackRepository(opts) {
|
|
1173
|
+
const repo = await repoNameWithOwner(opts);
|
|
1174
|
+
if (!repo.ok) return repo;
|
|
1175
|
+
const parts = repo.nameWithOwner.split('/');
|
|
1176
|
+
if (parts.length !== 2 || !parts.every((part) => part && !/\s/.test(part))) {
|
|
1177
|
+
return { ok: false, error: 'GitHub returned an invalid repository name' };
|
|
1178
|
+
}
|
|
1179
|
+
return { ok: true, owner: parts[0], name: parts[1], fullName: repo.nameWithOwner };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function getIssuePage(params = {}, opts = {}) {
|
|
1183
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) return { ok: false, error: 'invalid issue page' };
|
|
1184
|
+
const { query = '', after = null, first = DEFAULT_PAGE_SIZE } = params;
|
|
1185
|
+
if (!Number.isInteger(first) || first < 1 || first > 1000
|
|
1186
|
+
|| typeof query !== 'string' || (after !== null && (typeof after !== 'string' || !after))) {
|
|
1187
|
+
return { ok: false, error: 'invalid issue page: first must be 1..1000 and cursor must be a non-empty string or null' };
|
|
1188
|
+
}
|
|
1189
|
+
const repo = await feedbackRepository(opts);
|
|
1190
|
+
if (!repo.ok) return repo;
|
|
1191
|
+
const search = withLastUpdatedSort(`repo:${repo.fullName} is:issue is:open ${query}`.trim());
|
|
1192
|
+
const issues = [];
|
|
1193
|
+
const seen = new Set();
|
|
1194
|
+
const rowCursors = new Set(after === null ? [] : [after]);
|
|
1195
|
+
const cursors = new Set(after === null ? [] : [after]);
|
|
1196
|
+
let cursor = after;
|
|
1197
|
+
let total = 0;
|
|
1198
|
+
let hasNextPage = false;
|
|
1199
|
+
while (issues.length < first) {
|
|
1200
|
+
const count = Math.min(MAX_PAGE_SIZE, first - issues.length);
|
|
1201
|
+
const res = await feedbackGraphql(ISSUE_PAGE_QUERY, { search, first: count, cursor }, opts);
|
|
1202
|
+
if (!res.ok) return res;
|
|
1203
|
+
const page = res.data.search;
|
|
1204
|
+
if (!Array.isArray(page?.edges) || !Number.isInteger(page.issueCount) || page.issueCount < 0
|
|
1205
|
+
|| typeof page.pageInfo?.hasNextPage !== 'boolean') {
|
|
1206
|
+
return { ok: false, error: 'GitHub returned an invalid issue page' };
|
|
1207
|
+
}
|
|
1208
|
+
// GitHub search cannot page beyond 1000 matches. Never advertise a full
|
|
1209
|
+
// result set when its server-side limit makes that impossible.
|
|
1210
|
+
if (page.issueCount > 1000) return { ok: false, error: 'GitHub issue search exceeds 1000 matches; narrow the filter', truncated: true };
|
|
1211
|
+
total = page.issueCount;
|
|
1212
|
+
if (page.edges.length > count) return { ok: false, error: 'GitHub exceeded the requested issue page size' };
|
|
1213
|
+
for (const edge of page.edges) {
|
|
1214
|
+
if (typeof edge?.cursor !== 'string' || !edge.cursor || !Number.isInteger(edge.node?.number)
|
|
1215
|
+
|| edge.node.number < 1 || seen.has(edge.node.number) || rowCursors.has(edge.cursor)) {
|
|
1216
|
+
return { ok: false, error: 'GitHub returned an invalid issue cursor or duplicate issue' };
|
|
1217
|
+
}
|
|
1218
|
+
seen.add(edge.node.number);
|
|
1219
|
+
rowCursors.add(edge.cursor);
|
|
1220
|
+
if (String(edge.node.state).toLowerCase() === 'open') {
|
|
1221
|
+
issues.push({ ...normalizeIssue(graphqlIssueRow(edge.node)), cursor: edge.cursor });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
hasNextPage = page.pageInfo.hasNextPage;
|
|
1225
|
+
if (!hasNextPage) break;
|
|
1226
|
+
const next = page.pageInfo.endCursor;
|
|
1227
|
+
if (typeof next !== 'string' || !next || cursors.has(next) || page.edges.length === 0) {
|
|
1228
|
+
return { ok: false, error: 'GitHub issue pagination did not advance' };
|
|
1229
|
+
}
|
|
1230
|
+
cursors.add(next);
|
|
1231
|
+
// A short server page is still a valid page. Its cursor belongs to the
|
|
1232
|
+
// caller; filling it by issuing another request would skip that boundary.
|
|
1233
|
+
if (page.edges.length < count) break;
|
|
1234
|
+
cursor = next;
|
|
1235
|
+
}
|
|
1236
|
+
return { ok: true, issues, total, hasNextPage };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// The unfiltered `All` bucket reads the repository's own issue
|
|
1240
|
+
// CONNECTION instead of the search API. GitHub refuses to page a SEARCH result
|
|
1241
|
+
// set past 1000 matches, while `repository.issues` is cursor-paged to its end,
|
|
1242
|
+
// reports the exact `totalCount` and gives every edge its own `cursor`. The
|
|
1243
|
+
// ordering is part of the query (`UPDATED_AT DESC`), so no caller re-sorts, and
|
|
1244
|
+
// the state filter is an explicit literal: a `closed` read asks for CLOSED and
|
|
1245
|
+
// is never widened back to OPEN. The literal comes from this fixed whitelist,
|
|
1246
|
+
// never from caller text.
|
|
1247
|
+
const CONNECTION_STATE_LITERALS = {
|
|
1248
|
+
open: 'states: [OPEN]',
|
|
1249
|
+
closed: 'states: [CLOSED]',
|
|
1250
|
+
all: 'states: [OPEN, CLOSED]',
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
function issueConnectionQuery(states) {
|
|
1254
|
+
return `query($owner: String!, $name: String!, $first: Int!, $cursor: String) {
|
|
1255
|
+
repository(owner: $owner, name: $name) {
|
|
1256
|
+
issues(${states}, first: $first, after: $cursor, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
1257
|
+
totalCount
|
|
1258
|
+
pageInfo { hasNextPage endCursor }
|
|
1259
|
+
edges { cursor node { ${PAGE_ISSUE_FIELDS} } }
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}`;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// One cursor page of the UNFILTERED issue connection, in exactly the shape a
|
|
1266
|
+
// caller's page loader consumes (`{ok, issues, total, hasNextPage}` with a
|
|
1267
|
+
// per-row `cursor`), so a bucket can switch source without changing its reader.
|
|
1268
|
+
//
|
|
1269
|
+
// `state` is explicit — `open` (default, the caller's own scope), `closed` or
|
|
1270
|
+
// `all` — and maps to a fixed GraphQL `states` literal. There is no search text
|
|
1271
|
+
// here: `repository.issues` cannot take one, so a non-blank `query` is refused
|
|
1272
|
+
// rather than silently listed unfiltered; typed searches stay on
|
|
1273
|
+
// `getIssuePage`, whose 1000-match search cap is real.
|
|
1274
|
+
//
|
|
1275
|
+
// `first` is the caller's row budget (1..1000) and is filled by as many 100-row
|
|
1276
|
+
// server pages as it needs, keeping every row's own cursor. Pages are validated
|
|
1277
|
+
// before they are trusted: GraphQL errors fail the call, a cursor or issue
|
|
1278
|
+
// repeated inside one call is an error (a silent drop would make the totals
|
|
1279
|
+
// lie), and a non-advancing or cursorless `hasNextPage` fails instead of
|
|
1280
|
+
// looping. A failed continuation page is a FAILURE, never a short page a caller
|
|
1281
|
+
// could read as the end of the list. `total` is the server's exact `totalCount`
|
|
1282
|
+
// at request time; the rows are a cursor walk, not a snapshot, so concurrent
|
|
1283
|
+
// updates can shift what the walk sees.
|
|
1284
|
+
async function getIssueConnectionPage(params = {}, opts = {}) {
|
|
1285
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) return { ok: false, error: 'invalid issue page' };
|
|
1286
|
+
const { after = null, first = DEFAULT_PAGE_SIZE } = params;
|
|
1287
|
+
if (!Number.isInteger(first) || first < 1 || first > 1000
|
|
1288
|
+
|| (after !== null && (typeof after !== 'string' || !after))) {
|
|
1289
|
+
return { ok: false, error: 'invalid issue page: first must be 1..1000 and cursor must be a non-empty string or null' };
|
|
1290
|
+
}
|
|
1291
|
+
const state = params.state === undefined || params.state === null || params.state === ''
|
|
1292
|
+
? 'open'
|
|
1293
|
+
: String(params.state).toLowerCase();
|
|
1294
|
+
const states = CONNECTION_STATE_LITERALS[state];
|
|
1295
|
+
if (!states) {
|
|
1296
|
+
return { ok: false, error: `invalid issue state ${JSON.stringify(params.state)}: want open, closed or all` };
|
|
1297
|
+
}
|
|
1298
|
+
if (typeof params.query === 'string' && params.query.trim() !== '') {
|
|
1299
|
+
return { ok: false, error: 'the issue connection has no search text: pass no query and use getIssuePage for a search' };
|
|
1300
|
+
}
|
|
1301
|
+
const repo = await feedbackRepository(opts);
|
|
1302
|
+
if (!repo.ok) return repo;
|
|
1303
|
+
const query = issueConnectionQuery(states);
|
|
1304
|
+
const issues = [];
|
|
1305
|
+
const seen = new Set();
|
|
1306
|
+
const rowCursors = new Set(after === null ? [] : [after]);
|
|
1307
|
+
const cursors = new Set(after === null ? [] : [after]);
|
|
1308
|
+
let cursor = after;
|
|
1309
|
+
let total = 0;
|
|
1310
|
+
let hasNextPage = false;
|
|
1311
|
+
while (issues.length < first) {
|
|
1312
|
+
const count = Math.min(MAX_PAGE_SIZE, first - issues.length);
|
|
1313
|
+
const res = await feedbackGraphql(query, { owner: repo.owner, name: repo.name, first: count, cursor }, opts);
|
|
1314
|
+
if (!res.ok) return res;
|
|
1315
|
+
const connection = res.data.repository && typeof res.data.repository === 'object'
|
|
1316
|
+
? res.data.repository.issues
|
|
1317
|
+
: null;
|
|
1318
|
+
if (!Array.isArray(connection?.edges) || !Number.isInteger(connection.totalCount)
|
|
1319
|
+
|| connection.totalCount < 0 || typeof connection.pageInfo?.hasNextPage !== 'boolean') {
|
|
1320
|
+
return { ok: false, error: 'GitHub returned an invalid issue page' };
|
|
1321
|
+
}
|
|
1322
|
+
total = connection.totalCount;
|
|
1323
|
+
if (connection.edges.length > count) return { ok: false, error: 'GitHub exceeded the requested issue page size' };
|
|
1324
|
+
for (const edge of connection.edges) {
|
|
1325
|
+
if (typeof edge?.cursor !== 'string' || !edge.cursor || !Number.isInteger(edge.node?.number)
|
|
1326
|
+
|| edge.node.number < 1 || seen.has(edge.node.number) || rowCursors.has(edge.cursor)) {
|
|
1327
|
+
return { ok: false, error: 'GitHub returned an invalid issue cursor or duplicate issue' };
|
|
1328
|
+
}
|
|
1329
|
+
seen.add(edge.node.number);
|
|
1330
|
+
rowCursors.add(edge.cursor);
|
|
1331
|
+
issues.push({ ...normalizeIssue(graphqlIssueRow(edge.node)), cursor: edge.cursor });
|
|
1332
|
+
}
|
|
1333
|
+
hasNextPage = connection.pageInfo.hasNextPage;
|
|
1334
|
+
if (!hasNextPage) break;
|
|
1335
|
+
const next = connection.pageInfo.endCursor;
|
|
1336
|
+
if (typeof next !== 'string' || !next || cursors.has(next) || connection.edges.length === 0) {
|
|
1337
|
+
return { ok: false, error: 'GitHub issue pagination did not advance' };
|
|
1338
|
+
}
|
|
1339
|
+
cursors.add(next);
|
|
1340
|
+
// A short server page is still a valid page. Its cursor belongs to the
|
|
1341
|
+
// caller; filling it by issuing another request would skip that boundary.
|
|
1342
|
+
if (connection.edges.length < count) break;
|
|
1343
|
+
cursor = next;
|
|
1344
|
+
}
|
|
1345
|
+
return { ok: true, issues, total, hasNextPage };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// The repository capabilities the panel gates on, derived from ONE GraphQL
|
|
1349
|
+
// `viewerPermission`. Closing/reopening an issue, applying an existing label and
|
|
1350
|
+
// changing assignees are all triage-level; CREATING a repository label needs
|
|
1351
|
+
// write, so it is a distinct capability and a triage-only viewer may open the
|
|
1352
|
+
// label editor without being offered creation.
|
|
1353
|
+
function repositoryEditPermissions(role) {
|
|
1354
|
+
const edits = ['TRIAGE', 'WRITE', 'MAINTAIN', 'ADMIN'].includes(role);
|
|
1355
|
+
return {
|
|
1356
|
+
canEditLabels: edits,
|
|
1357
|
+
canEditAssignees: edits,
|
|
1358
|
+
canCloseIssues: edits,
|
|
1359
|
+
canCreateLabels: ['WRITE', 'MAINTAIN', 'ADMIN'].includes(role),
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
async function getIssueMetadata(number, opts = {}) {
|
|
1364
|
+
if (!Number.isInteger(number) || number < 1) return { ok: false, error: 'invalid issue number' };
|
|
1365
|
+
const repo = await feedbackRepository(opts);
|
|
1366
|
+
if (!repo.ok) return repo;
|
|
1367
|
+
const query = `query($owner: String!, $name: String!, $number: Int!) {
|
|
1368
|
+
repository(owner: $owner, name: $name) {
|
|
1369
|
+
viewerPermission
|
|
1370
|
+
issue(number: $number) {
|
|
1371
|
+
assignees(first: 100) { nodes { login } pageInfo { hasNextPage } }
|
|
1372
|
+
closedByPullRequestsReferences(first: 1, includeClosedPrs: true) { totalCount }
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}`;
|
|
1376
|
+
const res = await feedbackGraphql(query, { owner: repo.owner, name: repo.name, number }, opts);
|
|
1377
|
+
if (!res.ok) return res;
|
|
1378
|
+
const repository = res.data.repository;
|
|
1379
|
+
if (!repository?.issue) return { ok: false, error: 'Issue metadata is unavailable' };
|
|
1380
|
+
const issue = repository.issue;
|
|
1381
|
+
if (!Array.isArray(issue.assignees?.nodes) || issue.assignees.pageInfo?.hasNextPage) {
|
|
1382
|
+
return { ok: false, error: 'Issue assignees are unavailable or incomplete' };
|
|
1383
|
+
}
|
|
1384
|
+
const metadata = {
|
|
1385
|
+
ok: true,
|
|
1386
|
+
permissions: repositoryEditPermissions(repository.viewerPermission),
|
|
1387
|
+
assignees: issue.assignees.nodes.map(asLogin).filter(Boolean),
|
|
1388
|
+
};
|
|
1389
|
+
const count = issue.closedByPullRequestsReferences?.totalCount;
|
|
1390
|
+
if (Number.isInteger(count) && count > 0) metadata.linkedPullRequestCount = count;
|
|
1391
|
+
if (!Number.isInteger(count) || count < 0) metadata.metadataError = 'Linked PR metadata is unavailable';
|
|
1392
|
+
// Project visibility/scopes are independent of issue triage permissions.
|
|
1393
|
+
// A private or unsupported project relation must not disable known rights.
|
|
1394
|
+
const relations = await feedbackGraphql(`query($owner: String!, $name: String!, $number: Int!) {
|
|
1395
|
+
repository(owner: $owner, name: $name) {
|
|
1396
|
+
issue(number: $number) {
|
|
1397
|
+
projectItems(first: 100) { nodes { project { title url } } pageInfo { hasNextPage } }
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}`, { owner: repo.owner, name: repo.name, number }, opts);
|
|
1401
|
+
const related = relations.ok ? relations.data.repository?.issue : null;
|
|
1402
|
+
if (!Array.isArray(related?.projectItems?.nodes) || related.projectItems.pageInfo?.hasNextPage) {
|
|
1403
|
+
metadata.metadataError = relations.error || 'Project metadata is unavailable or incomplete';
|
|
1404
|
+
return metadata;
|
|
1405
|
+
}
|
|
1406
|
+
const projects = related.projectItems.nodes.map((item) => item?.project)
|
|
1407
|
+
.filter((project) => typeof project?.title === 'string' && project.title);
|
|
1408
|
+
if (projects.length > 0) metadata.projects = projects.map(({ title, url }) => ({ title, url }));
|
|
1409
|
+
return metadata;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
async function listAssignableUsers(opts = {}) {
|
|
1413
|
+
const repo = await feedbackRepository(opts);
|
|
1414
|
+
if (!repo.ok) return repo;
|
|
1415
|
+
const query = `query($owner: String!, $name: String!, $cursor: String) {
|
|
1416
|
+
repository(owner: $owner, name: $name) {
|
|
1417
|
+
assignableUsers(first: 100, after: $cursor) {
|
|
1418
|
+
nodes { login name } pageInfo { hasNextPage endCursor }
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}`;
|
|
1422
|
+
const users = [];
|
|
1423
|
+
const logins = new Set();
|
|
1424
|
+
const cursors = new Set();
|
|
1425
|
+
let cursor = null;
|
|
1426
|
+
for (let page = 0; page < SNAPSHOT_MAX_PAGES; page += 1) {
|
|
1427
|
+
const res = await feedbackGraphql(query, { owner: repo.owner, name: repo.name, cursor }, opts);
|
|
1428
|
+
if (!res.ok) return res;
|
|
1429
|
+
const connection = res.data.repository?.assignableUsers;
|
|
1430
|
+
if (!Array.isArray(connection?.nodes) || typeof connection.pageInfo?.hasNextPage !== 'boolean') {
|
|
1431
|
+
return { ok: false, error: 'GitHub returned an invalid assignable-user page' };
|
|
1432
|
+
}
|
|
1433
|
+
for (const user of connection.nodes) {
|
|
1434
|
+
if (typeof user?.login !== 'string' || !user.login) return { ok: false, error: 'GitHub returned an invalid assignable user' };
|
|
1435
|
+
const id = user.login.toLowerCase();
|
|
1436
|
+
if (!logins.has(id)) {
|
|
1437
|
+
logins.add(id);
|
|
1438
|
+
users.push({ login: user.login, name: typeof user.name === 'string' ? user.name : '' });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (!connection.pageInfo.hasNextPage) return { ok: true, users };
|
|
1442
|
+
const next = connection.pageInfo.endCursor;
|
|
1443
|
+
if (typeof next !== 'string' || !next || cursors.has(next)) return { ok: false, error: 'Assignable-user pagination did not advance' };
|
|
1444
|
+
cursors.add(next);
|
|
1445
|
+
cursor = next;
|
|
1446
|
+
}
|
|
1447
|
+
return { ok: false, error: 'Assignable-user pagination exceeded its safety budget' };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
async function editAssignees(number, changes = {}, opts = {}) {
|
|
1451
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) return { ok: false, error: 'invalid assignee changes' };
|
|
1452
|
+
const { add = [], remove = [] } = changes;
|
|
1453
|
+
const valid = (values) => Array.isArray(values) && values.every((value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(value));
|
|
1454
|
+
if (!Number.isInteger(number) || number < 1 || !valid(add) || !valid(remove)
|
|
1455
|
+
|| add.some((name) => remove.some((other) => name.toLowerCase() === other.toLowerCase()))) {
|
|
1456
|
+
return { ok: false, error: 'invalid assignee changes: supply distinct GitHub logins to add or remove' };
|
|
1457
|
+
}
|
|
1458
|
+
if (add.length === 0 && remove.length === 0) return { ok: true, changed: false };
|
|
1459
|
+
const repo = await feedbackRepository(opts);
|
|
1460
|
+
if (!repo.ok) return repo;
|
|
1461
|
+
const access = await feedbackGraphql('query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { viewerPermission } }', { owner: repo.owner, name: repo.name }, opts);
|
|
1462
|
+
if (!access.ok) return access;
|
|
1463
|
+
if (!repositoryEditPermissions(access.data.repository?.viewerPermission).canEditAssignees) {
|
|
1464
|
+
return { ok: false, error: 'Repository permission does not allow changing issue assignees' };
|
|
1465
|
+
}
|
|
1466
|
+
const argv = ['issue', 'edit', String(number)];
|
|
1467
|
+
for (const name of new Set(add)) argv.push('--add-assignee', name);
|
|
1468
|
+
for (const name of new Set(remove)) argv.push('--remove-assignee', name);
|
|
1469
|
+
const result = await runGh(argv, { ...opts, freshScope: true });
|
|
1470
|
+
if (result.error) return { ok: false, error: result.error };
|
|
1471
|
+
if (result.status !== 0) return { ok: false, error: exitError(result, 'Cannot edit issue assignees') };
|
|
1472
|
+
return { ok: true, changed: true };
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
async function listLabels(opts = {}) {
|
|
1476
|
+
const res = await runGh(['label', 'list', '--limit', String(LABEL_FETCH_MAX), '--json', LABEL_JSON_FIELDS], opts);
|
|
1477
|
+
if (res.error) return { ok: false, error: res.error };
|
|
1478
|
+
if (res.status !== 0) return { ok: false, error: exitError(res, 'gh label list failed') };
|
|
1479
|
+
const parsed = parseJsonBody(res.stdout, 'gh label list');
|
|
1480
|
+
if (!parsed.ok) return parsed;
|
|
1481
|
+
if (!Array.isArray(parsed.doc)) return { ok: false, error: 'gh label list returned an unexpected shape' };
|
|
1482
|
+
return { ok: true, labels: parsed.doc.map(asLabel) };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
async function createLabel({ name, color, description } = {}, opts = {}) {
|
|
1486
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
1487
|
+
return { ok: false, error: 'label name must be a non-empty string' };
|
|
1488
|
+
}
|
|
1489
|
+
const argv = ['label', 'create', name];
|
|
1490
|
+
if (typeof color === 'string' && color !== '') argv.push('--color', color);
|
|
1491
|
+
if (typeof description === 'string' && description !== '') argv.push('--description', description);
|
|
1492
|
+
const res = await runGh(argv, { ...opts, freshScope: true });
|
|
1493
|
+
if (res.error) return { ok: false, error: res.error };
|
|
1494
|
+
if (res.status !== 0) return { ok: false, error: exitError(res, `cannot create label ${name}`) };
|
|
1495
|
+
return { ok: true, label: { name, color: color || '', description: description || '' } };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async function editLabels(number, { add = [], remove = [] } = {}, opts = {}) {
|
|
1499
|
+
if (!Number.isInteger(number)) return { ok: false, error: `invalid issue number ${JSON.stringify(number)}: want an integer` };
|
|
1500
|
+
const additions = Array.isArray(add) ? add.filter((entry) => typeof entry === 'string' && entry !== '') : [];
|
|
1501
|
+
const removals = Array.isArray(remove) ? remove.filter((entry) => typeof entry === 'string' && entry !== '') : [];
|
|
1502
|
+
if (additions.length === 0 && removals.length === 0) return { ok: true };
|
|
1503
|
+
const argv = ['issue', 'edit', String(number)];
|
|
1504
|
+
for (const name of additions) argv.push('--add-label', name);
|
|
1505
|
+
for (const name of removals) argv.push('--remove-label', name);
|
|
1506
|
+
const res = await runGh(argv, { ...opts, freshScope: true });
|
|
1507
|
+
if (res.error) return { ok: false, error: res.error };
|
|
1508
|
+
if (res.status !== 0) return { ok: false, error: exitError(res, `cannot edit labels on issue #${number}`) };
|
|
1509
|
+
return { ok: true };
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
async function commentIssue(number, body, opts = {}) {
|
|
1513
|
+
if (!Number.isInteger(number)) return { ok: false, error: `invalid issue number ${JSON.stringify(number)}: want an integer` };
|
|
1514
|
+
if (typeof body !== 'string' || body.trim() === '') {
|
|
1515
|
+
return { ok: false, error: 'comment body must be a non-empty string' };
|
|
1516
|
+
}
|
|
1517
|
+
const res = await runGh(['issue', 'comment', String(number), '--body', body], { ...opts, freshScope: true });
|
|
1518
|
+
if (res.error) return { ok: false, error: res.error };
|
|
1519
|
+
if (res.status !== 0) return { ok: false, error: exitError(res, `cannot comment on issue #${number}`) };
|
|
1520
|
+
return { ok: true };
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async function setIssueState(number, state, opts = {}) {
|
|
1524
|
+
if (!Number.isInteger(number)) return { ok: false, error: `invalid issue number ${JSON.stringify(number)}: want an integer` };
|
|
1525
|
+
const verb = state === 'closed' ? 'close' : (state === 'open' ? 'reopen' : null);
|
|
1526
|
+
if (verb === null) return { ok: false, error: `invalid state ${JSON.stringify(state)}: want open or closed` };
|
|
1527
|
+
const res = await runGh(['issue', verb, String(number)], { ...opts, freshScope: true });
|
|
1528
|
+
if (res.error) return { ok: false, error: res.error };
|
|
1529
|
+
if (res.status !== 0) return { ok: false, error: exitError(res, `cannot ${verb} issue #${number}`) };
|
|
1530
|
+
return { ok: true };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
module.exports = {
|
|
1534
|
+
setGhRunner,
|
|
1535
|
+
setGitRunner,
|
|
1536
|
+
ghBinary,
|
|
1537
|
+
checkoutRepoScope,
|
|
1538
|
+
reportedRepoName,
|
|
1539
|
+
normalizeScopeIdentity,
|
|
1540
|
+
ghStatus,
|
|
1541
|
+
repoInfo,
|
|
1542
|
+
listIssues,
|
|
1543
|
+
listAllOpenIssues,
|
|
1544
|
+
getIssue,
|
|
1545
|
+
getIssuePage,
|
|
1546
|
+
getIssueConnectionPage,
|
|
1547
|
+
getIssueMetadata,
|
|
1548
|
+
listAssignableUsers,
|
|
1549
|
+
editAssignees,
|
|
1550
|
+
listLabels,
|
|
1551
|
+
createLabel,
|
|
1552
|
+
editLabels,
|
|
1553
|
+
commentIssue,
|
|
1554
|
+
setIssueState,
|
|
1555
|
+
};
|