Repository navigation
Expand file tree
/
Copy pathgithub.mjs
More file actions
104 lines (99 loc) · 5.4 KB
/
Copy pathgithub.mjs
File metadata and controls
104 lines (99 loc) · 5.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
import { spawn } from 'node:child_process';
import { requireValue } from './schema.mjs';
export function runGh(args, input, launch = spawn, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, ...options.env, GH_PROMPT_DISABLED: '1', GH_PAGER: 'cat' };
delete env.GH_DEBUG;
const child = launch('gh', args, { shell: false, windowsHide: true, env, stdio: ['pipe', 'pipe', 'ignore'] });
const chunks = [];
let bytes = 0;
let failure;
const timer = setTimeout(() => {
failure = new Error('GitHub request timed out.');
child.kill();
}, 45000);
child.stdout.on('data', (chunk) => {
bytes += chunk.length;
if (bytes > 16 * 1024 * 1024) {
failure = new Error('GitHub response exceeds the supported size.');
child.kill();
} else chunks.push(chunk);
});
child.once('error', (error) => { clearTimeout(timer); reject(error); });
child.stdin.on('error', () => {});
child.once('close', (code) => {
clearTimeout(timer);
if (failure || code !== 0) return reject(failure ?? new Error(`GitHub request failed (exit ${code}). Check gh auth status and repository access.`));
const output = Buffer.concat(chunks).toString('utf8');
if (options.raw) { resolve(output.trim()); return; }
try { resolve(JSON.parse(output)); } catch { reject(new Error('GitHub returned an invalid JSON response.')); }
});
child.stdin.end(input === undefined ? undefined : JSON.stringify(input));
});
}
export function ghAdapter(run = runGh, selectedLogin = null) {
const request = (path, method = 'GET', body, paginate = false) => run([
'api', '--hostname', 'github.com', '--method', method,
'-H', 'Accept: application/vnd.github+json',
...(paginate ? ['--paginate', '--slurp'] : []),
...(body === undefined ? [] : ['--input', '-']), path,
], body);
const base = (packet) => `repos/${packet.repository}/pulls/${packet.pull_request}`;
return {
mode: 'live',
actor: async () => {
const actor = await request('user');
requireValue(!selectedLogin || actor.login?.toLowerCase() === selectedLogin.toLowerCase(), 'The selected credential belongs to a different GitHub account.');
return actor;
},
accounts: async () => {
const result = await run(['auth', 'status', '--hostname', 'github.com', '--json', 'hosts']);
return (result.hosts?.['github.com'] ?? []).map((account) => ({ login: account.login, active: !!account.active,
available: account.state === 'success' })).filter((account) => typeof account.login === 'string');
},
// Capture a credential once per operation, without changing gh's global account.
session: async (login) => {
requireValue(login === null || /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(login), 'Invalid GitHub account.');
const token = await run(['auth', 'token', '--hostname', 'github.com', ...(login ? ['--user', login] : [])],
undefined, undefined, { raw: true, env: { GH_TOKEN: undefined, GITHUB_TOKEN: undefined } });
requireValue(typeof token === 'string' && token.length > 0 && token.length <= 1024 && !/[\s\0]/.test(token), 'Could not retrieve the selected GitHub credential.');
return ghAdapter((args, input) => run(args, input, undefined, { env: { GH_TOKEN: token, GITHUB_TOKEN: undefined } }), login);
},
pull: (packet) => request(base(packet)),
files: async (packet) => (await request(`${base(packet)}/files?per_page=100`, 'GET', undefined, true)).flat(),
reviews: async (packet) => (await request(`${base(packet)}/reviews?per_page=100`, 'GET', undefined, true)).flat(),
create: (packet, payload) => request(`${base(packet)}/reviews`, 'POST', payload),
};
}
export const demoSHA = '0123456789abcdef0123456789abcdef01234567';
export const demoPacket = {
repository: 'demo/review-publisher', pull_request: 1, reviewed_head_sha: demoSHA, event: 'COMMENT',
body: 'The error path needs an explicit test.',
reviewer_model: 'GPT-6 Astra',
inline_comments: [{ path: 'src/example.js', line: 2, side: 'RIGHT', body: 'Please cover an empty input in a unit test.' }],
};
export function demoAdapter() {
const published = [];
const make = (actor) => ({
mode: 'demo',
actor: async () => actor,
accounts: async () => [{ login: 'demo-user', active: true, available: true }, { login: 'demo-reviewer', active: false, available: true }],
session: async (login) => {
if (!login) return adapter;
requireValue(['demo-user', 'demo-reviewer'].includes(login), 'Unknown demo account.');
return make({ id: login === 'demo-user' ? 1 : 3, login });
},
pull: async () => ({ title: 'Demo: handle empty inputs', state: 'open', head: { sha: demoSHA }, user: { id: 2 } }),
files: async () => [{ filename: 'src/example.js', patch: '@@ -1,2 +1,3 @@\n export function first(items) {\n+ if (!items.length) return null;\n return items[0];' }],
reviews: async () => published,
create: async (packet, payload) => {
const result = { id: published.length + 1, user: { id: actor.id }, commit_id: payload.commit_id, body: payload.body, state: { COMMENT: 'COMMENTED', APPROVE: 'APPROVED', REQUEST_CHANGES: 'CHANGES_REQUESTED' }[payload.event], html_url: null };
published.push(result);
return result;
},
});
const adapter = make({ id: 1, login: 'demo-user' });
return adapter;
}