Skip to content

Commit 97de974

Browse files
feat: Add dry-run support and refactor bot-workflows.yml (hiero-ledger#1288) (hiero-ledger#1431)
Signed-off-by: Mounil <mounilkankhara@gmail.com> Signed-off-by: Mounil Kankhara <mounilkankhara@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 855cfc7 commit 97de974

File tree

3 files changed

+378
-45
lines changed

3 files changed

+378
-45
lines changed

.github/scripts/bot-workflows.js

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Workflow Failure Notifier - Looks up PR and posts failure notification
5+
* DRY_RUN controls behaviour:
6+
* DRY_RUN = 1 -> simulate only (no changes, just logs)
7+
* DRY_RUN = 0 -> real actions (post PR comments)
8+
*/
9+
10+
const { spawnSync } = require('child_process');
11+
const process = require('process');
12+
13+
// Configuration constants
14+
const MARKER = '<!-- workflowbot:workflow-failure-notifier -->';
15+
const MAX_PAGES = 10; // Safety bound for comment pagination
16+
17+
// Documentation links (edit these when URLs change)
18+
const DOC_SIGNING = 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/sdk_developers/signing.md';
19+
const DOC_CHANGELOG = 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/sdk_developers/changelog_entry.md';
20+
const DOC_MERGE_CONFLICTS = 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/sdk_developers/merge_conflicts.md';
21+
const DOC_REBASING = 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/sdk_developers/rebasing.md';
22+
const DOC_TESTING = 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/sdk_developers/testing.md';
23+
const DOC_DISCORD = 'https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md';
24+
const COMMUNITY_CALLS = 'https://zoom-lfx.platform.linuxfoundation.org/meetings/hiero?view=week';
25+
26+
/**
27+
* Execute gh CLI command safely
28+
* @param {string[]} args - Arguments array for gh command
29+
* @param {boolean} silent - Whether to suppress output
30+
* @returns {string} - Command output
31+
*/
32+
function ghCommand(args = [], silent = false) {
33+
try {
34+
const result = spawnSync('gh', args, {
35+
encoding: 'utf8',
36+
stdio: silent ? 'pipe' : ['pipe', 'pipe', 'pipe'],
37+
shell: false
38+
});
39+
40+
if (result.error) {
41+
throw result.error;
42+
}
43+
44+
if (result.status !== 0 && !silent) {
45+
throw new Error(`Command failed with exit code ${result.status}`);
46+
}
47+
48+
return (result.stdout || '').trim();
49+
} catch (error) {
50+
if (!silent) {
51+
throw error;
52+
}
53+
return '';
54+
}
55+
}
56+
57+
/**
58+
* Check if gh CLI exists
59+
* @returns {boolean}
60+
*/
61+
function ghExists() {
62+
try {
63+
if (process.platform === 'win32') {
64+
const result = spawnSync('where', ['gh'], {
65+
encoding: 'utf8',
66+
stdio: 'pipe',
67+
shell: false
68+
});
69+
return result.status === 0;
70+
} else {
71+
const result = spawnSync('which', ['gh'], {
72+
encoding: 'utf8',
73+
stdio: 'pipe',
74+
shell: false
75+
});
76+
return result.status === 0;
77+
}
78+
} catch {
79+
return false;
80+
}
81+
}
82+
83+
/**
84+
* Normalise DRY_RUN input ("true"/"false" -> 1/0, case-insensitive)
85+
* @param {string|number} value - Input value
86+
* @returns {number} - Normalised value (0 or 1)
87+
*/
88+
function normaliseDryRun(value) {
89+
const strValue = String(value).toLowerCase();
90+
91+
if (strValue === '1' || strValue === '0') {
92+
return parseInt(strValue);
93+
}
94+
95+
if (strValue === 'true') {
96+
return 1;
97+
}
98+
99+
if (strValue === 'false') {
100+
return 0;
101+
}
102+
103+
console.error(`ERROR: DRY_RUN must be one of: true, false, 1, 0 (got: ${value})`);
104+
process.exit(1);
105+
}
106+
107+
// Validate required environment variables
108+
let FAILED_WORKFLOW_NAME = process.env.FAILED_WORKFLOW_NAME || '';
109+
let FAILED_RUN_ID = process.env.FAILED_RUN_ID || '';
110+
let GH_TOKEN = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '';
111+
const REPO = process.env.REPO || process.env.GITHUB_REPOSITORY || '';
112+
let DRY_RUN = normaliseDryRun(process.env.DRY_RUN || '1');
113+
let PR_NUMBER = process.env.PR_NUMBER || '';
114+
115+
// Validate workflow name contains only safe characters
116+
if (FAILED_WORKFLOW_NAME && !/^[\w\s\-\.]+$/.test(FAILED_WORKFLOW_NAME)) {
117+
console.error(`ERROR: FAILED_WORKFLOW_NAME contains invalid characters: ${FAILED_WORKFLOW_NAME}`);
118+
process.exit(1);
119+
}
120+
121+
// Set GH_TOKEN environment variable for gh CLI
122+
if (GH_TOKEN) {
123+
process.env.GH_TOKEN = GH_TOKEN;
124+
}
125+
126+
// Validate required variables or set defaults in dry-run mode
127+
if (!FAILED_WORKFLOW_NAME) {
128+
if (DRY_RUN === 1) {
129+
console.log('WARN: FAILED_WORKFLOW_NAME not set, using default for dry-run.');
130+
FAILED_WORKFLOW_NAME = 'DRY_RUN_TEST';
131+
} else {
132+
console.error('ERROR: FAILED_WORKFLOW_NAME environment variable not set.');
133+
process.exit(1);
134+
}
135+
}
136+
137+
if (!FAILED_RUN_ID) {
138+
if (DRY_RUN === 1) {
139+
console.log('WARN: FAILED_RUN_ID not set, using default for dry-run.');
140+
FAILED_RUN_ID = '12345';
141+
} else {
142+
console.error('ERROR: FAILED_RUN_ID environment variable not set.');
143+
process.exit(1);
144+
}
145+
}
146+
147+
// Validate FAILED_RUN_ID is numeric (always check when provided)
148+
if (!/^\d+$/.test(FAILED_RUN_ID)) {
149+
console.error(`ERROR: FAILED_RUN_ID must be a numeric integer (got: '${FAILED_RUN_ID}')`);
150+
process.exit(1);
151+
}
152+
153+
// Validate PR_NUMBER if provided
154+
if (PR_NUMBER && PR_NUMBER !== 'null' && !/^\d+$/.test(PR_NUMBER)) {
155+
console.error(`ERROR: PR_NUMBER must be a numeric integer (got: '${PR_NUMBER}')`);
156+
process.exit(1);
157+
}
158+
159+
if (!GH_TOKEN) {
160+
if (DRY_RUN === 1) {
161+
console.log('WARN: GH_TOKEN not set. Some dry-run operations may fail.');
162+
} else {
163+
console.error('ERROR: GH_TOKEN (or GITHUB_TOKEN) environment variable not set.');
164+
process.exit(1);
165+
}
166+
}
167+
168+
if (!REPO) {
169+
console.error('ERROR: REPO environment variable not set.');
170+
process.exit(1);
171+
}
172+
173+
console.log('------------------------------------------------------------');
174+
console.log(' Workflow Failure Notifier');
175+
console.log(` Repo: ${REPO}`);
176+
console.log(` Failed Workflow: ${FAILED_WORKFLOW_NAME}`);
177+
console.log(` Failed Run ID: ${FAILED_RUN_ID}`);
178+
console.log(` DRY_RUN: ${DRY_RUN}`);
179+
console.log('------------------------------------------------------------');
180+
181+
// Quick gh availability/auth checks
182+
if (!ghExists()) {
183+
console.error('ERROR: gh CLI not found. Install it and ensure it\'s on PATH.');
184+
process.exit(1);
185+
}
186+
187+
try {
188+
ghCommand(['auth', 'status'], true);
189+
} catch (error) {
190+
if (DRY_RUN === 0) {
191+
console.error('ERROR: gh authentication required for non-dry-run mode.');
192+
process.exit(1);
193+
} else {
194+
console.log(`WARN: gh auth status failed — some dry-run operations may not work. (${error.message})`);
195+
}
196+
}
197+
198+
// PR lookup logic - use PR_NUMBER from workflow_run payload if available, otherwise fallback to branch-based approach
199+
console.log('Looking up PR for failed workflow run...');
200+
201+
// Use PR_NUMBER from workflow_run payload if provided (optimized path)
202+
if (PR_NUMBER && PR_NUMBER !== 'null') {
203+
console.log(`Using PR number from workflow_run payload: ${PR_NUMBER}`);
204+
} else {
205+
console.log('PR_NUMBER not provided, falling back to branch-based lookup...');
206+
207+
let HEAD_BRANCH = '';
208+
try {
209+
HEAD_BRANCH = ghCommand(['run', 'view', FAILED_RUN_ID, '--repo', REPO, '--json', 'headBranch', '--jq', '.headBranch'], true);
210+
} catch {
211+
HEAD_BRANCH = '';
212+
}
213+
214+
if (!HEAD_BRANCH) {
215+
if (DRY_RUN === 1) {
216+
console.log('WARN: Could not retrieve head branch in dry-run mode (run ID may be invalid). Exiting gracefully.');
217+
process.exit(0);
218+
} else {
219+
console.error(`ERROR: Could not retrieve head branch from workflow run ${FAILED_RUN_ID}`);
220+
process.exit(1);
221+
}
222+
}
223+
224+
console.log(`Found head branch: ${HEAD_BRANCH}`);
225+
226+
// Validate branch name format
227+
if (HEAD_BRANCH.startsWith('-') || !/^[\w.\/-]+$/.test(HEAD_BRANCH)) {
228+
console.error(`ERROR: HEAD_BRANCH contains invalid characters: ${HEAD_BRANCH}`);
229+
process.exit(1);
230+
}
231+
232+
// Find PR number for this branch (only open PRs)
233+
try {
234+
PR_NUMBER = ghCommand(['pr', 'list', '--repo', REPO, '--head', HEAD_BRANCH, '--json', 'number', '--jq', '.[0].number'], true);
235+
} catch {
236+
PR_NUMBER = '';
237+
}
238+
239+
if (!PR_NUMBER) {
240+
if (DRY_RUN === 1) {
241+
console.log(`No PR associated with workflow run ${FAILED_RUN_ID}, but DRY_RUN=1 - exiting successfully.`);
242+
process.exit(0);
243+
} else {
244+
console.log(`INFO: No open PR found for branch '${HEAD_BRANCH}' (workflow run ${FAILED_RUN_ID}). Nothing to notify.`);
245+
process.exit(0);
246+
}
247+
}
248+
}
249+
250+
console.log(`Found PR #${PR_NUMBER}`);
251+
252+
// Build notification message with failure details and documentation links
253+
const COMMENT = `${MARKER}
254+
Hi, this is WorkflowBot.
255+
Your pull request cannot be merged as it is not passing all our workflow checks.
256+
Please click on each check to review the logs and resolve issues so all checks pass.
257+
To help you:
258+
- [DCO signing guide](${DOC_SIGNING})
259+
- [Changelog guide](${DOC_CHANGELOG})
260+
- [Merge conflicts guide](${DOC_MERGE_CONFLICTS})
261+
- [Rebase guide](${DOC_REBASING})
262+
- [Testing guide](${DOC_TESTING})
263+
- [Discord](${DOC_DISCORD})
264+
- [Community Calls](${COMMUNITY_CALLS})
265+
Thank you for contributing!
266+
From the Hiero Python SDK Team`;
267+
268+
// Check for duplicate comments using the correct endpoint for issue comments
269+
let PAGE = 1;
270+
let DUPLICATE_EXISTS = false;
271+
272+
while (PAGE <= MAX_PAGES) {
273+
let COMMENTS_PAGE = '';
274+
try {
275+
COMMENTS_PAGE = ghCommand(['api', '--header', 'Accept: application/vnd.github.v3+json', `/repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100&page=${PAGE}`], true);
276+
} catch (error) {
277+
console.log(`WARN: Failed to fetch comments page ${PAGE}: ${error.message}`);
278+
COMMENTS_PAGE = '[]';
279+
}
280+
281+
// Parse JSON
282+
let comments = [];
283+
try {
284+
comments = JSON.parse(COMMENTS_PAGE);
285+
} catch (error) {
286+
console.log(`WARN: Failed to parse comments JSON on page ${PAGE}: ${error.message}`);
287+
comments = [];
288+
}
289+
290+
// Check if the page is empty (no more comments)
291+
if (comments.length === 0) {
292+
break;
293+
}
294+
295+
// Check this page for the marker
296+
const foundDuplicate = comments.some(comment => {
297+
return comment.body && comment.body.includes(MARKER);
298+
});
299+
300+
if (foundDuplicate) {
301+
DUPLICATE_EXISTS = true;
302+
console.log('Found existing duplicate comment. Skipping.');
303+
break;
304+
}
305+
306+
PAGE++;
307+
}
308+
309+
if (!DUPLICATE_EXISTS) {
310+
console.log('No existing duplicate comment found.');
311+
}
312+
313+
// Dry-run mode or actual posting
314+
if (DRY_RUN === 1) {
315+
console.log(`[DRY RUN] Would post comment to PR #${PR_NUMBER}:`);
316+
console.log('----------------------------------------');
317+
console.log(COMMENT);
318+
console.log('----------------------------------------');
319+
if (DUPLICATE_EXISTS) {
320+
console.log('[DRY RUN] Would skip posting due to duplicate comment');
321+
} else {
322+
console.log('[DRY RUN] Would post new comment (no duplicates found)');
323+
}
324+
} else {
325+
if (DUPLICATE_EXISTS) {
326+
console.log('Comment already exists, skipping.');
327+
} else {
328+
console.log(`Posting new comment to PR #${PR_NUMBER}...`);
329+
330+
try {
331+
ghCommand(['pr', 'comment', PR_NUMBER, '--repo', REPO, '--body', COMMENT]);
332+
console.log(`Successfully posted comment to PR #${PR_NUMBER}`);
333+
} catch (error) {
334+
console.error(`ERROR: Failed to post comment to PR #${PR_NUMBER}`);
335+
console.error(error.message);
336+
process.exit(1);
337+
}
338+
}
339+
}
340+
341+
console.log('------------------------------------------------------------');
342+
console.log(' Workflow Failure Notifier Complete');
343+
console.log(` DRY_RUN: ${DRY_RUN}`);
344+
console.log('------------------------------------------------------------');

0 commit comments

Comments
 (0)