@remogram/provider-github-api 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +434 -0
  2. package/package.json +27 -0
package/index.js ADDED
@@ -0,0 +1,434 @@
1
+ import {
2
+ fetchJson,
3
+ sanitizeField,
4
+ sanitizeUrl,
5
+ assertGitRef,
6
+ assertGitRemote,
7
+ gitRevParse,
8
+ gitCurrentBranch,
9
+ gitAheadBehind,
10
+ ERROR_CODES,
11
+ forgeError,
12
+ } from '@remogram/core';
13
+
14
+ const PUBLIC_GITHUB_HOST = 'github.com';
15
+ const PUBLIC_GITHUB_API = 'https://api.github.com';
16
+ const PUBLIC_GITHUB_GRAPHQL = 'https://api.github.com/graphql';
17
+
18
+ const PR_VIEW_QUERY = `
19
+ query RemogramPrView($owner: String!, $repo: String!, $number: Int!) {
20
+ repository(owner: $owner, name: $repo) {
21
+ pullRequest(number: $number) {
22
+ number
23
+ url
24
+ title
25
+ state
26
+ mergeable
27
+ mergeStateStatus
28
+ baseRefName
29
+ baseRefOid
30
+ headRefName
31
+ headRefOid
32
+ }
33
+ }
34
+ }
35
+ `;
36
+ const AUTH_CAPABILITIES = [
37
+ 'repo_status',
38
+ 'ref_compare',
39
+ 'pr_status',
40
+ 'pr_checks',
41
+ 'merge_plan',
42
+ 'sync_plan',
43
+ ];
44
+
45
+ const STRUCTURED_COMMANDS = AUTH_CAPABILITIES.map((name) => ({ name, implemented: true }));
46
+
47
+ export function githubToken() {
48
+ if (process.env.GITHUB_TOKEN) return { token: process.env.GITHUB_TOKEN, env: 'GITHUB_TOKEN' };
49
+ if (process.env.GH_TOKEN) return { token: process.env.GH_TOKEN, env: 'GH_TOKEN' };
50
+ return { token: null, env: null };
51
+ }
52
+
53
+ export function requireToken() {
54
+ const auth = githubToken();
55
+ if (!auth.token) {
56
+ throw Object.assign(new Error('GITHUB_TOKEN or GH_TOKEN not set'), {
57
+ forgeError: forgeError(
58
+ ERROR_CODES.UNAUTHENTICATED_PROVIDER,
59
+ 'GITHUB_TOKEN or GH_TOKEN not set',
60
+ ),
61
+ });
62
+ }
63
+ return auth;
64
+ }
65
+
66
+ function configOrigin(config) {
67
+ if (!config.baseUrl) return null;
68
+ try {
69
+ const url = new URL(config.baseUrl);
70
+ return url.origin;
71
+ } catch {
72
+ throw Object.assign(new Error('Invalid baseUrl for github-api'), {
73
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'Invalid baseUrl for github-api provider'),
74
+ });
75
+ }
76
+ }
77
+
78
+ function configuredHost(config) {
79
+ if (!config.baseUrl) return null;
80
+ try {
81
+ return new URL(config.baseUrl).host;
82
+ } catch {
83
+ throw Object.assign(new Error('Invalid baseUrl for github-api'), {
84
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'Invalid baseUrl for github-api provider'),
85
+ });
86
+ }
87
+ }
88
+
89
+ export function apiBase(config, parsed = {}) {
90
+ const remoteHost = parsed.host || configuredHost(config);
91
+ if (!remoteHost) {
92
+ throw Object.assign(new Error('remote host required for github-api'), {
93
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'remote host required for github-api provider'),
94
+ });
95
+ }
96
+
97
+ const host = remoteHost.toLowerCase();
98
+ const configured = configuredHost(config);
99
+ if (host === PUBLIC_GITHUB_HOST) {
100
+ if (configured && configured.toLowerCase() !== PUBLIC_GITHUB_HOST) {
101
+ const message = 'github.com remotes may use only https://api.github.com for API requests';
102
+ throw Object.assign(new Error(message), {
103
+ forgeError: forgeError(
104
+ ERROR_CODES.UNTRUSTED_BASE_URL,
105
+ message,
106
+ ),
107
+ });
108
+ }
109
+ return PUBLIC_GITHUB_API;
110
+ }
111
+
112
+ if (configured && configured.toLowerCase() !== host) {
113
+ const message = `GitHub Enterprise API host must match remote host ${remoteHost}`;
114
+ throw Object.assign(new Error(message), {
115
+ forgeError: forgeError(
116
+ ERROR_CODES.UNTRUSTED_BASE_URL,
117
+ message,
118
+ ),
119
+ });
120
+ }
121
+
122
+ const origin = configOrigin(config) || `https://${remoteHost}`;
123
+ return `${origin.replace(/\/$/, '')}/api/v3`;
124
+ }
125
+
126
+ export function authHeaders(token) {
127
+ return {
128
+ Authorization: `Bearer ${token}`,
129
+ Accept: 'application/vnd.github+json',
130
+ 'X-GitHub-Api-Version': '2022-11-28',
131
+ };
132
+ }
133
+
134
+ export function repoApiPath(config, ...segments) {
135
+ const owner = encodeURIComponent(config.owner);
136
+ const repo = encodeURIComponent(config.repo);
137
+ const base = `/repos/${owner}/${repo}`;
138
+ if (!segments.length) return base;
139
+ return `${base}/${segments.map((s) => encodeURIComponent(String(s))).join('/')}`;
140
+ }
141
+
142
+ export async function githubFetch(config, parsed, path, options = {}) {
143
+ const base = apiBase(config, parsed);
144
+ const { token } = requireToken();
145
+ const url = `${base}${path}`;
146
+ return fetchJson(url, {
147
+ ...options,
148
+ headers: { ...authHeaders(token), ...(options.headers || {}) },
149
+ });
150
+ }
151
+
152
+ export function graphqlEndpoint(config, parsed = {}) {
153
+ const remoteHost = (parsed.host || configuredHost(config) || '').toLowerCase();
154
+ if (remoteHost === PUBLIC_GITHUB_HOST) {
155
+ return PUBLIC_GITHUB_GRAPHQL;
156
+ }
157
+ const origin = configOrigin(config) || `https://${parsed.host}`;
158
+ return `${origin.replace(/\/$/, '')}/api/graphql`;
159
+ }
160
+
161
+ export function mapMergeStateStatus(status) {
162
+ if (!status) return undefined;
163
+ return String(status).toLowerCase();
164
+ }
165
+
166
+ export function graphqlPullToRestShape(node) {
167
+ if (!node) return null;
168
+ let mergeable = null;
169
+ if (node.mergeable === 'CONFLICTING') mergeable = false;
170
+ else if (node.mergeable === 'MERGEABLE') mergeable = true;
171
+
172
+ return {
173
+ number: node.number,
174
+ html_url: node.url,
175
+ title: node.title,
176
+ state: mapMergeStateStatus(node.state),
177
+ mergeable,
178
+ mergeable_state: mapMergeStateStatus(node.mergeStateStatus),
179
+ base: { ref: node.baseRefName, sha: node.baseRefOid },
180
+ head: { ref: node.headRefName, sha: node.headRefOid },
181
+ };
182
+ }
183
+
184
+ export async function githubGraphql(config, parsed, query, variables) {
185
+ const { token } = requireToken();
186
+ const url = graphqlEndpoint(config, parsed);
187
+ const body = await fetchJson(url, {
188
+ method: 'POST',
189
+ headers: {
190
+ ...authHeaders(token),
191
+ 'Content-Type': 'application/json',
192
+ },
193
+ body: JSON.stringify({ query, variables }),
194
+ });
195
+
196
+ if (body.errors?.length) {
197
+ const message = sanitizeField(body.errors[0]?.message) || 'GraphQL request failed';
198
+ throw Object.assign(new Error(message), {
199
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message),
200
+ });
201
+ }
202
+
203
+ return body.data;
204
+ }
205
+
206
+ export async function fetchPullGraphql(config, parsed, number) {
207
+ if (number == null) {
208
+ throw Object.assign(new Error('--number required'), {
209
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for PR lookup'),
210
+ });
211
+ }
212
+
213
+ const data = await githubGraphql(config, parsed, PR_VIEW_QUERY, {
214
+ owner: config.owner,
215
+ repo: config.repo,
216
+ number,
217
+ });
218
+
219
+ const pr = graphqlPullToRestShape(data?.repository?.pullRequest);
220
+ if (!pr) {
221
+ throw Object.assign(new Error('Pull request not found'), {
222
+ forgeError: forgeError(ERROR_CODES.API_ERROR, `Pull request ${number} not found`),
223
+ });
224
+ }
225
+
226
+ return pr;
227
+ }
228
+
229
+ export async function repoStatus(ctx) {
230
+ const auth = githubToken();
231
+ let defaultBranch = null;
232
+ if (auth.token) {
233
+ const repo = await githubFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config));
234
+ defaultBranch = sanitizeField(repo.default_branch);
235
+ }
236
+ return {
237
+ auth_present: Boolean(auth.token),
238
+ auth_env: auth.env,
239
+ capabilities: auth.token ? AUTH_CAPABILITIES : ['repo_status'],
240
+ default_branch: defaultBranch,
241
+ };
242
+ }
243
+
244
+ export function providerCapabilities() {
245
+ return {
246
+ commands: STRUCTURED_COMMANDS,
247
+ auth_envs: ['GITHUB_TOKEN', 'GH_TOKEN'],
248
+ check_sources: ['commit_statuses', 'check_runs'],
249
+ mergeability_confidence: 'derived',
250
+ host_binding: 'verified_remote_host',
251
+ pagination: 'first_page_only',
252
+ write_support: false,
253
+ };
254
+ }
255
+
256
+ export async function refsCompare(ctx, baseRef, headRef) {
257
+ apiBase(ctx.config, ctx.parsed);
258
+ requireToken();
259
+ assertGitRef(baseRef, 'base');
260
+ assertGitRef(headRef, 'head');
261
+ const baseSha = gitRevParse(ctx.cwd, baseRef);
262
+ const headSha = gitRevParse(ctx.cwd, headRef);
263
+ if (!baseSha || !headSha) {
264
+ throw Object.assign(new Error('Missing ref'), {
265
+ forgeError: forgeError(ERROR_CODES.MISSING_REF, 'Could not resolve base or head ref'),
266
+ });
267
+ }
268
+ const counts = gitAheadBehind(ctx.cwd, baseSha, headSha);
269
+ return {
270
+ base_ref: sanitizeField(baseRef),
271
+ base_sha: baseSha,
272
+ head_ref: sanitizeField(headRef),
273
+ head_sha: headSha,
274
+ ...counts,
275
+ };
276
+ }
277
+
278
+ export function mergeability(pr) {
279
+ if (pr.mergeable === false || pr.mergeable_state === 'dirty') return 'conflicted';
280
+ if (pr.mergeable === true) {
281
+ if (!pr.mergeable_state || ['clean', 'has_hooks', 'unstable'].includes(pr.mergeable_state)) {
282
+ return 'clean';
283
+ }
284
+ }
285
+ return 'unknown';
286
+ }
287
+
288
+ export async function prView(ctx, opts) {
289
+ const pr = await fetchPullGraphql(ctx.config, ctx.parsed, opts.number);
290
+ return {
291
+ pr_number: pr.number,
292
+ url: sanitizeUrl(pr.html_url ?? pr.url),
293
+ title: sanitizeField(pr.title),
294
+ state: sanitizeField(pr.state),
295
+ base_ref: sanitizeField(pr.base?.ref),
296
+ base_sha: sanitizeField(pr.base?.sha),
297
+ head_ref: sanitizeField(pr.head?.ref),
298
+ head_sha: sanitizeField(pr.head?.sha),
299
+ mergeability: mergeability(pr),
300
+ };
301
+ }
302
+
303
+ function normalizeCommitStatusState(state) {
304
+ if (state === 'success') return 'success';
305
+ if (state === 'pending') return 'pending';
306
+ if (state === 'failure' || state === 'error') return 'failure';
307
+ return 'unknown';
308
+ }
309
+
310
+ function normalizeCheckRunState(run) {
311
+ if (run.status && run.status !== 'completed') return 'pending';
312
+ if (run.conclusion === 'success' || run.conclusion === 'neutral' || run.conclusion === 'skipped') {
313
+ return 'success';
314
+ }
315
+ if (
316
+ run.conclusion === 'failure' ||
317
+ run.conclusion === 'timed_out' ||
318
+ run.conclusion === 'cancelled' ||
319
+ run.conclusion === 'action_required' ||
320
+ run.conclusion === 'startup_failure'
321
+ ) {
322
+ return 'failure';
323
+ }
324
+ if (!run.conclusion) return 'pending';
325
+ return 'unknown';
326
+ }
327
+
328
+ function checkRunDescription(run) {
329
+ return run.output?.title || run.output?.summary || run.conclusion || run.status || null;
330
+ }
331
+
332
+ export function summarizeChecks(statuses) {
333
+ if (!statuses.length) return 'missing';
334
+ if (statuses.some((s) => s.state === 'failure')) return 'failure';
335
+ if (statuses.some((s) => s.state === 'pending')) return 'pending';
336
+ if (statuses.every((s) => s.state === 'success')) return 'success';
337
+ return 'unknown';
338
+ }
339
+
340
+ export async function prChecks(ctx, opts) {
341
+ apiBase(ctx.config, ctx.parsed);
342
+ requireToken();
343
+ let sha;
344
+ if (opts.ref) {
345
+ assertGitRef(opts.ref, 'ref');
346
+ sha = gitRevParse(ctx.cwd, opts.ref);
347
+ if (!sha) {
348
+ throw Object.assign(new Error('Missing ref'), {
349
+ forgeError: forgeError(ERROR_CODES.MISSING_REF, `Could not resolve ref ${opts.ref}`),
350
+ });
351
+ }
352
+ } else {
353
+ const pr = await fetchPullGraphql(ctx.config, ctx.parsed, opts.number);
354
+ sha = pr.head?.sha;
355
+ }
356
+ if (!sha) {
357
+ throw Object.assign(new Error('No SHA'), {
358
+ forgeError: forgeError(ERROR_CODES.MISSING_REF, 'Could not determine head SHA for checks'),
359
+ });
360
+ }
361
+
362
+ const [statuses, checkRuns] = await Promise.all([
363
+ githubFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'commits', sha, 'statuses')),
364
+ githubFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'commits', sha, 'check-runs')),
365
+ ]);
366
+ const mappedStatuses = (statuses || []).map((s) => ({
367
+ context: sanitizeField(s.context),
368
+ state: normalizeCommitStatusState(s.state),
369
+ description: sanitizeField(s.description),
370
+ }));
371
+ const mappedCheckRuns = (checkRuns?.check_runs || []).map((run) => ({
372
+ context: sanitizeField(run.name),
373
+ state: normalizeCheckRunState(run),
374
+ description: sanitizeField(checkRunDescription(run)),
375
+ }));
376
+ const mapped = [...mappedStatuses, ...mappedCheckRuns];
377
+ return { head_sha: sha, check_conclusion: summarizeChecks(mapped), statuses: mapped };
378
+ }
379
+
380
+ export async function mergePlan(ctx, opts) {
381
+ const view = await prView(ctx, opts);
382
+ const checks = await prChecks(ctx, { number: view.pr_number });
383
+ const blockers = [];
384
+ if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
385
+ if (view.state !== 'open') blockers.push('pr_not_open');
386
+ if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
387
+ if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
388
+ if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
389
+ return {
390
+ pr_number: view.pr_number,
391
+ mergeability: view.mergeability,
392
+ checks_conclusion: checks.check_conclusion,
393
+ blockers,
394
+ };
395
+ }
396
+
397
+ export async function syncPlan(ctx, remoteName = 'origin') {
398
+ assertGitRemote(remoteName, 'remote');
399
+ apiBase(ctx.config, ctx.parsed);
400
+ const localSha = gitRevParse(ctx.cwd, 'HEAD');
401
+ const branch = gitCurrentBranch(ctx.cwd);
402
+ let remoteSha = null;
403
+ if (branch && branch !== 'HEAD') {
404
+ remoteSha = gitRevParse(ctx.cwd, `${remoteName}/${branch}`);
405
+ }
406
+ const blockers = [];
407
+ let diverged = false;
408
+ if (localSha && remoteSha && localSha !== remoteSha) {
409
+ const { ahead_by, behind_by } = gitAheadBehind(ctx.cwd, remoteSha, localSha);
410
+ if (ahead_by > 0 && behind_by > 0) {
411
+ diverged = true;
412
+ blockers.push('divergent_remotes');
413
+ }
414
+ }
415
+ if (!remoteSha) blockers.push('missing_remote_ref');
416
+ return {
417
+ remote: sanitizeField(remoteName),
418
+ local_sha: localSha,
419
+ remote_sha: remoteSha,
420
+ diverged,
421
+ blockers,
422
+ };
423
+ }
424
+
425
+ export const provider = {
426
+ id: 'github-api',
427
+ providerCapabilities,
428
+ repoStatus,
429
+ refsCompare,
430
+ prView,
431
+ prChecks,
432
+ mergePlan,
433
+ syncPlan,
434
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@remogram/provider-github-api",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "GitHub API provider for remogram",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/attebury/remogram.git",
10
+ "directory": "packages/provider-github-api"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "*.js"
17
+ ],
18
+ "exports": {
19
+ ".": "./index.js"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "dependencies": {
25
+ "@remogram/core": "0.1.0-beta.0"
26
+ }
27
+ }