spooder 3.2.8 → 4.0.1
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 +584 -277
- package/package.json +1 -4
- package/src/api.d.ts +46 -16
- package/src/api.ts +355 -114
- package/src/config.ts +10 -1
- package/src/dispatch.ts +32 -43
- package/src/github.d.ts +11 -0
- package/src/github.ts +121 -0
package/src/config.ts
CHANGED
|
@@ -18,6 +18,8 @@ const internal_config = {
|
|
|
18
18
|
type Config = typeof internal_config;
|
|
19
19
|
type ConfigObject = Record<string, unknown>;
|
|
20
20
|
|
|
21
|
+
let cached_config: Config | null = null;
|
|
22
|
+
|
|
21
23
|
function validate_config_option(source: ConfigObject, target: ConfigObject, root_name: string) {
|
|
22
24
|
for (const [key, value] of Object.entries(target)) {
|
|
23
25
|
const key_name = `${root_name}.${key}`;
|
|
@@ -60,7 +62,7 @@ function validate_config_option(source: ConfigObject, target: ConfigObject, root
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
async function load_config(): Promise<Config> {
|
|
64
66
|
try {
|
|
65
67
|
const config_file = Bun.file(path.join(process.cwd(), 'package.json'));
|
|
66
68
|
const json = await config_file.json();
|
|
@@ -76,4 +78,11 @@ export async function get_config(): Promise<Config> {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
return internal_config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function get_config(): Promise<Config> {
|
|
84
|
+
if (cached_config === null)
|
|
85
|
+
cached_config = await load_config();
|
|
86
|
+
|
|
87
|
+
return cached_config;
|
|
79
88
|
}
|
package/src/dispatch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { App } from '@octokit/app';
|
|
2
1
|
import { get_config } from './config';
|
|
2
|
+
import { create_github_issue } from './github';
|
|
3
3
|
import { log } from './utils';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -93,9 +93,9 @@ async function check_cache_table(key: string, repository: string, expiry: number
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
} catch (e) {
|
|
96
|
-
log('
|
|
97
|
-
log('
|
|
98
|
-
log('
|
|
96
|
+
log('failed to read canary cache file ' + cache_file_path);
|
|
97
|
+
log('error: ' + (e as Error).message);
|
|
98
|
+
log('you should resolve this issue to prevent spamming GitHub with canary reports');
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
if (cache_table.has(key_hash)) {
|
|
@@ -145,6 +145,8 @@ function generate_diagnostics(): object {
|
|
|
145
145
|
'bun': {
|
|
146
146
|
'version': Bun.version,
|
|
147
147
|
'rev': Bun.revision,
|
|
148
|
+
'memory_usage': process.memoryUsage(),
|
|
149
|
+
'cpu_usage': process.cpuUsage()
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
}
|
|
@@ -153,16 +155,17 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
153
155
|
try {
|
|
154
156
|
const config = await get_config();
|
|
155
157
|
|
|
156
|
-
const canary_account = config.canary.account
|
|
157
|
-
const canary_repostiory = config.canary.repository
|
|
158
|
-
const canary_labels = config.canary.labels;
|
|
158
|
+
const canary_account = config.canary.account;
|
|
159
|
+
const canary_repostiory = config.canary.repository;
|
|
159
160
|
|
|
160
|
-
if (canary_account.length === 0|| canary_repostiory.length === 0)
|
|
161
|
+
if (canary_account.length === 0|| canary_repostiory.length === 0) {
|
|
162
|
+
log('[canary] report dispatch failed; no account/repository configured');
|
|
161
163
|
return;
|
|
164
|
+
}
|
|
162
165
|
|
|
163
166
|
const is_cached = await check_cache_table(report_title, canary_repostiory, config.canary.throttle);
|
|
164
167
|
if (is_cached) {
|
|
165
|
-
log('
|
|
168
|
+
log('[canary] throttled canary report: ' + report_title);
|
|
166
169
|
return;
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -170,58 +173,44 @@ export async function dispatch_report(report_title: string, report_body: Array<u
|
|
|
170
173
|
const canary_app_key = process.env.SPOODER_CANARY_KEY as string;
|
|
171
174
|
|
|
172
175
|
if (canary_app_id === undefined)
|
|
173
|
-
throw new Error('
|
|
176
|
+
throw new Error('SPOODER_CANARY_APP_ID environment variable is not set');
|
|
174
177
|
|
|
175
178
|
if (canary_app_key === undefined)
|
|
176
|
-
throw new Error('
|
|
179
|
+
throw new Error('SPOODER_CANARY_KEY environment variable is not set');
|
|
177
180
|
|
|
178
181
|
const key_file = Bun.file(canary_app_key);
|
|
179
182
|
if (key_file.size === 0)
|
|
180
|
-
throw new Error('
|
|
183
|
+
throw new Error('Unable to read private key file defined by SPOODER_CANARY_KEY environment variable');
|
|
181
184
|
|
|
182
185
|
const app_id = parseInt(canary_app_id, 10);
|
|
183
186
|
if (isNaN(app_id))
|
|
184
|
-
throw new Error('
|
|
185
|
-
|
|
186
|
-
const app = new App({
|
|
187
|
-
appId: app_id,
|
|
188
|
-
privateKey: await key_file.text(),
|
|
189
|
-
});
|
|
187
|
+
throw new Error('Invalid app ID defined by SPOODER_CANARY_APP_ID environment variable');
|
|
190
188
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const post_object = {
|
|
194
|
-
title: report_title,
|
|
195
|
-
body: '',
|
|
196
|
-
labels: canary_labels
|
|
197
|
-
};
|
|
189
|
+
let issue_title = report_title;
|
|
190
|
+
let issue_body = '';
|
|
198
191
|
|
|
199
192
|
report_body.push(generate_diagnostics());
|
|
200
193
|
|
|
201
194
|
if (config.canary.sanitize) {
|
|
202
195
|
const local_env = await load_local_env();
|
|
203
|
-
|
|
204
|
-
|
|
196
|
+
issue_body = sanitize_string(JSON.stringify(report_body, null, 4), local_env);
|
|
197
|
+
issue_title = sanitize_string(report_title, local_env);
|
|
205
198
|
} else {
|
|
206
|
-
|
|
199
|
+
issue_body = JSON.stringify(report_body, null, 4);
|
|
207
200
|
}
|
|
208
201
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
for await (const { installation } of app.eachInstallation.iterator()) {
|
|
212
|
-
const login = (installation?.account as { login: string })?.login;
|
|
213
|
-
if (login?.toLowerCase() !== canary_account)
|
|
214
|
-
continue;
|
|
215
|
-
|
|
216
|
-
for await (const { octokit, repository } of app.eachRepository.iterator({ installationId: installation.id })) {
|
|
217
|
-
if (repository.full_name.toLowerCase() !== canary_repostiory)
|
|
218
|
-
continue;
|
|
202
|
+
issue_body = '```json\n' + issue_body + '\n```\n\nℹ️ *This issue has been created automatically in response to a server panic, caution or crash.*';
|
|
219
203
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
204
|
+
await create_github_issue({
|
|
205
|
+
app_id,
|
|
206
|
+
private_key: await key_file.text(),
|
|
207
|
+
repository_name: canary_repostiory,
|
|
208
|
+
login_name: canary_account,
|
|
209
|
+
issue_title,
|
|
210
|
+
issue_body,
|
|
211
|
+
issue_labels: config.canary.labels
|
|
212
|
+
});
|
|
224
213
|
} catch (e) {
|
|
225
|
-
log('
|
|
214
|
+
log('[canary error] ' + (e as Error)?.message ?? 'unspecified error');
|
|
226
215
|
}
|
|
227
216
|
}
|
package/src/github.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Issue = {
|
|
2
|
+
app_id: number;
|
|
3
|
+
private_key: string;
|
|
4
|
+
login_name: string;
|
|
5
|
+
repository_name: string;
|
|
6
|
+
issue_title: string;
|
|
7
|
+
issue_body: string;
|
|
8
|
+
issue_labels?: Array<string>;
|
|
9
|
+
};
|
|
10
|
+
export declare function create_github_issue(issue: Issue): Promise<void>;
|
|
11
|
+
export {};
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { log } from './utils';
|
|
3
|
+
|
|
4
|
+
type InstallationResponse = Array<{
|
|
5
|
+
id: number,
|
|
6
|
+
account: {
|
|
7
|
+
login: string
|
|
8
|
+
},
|
|
9
|
+
access_tokens_url: string,
|
|
10
|
+
repositories_url: string
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
type AccessTokenResponse = {
|
|
14
|
+
token: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type RepositoryResponse = {
|
|
18
|
+
repositories: Array<{
|
|
19
|
+
full_name: string,
|
|
20
|
+
name: string,
|
|
21
|
+
url: string,
|
|
22
|
+
owner: {
|
|
23
|
+
login: string
|
|
24
|
+
}
|
|
25
|
+
}>
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type IssueResponse = {
|
|
29
|
+
number: number,
|
|
30
|
+
url: string
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Issue = {
|
|
34
|
+
app_id: number,
|
|
35
|
+
private_key: string,
|
|
36
|
+
login_name: string,
|
|
37
|
+
repository_name: string,
|
|
38
|
+
issue_title: string,
|
|
39
|
+
issue_body: string
|
|
40
|
+
issue_labels?: Array<string>
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function generate_jwt(app_id: number, private_key: string): string {
|
|
44
|
+
const encoded_header = Buffer.from(JSON.stringify({
|
|
45
|
+
alg: 'RS256',
|
|
46
|
+
typ: 'JWT'
|
|
47
|
+
})).toString('base64');
|
|
48
|
+
|
|
49
|
+
const encoded_payload = Buffer.from(JSON.stringify({
|
|
50
|
+
iat: Math.floor(Date.now() / 1000),
|
|
51
|
+
exp: Math.floor(Date.now() / 1000) + 60,
|
|
52
|
+
iss: app_id
|
|
53
|
+
})).toString('base64');
|
|
54
|
+
|
|
55
|
+
const sign = crypto.createSign('RSA-SHA256');
|
|
56
|
+
sign.update(encoded_header + '.' + encoded_payload);
|
|
57
|
+
|
|
58
|
+
return encoded_header + '.' + encoded_payload + '.' + sign.sign(private_key, 'base64');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function request_endpoint(url: string, bearer: string, method: string = 'GET', body?: object): Promise<Response> {
|
|
62
|
+
return fetch(url, {
|
|
63
|
+
method,
|
|
64
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: 'Bearer ' + bearer,
|
|
67
|
+
Accept: 'application/vnd.github.v3+json'
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function check_response_is_ok(res: Response, message: string): void {
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
throw new Error(message + ' (' + res.status + ' ' + res.statusText + ')');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function create_github_issue(issue: Issue): Promise<void> {
|
|
78
|
+
const jwt = generate_jwt(issue.app_id, issue.private_key);
|
|
79
|
+
const app_res = await request_endpoint('https://api.github.com/app', jwt);
|
|
80
|
+
|
|
81
|
+
check_response_is_ok(app_res, 'cannot authenticate GitHub app ' + issue.app_id);
|
|
82
|
+
|
|
83
|
+
const res_installs = await request_endpoint('https://api.github.com/app/installations', jwt);
|
|
84
|
+
check_response_is_ok(res_installs, 'cannot fetch GitHub app installations');
|
|
85
|
+
|
|
86
|
+
const json_installs = await res_installs.json() as InstallationResponse;
|
|
87
|
+
|
|
88
|
+
const login_name = issue.login_name.toLowerCase();
|
|
89
|
+
const install = json_installs.find((install) => install.account.login.toLowerCase() === login_name);
|
|
90
|
+
|
|
91
|
+
if (!install)
|
|
92
|
+
throw new Error('spooder-bot is not installed on account ' + login_name);
|
|
93
|
+
|
|
94
|
+
const res_access_token = await request_endpoint(install.access_tokens_url, jwt, 'POST');
|
|
95
|
+
check_response_is_ok(res_access_token, 'cannot fetch GitHub app access token');
|
|
96
|
+
|
|
97
|
+
const json_access_token = await res_access_token.json() as AccessTokenResponse;
|
|
98
|
+
const access_token = json_access_token.token;
|
|
99
|
+
|
|
100
|
+
const repositories = await request_endpoint(install.repositories_url, access_token);
|
|
101
|
+
check_response_is_ok(repositories, 'cannot fetch GitHub app repositories');
|
|
102
|
+
|
|
103
|
+
const repositories_json = await repositories.json() as RepositoryResponse;
|
|
104
|
+
|
|
105
|
+
const repository_name = issue.repository_name.toLowerCase();
|
|
106
|
+
const repository = repositories_json.repositories.find((repository) => repository.full_name.toLowerCase() === repository_name);
|
|
107
|
+
|
|
108
|
+
if (!repository)
|
|
109
|
+
throw new Error('spooder-bot is not installed on repository ' + repository_name);
|
|
110
|
+
|
|
111
|
+
const issue_res = await request_endpoint(repository.url + '/issues', access_token, 'POST', {
|
|
112
|
+
title: issue.issue_title,
|
|
113
|
+
body: issue.issue_body,
|
|
114
|
+
labels: issue.issue_labels
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
check_response_is_ok(issue_res, 'cannot create GitHub issue');
|
|
118
|
+
|
|
119
|
+
const json_issue = await issue_res.json() as IssueResponse;
|
|
120
|
+
log('raised canary issue #%d in %s: %s', json_issue.number, repository.full_name, json_issue.url);
|
|
121
|
+
}
|