@manojkmfsi/monodog 1.1.28 → 1.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.
@@ -0,0 +1,552 @@
1
+ "use strict";
2
+ /**
3
+ * GitHub Actions API Service
4
+ * Handles GitHub Actions workflow management, runs, jobs, and logs
5
+ */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.listWorkflows = listWorkflows;
11
+ exports.getWorkflowRuns = getWorkflowRuns;
12
+ exports.getWorkflowRun = getWorkflowRun;
13
+ exports.getWorkflowRunJobs = getWorkflowRunJobs;
14
+ exports.getJobLogs = getJobLogs;
15
+ exports.parseJobLogs = parseJobLogs;
16
+ exports.triggerWorkflow = triggerWorkflow;
17
+ exports.cancelWorkflowRun = cancelWorkflowRun;
18
+ exports.rerunWorkflow = rerunWorkflow;
19
+ const https_1 = __importDefault(require("https"));
20
+ const logger_1 = require("../middleware/logger");
21
+ const GITHUB_API_BASE = 'api.github.com';
22
+ /**
23
+ * Make an HTTPS request to GitHub API
24
+ */
25
+ function makeGitHubRequest(options, data) {
26
+ return new Promise((resolve, reject) => {
27
+ const request = https_1.default.request(options, (response) => {
28
+ let body = '';
29
+ const rateLimit = {
30
+ limit: parseInt(response.headers['x-ratelimit-limit']) || 5000,
31
+ remaining: parseInt(response.headers['x-ratelimit-remaining']) || 0,
32
+ reset: parseInt(response.headers['x-ratelimit-reset']) || 0,
33
+ used: parseInt(response.headers['x-ratelimit-used']) || 0,
34
+ };
35
+ response.on('data', (chunk) => {
36
+ body += chunk;
37
+ });
38
+ response.on('end', () => {
39
+ try {
40
+ if (response.statusCode && response.statusCode >= 400) {
41
+ reject(new Error(`GitHub API error: ${response.statusCode} - ${body}`));
42
+ }
43
+ else {
44
+ const result = body ? JSON.parse(body) : {};
45
+ resolve({ data: result, rateLimit });
46
+ }
47
+ }
48
+ catch (error) {
49
+ reject(new Error(`Failed to parse GitHub API response: ${error}`));
50
+ }
51
+ });
52
+ });
53
+ request.on('error', (error) => {
54
+ logger_1.AppLogger.error(`GitHub API request failed: ${error.message}`);
55
+ reject(error);
56
+ });
57
+ request.setTimeout(15000, () => {
58
+ request.destroy();
59
+ reject(new Error('GitHub API request timeout'));
60
+ });
61
+ if (data) {
62
+ request.write(data);
63
+ }
64
+ request.end();
65
+ });
66
+ }
67
+ /**
68
+ * List available workflows in a repository
69
+ */
70
+ async function listWorkflows(owner, repo, accessToken) {
71
+ const path = `/repos/${owner}/${repo}/actions/workflows`;
72
+ const requestOptions = {
73
+ hostname: GITHUB_API_BASE,
74
+ path,
75
+ method: 'GET',
76
+ headers: {
77
+ Authorization: `Bearer ${accessToken}`,
78
+ 'User-Agent': 'MonoDog',
79
+ Accept: 'application/vnd.github+json',
80
+ },
81
+ };
82
+ try {
83
+ const { data, rateLimit } = await makeGitHubRequest(requestOptions);
84
+ return {
85
+ workflows: data.workflows,
86
+ rateLimit,
87
+ };
88
+ }
89
+ catch (error) {
90
+ logger_1.AppLogger.error(`Failed to list workflows: ${error}`);
91
+ throw error;
92
+ }
93
+ }
94
+ /**
95
+ * Get workflow runs for a repository
96
+ */
97
+ async function getWorkflowRuns(owner, repo, accessToken, options) {
98
+ const params = new URLSearchParams();
99
+ // Determine which workflow to filter by
100
+ let workflowToFilter = null;
101
+ // Prioritize numeric workflow ID - it's the most reliable GitHub API parameter
102
+ if (options?.workflowId && Number(options.workflowId) !== 1) {
103
+ params.append('workflow_id', String(options.workflowId));
104
+ logger_1.AppLogger.info(`getWorkflowRuns: Using numeric workflow_id=${options.workflowId} (most reliable)`);
105
+ }
106
+ else if (options?.workflowPath) {
107
+ // Fall back to full workflow path if numeric ID is not valid
108
+ // Use the full path (.github/workflows/release.yml) not just the filename
109
+ workflowToFilter = options.workflowPath;
110
+ logger_1.AppLogger.info(`getWorkflowRuns: Using full workflowPath=${options.workflowPath}`);
111
+ }
112
+ else {
113
+ // Last resort: default to release.yml workflow
114
+ workflowToFilter = '.github/workflows/release.yml';
115
+ logger_1.AppLogger.warn(`getWorkflowRuns: Using default workflow path (no valid ID or path provided)`);
116
+ }
117
+ // Add the workflow filter if we have one and didn't already use workflow_id
118
+ if (workflowToFilter && !params.has('workflow_id')) {
119
+ params.append('workflow', workflowToFilter);
120
+ logger_1.AppLogger.info(`getWorkflowRuns: Added workflow filter=${workflowToFilter} (using path parameter)`);
121
+ }
122
+ if (options?.status) {
123
+ params.append('status', options.status);
124
+ }
125
+ if (options?.conclusion) {
126
+ params.append('conclusion', options.conclusion);
127
+ }
128
+ params.append('page', String(options?.page || 1));
129
+ params.append('per_page', String(options?.per_page || 30));
130
+ const path = `/repos/${owner}/${repo}/actions/runs?${params.toString()}`;
131
+ const requestOptions = {
132
+ hostname: GITHUB_API_BASE,
133
+ path,
134
+ method: 'GET',
135
+ headers: {
136
+ Authorization: `Bearer ${accessToken}`,
137
+ 'User-Agent': 'MonoDog',
138
+ Accept: 'application/vnd.github+json',
139
+ },
140
+ };
141
+ try {
142
+ const fullUrl = `https://${GITHUB_API_BASE}${path}`;
143
+ logger_1.AppLogger.info(`GitHub API Request: GET ${fullUrl.substring(0, 150)}...`);
144
+ const { data, rateLimit } = await makeGitHubRequest(requestOptions);
145
+ logger_1.AppLogger.info(`GitHub API Response: total_count=${data.total_count}, returned ${data.workflow_runs.length} runs`);
146
+ if (workflowToFilter || (options?.workflowId && Number(options.workflowId) !== 1)) {
147
+ logger_1.AppLogger.info(`Filtered request returned ${data.workflow_runs.length} runs (workflow=${workflowToFilter})`);
148
+ }
149
+ return {
150
+ runs: data.workflow_runs,
151
+ totalCount: data.total_count,
152
+ rateLimit,
153
+ };
154
+ }
155
+ catch (error) {
156
+ logger_1.AppLogger.error(`Failed to get workflow runs: ${error}`);
157
+ throw error;
158
+ }
159
+ }
160
+ /**
161
+ * Get specific workflow run
162
+ */
163
+ async function getWorkflowRun(owner, repo, runId, accessToken) {
164
+ const path = `/repos/${owner}/${repo}/actions/runs/${runId}`;
165
+ const requestOptions = {
166
+ hostname: GITHUB_API_BASE,
167
+ path,
168
+ method: 'GET',
169
+ headers: {
170
+ Authorization: `Bearer ${accessToken}`,
171
+ 'User-Agent': 'MonoDog',
172
+ Accept: 'application/vnd.github+json',
173
+ },
174
+ };
175
+ try {
176
+ const { data, rateLimit } = await makeGitHubRequest(requestOptions);
177
+ return { run: data, rateLimit };
178
+ }
179
+ catch (error) {
180
+ logger_1.AppLogger.error(`Failed to get workflow run ${runId}: ${error}`);
181
+ throw error;
182
+ }
183
+ }
184
+ /**
185
+ * Get jobs for a workflow run
186
+ */
187
+ async function getWorkflowRunJobs(owner, repo, runId, accessToken, page = 1, perPage = 30) {
188
+ const params = new URLSearchParams();
189
+ params.append('page', String(page));
190
+ params.append('per_page', String(perPage));
191
+ const path = `/repos/${owner}/${repo}/actions/runs/${runId}/jobs?${params.toString()}`;
192
+ const requestOptions = {
193
+ hostname: GITHUB_API_BASE,
194
+ path,
195
+ method: 'GET',
196
+ headers: {
197
+ Authorization: `Bearer ${accessToken}`,
198
+ 'User-Agent': 'MonoDog',
199
+ Accept: 'application/vnd.github+json',
200
+ },
201
+ };
202
+ try {
203
+ const { data, rateLimit } = await makeGitHubRequest(requestOptions);
204
+ return {
205
+ jobs: data.jobs,
206
+ totalCount: data.total_count,
207
+ rateLimit,
208
+ };
209
+ }
210
+ catch (error) {
211
+ logger_1.AppLogger.error(`Failed to get jobs for run ${runId}: ${error}`);
212
+ throw error;
213
+ }
214
+ }
215
+ /**
216
+ * Get logs for a specific job
217
+ * Returns raw logs that need to be parsed
218
+ */
219
+ async function getJobLogs(owner, repo, jobId, accessToken) {
220
+ const path = `/repos/${owner}/${repo}/actions/jobs/${jobId}/logs`;
221
+ logger_1.AppLogger.info(`Fetching job logs from GitHub: owner=${owner}, repo=${repo}, jobId=${jobId}`);
222
+ const requestOptions = {
223
+ hostname: GITHUB_API_BASE,
224
+ path,
225
+ method: 'GET',
226
+ headers: {
227
+ Authorization: `Bearer ${accessToken}`,
228
+ 'User-Agent': 'MonoDog',
229
+ Accept: 'application/vnd.github.raw',
230
+ },
231
+ };
232
+ return new Promise((resolve, reject) => {
233
+ const request = https_1.default.request(requestOptions, (response) => {
234
+ let body = '';
235
+ // Handle redirects
236
+ if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303) {
237
+ const location = response.headers.location;
238
+ logger_1.AppLogger.info(`GitHub redirected logs request to: ${location}`);
239
+ if (location) {
240
+ // Recursively follow redirect with GET
241
+ const url = new URL(location);
242
+ const redirectOptions = {
243
+ hostname: url.hostname,
244
+ path: url.pathname + url.search,
245
+ method: 'GET',
246
+ headers: {
247
+ 'User-Agent': 'MonoDog',
248
+ // Authorization: `Bearer ${accessToken}`,
249
+ },
250
+ };
251
+ const redirectRequest = https_1.default.request(redirectOptions, (redirectResponse) => {
252
+ let redirectBody = '';
253
+ redirectResponse.on('data', (chunk) => {
254
+ redirectBody += chunk;
255
+ });
256
+ redirectResponse.on('end', () => {
257
+ const rateLimit = {
258
+ limit: parseInt(redirectResponse.headers['x-ratelimit-limit']) || 5000,
259
+ remaining: parseInt(redirectResponse.headers['x-ratelimit-remaining']) || 0,
260
+ reset: parseInt(redirectResponse.headers['x-ratelimit-reset']) || 0,
261
+ used: parseInt(redirectResponse.headers['x-ratelimit-used']) || 0,
262
+ };
263
+ // Check for errors in the redirect response too
264
+ if (redirectResponse.statusCode && redirectResponse.statusCode >= 400) {
265
+ logger_1.AppLogger.error(`GitHub API error after redirect ${redirectResponse.statusCode}: ${redirectBody.substring(0, 200)}`);
266
+ reject(new Error(`GitHub API error: ${redirectResponse.statusCode} - ${redirectBody}`));
267
+ }
268
+ else if (!redirectBody || redirectBody.trim().length === 0) {
269
+ logger_1.AppLogger.warn(`Job logs are empty after redirect (status=${redirectResponse.statusCode})`);
270
+ resolve({ logs: '', rateLimit });
271
+ }
272
+ else {
273
+ logger_1.AppLogger.info(`Fetched ${redirectBody.length} characters of job logs from redirect`);
274
+ resolve({ logs: redirectBody, rateLimit });
275
+ }
276
+ });
277
+ });
278
+ redirectRequest.on('error', (error) => {
279
+ logger_1.AppLogger.error(`Failed to follow redirect for job logs: ${error.message}`);
280
+ reject(error);
281
+ });
282
+ redirectRequest.end();
283
+ return;
284
+ }
285
+ }
286
+ const rateLimit = {
287
+ limit: parseInt(response.headers['x-ratelimit-limit']) || 5000,
288
+ remaining: parseInt(response.headers['x-ratelimit-remaining']) || 0,
289
+ reset: parseInt(response.headers['x-ratelimit-reset']) || 0,
290
+ used: parseInt(response.headers['x-ratelimit-used']) || 0,
291
+ };
292
+ response.on('data', (chunk) => {
293
+ body += chunk;
294
+ });
295
+ response.on('end', () => {
296
+ try {
297
+ logger_1.AppLogger.debug(`Job logs response: status=${response.statusCode}, length=${body.length}`);
298
+ if (response.statusCode && response.statusCode >= 400) {
299
+ logger_1.AppLogger.error(`GitHub API error ${response.statusCode}: ${body.substring(0, 200)}`);
300
+ reject(new Error(`GitHub API error: ${response.statusCode} - ${body}`));
301
+ }
302
+ else if (!body || body.trim().length === 0) {
303
+ logger_1.AppLogger.warn(`Job logs are empty (status=${response.statusCode})`);
304
+ resolve({ logs: '', rateLimit });
305
+ }
306
+ else {
307
+ logger_1.AppLogger.info(`Fetched ${body.length} characters of job logs`);
308
+ resolve({ logs: body, rateLimit });
309
+ }
310
+ }
311
+ catch (error) {
312
+ reject(new Error(`Failed to parse job logs: ${error}`));
313
+ }
314
+ });
315
+ });
316
+ request.on('error', (error) => {
317
+ logger_1.AppLogger.error(`Failed to fetch job logs: ${error.message}`);
318
+ reject(error);
319
+ });
320
+ request.setTimeout(30000, () => {
321
+ request.destroy();
322
+ reject(new Error('Job logs request timeout'));
323
+ });
324
+ request.end();
325
+ });
326
+ }
327
+ /**
328
+ * Parse raw GitHub Actions logs into structured step logs
329
+ * GitHub Actions logs have format:
330
+ * \u001b[36m##[group]Group Name\u001b[0m
331
+ * Log line here
332
+ * \u001b[36m##[endgroup]\u001b[0m
333
+ */
334
+ function parseJobLogs(rawLogs, job) {
335
+ const steps = [];
336
+ const lines = rawLogs.split('\n');
337
+ let currentStep = null;
338
+ let lineNumber = 0;
339
+ for (const line of lines) {
340
+ lineNumber++;
341
+ // Check for group start (step start)
342
+ if (line.includes('##[group]')) {
343
+ // Save previous step if exists
344
+ if (currentStep) {
345
+ steps.push(currentStep);
346
+ }
347
+ // Extract step name
348
+ // eslint-disable-next-line no-control-regex
349
+ const nameMatch = line.match(/##\[group\](.*?)(\u001b\[0m)?$/);
350
+ const stepName = nameMatch ? nameMatch[1].trim() : `Step ${steps.length + 1}`;
351
+ // Find matching step in workflow
352
+ const workflowStep = job.steps.find((s) => s.name.toLowerCase() === stepName.toLowerCase());
353
+ currentStep = {
354
+ stepNumber: steps.length + 1,
355
+ stepName,
356
+ startedAt: workflowStep?.started_at || null,
357
+ completedAt: workflowStep?.completed_at || null,
358
+ conclusion: workflowStep?.conclusion || null,
359
+ status: workflowStep?.status || 'queued',
360
+ logs: [],
361
+ expanded: true,
362
+ };
363
+ }
364
+ else if (line.includes('##[endgroup]')) {
365
+ // Step end marker - do nothing, step will be saved on next group or end
366
+ }
367
+ else if (line.trim() && currentStep) {
368
+ // Add log line to current step
369
+ const logLine = {
370
+ lineNumber,
371
+ timestamp: extractTimestamp(line),
372
+ content: stripAnsiCodes(line),
373
+ ansiContent: line,
374
+ };
375
+ currentStep.logs.push(logLine);
376
+ }
377
+ }
378
+ // Add last step if exists
379
+ if (currentStep) {
380
+ steps.push(currentStep);
381
+ }
382
+ // If no steps were parsed, create one for all logs
383
+ if (steps.length === 0 && lines.length > 0) {
384
+ steps.push({
385
+ stepNumber: 1,
386
+ stepName: 'Output',
387
+ startedAt: job.started_at,
388
+ completedAt: job.completed_at,
389
+ conclusion: job.conclusion,
390
+ status: job.status,
391
+ logs: lines.map((line, idx) => ({
392
+ lineNumber: idx + 1,
393
+ timestamp: extractTimestamp(line),
394
+ content: stripAnsiCodes(line),
395
+ ansiContent: line,
396
+ })),
397
+ expanded: true,
398
+ });
399
+ }
400
+ return steps;
401
+ }
402
+ /**
403
+ * Extract timestamp from log line
404
+ * GitHub format: 2024-02-11T12:34:56.123456789Z
405
+ */
406
+ function extractTimestamp(line) {
407
+ const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/;
408
+ const match = line.match(isoRegex);
409
+ return match ? match[0] : new Date().toISOString();
410
+ }
411
+ /**
412
+ * Strip ANSI codes from text
413
+ * Keeps the text content but removes styling
414
+ */
415
+ function stripAnsiCodes(text) {
416
+ // eslint-disable-next-line no-control-regex
417
+ return text.replace(/\u001b\[[0-9;]*m/g, '').replace(/\u001b\[K/g, '');
418
+ }
419
+ /**
420
+ * Trigger a workflow run
421
+ */
422
+ async function triggerWorkflow(accessToken, request) {
423
+ // Ensure workflow is a string - convert number if needed
424
+ let workflowIdentifier = String(request.workflow);
425
+ // If it looks like a path (contains slashes), extract filename
426
+ if (workflowIdentifier.includes('/')) {
427
+ // Convert .github/workflows/ci.yml to ci.yml
428
+ workflowIdentifier = workflowIdentifier.split('/').pop() || workflowIdentifier;
429
+ }
430
+ const path = `/repos/${request.owner}/${request.repo}/actions/workflows/${workflowIdentifier}/dispatches`;
431
+ const payload = JSON.stringify({
432
+ ref: request.ref,
433
+ inputs: request.inputs || {},
434
+ });
435
+ const requestOptions = {
436
+ hostname: GITHUB_API_BASE,
437
+ path,
438
+ method: 'POST',
439
+ headers: {
440
+ Authorization: `Bearer ${accessToken}`,
441
+ 'User-Agent': 'MonoDog',
442
+ 'Content-Type': 'application/json',
443
+ 'Content-Length': String(Buffer.byteLength(payload)),
444
+ Accept: 'application/vnd.github+json',
445
+ },
446
+ };
447
+ try {
448
+ logger_1.AppLogger.debug(`Triggering workflow: ${workflowIdentifier} (original: ${request.workflow}) on ${request.owner}/${request.repo} branch: ${request.ref}`);
449
+ const response = await makeGitHubRequest(requestOptions, payload);
450
+ // GitHub returns 204 No Content on success
451
+ logger_1.AppLogger.info(`Workflow triggered successfully: ${workflowIdentifier}`);
452
+ return {
453
+ response: {
454
+ success: true,
455
+ message: 'Workflow triggered successfully',
456
+ },
457
+ rateLimit: response.rateLimit,
458
+ };
459
+ }
460
+ catch (error) {
461
+ logger_1.AppLogger.error(`Failed to trigger workflow ${workflowIdentifier}: ${error}`);
462
+ return {
463
+ response: {
464
+ success: false,
465
+ message: `Failed to trigger workflow: ${error}`,
466
+ },
467
+ rateLimit: { limit: 0, remaining: 0, reset: 0, used: 0 },
468
+ };
469
+ }
470
+ }
471
+ /**
472
+ * Cancel a workflow run
473
+ */
474
+ async function cancelWorkflowRun(owner, repo, runId, accessToken) {
475
+ const path = `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`;
476
+ const requestOptions = {
477
+ hostname: GITHUB_API_BASE,
478
+ path,
479
+ method: 'POST',
480
+ headers: {
481
+ Authorization: `Bearer ${accessToken}`,
482
+ 'User-Agent': 'MonoDog',
483
+ Accept: 'application/vnd.github+json',
484
+ },
485
+ };
486
+ try {
487
+ const { rateLimit } = await makeGitHubRequest(requestOptions);
488
+ return { success: true, rateLimit };
489
+ }
490
+ catch (error) {
491
+ logger_1.AppLogger.error(`Failed to cancel workflow run: ${error}`);
492
+ return {
493
+ success: false,
494
+ rateLimit: { limit: 0, remaining: 0, reset: 0, used: 0 },
495
+ };
496
+ }
497
+ }
498
+ /**
499
+ * Re-run a workflow run
500
+ */
501
+ async function rerunWorkflow(owner, repo, runId, accessToken, failedOnly = false) {
502
+ const path = failedOnly
503
+ ? `/repos/${owner}/${repo}/actions/runs/${runId}/rerun-failed-jobs`
504
+ : `/repos/${owner}/${repo}/actions/runs/${runId}/rerun`;
505
+ const requestOptions = {
506
+ hostname: GITHUB_API_BASE,
507
+ path,
508
+ method: 'POST',
509
+ headers: {
510
+ Authorization: `Bearer ${accessToken}`,
511
+ 'User-Agent': 'MonoDog',
512
+ Accept: 'application/vnd.github+json',
513
+ },
514
+ };
515
+ try {
516
+ const { rateLimit } = await makeGitHubRequest(requestOptions);
517
+ return { success: true, rateLimit };
518
+ }
519
+ catch (error) {
520
+ logger_1.AppLogger.error(`Failed to rerun workflow: ${error}`);
521
+ return {
522
+ success: false,
523
+ rateLimit: { limit: 0, remaining: 0, reset: 0, used: 0 },
524
+ };
525
+ }
526
+ }
527
+ /**
528
+ * Get rate limit information
529
+ */
530
+ // export async function getRateLimit(
531
+ // accessToken: string
532
+ // ): Promise<RateLimitInfo> {
533
+ // const requestOptions: GitHubRequestOptions = {
534
+ // hostname: GITHUB_API_BASE,
535
+ // path: '/rate_limit',
536
+ // method: 'GET',
537
+ // headers: {
538
+ // Authorization: `Bearer ${accessToken}`,
539
+ // 'User-Agent': 'MonoDog',
540
+ // Accept: 'application/vnd.github+json',
541
+ // },
542
+ // };
543
+ // try {
544
+ // const { data } = await makeGitHubRequest<{
545
+ // resources: { core: RateLimitInfo };
546
+ // }>(requestOptions);
547
+ // return data.resources.core;
548
+ // } catch (error) {
549
+ // AppLogger.error(`Failed to get rate limit: ${error}`);
550
+ // return { limit: 0, remaining: 0, reset: 0, used: 0 };
551
+ // }
552
+ // }