@h-rig/github-lib 0.0.6-alpha.158
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 +1 -0
- package/dist/src/auth-store.d.ts +42 -0
- package/dist/src/auth-store.js +227 -0
- package/dist/src/credentials.d.ts +20 -0
- package/dist/src/credentials.js +118 -0
- package/dist/src/github-api.d.ts +107 -0
- package/dist/src/github-api.js +452 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +713 -0
- package/dist/src/projects.d.ts +31 -0
- package/dist/src/projects.js +148 -0
- package/package.json +31 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/github-lib/src/auth-store.ts
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { dirname, resolve } from "path";
|
|
6
|
+
import { resolveRigStatePaths } from "@rig/core/server-paths";
|
|
7
|
+
function cleanString(value) {
|
|
8
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
9
|
+
}
|
|
10
|
+
function cleanScopes(value) {
|
|
11
|
+
if (!Array.isArray(value))
|
|
12
|
+
return [];
|
|
13
|
+
return value.flatMap((entry) => {
|
|
14
|
+
const clean = cleanString(entry);
|
|
15
|
+
return clean ? [clean] : [];
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function parseApiSessions(value) {
|
|
19
|
+
if (!Array.isArray(value))
|
|
20
|
+
return [];
|
|
21
|
+
return value.flatMap((entry) => {
|
|
22
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
23
|
+
return [];
|
|
24
|
+
const record = entry;
|
|
25
|
+
const token = cleanString(record.token);
|
|
26
|
+
if (!token)
|
|
27
|
+
return [];
|
|
28
|
+
const createdAt = cleanString(record.createdAt);
|
|
29
|
+
return [{
|
|
30
|
+
token,
|
|
31
|
+
login: cleanString(record.login),
|
|
32
|
+
userId: cleanString(record.userId),
|
|
33
|
+
...createdAt ? { createdAt } : {}
|
|
34
|
+
}];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function parsePendingDevice(value) {
|
|
38
|
+
if (!value || typeof value !== "object")
|
|
39
|
+
return null;
|
|
40
|
+
const record = value;
|
|
41
|
+
const pollId = cleanString(record.pollId);
|
|
42
|
+
const deviceCode = cleanString(record.deviceCode);
|
|
43
|
+
const expiresAt = cleanString(record.expiresAt);
|
|
44
|
+
const intervalSeconds = typeof record.intervalSeconds === "number" && Number.isFinite(record.intervalSeconds) ? Math.max(1, Math.floor(record.intervalSeconds)) : null;
|
|
45
|
+
if (!pollId || !deviceCode || !expiresAt || !intervalSeconds)
|
|
46
|
+
return null;
|
|
47
|
+
return { pollId, deviceCode, expiresAt, intervalSeconds };
|
|
48
|
+
}
|
|
49
|
+
function parsePendingDevices(value) {
|
|
50
|
+
if (!Array.isArray(value))
|
|
51
|
+
return [];
|
|
52
|
+
return value.flatMap((entry) => {
|
|
53
|
+
const pending = parsePendingDevice(entry);
|
|
54
|
+
return pending ? [pending] : [];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function readStoredAuth(stateFile) {
|
|
58
|
+
if (!existsSync(stateFile))
|
|
59
|
+
return {};
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
62
|
+
return {
|
|
63
|
+
...cleanString(parsed.token) ? { token: cleanString(parsed.token) } : {},
|
|
64
|
+
login: cleanString(parsed.login),
|
|
65
|
+
userId: cleanString(parsed.userId),
|
|
66
|
+
scopes: cleanScopes(parsed.scopes),
|
|
67
|
+
selectedRepo: cleanString(parsed.selectedRepo),
|
|
68
|
+
...parsed.tokenSource === "oauth-device" || parsed.tokenSource === "manual-token" || parsed.tokenSource === "env" ? { tokenSource: parsed.tokenSource } : {},
|
|
69
|
+
pendingDevice: parsePendingDevice(parsed.pendingDevice),
|
|
70
|
+
pendingDevices: parsePendingDevices(parsed.pendingDevices),
|
|
71
|
+
apiSessions: parseApiSessions(parsed.apiSessions),
|
|
72
|
+
...cleanString(parsed.updatedAt) ? { updatedAt: cleanString(parsed.updatedAt) } : {}
|
|
73
|
+
};
|
|
74
|
+
} catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function newApiSessionToken() {
|
|
79
|
+
return `rig_${randomBytes(32).toString("base64url")}`;
|
|
80
|
+
}
|
|
81
|
+
function writeStoredAuth(stateFile, payload) {
|
|
82
|
+
mkdirSync(dirname(stateFile), { recursive: true });
|
|
83
|
+
writeFileSync(stateFile, `${JSON.stringify(payload, null, 2)}
|
|
84
|
+
`, { encoding: "utf8", mode: 384 });
|
|
85
|
+
try {
|
|
86
|
+
chmodSync(stateFile, 384);
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
function localProjectAuthStateFile(projectRoot) {
|
|
90
|
+
return resolve(projectRoot, ".rig", "state", "github-auth.json");
|
|
91
|
+
}
|
|
92
|
+
function resolveGitHubAuthStateFile(projectRoot) {
|
|
93
|
+
return resolve(resolveRigStatePaths(projectRoot).stateDir, "github-auth.json");
|
|
94
|
+
}
|
|
95
|
+
function copyGitHubAuthStateToLocalProjectRoot(stateFile, projectRoot) {
|
|
96
|
+
const targetFile = localProjectAuthStateFile(projectRoot);
|
|
97
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
98
|
+
if (existsSync(stateFile)) {
|
|
99
|
+
copyFileSync(stateFile, targetFile);
|
|
100
|
+
try {
|
|
101
|
+
chmodSync(targetFile, 384);
|
|
102
|
+
} catch {}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
writeStoredAuth(targetFile, {});
|
|
106
|
+
}
|
|
107
|
+
function createGitHubAuthStoreFromStateFile(stateFile) {
|
|
108
|
+
return {
|
|
109
|
+
stateFile,
|
|
110
|
+
status(options) {
|
|
111
|
+
const stored = readStoredAuth(stateFile);
|
|
112
|
+
const token = cleanString(stored.token);
|
|
113
|
+
return {
|
|
114
|
+
signedIn: Boolean(token),
|
|
115
|
+
login: cleanString(stored.login),
|
|
116
|
+
userId: cleanString(stored.userId),
|
|
117
|
+
scopes: cleanScopes(stored.scopes),
|
|
118
|
+
selectedRepo: cleanString(stored.selectedRepo),
|
|
119
|
+
oauthConfigured: options?.oauthConfigured === true,
|
|
120
|
+
tokenSource: token ? stored.tokenSource ?? "manual-token" : null
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
readToken() {
|
|
124
|
+
return cleanString(readStoredAuth(stateFile).token);
|
|
125
|
+
},
|
|
126
|
+
saveToken(input) {
|
|
127
|
+
const previous = readStoredAuth(stateFile);
|
|
128
|
+
writeStoredAuth(stateFile, {
|
|
129
|
+
...previous,
|
|
130
|
+
token: input.token,
|
|
131
|
+
tokenSource: input.tokenSource,
|
|
132
|
+
login: input.login ?? null,
|
|
133
|
+
userId: input.userId ?? null,
|
|
134
|
+
scopes: input.scopes ?? [],
|
|
135
|
+
selectedRepo: input.selectedRepo ?? previous.selectedRepo ?? null,
|
|
136
|
+
pendingDevice: null,
|
|
137
|
+
pendingDevices: [],
|
|
138
|
+
apiSessions: previous.apiSessions ?? [],
|
|
139
|
+
updatedAt: new Date().toISOString()
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
createApiSession() {
|
|
143
|
+
const previous = readStoredAuth(stateFile);
|
|
144
|
+
const token = newApiSessionToken();
|
|
145
|
+
const session = {
|
|
146
|
+
token,
|
|
147
|
+
login: cleanString(previous.login),
|
|
148
|
+
userId: cleanString(previous.userId),
|
|
149
|
+
createdAt: new Date().toISOString()
|
|
150
|
+
};
|
|
151
|
+
writeStoredAuth(stateFile, {
|
|
152
|
+
...previous,
|
|
153
|
+
apiSessions: [...(previous.apiSessions ?? []).slice(-9), session],
|
|
154
|
+
updatedAt: new Date().toISOString()
|
|
155
|
+
});
|
|
156
|
+
return { token, login: session.login ?? null, userId: session.userId ?? null };
|
|
157
|
+
},
|
|
158
|
+
readApiSession(token) {
|
|
159
|
+
const clean = cleanString(token);
|
|
160
|
+
if (!clean)
|
|
161
|
+
return null;
|
|
162
|
+
const previous = readStoredAuth(stateFile);
|
|
163
|
+
const session = (previous.apiSessions ?? []).find((candidate) => candidate.token === clean);
|
|
164
|
+
return session ? { login: cleanString(session.login), userId: cleanString(session.userId) } : null;
|
|
165
|
+
},
|
|
166
|
+
copyToProjectRoot(projectRoot) {
|
|
167
|
+
const targetFile = resolveGitHubAuthStateFile(projectRoot);
|
|
168
|
+
writeStoredAuth(targetFile, readStoredAuth(stateFile));
|
|
169
|
+
},
|
|
170
|
+
copyToLocalProjectRoot(projectRoot) {
|
|
171
|
+
copyGitHubAuthStateToLocalProjectRoot(stateFile, projectRoot);
|
|
172
|
+
},
|
|
173
|
+
savePendingDevice(input) {
|
|
174
|
+
const previous = readStoredAuth(stateFile);
|
|
175
|
+
const pendingDevices = [
|
|
176
|
+
...previous.pendingDevice ? [previous.pendingDevice] : [],
|
|
177
|
+
...previous.pendingDevices ?? [],
|
|
178
|
+
input
|
|
179
|
+
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.pollId === entry.pollId) === index);
|
|
180
|
+
writeStoredAuth(stateFile, {
|
|
181
|
+
...previous,
|
|
182
|
+
pendingDevice: null,
|
|
183
|
+
pendingDevices,
|
|
184
|
+
updatedAt: new Date().toISOString()
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
saveSelectedRepo(selectedRepo) {
|
|
188
|
+
const previous = readStoredAuth(stateFile);
|
|
189
|
+
writeStoredAuth(stateFile, {
|
|
190
|
+
...previous,
|
|
191
|
+
selectedRepo: selectedRepo ?? null,
|
|
192
|
+
updatedAt: new Date().toISOString()
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
readPendingDevice(pollId) {
|
|
196
|
+
const previous = readStoredAuth(stateFile);
|
|
197
|
+
const pending = [
|
|
198
|
+
...previous.pendingDevice ? [previous.pendingDevice] : [],
|
|
199
|
+
...previous.pendingDevices ?? []
|
|
200
|
+
].find((entry) => entry.pollId === pollId) ?? null;
|
|
201
|
+
if (!pending)
|
|
202
|
+
return null;
|
|
203
|
+
if (Date.parse(pending.expiresAt) <= Date.now())
|
|
204
|
+
return null;
|
|
205
|
+
return pending;
|
|
206
|
+
},
|
|
207
|
+
clearPendingDevice(pollId) {
|
|
208
|
+
const previous = readStoredAuth(stateFile);
|
|
209
|
+
const remaining = pollId ? (previous.pendingDevices ?? []).filter((entry) => entry.pollId !== pollId) : [];
|
|
210
|
+
writeStoredAuth(stateFile, {
|
|
211
|
+
...previous,
|
|
212
|
+
pendingDevice: null,
|
|
213
|
+
pendingDevices: remaining,
|
|
214
|
+
updatedAt: new Date().toISOString()
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function createGitHubAuthStore(projectRoot) {
|
|
220
|
+
return createGitHubAuthStoreFromStateFile(resolveGitHubAuthStateFile(projectRoot));
|
|
221
|
+
}
|
|
222
|
+
// packages/github-lib/src/credentials.ts
|
|
223
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
224
|
+
import { resolve as resolve2 } from "path";
|
|
225
|
+
function selectedRepoTokenKey(input) {
|
|
226
|
+
return `user:${input.userId}|repo:${input.owner}/${input.repo}|workspace:${input.workspaceId}`;
|
|
227
|
+
}
|
|
228
|
+
function cleanToken(value) {
|
|
229
|
+
const trimmed = value?.trim() ?? "";
|
|
230
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
231
|
+
}
|
|
232
|
+
function createGitHubCredentialProvider(options = {}) {
|
|
233
|
+
const sessionTokens = options.sessionTokens ?? {};
|
|
234
|
+
const hostToken = cleanToken(options.hostToken ?? process.env.GH_TOKEN ?? null);
|
|
235
|
+
return {
|
|
236
|
+
async resolveGitHubToken(input) {
|
|
237
|
+
const owner = input.owner.trim();
|
|
238
|
+
const repo = input.repo.trim();
|
|
239
|
+
const workspaceId = input.workspaceId.trim();
|
|
240
|
+
const userId = input.userId?.trim() ?? "";
|
|
241
|
+
if (input.purpose === "selected-repo") {
|
|
242
|
+
if (!owner || !repo || !workspaceId || !userId) {
|
|
243
|
+
throw new Error("No signed-in GitHub token is available for the selected repo; sign in to GitHub for this workspace.");
|
|
244
|
+
}
|
|
245
|
+
const token = cleanToken(sessionTokens[selectedRepoTokenKey({ owner, repo, workspaceId, userId })]);
|
|
246
|
+
if (!token) {
|
|
247
|
+
throw new Error("No signed-in GitHub token is available for the selected repo; sign in to GitHub for this workspace.");
|
|
248
|
+
}
|
|
249
|
+
return { token, source: "signed-in-user" };
|
|
250
|
+
}
|
|
251
|
+
if (hostToken) {
|
|
252
|
+
return { token: hostToken, source: "host-admin-fallback" };
|
|
253
|
+
}
|
|
254
|
+
throw new Error("No host GitHub token is configured for the explicit admin fallback operation.");
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function createEnvGitHubCredentialProvider() {
|
|
259
|
+
return {
|
|
260
|
+
async resolveGitHubToken(input) {
|
|
261
|
+
if (input.purpose === "selected-repo") {
|
|
262
|
+
return { token: cleanToken(process.env.RIG_GITHUB_SELECTED_TOKEN ?? null) ?? "", source: "signed-in-user" };
|
|
263
|
+
}
|
|
264
|
+
const token = cleanToken(process.env.RIG_GITHUB_TOKEN ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null);
|
|
265
|
+
if (!token) {
|
|
266
|
+
throw new Error("No host GitHub token is configured for admin fallback.");
|
|
267
|
+
}
|
|
268
|
+
return { token, source: "host-admin-fallback" };
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function createStateGitHubCredentialProvider(options = {}) {
|
|
273
|
+
const addCandidate = (candidates, path) => {
|
|
274
|
+
const trimmed = path?.trim();
|
|
275
|
+
if (!trimmed)
|
|
276
|
+
return;
|
|
277
|
+
const resolved = resolve2(trimmed);
|
|
278
|
+
if (!candidates.includes(resolved))
|
|
279
|
+
candidates.push(resolved);
|
|
280
|
+
};
|
|
281
|
+
const addStateDir = (candidates, dir) => {
|
|
282
|
+
const trimmed = dir?.trim();
|
|
283
|
+
if (!trimmed)
|
|
284
|
+
return;
|
|
285
|
+
addCandidate(candidates, resolve2(trimmed, "github-auth.json"));
|
|
286
|
+
};
|
|
287
|
+
const addProjectStateDir = (candidates, root) => {
|
|
288
|
+
const trimmed = root?.trim();
|
|
289
|
+
if (!trimmed)
|
|
290
|
+
return;
|
|
291
|
+
addStateDir(candidates, resolve2(trimmed, ".rig", "state"));
|
|
292
|
+
};
|
|
293
|
+
const stateFileCandidates = () => {
|
|
294
|
+
const candidates = [];
|
|
295
|
+
addCandidate(candidates, options.stateFile ?? process.env.RIG_GITHUB_AUTH_STATE_FILE);
|
|
296
|
+
addStateDir(candidates, options.stateDir);
|
|
297
|
+
addStateDir(candidates, process.env.RIG_STATE_DIR);
|
|
298
|
+
addProjectStateDir(candidates, process.env.PROJECT_RIG_ROOT);
|
|
299
|
+
addProjectStateDir(candidates, process.env.RIG_PROJECT_ROOT);
|
|
300
|
+
addProjectStateDir(candidates, process.env.RIG_HOST_PROJECT_ROOT);
|
|
301
|
+
addProjectStateDir(candidates, process.cwd());
|
|
302
|
+
return candidates;
|
|
303
|
+
};
|
|
304
|
+
const readToken = () => {
|
|
305
|
+
for (const stateFile of stateFileCandidates()) {
|
|
306
|
+
if (!existsSync2(stateFile))
|
|
307
|
+
continue;
|
|
308
|
+
try {
|
|
309
|
+
const parsed = JSON.parse(readFileSync2(stateFile, "utf8"));
|
|
310
|
+
const token = typeof parsed.token === "string" ? cleanToken(parsed.token) : null;
|
|
311
|
+
if (token)
|
|
312
|
+
return token;
|
|
313
|
+
} catch {}
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
};
|
|
317
|
+
return {
|
|
318
|
+
async resolveGitHubToken(input) {
|
|
319
|
+
const token = readToken();
|
|
320
|
+
if (input.purpose === "selected-repo") {
|
|
321
|
+
return { token: token ?? cleanToken(process.env.RIG_GITHUB_SELECTED_TOKEN ?? null) ?? "", source: "signed-in-user" };
|
|
322
|
+
}
|
|
323
|
+
if (token) {
|
|
324
|
+
return { token, source: "signed-in-user" };
|
|
325
|
+
}
|
|
326
|
+
const fallback = cleanToken(process.env.RIG_GITHUB_TOKEN ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null);
|
|
327
|
+
if (!fallback) {
|
|
328
|
+
throw new Error("No signed-in GitHub token is stored for Rig and no host admin fallback token is configured.");
|
|
329
|
+
}
|
|
330
|
+
return { token: fallback, source: "host-admin-fallback" };
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// packages/github-lib/src/projects.ts
|
|
335
|
+
function asRecord(value) {
|
|
336
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
337
|
+
}
|
|
338
|
+
function asString(value) {
|
|
339
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
340
|
+
}
|
|
341
|
+
function asNumber(value) {
|
|
342
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
343
|
+
}
|
|
344
|
+
async function defaultGraphQLFetch(query, variables, token) {
|
|
345
|
+
const response = await fetch("https://api.github.com/graphql", {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: {
|
|
348
|
+
"content-type": "application/json",
|
|
349
|
+
authorization: `Bearer ${token}`,
|
|
350
|
+
accept: "application/vnd.github+json"
|
|
351
|
+
},
|
|
352
|
+
body: JSON.stringify({ query, variables })
|
|
353
|
+
});
|
|
354
|
+
const json = await response.json().catch(() => ({}));
|
|
355
|
+
if (!response.ok || json.errors) {
|
|
356
|
+
throw new Error(`GitHub Projects GraphQL request failed: ${JSON.stringify(json.errors ?? { status: response.status })}`);
|
|
357
|
+
}
|
|
358
|
+
return json.data;
|
|
359
|
+
}
|
|
360
|
+
function projectNodesFrom(data) {
|
|
361
|
+
const root = asRecord(data);
|
|
362
|
+
const owner = asRecord(root?.organization) ?? asRecord(root?.user);
|
|
363
|
+
const projects = asRecord(owner?.projectsV2);
|
|
364
|
+
const nodes = projects?.nodes;
|
|
365
|
+
return Array.isArray(nodes) ? nodes : [];
|
|
366
|
+
}
|
|
367
|
+
async function listGitHubProjects(input) {
|
|
368
|
+
const query = `
|
|
369
|
+
query RigListProjects($owner: String!, $first: Int!) {
|
|
370
|
+
organization(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
|
|
371
|
+
user(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
|
|
372
|
+
}
|
|
373
|
+
`;
|
|
374
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
375
|
+
const data = await fetchGraphQL(query, { owner: input.owner, first: input.first ?? 20 }, input.token);
|
|
376
|
+
return projectNodesFrom(data).flatMap((node) => {
|
|
377
|
+
const record = asRecord(node);
|
|
378
|
+
const id = asString(record?.id);
|
|
379
|
+
const number = asNumber(record?.number);
|
|
380
|
+
const title = asString(record?.title);
|
|
381
|
+
if (!id || number === undefined || !title)
|
|
382
|
+
return [];
|
|
383
|
+
const url = asString(record?.url);
|
|
384
|
+
return [{ id, number, title, ...url ? { url } : {} }];
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async function resolveProjectStatusField(input) {
|
|
388
|
+
const query = `
|
|
389
|
+
query RigProjectStatusField($projectId: ID!) {
|
|
390
|
+
node(id: $projectId) {
|
|
391
|
+
... on ProjectV2 {
|
|
392
|
+
fields(first: 50) {
|
|
393
|
+
nodes {
|
|
394
|
+
... on ProjectV2FieldCommon { id name }
|
|
395
|
+
... on ProjectV2SingleSelectField { id name options { id name } }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
`;
|
|
402
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
403
|
+
const data = await fetchGraphQL(query, { projectId: input.projectId }, input.token);
|
|
404
|
+
const fields = asRecord(asRecord(asRecord(data)?.node)?.fields)?.nodes;
|
|
405
|
+
for (const node of Array.isArray(fields) ? fields : []) {
|
|
406
|
+
const record = asRecord(node);
|
|
407
|
+
if (asString(record?.name)?.toLowerCase() !== "status")
|
|
408
|
+
continue;
|
|
409
|
+
const id = asString(record?.id);
|
|
410
|
+
if (!id)
|
|
411
|
+
continue;
|
|
412
|
+
const options = Array.isArray(record?.options) ? record.options.flatMap((option) => {
|
|
413
|
+
const optionRecord = asRecord(option);
|
|
414
|
+
const optionId = asString(optionRecord?.id);
|
|
415
|
+
const name = asString(optionRecord?.name);
|
|
416
|
+
return optionId && name ? [{ id: optionId, name }] : [];
|
|
417
|
+
}) : [];
|
|
418
|
+
return { id, name: "Status", options };
|
|
419
|
+
}
|
|
420
|
+
throw new Error(`GitHub Project ${input.projectId} does not expose a Status single-select field.`);
|
|
421
|
+
}
|
|
422
|
+
async function ensureIssueProjectItem(input) {
|
|
423
|
+
const query = `
|
|
424
|
+
query RigFindProjectIssueItem($projectId: ID!, $issueNodeId: ID!) {
|
|
425
|
+
node(id: $projectId) {
|
|
426
|
+
... on ProjectV2 {
|
|
427
|
+
items(first: 100) { nodes { id content { ... on Issue { id } } } }
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
`;
|
|
432
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
433
|
+
const data = await fetchGraphQL(query, { projectId: input.projectId, issueNodeId: input.issueNodeId }, input.token);
|
|
434
|
+
const nodes = asRecord(asRecord(asRecord(data)?.node)?.items)?.nodes;
|
|
435
|
+
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
436
|
+
const record = asRecord(node);
|
|
437
|
+
const content = asRecord(record?.content);
|
|
438
|
+
if (asString(content?.id) === input.issueNodeId) {
|
|
439
|
+
const id2 = asString(record?.id);
|
|
440
|
+
if (id2)
|
|
441
|
+
return { id: id2, created: false };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const mutation = `
|
|
445
|
+
mutation RigAddIssueToProject($projectId: ID!, $contentId: ID!) {
|
|
446
|
+
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } }
|
|
447
|
+
}
|
|
448
|
+
`;
|
|
449
|
+
const created = await fetchGraphQL(mutation, { projectId: input.projectId, contentId: input.issueNodeId }, input.token);
|
|
450
|
+
const addResult = asRecord(asRecord(created)?.addProjectV2ItemById);
|
|
451
|
+
const id = asString(asRecord(addResult?.item)?.id);
|
|
452
|
+
if (!id)
|
|
453
|
+
throw new Error("GitHub Project item creation did not return an item id.");
|
|
454
|
+
return { id, created: true };
|
|
455
|
+
}
|
|
456
|
+
async function updateIssueProjectStatus(input) {
|
|
457
|
+
const mutation = `
|
|
458
|
+
mutation RigUpdateProjectStatus($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
459
|
+
updateProjectV2ItemFieldValue(input: {
|
|
460
|
+
projectId: $projectId,
|
|
461
|
+
itemId: $itemId,
|
|
462
|
+
fieldId: $fieldId,
|
|
463
|
+
value: { singleSelectOptionId: $optionId }
|
|
464
|
+
}) { projectV2Item { id } }
|
|
465
|
+
}
|
|
466
|
+
`;
|
|
467
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
468
|
+
await fetchGraphQL(mutation, {
|
|
469
|
+
projectId: input.projectId,
|
|
470
|
+
itemId: input.itemId,
|
|
471
|
+
fieldId: input.fieldId,
|
|
472
|
+
optionId: input.optionId
|
|
473
|
+
}, input.token);
|
|
474
|
+
}
|
|
475
|
+
// packages/github-lib/src/github-api.ts
|
|
476
|
+
import { randomUUID } from "crypto";
|
|
477
|
+
function normalizeString(value) {
|
|
478
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
479
|
+
}
|
|
480
|
+
function normalizeScopes(value) {
|
|
481
|
+
if (Array.isArray(value)) {
|
|
482
|
+
return value.flatMap((entry) => {
|
|
483
|
+
const normalized = normalizeString(entry);
|
|
484
|
+
return normalized ? [normalized] : [];
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
if (typeof value === "string") {
|
|
488
|
+
return value.split(/[ ,]+/).map((entry) => entry.trim()).filter(Boolean);
|
|
489
|
+
}
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
async function defaultPostGitHubForm(endpoint, body) {
|
|
493
|
+
const response = await fetch(endpoint, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers: {
|
|
496
|
+
accept: "application/json",
|
|
497
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
498
|
+
"user-agent": "rig"
|
|
499
|
+
},
|
|
500
|
+
body: new URLSearchParams(body)
|
|
501
|
+
});
|
|
502
|
+
const payload = await response.json().catch(() => ({}));
|
|
503
|
+
return { status: response.status, payload };
|
|
504
|
+
}
|
|
505
|
+
async function fetchGitHubUserInfo(token) {
|
|
506
|
+
const response = await fetch("https://api.github.com/user", {
|
|
507
|
+
headers: {
|
|
508
|
+
accept: "application/vnd.github+json",
|
|
509
|
+
authorization: `Bearer ${token}`,
|
|
510
|
+
"user-agent": "rig"
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
const payload = await response.json().catch(() => ({}));
|
|
514
|
+
if (!response.ok) {
|
|
515
|
+
throw new Error(typeof payload.message === "string" ? payload.message : `GitHub user lookup failed (${response.status}).`);
|
|
516
|
+
}
|
|
517
|
+
const login = normalizeString(payload.login);
|
|
518
|
+
const id = typeof payload.id === "number" ? String(payload.id) : normalizeString(payload.id);
|
|
519
|
+
if (!login || !id) {
|
|
520
|
+
throw new Error("GitHub user lookup did not return login/id.");
|
|
521
|
+
}
|
|
522
|
+
return { login, id, scopes: normalizeScopes(response.headers.get("x-oauth-scopes")) };
|
|
523
|
+
}
|
|
524
|
+
function resolveGitHubAuthStatus(input) {
|
|
525
|
+
return createGitHubAuthStore(input.projectRoot).status(input.oauthConfigured !== undefined ? { oauthConfigured: input.oauthConfigured } : {});
|
|
526
|
+
}
|
|
527
|
+
async function saveGitHubTokenForProject(input) {
|
|
528
|
+
const token = normalizeString(input.token);
|
|
529
|
+
if (!token) {
|
|
530
|
+
throw new Error("token is required");
|
|
531
|
+
}
|
|
532
|
+
const user = await (input.fetchUser ?? fetchGitHubUserInfo)(token);
|
|
533
|
+
const store = createGitHubAuthStore(input.projectRoot);
|
|
534
|
+
store.saveToken({
|
|
535
|
+
token,
|
|
536
|
+
tokenSource: input.tokenSource ?? "manual-token",
|
|
537
|
+
login: user.login,
|
|
538
|
+
userId: user.id,
|
|
539
|
+
scopes: user.scopes ?? [],
|
|
540
|
+
selectedRepo: input.selectedRepo ?? null
|
|
541
|
+
});
|
|
542
|
+
return { ok: true, ...store.status({ oauthConfigured: true }) };
|
|
543
|
+
}
|
|
544
|
+
async function beginGitHubDeviceFlow(input) {
|
|
545
|
+
const clientId = normalizeString(input.clientId);
|
|
546
|
+
if (!clientId) {
|
|
547
|
+
throw new Error("clientId is required");
|
|
548
|
+
}
|
|
549
|
+
const postForm = input.postForm ?? defaultPostGitHubForm;
|
|
550
|
+
const result = await postForm("https://github.com/login/device/code", {
|
|
551
|
+
client_id: clientId,
|
|
552
|
+
scope: normalizeString(input.scope) ?? "repo read:project user:email"
|
|
553
|
+
});
|
|
554
|
+
const deviceCode = normalizeString(result.payload.device_code);
|
|
555
|
+
if (result.status < 200 || result.status >= 300 || !deviceCode) {
|
|
556
|
+
throw new Error(normalizeString(result.payload.error_description) ?? "GitHub device flow start failed.");
|
|
557
|
+
}
|
|
558
|
+
const expiresIn = typeof result.payload.expires_in === "number" ? result.payload.expires_in : 900;
|
|
559
|
+
const intervalSeconds = typeof result.payload.interval === "number" ? result.payload.interval : 5;
|
|
560
|
+
const pollId = randomUUID();
|
|
561
|
+
createGitHubAuthStore(input.projectRoot).savePendingDevice({
|
|
562
|
+
pollId,
|
|
563
|
+
deviceCode,
|
|
564
|
+
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
|
|
565
|
+
intervalSeconds
|
|
566
|
+
});
|
|
567
|
+
if (input.selectedRepo) {
|
|
568
|
+
createGitHubAuthStore(input.projectRoot).saveSelectedRepo(input.selectedRepo);
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
pollId,
|
|
573
|
+
userCode: normalizeString(result.payload.user_code),
|
|
574
|
+
verificationUri: normalizeString(result.payload.verification_uri),
|
|
575
|
+
expiresIn,
|
|
576
|
+
intervalSeconds
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
async function pollGitHubDeviceFlow(input) {
|
|
580
|
+
const clientId = normalizeString(input.clientId);
|
|
581
|
+
if (!clientId) {
|
|
582
|
+
throw new Error("clientId is required");
|
|
583
|
+
}
|
|
584
|
+
const store = createGitHubAuthStore(input.projectRoot);
|
|
585
|
+
const pending = store.readPendingDevice(input.pollId);
|
|
586
|
+
if (!pending) {
|
|
587
|
+
return { ok: false, status: "expired", error: "GitHub device flow expired or unknown." };
|
|
588
|
+
}
|
|
589
|
+
const result = await (input.postForm ?? defaultPostGitHubForm)("https://github.com/login/oauth/access_token", {
|
|
590
|
+
client_id: clientId,
|
|
591
|
+
device_code: pending.deviceCode,
|
|
592
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
593
|
+
});
|
|
594
|
+
const error = normalizeString(result.payload.error);
|
|
595
|
+
if (error === "authorization_pending" || error === "slow_down") {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
status: error === "slow_down" ? "slow-down" : "pending",
|
|
599
|
+
intervalSeconds: error === "slow_down" ? pending.intervalSeconds + 5 : pending.intervalSeconds
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (error || typeof result.payload.access_token !== "string") {
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
status: "error",
|
|
606
|
+
error: normalizeString(result.payload.error_description) ?? "GitHub device authorization failed."
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const token = result.payload.access_token;
|
|
610
|
+
const user = await (input.fetchUser ?? fetchGitHubUserInfo)(token);
|
|
611
|
+
store.saveToken({
|
|
612
|
+
token,
|
|
613
|
+
tokenSource: "oauth-device",
|
|
614
|
+
login: user.login,
|
|
615
|
+
userId: user.id,
|
|
616
|
+
scopes: user.scopes ?? normalizeScopes(result.payload.scope),
|
|
617
|
+
selectedRepo: input.selectedRepo ?? null
|
|
618
|
+
});
|
|
619
|
+
store.clearPendingDevice(input.pollId);
|
|
620
|
+
return { ok: true, status: "signed-in", ...store.status({ oauthConfigured: true }) };
|
|
621
|
+
}
|
|
622
|
+
function checkGitHubRepoPermissions(input) {
|
|
623
|
+
const store = createGitHubAuthStore(input.projectRoot);
|
|
624
|
+
const auth = store.status(input.oauthConfigured !== undefined ? { oauthConfigured: input.oauthConfigured } : {});
|
|
625
|
+
if (!auth.signedIn) {
|
|
626
|
+
return { ok: false, signedIn: false, canOpenPullRequest: false, reason: "not-authenticated" };
|
|
627
|
+
}
|
|
628
|
+
const normalizedScopes = auth.scopes.map((scope) => scope.toLowerCase());
|
|
629
|
+
const broadEnough = normalizedScopes.includes("repo") || normalizedScopes.includes("public_repo");
|
|
630
|
+
return {
|
|
631
|
+
ok: true,
|
|
632
|
+
signedIn: true,
|
|
633
|
+
login: auth.login,
|
|
634
|
+
scopes: auth.scopes,
|
|
635
|
+
canOpenPullRequest: broadEnough,
|
|
636
|
+
pullRequests: broadEnough,
|
|
637
|
+
push: broadEnough,
|
|
638
|
+
reason: broadEnough ? "stored-token" : "token-scope-unverified"
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
async function probeGitHubRepository(input) {
|
|
642
|
+
const headers = {
|
|
643
|
+
accept: "application/vnd.github+json",
|
|
644
|
+
"user-agent": "rig"
|
|
645
|
+
};
|
|
646
|
+
if (input.token) {
|
|
647
|
+
headers.authorization = `Bearer ${input.token}`;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const response = await (input.fetchRepository ?? fetch)(`https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}`, { headers });
|
|
651
|
+
const payload = await response.json().catch(() => ({}));
|
|
652
|
+
if (response.ok) {
|
|
653
|
+
return {
|
|
654
|
+
ok: true,
|
|
655
|
+
owner: input.owner,
|
|
656
|
+
repo: input.repo,
|
|
657
|
+
status: response.status,
|
|
658
|
+
authenticated: Boolean(input.token),
|
|
659
|
+
authenticationRequired: payload.private === true && !input.token,
|
|
660
|
+
fullName: typeof payload.full_name === "string" ? payload.full_name : `${input.owner}/${input.repo}`,
|
|
661
|
+
private: typeof payload.private === "boolean" ? payload.private : null,
|
|
662
|
+
message: input.token ? "Repository access verified with signed-in GitHub credentials." : "Public repository access verified without credentials.",
|
|
663
|
+
scopes: input.scopes
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const authenticationRequired = !input.token && (response.status === 401 || response.status === 403 || response.status === 404);
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
owner: input.owner,
|
|
670
|
+
repo: input.repo,
|
|
671
|
+
status: response.status,
|
|
672
|
+
authenticated: Boolean(input.token),
|
|
673
|
+
authenticationRequired,
|
|
674
|
+
fullName: null,
|
|
675
|
+
private: null,
|
|
676
|
+
message: authenticationRequired ? "Repository is private, missing, or inaccessible without GitHub sign-in. Sign in before saving this config." : typeof payload.message === "string" ? payload.message : `GitHub repository probe failed (${response.status}).`,
|
|
677
|
+
scopes: input.scopes
|
|
678
|
+
};
|
|
679
|
+
} catch (error) {
|
|
680
|
+
return {
|
|
681
|
+
ok: false,
|
|
682
|
+
owner: input.owner,
|
|
683
|
+
repo: input.repo,
|
|
684
|
+
status: 0,
|
|
685
|
+
authenticated: Boolean(input.token),
|
|
686
|
+
authenticationRequired: !input.token,
|
|
687
|
+
fullName: null,
|
|
688
|
+
private: null,
|
|
689
|
+
message: error instanceof Error ? error.message : "GitHub repository probe failed.",
|
|
690
|
+
scopes: input.scopes
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
export {
|
|
695
|
+
updateIssueProjectStatus,
|
|
696
|
+
saveGitHubTokenForProject,
|
|
697
|
+
resolveProjectStatusField,
|
|
698
|
+
resolveGitHubAuthStatus,
|
|
699
|
+
resolveGitHubAuthStateFile,
|
|
700
|
+
probeGitHubRepository,
|
|
701
|
+
pollGitHubDeviceFlow,
|
|
702
|
+
listGitHubProjects,
|
|
703
|
+
fetchGitHubUserInfo,
|
|
704
|
+
ensureIssueProjectItem,
|
|
705
|
+
createStateGitHubCredentialProvider,
|
|
706
|
+
createGitHubCredentialProvider,
|
|
707
|
+
createGitHubAuthStoreFromStateFile,
|
|
708
|
+
createGitHubAuthStore,
|
|
709
|
+
createEnvGitHubCredentialProvider,
|
|
710
|
+
copyGitHubAuthStateToLocalProjectRoot,
|
|
711
|
+
checkGitHubRepoPermissions,
|
|
712
|
+
beginGitHubDeviceFlow
|
|
713
|
+
};
|