spooder 3.2.8 → 4.0.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.
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
- export async function get_config(): Promise<Config> {
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('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.');
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.toLowerCase();
157
- const canary_repostiory = config.canary.repository.toLowerCase();
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('Throttled canary report: ' + report_title);
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('dispatch_report() called without SPOODER_CANARY_APP_ID environment variable set');
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('dispatch_report() called without SPOODER_CANARY_KEY environment variable set');
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('dispatch_report() failed to read canary private key file');
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('dispatch_report() failed to parse SPOODER_CANARY_APP_ID environment variable as integer');
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
- await app.octokit.request('GET /app');
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
- post_object.body = sanitize_string(JSON.stringify(report_body, null, 4), local_env);
204
- post_object.title = sanitize_string(report_title, local_env);
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
- post_object.body = JSON.stringify(report_body, null, 4);
199
+ issue_body = JSON.stringify(report_body, null, 4);
207
200
  }
208
201
 
209
- post_object.body = '```json\n' + post_object.body + '\n```\n\nℹ️ *This issue has been created automatically in response to a server panic, caution or crash.*';
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
- await octokit.request('POST /repos/' + canary_repostiory + '/issues', post_object);
221
- log('Dispatched canary report to %s: %s', canary_repostiory, report_title);
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('Failed to dispatch canary report: ' + (e as Error)?.message ?? 'unspecified error');
214
+ log('[canary error] ' + (e as Error)?.message ?? 'unspecified error');
226
215
  }
227
216
  }
@@ -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
+ }