@dsh-enhanced/assistant-actions 0.1.30
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/LICENSE +21 -0
- package/README.md +247 -0
- package/cordis.patch.yml +3 -0
- package/lib/broker-cli.d.ts +70 -0
- package/lib/broker-cli.d.ts.map +1 -0
- package/lib/broker-cli.js +255 -0
- package/lib/broker-cli.js.map +1 -0
- package/lib/broker-client.d.ts +54 -0
- package/lib/broker-client.d.ts.map +1 -0
- package/lib/broker-client.js +266 -0
- package/lib/broker-client.js.map +1 -0
- package/lib/broker-core.d.ts +51 -0
- package/lib/broker-core.d.ts.map +1 -0
- package/lib/broker-core.js +441 -0
- package/lib/broker-core.js.map +1 -0
- package/lib/broker-ledger.d.ts +131 -0
- package/lib/broker-ledger.d.ts.map +1 -0
- package/lib/broker-ledger.js +925 -0
- package/lib/broker-ledger.js.map +1 -0
- package/lib/broker-peer-linux.d.ts +23 -0
- package/lib/broker-peer-linux.d.ts.map +1 -0
- package/lib/broker-peer-linux.js +68 -0
- package/lib/broker-peer-linux.js.map +1 -0
- package/lib/broker-protocol.d.ts +419 -0
- package/lib/broker-protocol.d.ts.map +1 -0
- package/lib/broker-protocol.js +786 -0
- package/lib/broker-protocol.js.map +1 -0
- package/lib/broker-server.d.ts +77 -0
- package/lib/broker-server.d.ts.map +1 -0
- package/lib/broker-server.js +467 -0
- package/lib/broker-server.js.map +1 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +36 -0
- package/lib/cli.js.map +1 -0
- package/lib/config.d.ts +54 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +165 -0
- package/lib/config.js.map +1 -0
- package/lib/external-delivery-receipt.d.ts +11 -0
- package/lib/external-delivery-receipt.d.ts.map +1 -0
- package/lib/external-delivery-receipt.js +25 -0
- package/lib/external-delivery-receipt.js.map +1 -0
- package/lib/github.d.ts +78 -0
- package/lib/github.d.ts.map +1 -0
- package/lib/github.js +611 -0
- package/lib/github.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +23 -0
- package/lib/index.js.map +1 -0
- package/lib/ledger.d.ts +78 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +1064 -0
- package/lib/ledger.js.map +1 -0
- package/lib/repository-readback.d.ts +34 -0
- package/lib/repository-readback.d.ts.map +1 -0
- package/lib/repository-readback.js +193 -0
- package/lib/repository-readback.js.map +1 -0
- package/lib/service.d.ts +86 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +1259 -0
- package/lib/service.js.map +1 -0
- package/lib/types.d.ts +174 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/verified-delivery.d.ts +104 -0
- package/lib/verified-delivery.d.ts.map +1 -0
- package/lib/verified-delivery.js +262 -0
- package/lib/verified-delivery.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +123 -0
package/lib/github.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { request as httpsRequest } from 'node:https';
|
|
2
|
+
const GITHUB_GRAPHQL_URL = new URL('https://api.github.com/graphql');
|
|
3
|
+
const RESPONSE_LIMIT = 16_384;
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
5
|
+
const UNKNOWN_REASON = 'github-commit-unknown';
|
|
6
|
+
const COMPENSATION_UNKNOWN_REASON = 'github-compensation-unknown';
|
|
7
|
+
const PREIMAGE_FILE_LIMIT = 1_048_576;
|
|
8
|
+
const PREIMAGE_FILE_COUNT_LIMIT = 32;
|
|
9
|
+
const PREIMAGE_TOTAL_LIMIT = 1_048_576;
|
|
10
|
+
const REST_RESPONSE_LIMIT = 262_144;
|
|
11
|
+
const CONTENT_RESPONSE_LIMIT = 1_500_000;
|
|
12
|
+
const TREE_RESPONSE_LIMIT = 1_500_000;
|
|
13
|
+
const API = 'https://api.github.com/repos/';
|
|
14
|
+
function unknown(actionId) {
|
|
15
|
+
return { actionId, status: 'unknown', reason: UNKNOWN_REASON };
|
|
16
|
+
}
|
|
17
|
+
function validHeaderToken(token) {
|
|
18
|
+
return token.length > 0 && token.length <= 8192 && /^[\x21-\x7e]+$/.test(token);
|
|
19
|
+
}
|
|
20
|
+
function validActionId(actionId) {
|
|
21
|
+
return actionId.length > 0 && actionId.length <= 256 && !/\p{Cc}/u.test(actionId);
|
|
22
|
+
}
|
|
23
|
+
function validRepository(repository) {
|
|
24
|
+
return repository.length <= 256 && /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(repository);
|
|
25
|
+
}
|
|
26
|
+
function validBranch(branch) {
|
|
27
|
+
return branch.length > 0 && branch.length <= 255 && !branch.startsWith('refs/') && !/\p{Cc}/u.test(branch);
|
|
28
|
+
}
|
|
29
|
+
function validScopedPath(path) {
|
|
30
|
+
return path.length > 0 && path.length <= 1024 && !/[\\\p{Cc}]/u.test(path)
|
|
31
|
+
&& path.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part.toLowerCase() !== '.git');
|
|
32
|
+
}
|
|
33
|
+
function validInput(input) {
|
|
34
|
+
const { actionId, grant, request } = input;
|
|
35
|
+
return validActionId(actionId)
|
|
36
|
+
&& validRepository(grant.repository)
|
|
37
|
+
&& validBranch(grant.branch)
|
|
38
|
+
&& /^[0-9a-f]{40,128}$/i.test(request.expectedHeadOid)
|
|
39
|
+
&& request.files.length > 0
|
|
40
|
+
&& request.files.every((file) => validScopedPath(file.path));
|
|
41
|
+
}
|
|
42
|
+
function makePayload(input) {
|
|
43
|
+
const clientMutationId = `dsh-action:${input.actionId}`;
|
|
44
|
+
return JSON.stringify({
|
|
45
|
+
query: `mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) {
|
|
46
|
+
createCommitOnBranch(input: $input) {
|
|
47
|
+
clientMutationId
|
|
48
|
+
commit { oid parents(first: 2) { nodes { oid } } repository { nameWithOwner } }
|
|
49
|
+
ref { name target { oid } }
|
|
50
|
+
}
|
|
51
|
+
}`,
|
|
52
|
+
variables: {
|
|
53
|
+
input: {
|
|
54
|
+
branch: {
|
|
55
|
+
repositoryNameWithOwner: input.grant.repository,
|
|
56
|
+
branchName: input.grant.branch,
|
|
57
|
+
},
|
|
58
|
+
expectedHeadOid: input.request.expectedHeadOid,
|
|
59
|
+
message: { headline: input.request.headline, body: `dsh-action:${input.actionId}` },
|
|
60
|
+
fileChanges: {
|
|
61
|
+
additions: input.request.files.map((file) => ({
|
|
62
|
+
path: file.path,
|
|
63
|
+
contents: Buffer.from(file.content, 'utf8').toString('base64'),
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
clientMutationId,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function matchingCommitOid(response, expected) {
|
|
72
|
+
if (response.errors !== undefined || response.data === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const result = response.data.createCommitOnBranch;
|
|
75
|
+
const commit = result?.commit;
|
|
76
|
+
const parents = commit?.parents?.nodes;
|
|
77
|
+
const oid = commit?.oid;
|
|
78
|
+
const refOid = result?.ref?.target?.oid;
|
|
79
|
+
if (result?.clientMutationId !== expected.clientMutationId
|
|
80
|
+
|| commit?.repository?.nameWithOwner !== expected.repository
|
|
81
|
+
|| result?.ref?.name !== expected.branch
|
|
82
|
+
|| !Array.isArray(parents)
|
|
83
|
+
|| parents.length !== 1
|
|
84
|
+
|| parents[0]?.oid !== expected.parentOid
|
|
85
|
+
|| typeof oid !== 'string'
|
|
86
|
+
|| !/^[0-9a-f]{40,128}$/i.test(oid)
|
|
87
|
+
|| refOid !== oid)
|
|
88
|
+
return undefined;
|
|
89
|
+
return oid;
|
|
90
|
+
}
|
|
91
|
+
function responseMatches(response, input) {
|
|
92
|
+
return matchingCommitOid(response, {
|
|
93
|
+
clientMutationId: `dsh-action:${input.actionId}`,
|
|
94
|
+
repository: input.grant.repository,
|
|
95
|
+
branch: input.grant.branch,
|
|
96
|
+
parentOid: input.request.expectedHeadOid,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async function graphql(payload, token, signal, transport) {
|
|
100
|
+
if (!validHeaderToken(token) || signal.aborted)
|
|
101
|
+
return undefined;
|
|
102
|
+
return await new Promise((resolve) => {
|
|
103
|
+
let settled = false;
|
|
104
|
+
let timer;
|
|
105
|
+
let request;
|
|
106
|
+
const finish = (result) => {
|
|
107
|
+
if (settled)
|
|
108
|
+
return;
|
|
109
|
+
settled = true;
|
|
110
|
+
if (timer !== undefined)
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
signal.removeEventListener('abort', onAbort);
|
|
113
|
+
resolve(result);
|
|
114
|
+
};
|
|
115
|
+
const onAbort = () => { request?.destroy(); finish(undefined); };
|
|
116
|
+
try {
|
|
117
|
+
request = transport(GITHUB_GRAPHQL_URL, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
agent: false,
|
|
120
|
+
signal,
|
|
121
|
+
headers: {
|
|
122
|
+
accept: 'application/json',
|
|
123
|
+
authorization: `Bearer ${token}`,
|
|
124
|
+
'content-type': 'application/json',
|
|
125
|
+
'content-length': Buffer.byteLength(payload),
|
|
126
|
+
'user-agent': 'dsh-enhanced-assistant-actions',
|
|
127
|
+
},
|
|
128
|
+
}, (response) => {
|
|
129
|
+
let size = 0;
|
|
130
|
+
const chunks = [];
|
|
131
|
+
response.on('data', (chunk) => {
|
|
132
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
133
|
+
size += buffer.length;
|
|
134
|
+
if (size > RESPONSE_LIMIT) {
|
|
135
|
+
response.destroy();
|
|
136
|
+
request?.destroy();
|
|
137
|
+
finish(undefined);
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
chunks.push(buffer);
|
|
141
|
+
});
|
|
142
|
+
response.once('error', () => finish(undefined));
|
|
143
|
+
response.once('aborted', () => finish(undefined));
|
|
144
|
+
response.once('end', () => {
|
|
145
|
+
if (settled || signal.aborted || response.statusCode === undefined || response.statusCode < 200 || response.statusCode >= 300) {
|
|
146
|
+
finish(undefined);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
151
|
+
const parsed = JSON.parse(raw);
|
|
152
|
+
finish(raw.includes(token) || JSON.stringify(parsed).includes(token) ? undefined : parsed);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
finish(undefined);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
request.once('error', () => finish(undefined));
|
|
160
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
161
|
+
if (signal.aborted) {
|
|
162
|
+
onAbort();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
timer = setTimeout(onAbort, REQUEST_TIMEOUT_MS);
|
|
166
|
+
timer.unref();
|
|
167
|
+
request.end(payload);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
request?.destroy();
|
|
171
|
+
finish(undefined);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Commit exactly the approved files through GitHub's atomic expected-head mutation. */
|
|
176
|
+
export async function commitOnGitHub(input, transport = httpsRequest) {
|
|
177
|
+
if (input.signal.aborted)
|
|
178
|
+
return { actionId: input.actionId, status: 'failed', reason: 'dispatch-aborted' };
|
|
179
|
+
if (!validHeaderToken(input.token) || !validInput(input))
|
|
180
|
+
return unknown(input.actionId);
|
|
181
|
+
const payload = makePayload(input);
|
|
182
|
+
const response = await graphql(payload, input.token, input.signal, transport);
|
|
183
|
+
const resultOid = response === undefined ? undefined : responseMatches(response, input);
|
|
184
|
+
return resultOid === undefined ? unknown(input.actionId) : { actionId: input.actionId, status: 'succeeded', commitOid: resultOid };
|
|
185
|
+
}
|
|
186
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
187
|
+
const oid = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value);
|
|
188
|
+
const positiveInteger = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
189
|
+
async function rest(path, method, token, body, signal, transport = httpsRequest, acceptedStatuses, responseLimit = REST_RESPONSE_LIMIT) {
|
|
190
|
+
if (!validHeaderToken(token) || signal.aborted || !path.startsWith('/'))
|
|
191
|
+
return undefined;
|
|
192
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
193
|
+
return await new Promise(resolve => {
|
|
194
|
+
let done = false;
|
|
195
|
+
let timer;
|
|
196
|
+
let request;
|
|
197
|
+
const finish = (value) => {
|
|
198
|
+
if (done)
|
|
199
|
+
return;
|
|
200
|
+
done = true;
|
|
201
|
+
if (timer)
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
signal.removeEventListener('abort', onAbort);
|
|
204
|
+
resolve(value);
|
|
205
|
+
};
|
|
206
|
+
const onAbort = () => { request?.destroy(); finish(undefined); };
|
|
207
|
+
try {
|
|
208
|
+
request = transport(new URL(`${API}${path.slice(1)}`), { method, agent: false, headers: {
|
|
209
|
+
accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, 'content-type': 'application/json',
|
|
210
|
+
...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}),
|
|
211
|
+
'user-agent': 'dsh-enhanced-assistant-actions', 'x-github-api-version': '2022-11-28',
|
|
212
|
+
} }, response => {
|
|
213
|
+
let bytes = 0;
|
|
214
|
+
const chunks = [];
|
|
215
|
+
response.on('data', (chunk) => {
|
|
216
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
217
|
+
bytes += value.length;
|
|
218
|
+
if (bytes > responseLimit) {
|
|
219
|
+
response.destroy();
|
|
220
|
+
request?.destroy();
|
|
221
|
+
finish(undefined);
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
chunks.push(value);
|
|
225
|
+
});
|
|
226
|
+
response.once('error', () => finish(undefined));
|
|
227
|
+
response.once('aborted', () => finish(undefined));
|
|
228
|
+
response.once('end', () => {
|
|
229
|
+
if (done || signal.aborted || !response.statusCode
|
|
230
|
+
|| (response.statusCode < 200 || response.statusCode >= 300) && !acceptedStatuses?.includes(response.statusCode)) {
|
|
231
|
+
finish(undefined);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
236
|
+
if (raw.includes(token)) {
|
|
237
|
+
finish(undefined);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (acceptedStatuses?.includes(response.statusCode) && (response.statusCode < 200 || response.statusCode >= 300)) {
|
|
241
|
+
finish({ status: response.statusCode, body: undefined, hasNextPage: false });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const parsed = JSON.parse(raw);
|
|
245
|
+
// Never release a credential echoed anywhere in a remote response.
|
|
246
|
+
if (JSON.stringify(parsed).includes(token)) {
|
|
247
|
+
finish(undefined);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
finish({ status: response.statusCode, body: parsed, hasNextPage: /\brel="?next\b/i.test(String(response.headers.link ?? '')) });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
finish(undefined);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
request.once('error', () => finish(undefined));
|
|
258
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
259
|
+
if (signal.aborted) {
|
|
260
|
+
onAbort();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
timer = setTimeout(onAbort, REQUEST_TIMEOUT_MS);
|
|
264
|
+
timer.unref();
|
|
265
|
+
request.end(payload);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
request?.destroy();
|
|
269
|
+
finish(undefined);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const repoPath = (grant) => `${encodeURIComponent(grant.repository.split('/')[0])}/${encodeURIComponent(grant.repository.split('/')[1])}`;
|
|
274
|
+
const contentPath = (grant, path, ref) => `/${repoPath(grant)}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(ref)}`;
|
|
275
|
+
function decodedFile(value, expectedPath, token) {
|
|
276
|
+
const body = record(value);
|
|
277
|
+
if (!body || body.path !== expectedPath || body.type !== 'file' || body.encoding !== 'base64'
|
|
278
|
+
|| typeof body.content !== 'string' || !oid(body.sha) || !Number.isSafeInteger(body.size)
|
|
279
|
+
|| body.size < 0 || body.size > PREIMAGE_FILE_LIMIT || body.truncated === true)
|
|
280
|
+
return undefined;
|
|
281
|
+
const encoded = body.content.replace(/\n/g, '');
|
|
282
|
+
const content = Buffer.from(encoded, 'base64');
|
|
283
|
+
const text = content.toString('utf8');
|
|
284
|
+
if (content.length !== body.size || content.toString('base64') !== encoded
|
|
285
|
+
|| Buffer.from(text, 'utf8').compare(content) !== 0 || text.includes(token))
|
|
286
|
+
return undefined;
|
|
287
|
+
return Object.freeze({ path: expectedPath, state: 'present', blobOid: body.sha, content: text, size: content.length });
|
|
288
|
+
}
|
|
289
|
+
const BLOB_MODES = new Set(['100644', '100755', '120000']);
|
|
290
|
+
const TREE_MODE = '040000';
|
|
291
|
+
const COMMIT_MODE = '160000';
|
|
292
|
+
/**
|
|
293
|
+
* Index every entry in the immutable parent commit's (possibly recursive) tree
|
|
294
|
+
* via the Git Data API. Contents 404s are cross-checked against this index so
|
|
295
|
+
* that an existing-but-unreadable path (an oversized blob, a directory or
|
|
296
|
+
* submodule, or an endpoint that hides the path from this credential) can never
|
|
297
|
+
* be misread as "absent" and turned into a destructive deletion. Returns
|
|
298
|
+
* undefined whenever the index cannot be established completely; callers must
|
|
299
|
+
* then abort the compensation.
|
|
300
|
+
*/
|
|
301
|
+
async function readGitHubTreeIndex(grant, commitOid, token, signal, transport) {
|
|
302
|
+
const commitReply = await rest(`/${repoPath(grant)}/git/commits/${encodeURIComponent(commitOid)}`, 'GET', token, undefined, signal, transport);
|
|
303
|
+
if (commitReply?.status !== 200)
|
|
304
|
+
return undefined;
|
|
305
|
+
const commitBody = record(commitReply.body);
|
|
306
|
+
const tree = record(commitBody?.tree);
|
|
307
|
+
const treeSha = typeof tree?.sha === 'string' ? tree.sha : undefined;
|
|
308
|
+
if (commitBody?.sha !== commitOid || !oid(treeSha))
|
|
309
|
+
return undefined;
|
|
310
|
+
const treeReply = await rest(`/${repoPath(grant)}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, 'GET', token, undefined, signal, transport, undefined, TREE_RESPONSE_LIMIT);
|
|
311
|
+
if (treeReply?.status !== 200)
|
|
312
|
+
return undefined;
|
|
313
|
+
const treeBody = record(treeReply.body);
|
|
314
|
+
const entries = treeBody?.tree;
|
|
315
|
+
if (treeBody?.sha !== treeSha || !Array.isArray(entries) || treeBody.truncated === true)
|
|
316
|
+
return undefined;
|
|
317
|
+
const index = new Map();
|
|
318
|
+
for (const value of entries) {
|
|
319
|
+
const entry = record(value);
|
|
320
|
+
if (entry === undefined || typeof entry.path !== 'string' || typeof entry.type !== 'string' || typeof entry.mode !== 'string')
|
|
321
|
+
return undefined;
|
|
322
|
+
// GitHub tree type/mode pairs are fixed. A mismatch (or a type/mode we do
|
|
323
|
+
// not recognize) means the response is not the tree shape compensations are
|
|
324
|
+
// proven against: fail closed rather than silently ignoring the entry.
|
|
325
|
+
let parsed;
|
|
326
|
+
if (entry.type === 'blob') {
|
|
327
|
+
if (!BLOB_MODES.has(entry.mode) || !oid(entry.sha))
|
|
328
|
+
return undefined;
|
|
329
|
+
parsed = Object.freeze({ type: 'blob', mode: entry.mode, oid: entry.sha });
|
|
330
|
+
}
|
|
331
|
+
else if (entry.type === 'tree') {
|
|
332
|
+
if (entry.mode !== TREE_MODE)
|
|
333
|
+
return undefined;
|
|
334
|
+
parsed = Object.freeze({ type: 'tree', mode: entry.mode, oid: oid(entry.sha) ? entry.sha : undefined });
|
|
335
|
+
}
|
|
336
|
+
else if (entry.type === 'commit') {
|
|
337
|
+
if (entry.mode !== COMMIT_MODE)
|
|
338
|
+
return undefined;
|
|
339
|
+
parsed = Object.freeze({ type: 'commit', mode: entry.mode, oid: oid(entry.sha) ? entry.sha : undefined });
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
return undefined;
|
|
343
|
+
const prior = index.get(entry.path);
|
|
344
|
+
if (prior !== undefined && (prior.type !== parsed.type || prior.mode !== parsed.mode || prior.oid !== parsed.oid))
|
|
345
|
+
return undefined;
|
|
346
|
+
index.set(entry.path, parsed);
|
|
347
|
+
}
|
|
348
|
+
return index;
|
|
349
|
+
}
|
|
350
|
+
/** Read exact allowed file states at one immutable Git commit without following redirects. */
|
|
351
|
+
export async function readGitHubPreimage(input, transport) {
|
|
352
|
+
if (!validRepository(input.grant.repository) || !validBranch(input.grant.branch) || !oid(input.commitOid)
|
|
353
|
+
|| !Array.isArray(input.paths) || input.paths.length < 1 || input.paths.length > PREIMAGE_FILE_COUNT_LIMIT
|
|
354
|
+
|| new Set(input.paths).size !== input.paths.length
|
|
355
|
+
|| input.paths.some(path => !validScopedPath(path) || !input.grant.paths.includes(path)))
|
|
356
|
+
return undefined;
|
|
357
|
+
const resolvedTransport = transport ?? httpsRequest;
|
|
358
|
+
// Bind the capture to the immutable parent commit: its tree is the authority
|
|
359
|
+
// on which grant paths exist. A contents 404 contradicted by this tree, or a
|
|
360
|
+
// contents blob whose OID differs from the tree entry, aborts the capture.
|
|
361
|
+
const index = await readGitHubTreeIndex(input.grant, input.commitOid, input.token, input.signal, resolvedTransport);
|
|
362
|
+
if (!index)
|
|
363
|
+
return undefined;
|
|
364
|
+
const files = [];
|
|
365
|
+
let total = 0;
|
|
366
|
+
for (const path of input.paths) {
|
|
367
|
+
total += Buffer.byteLength(path);
|
|
368
|
+
if (total > PREIMAGE_TOTAL_LIMIT)
|
|
369
|
+
return undefined;
|
|
370
|
+
const reply = await rest(contentPath(input.grant, path, input.commitOid), 'GET', input.token, undefined, input.signal, resolvedTransport, [404], CONTENT_RESPONSE_LIMIT);
|
|
371
|
+
const indexed = index.get(path);
|
|
372
|
+
if (reply?.status === 404) {
|
|
373
|
+
// The parent tree lists any entry (blob, directory tree, or submodule)
|
|
374
|
+
// at this path; a 404 then means an unreadable/hidden path, not absence.
|
|
375
|
+
if (indexed !== undefined)
|
|
376
|
+
return undefined;
|
|
377
|
+
files.push(Object.freeze({ path, state: 'absent' }));
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (reply?.status !== 200)
|
|
381
|
+
return undefined;
|
|
382
|
+
const file = decodedFile(reply.body, path, input.token);
|
|
383
|
+
// A 200 is a file only when the tree agrees it is a blob with the same OID;
|
|
384
|
+
// a directory/submodule entry masquerading as contents must abort the capture.
|
|
385
|
+
if (!file || indexed?.type !== 'blob' || indexed.oid !== file.blobOid)
|
|
386
|
+
return undefined;
|
|
387
|
+
total += file.size;
|
|
388
|
+
if (total > PREIMAGE_TOTAL_LIMIT)
|
|
389
|
+
return undefined;
|
|
390
|
+
files.push(file);
|
|
391
|
+
}
|
|
392
|
+
return Object.freeze({ repository: input.grant.repository, branch: input.grant.branch, commitOid: input.commitOid, files: Object.freeze(files) });
|
|
393
|
+
}
|
|
394
|
+
/** Inspect only the grant-fixed branch head and bind the observation to its scope. */
|
|
395
|
+
export async function inspectGitHubBranchHead(input, transport) {
|
|
396
|
+
if (!validRepository(input.grant.repository) || !validBranch(input.grant.branch))
|
|
397
|
+
return undefined;
|
|
398
|
+
const reply = await rest(`/${repoPath(input.grant)}/branches/${encodeURIComponent(input.grant.branch)}`, 'GET', input.token, undefined, input.signal, transport);
|
|
399
|
+
const body = record(reply?.body);
|
|
400
|
+
const headOid = record(body?.commit)?.sha;
|
|
401
|
+
return reply?.status === 200 && body?.name === input.grant.branch && oid(headOid)
|
|
402
|
+
? Object.freeze({ repository: input.grant.repository, branch: input.grant.branch, headOid }) : undefined;
|
|
403
|
+
}
|
|
404
|
+
function validPreimage(input) {
|
|
405
|
+
const { preimage, grant } = input;
|
|
406
|
+
if (!validActionId(input.actionId) || !validRepository(grant.repository) || !validBranch(grant.branch) || !oid(input.forwardCommitOid)
|
|
407
|
+
|| preimage.repository !== grant.repository || preimage.branch !== grant.branch || !oid(preimage.commitOid)
|
|
408
|
+
|| !Array.isArray(preimage.files) || preimage.files.length < 1 || preimage.files.length > PREIMAGE_FILE_COUNT_LIMIT
|
|
409
|
+
|| new Set(preimage.files.map(file => file.path)).size !== preimage.files.length)
|
|
410
|
+
return false;
|
|
411
|
+
let total = 0;
|
|
412
|
+
for (const file of preimage.files) {
|
|
413
|
+
if (!validScopedPath(file.path) || !grant.paths.includes(file.path))
|
|
414
|
+
return false;
|
|
415
|
+
total += Buffer.byteLength(file.path);
|
|
416
|
+
if (file.state === 'present') {
|
|
417
|
+
const bytes = Buffer.from(file.content, 'utf8');
|
|
418
|
+
if (!oid(file.blobOid) || !Number.isSafeInteger(file.size) || file.size < 0 || file.size > PREIMAGE_FILE_LIMIT
|
|
419
|
+
|| bytes.length !== file.size || bytes.toString('utf8') !== file.content || file.content.includes(input.token))
|
|
420
|
+
return false;
|
|
421
|
+
total += bytes.length;
|
|
422
|
+
}
|
|
423
|
+
else if (file.state !== 'absent')
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
return total <= PREIMAGE_TOTAL_LIMIT;
|
|
427
|
+
}
|
|
428
|
+
function compensationPayload(input, actionMarker) {
|
|
429
|
+
const additions = input.preimage.files.filter((file) => file.state === 'present')
|
|
430
|
+
.map(file => ({ path: file.path, contents: Buffer.from(file.content, 'utf8').toString('base64') }));
|
|
431
|
+
const deletions = input.preimage.files.filter(file => file.state === 'absent').map(file => ({ path: file.path }));
|
|
432
|
+
return JSON.stringify({
|
|
433
|
+
query: `mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) {
|
|
434
|
+
createCommitOnBranch(input: $input) {
|
|
435
|
+
clientMutationId
|
|
436
|
+
commit { oid parents(first: 2) { nodes { oid } } repository { nameWithOwner } }
|
|
437
|
+
ref { name target { oid } }
|
|
438
|
+
}
|
|
439
|
+
}`,
|
|
440
|
+
variables: { input: {
|
|
441
|
+
branch: { repositoryNameWithOwner: input.grant.repository, branchName: input.grant.branch },
|
|
442
|
+
expectedHeadOid: input.forwardCommitOid,
|
|
443
|
+
message: { headline: `Compensate ${input.actionId}`, body: actionMarker },
|
|
444
|
+
fileChanges: { additions, deletions },
|
|
445
|
+
clientMutationId: actionMarker,
|
|
446
|
+
} },
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/** Restore a captured preimage through an expected-head commit; never force-pushes. */
|
|
450
|
+
export async function createCompensatingCommitOnGitHub(input, transports = {}) {
|
|
451
|
+
const actionMarker = `dsh-compensation:${input.actionId}`;
|
|
452
|
+
const base = { actionId: input.actionId, repository: input.grant.repository, branch: input.grant.branch, parentOid: input.forwardCommitOid, actionMarker };
|
|
453
|
+
if (input.signal.aborted)
|
|
454
|
+
return { ...base, status: 'failed', reason: 'dispatch-aborted' };
|
|
455
|
+
if (!validHeaderToken(input.token) || !validPreimage(input))
|
|
456
|
+
return { ...base, status: 'unknown', reason: COMPENSATION_UNKNOWN_REASON };
|
|
457
|
+
const head = await inspectGitHubBranchHead(input, transports.rest);
|
|
458
|
+
if (!head)
|
|
459
|
+
return { ...base, status: 'unknown', reason: COMPENSATION_UNKNOWN_REASON };
|
|
460
|
+
if (head.headOid !== input.forwardCommitOid)
|
|
461
|
+
return { ...base, status: 'failed', reason: 'github-compensation-head-conflict' };
|
|
462
|
+
const response = await graphql(compensationPayload(input, actionMarker), input.token, input.signal, transports.graphql ?? httpsRequest);
|
|
463
|
+
// GraphQL may return both `errors` and a populated `data`. Any error entry is
|
|
464
|
+
// treated as a hard rejection (fail closed) even when a commit OID is also
|
|
465
|
+
// present; the commit is never reported as succeeded from a partial response.
|
|
466
|
+
if (response?.errors !== undefined)
|
|
467
|
+
return { ...base, status: 'failed', reason: 'github-compensation-rejected' };
|
|
468
|
+
const resultOid = response === undefined ? undefined : matchingCommitOid(response, {
|
|
469
|
+
clientMutationId: actionMarker, repository: input.grant.repository, branch: input.grant.branch, parentOid: input.forwardCommitOid,
|
|
470
|
+
});
|
|
471
|
+
return resultOid === undefined ? { ...base, status: 'unknown', reason: COMPENSATION_UNKNOWN_REASON } : { ...base, status: 'succeeded', resultOid };
|
|
472
|
+
}
|
|
473
|
+
function scopedPr(value, grant, number, headOid) {
|
|
474
|
+
const body = record(value), head = record(body?.head), base = record(body?.base);
|
|
475
|
+
if (!body || !grant.repoWorkflow || !positiveInteger(body.number) || (number !== undefined && body.number !== number)
|
|
476
|
+
|| head?.ref !== grant.branch || !oid(head.sha) || (headOid !== undefined && head.sha !== headOid)
|
|
477
|
+
|| base?.ref !== grant.repoWorkflow.baseBranch || record(head.repo)?.full_name !== grant.repository || record(base.repo)?.full_name !== grant.repository)
|
|
478
|
+
return undefined;
|
|
479
|
+
return body;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Project a GitHub pull-request response onto the exact fields consumed by the
|
|
483
|
+
* broker and repository-readback reducer. Never return the raw response: it
|
|
484
|
+
* can contain bodies, URLs, user objects, permissions, or future fields that
|
|
485
|
+
* are outside this capability.
|
|
486
|
+
*/
|
|
487
|
+
function pullRequestObservation(value, grant, number, headOid) {
|
|
488
|
+
const body = scopedPr(value, grant, number, headOid);
|
|
489
|
+
if (!body)
|
|
490
|
+
return undefined;
|
|
491
|
+
const head = record(body.head);
|
|
492
|
+
const base = record(body.base);
|
|
493
|
+
const headRepository = record(head.repo);
|
|
494
|
+
const baseRepository = record(base.repo);
|
|
495
|
+
if (body.state !== undefined && !['open', 'closed'].includes(String(body.state)))
|
|
496
|
+
return undefined;
|
|
497
|
+
if (body.merged !== undefined && typeof body.merged !== 'boolean')
|
|
498
|
+
return undefined;
|
|
499
|
+
return {
|
|
500
|
+
number: body.number,
|
|
501
|
+
...(body.state === undefined ? {} : { state: body.state }),
|
|
502
|
+
...(body.merged === undefined ? {} : { merged: body.merged }),
|
|
503
|
+
head: { ref: head.ref, sha: head.sha, repo: { full_name: headRepository.full_name } },
|
|
504
|
+
base: { ref: base.ref, repo: { full_name: baseRepository.full_name } },
|
|
505
|
+
untrusted: true,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function boundedObservationText(value, maximum = 256) {
|
|
509
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
|
|
510
|
+
}
|
|
511
|
+
function checkObservation(value, headOid) {
|
|
512
|
+
const item = record(value);
|
|
513
|
+
if (!item || !positiveInteger(item.id) || !boundedObservationText(item.name) || item.head_sha !== headOid
|
|
514
|
+
|| !['queued', 'in_progress', 'completed', 'waiting', 'requested', 'pending'].includes(String(item.status))
|
|
515
|
+
|| (item.conclusion !== null && !boundedObservationText(item.conclusion)))
|
|
516
|
+
return undefined;
|
|
517
|
+
const app = item.app === undefined ? undefined : record(item.app);
|
|
518
|
+
if (item.app !== undefined && (!app || !positiveInteger(app.id)))
|
|
519
|
+
return undefined;
|
|
520
|
+
return { id: item.id, name: item.name, ...(app ? { app: { id: app.id } } : {}), head_sha: headOid, status: item.status, conclusion: item.conclusion };
|
|
521
|
+
}
|
|
522
|
+
function reviewObservation(value) {
|
|
523
|
+
const item = record(value);
|
|
524
|
+
if (!item || !positiveInteger(item.id) || !oid(item.commit_id)
|
|
525
|
+
|| !['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'].includes(String(item.state)))
|
|
526
|
+
return undefined;
|
|
527
|
+
const user = item.user === undefined ? undefined : record(item.user);
|
|
528
|
+
if (item.user !== undefined && (!user || !positiveInteger(user.id)))
|
|
529
|
+
return undefined;
|
|
530
|
+
if (item.submitted_at !== undefined && (!boundedObservationText(item.submitted_at, 64) || Number.isNaN(Date.parse(item.submitted_at))))
|
|
531
|
+
return undefined;
|
|
532
|
+
return { id: item.id, ...(user ? { user: { id: user.id } } : {}), commit_id: item.commit_id, state: item.state,
|
|
533
|
+
...(item.submitted_at === undefined ? {} : { submitted_at: item.submitted_at }) };
|
|
534
|
+
}
|
|
535
|
+
export async function createBranchOnGitHub(input, transport) {
|
|
536
|
+
const workflow = input.grant.repoWorkflow;
|
|
537
|
+
if (!workflow?.allowBranchCreate || !oid(input.baseHeadOid))
|
|
538
|
+
return unknown(input.actionId);
|
|
539
|
+
const base = await rest(`/${repoPath(input.grant)}/branches/${encodeURIComponent(workflow.baseBranch)}`, 'GET', input.token, undefined, input.signal, transport);
|
|
540
|
+
const baseBody = record(base?.body);
|
|
541
|
+
if (base?.status !== 200 || baseBody?.name !== workflow.baseBranch || record(baseBody.commit)?.sha !== input.baseHeadOid)
|
|
542
|
+
return { actionId: input.actionId, status: 'failed', reason: 'github-base-head-unconfirmed' };
|
|
543
|
+
const reply = await rest(`/${repoPath(input.grant)}/git/refs`, 'POST', input.token, { ref: `refs/heads/${input.grant.branch}`, sha: input.baseHeadOid }, input.signal, transport);
|
|
544
|
+
const body = record(reply?.body);
|
|
545
|
+
return reply?.status === 201 && body?.ref === `refs/heads/${input.grant.branch}` && record(body.object)?.sha === input.baseHeadOid
|
|
546
|
+
? { actionId: input.actionId, status: 'succeeded', branch: input.grant.branch } : unknown(input.actionId);
|
|
547
|
+
}
|
|
548
|
+
export async function createPullRequestOnGitHub(input, transport) {
|
|
549
|
+
const workflow = input.grant.repoWorkflow;
|
|
550
|
+
if (!workflow?.allowPullRequest || !oid(input.expectedHeadOid))
|
|
551
|
+
return unknown(input.actionId);
|
|
552
|
+
// GitHub's PR API offers no atomic expected-head CAS. A mismatched receipt
|
|
553
|
+
// stays unknown; it must not be retried as if no PR had been created.
|
|
554
|
+
const reply = await rest(`/${repoPath(input.grant)}/pulls`, 'POST', input.token, { title: input.title, body: input.body, head: input.grant.branch, base: workflow.baseBranch }, input.signal, transport);
|
|
555
|
+
const body = scopedPr(reply?.body, input.grant, undefined, input.expectedHeadOid);
|
|
556
|
+
return reply?.status === 201 && body ? { actionId: input.actionId, status: 'succeeded', pullRequestNumber: body.number } : unknown(input.actionId);
|
|
557
|
+
}
|
|
558
|
+
export async function inspectGitHub(input, transport) {
|
|
559
|
+
const isPr = ['pull-request', 'checks', 'reviews'].includes(input.kind);
|
|
560
|
+
const suffix = input.kind === 'repository' ? '' : input.kind === 'branch' ? `/branches/${encodeURIComponent(input.grant.branch)}`
|
|
561
|
+
: input.kind === 'file' && input.path && input.grant.paths.includes(input.path) ? `/contents/${input.path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(input.grant.branch)}`
|
|
562
|
+
: isPr && input.grant.repoWorkflow && positiveInteger(input.pullRequestNumber) ? `/pulls/${input.pullRequestNumber}` : undefined;
|
|
563
|
+
if (suffix === undefined)
|
|
564
|
+
return undefined;
|
|
565
|
+
const reply = await rest(`/${repoPath(input.grant)}${suffix}`, 'GET', input.token, undefined, input.signal, transport);
|
|
566
|
+
const body = record(reply?.body);
|
|
567
|
+
if (reply?.status !== 200 || !body)
|
|
568
|
+
return undefined;
|
|
569
|
+
if (input.kind === 'repository')
|
|
570
|
+
return body.full_name === input.grant.repository
|
|
571
|
+
? { observed: { full_name: input.grant.repository, untrusted: true } } : undefined;
|
|
572
|
+
if (input.kind === 'branch') {
|
|
573
|
+
const commit = record(body.commit);
|
|
574
|
+
return body.name === input.grant.branch && oid(commit?.sha)
|
|
575
|
+
? { observed: { name: input.grant.branch, commit: { sha: commit.sha }, untrusted: true } } : undefined;
|
|
576
|
+
}
|
|
577
|
+
const pullRequest = isPr ? pullRequestObservation(body, input.grant, input.pullRequestNumber) : undefined;
|
|
578
|
+
if (isPr && !pullRequest)
|
|
579
|
+
return undefined;
|
|
580
|
+
if (input.kind === 'file') {
|
|
581
|
+
if (body.path !== input.path || body.type !== 'file' || body.encoding !== 'base64' || typeof body.content !== 'string' || !oid(body.sha) || typeof body.size !== 'number' || body.size < 0 || body.size > 65_536)
|
|
582
|
+
return undefined;
|
|
583
|
+
const encoded = body.content.replace(/\n/g, '');
|
|
584
|
+
const content = Buffer.from(encoded, 'base64');
|
|
585
|
+
if (content.length !== body.size || content.toString('base64') !== encoded || Buffer.from(content.toString('utf8')).compare(content) !== 0 || content.toString('utf8').includes(input.token))
|
|
586
|
+
return undefined;
|
|
587
|
+
return { observed: { path: body.path, sha: body.sha, content: content.toString('utf8'), untrusted: true } };
|
|
588
|
+
}
|
|
589
|
+
if (input.kind === 'pull-request')
|
|
590
|
+
return { observed: pullRequest };
|
|
591
|
+
if (input.kind === 'checks' || input.kind === 'reviews') {
|
|
592
|
+
const sha = pullRequest.head.sha;
|
|
593
|
+
const maximum = input.kind === 'checks' ? 20 : 30;
|
|
594
|
+
const tail = input.kind === 'checks' ? `/commits/${sha}/check-runs?per_page=${maximum}` : `/pulls/${input.pullRequestNumber}/reviews?per_page=${maximum}`;
|
|
595
|
+
const list = await rest(`/${repoPath(input.grant)}${tail}`, 'GET', input.token, undefined, input.signal, transport);
|
|
596
|
+
if (list?.status !== 200)
|
|
597
|
+
return undefined;
|
|
598
|
+
const items = input.kind === 'checks' ? record(list.body)?.check_runs : list.body;
|
|
599
|
+
if (!Array.isArray(items) || items.length > maximum)
|
|
600
|
+
return undefined;
|
|
601
|
+
const total = input.kind === 'checks' ? record(list.body)?.total_count : undefined;
|
|
602
|
+
if (input.kind === 'checks' && (typeof total !== 'number' || !Number.isSafeInteger(total) || total < items.length))
|
|
603
|
+
return undefined;
|
|
604
|
+
const projected = input.kind === 'checks' ? items.map(item => checkObservation(item, sha)) : items.map(reviewObservation);
|
|
605
|
+
if (projected.some(item => item === undefined))
|
|
606
|
+
return undefined;
|
|
607
|
+
return { observed: { pullRequest, headOid: sha, items: projected, truncated: list.hasNextPage || (typeof total === 'number' && total > items.length) || items.length === maximum, untrusted: true } };
|
|
608
|
+
}
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
//# sourceMappingURL=github.js.map
|