@codfish/actions 0.0.0-PR-58--24ced07
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 +283 -0
- package/bin/generate-docs.js +432 -0
- package/comment/README.md +82 -0
- package/comment/action.yml +102 -0
- package/npm-publish-pr/README.md +424 -0
- package/npm-publish-pr/action.yml +362 -0
- package/package.json +57 -0
- package/setup-node-and-install/README.md +184 -0
- package/setup-node-and-install/action.yml +228 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import yml from 'js-yaml';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate documentation for all GitHub Actions in the repository
|
|
9
|
+
*/
|
|
10
|
+
class DocumentationGenerator {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.rootDir = process.cwd();
|
|
13
|
+
this.actions = [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find all action directories by looking for action.yml files
|
|
18
|
+
*/
|
|
19
|
+
findActionDirectories() {
|
|
20
|
+
const entries = fs.readdirSync(this.rootDir, { withFileTypes: true });
|
|
21
|
+
|
|
22
|
+
return entries
|
|
23
|
+
.filter(entry => entry.isDirectory())
|
|
24
|
+
.filter(entry => !entry.name.startsWith('.') && entry.name !== 'node_modules')
|
|
25
|
+
.map(entry => entry.name)
|
|
26
|
+
.filter(dirName => {
|
|
27
|
+
const actionFile = path.join(this.rootDir, dirName, 'action.yml');
|
|
28
|
+
return fs.existsSync(actionFile);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse action.yml file to extract metadata
|
|
34
|
+
*/
|
|
35
|
+
parseActionFile(dirName) {
|
|
36
|
+
const actionFile = path.join(this.rootDir, dirName, 'action.yml');
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const content = fs.readFileSync(actionFile, 'utf8');
|
|
40
|
+
const actionData = yml.load(content);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
directory: dirName,
|
|
44
|
+
name: actionData.name || dirName,
|
|
45
|
+
description: actionData.description || 'No description available',
|
|
46
|
+
inputs: actionData.inputs || {},
|
|
47
|
+
outputs: actionData.outputs || {},
|
|
48
|
+
rawData: actionData,
|
|
49
|
+
};
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`Error parsing ${actionFile}:`, error.message);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract usage example from README.md
|
|
58
|
+
*/
|
|
59
|
+
extractUsageExample(dirName) {
|
|
60
|
+
const readmeFile = path.join(this.rootDir, dirName, 'README.md');
|
|
61
|
+
|
|
62
|
+
if (!fs.existsSync(readmeFile)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const content = fs.readFileSync(readmeFile, 'utf8');
|
|
68
|
+
|
|
69
|
+
// Look for usage examples in various sections
|
|
70
|
+
const patterns = [
|
|
71
|
+
// Look for "## Usage" section with yml code block
|
|
72
|
+
/## Usage[\s\S]*?```yml\n([\s\S]*?)\n```/i,
|
|
73
|
+
// Look for any yml code block with "uses: "
|
|
74
|
+
/```yml\n([\s\S]*?uses:\s*[.\w/-]+[\s\S]*?)\n```/i,
|
|
75
|
+
// Look for specific action usage
|
|
76
|
+
new RegExp(`\`\`\`yml\\n([\\s\\S]*?uses:\\s*[^\\n]*${dirName}[\\s\\S]*?)\\n\`\`\``, 'i'),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const pattern of patterns) {
|
|
80
|
+
const match = content.match(pattern);
|
|
81
|
+
if (match && match[1]) {
|
|
82
|
+
// Clean up the example and ensure it's properly formatted
|
|
83
|
+
const example = match[1].trim();
|
|
84
|
+
|
|
85
|
+
// If it doesn't start with a step name, add one
|
|
86
|
+
if (!example.match(/^\s*-\s*name:/m) && !example.match(/^\s*-\s*uses:/m)) {
|
|
87
|
+
return `- uses: codfish/actions/${dirName}@v3\n${example.replace(/^/gm, ' ')}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return example;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fallback: create a basic example based on inputs
|
|
95
|
+
return this.generateBasicExample(dirName);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(`Error reading README for ${dirName}:`, error.message);
|
|
98
|
+
return this.generateBasicExample(dirName);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generate a basic usage example based on action inputs
|
|
104
|
+
*/
|
|
105
|
+
generateBasicExample(dirName, inputs = {}) {
|
|
106
|
+
let example = `- uses: codfish/actions/${dirName}@v3`;
|
|
107
|
+
|
|
108
|
+
const inputKeys = Object.keys(inputs);
|
|
109
|
+
if (inputKeys.length > 0) {
|
|
110
|
+
example += '\n with:';
|
|
111
|
+
|
|
112
|
+
// Add required inputs first
|
|
113
|
+
const requiredInputs = inputKeys.filter(key => inputs[key].required);
|
|
114
|
+
const optionalInputs = inputKeys.filter(key => !inputs[key].required);
|
|
115
|
+
|
|
116
|
+
[...requiredInputs, ...optionalInputs.slice(0, 2)].forEach(key => {
|
|
117
|
+
const input = inputs[key];
|
|
118
|
+
let value = 'value';
|
|
119
|
+
|
|
120
|
+
// Smart defaults based on input name
|
|
121
|
+
if (key.includes('token')) value = '${{ secrets.TOKEN_NAME }}';
|
|
122
|
+
else if (key.includes('version')) value = 'lts/*';
|
|
123
|
+
else if (key.includes('message')) value = 'Your message here';
|
|
124
|
+
else if (key.includes('tag')) value = 'tag-name';
|
|
125
|
+
else if (input.default) value = input.default;
|
|
126
|
+
|
|
127
|
+
example += `\n ${key}: ${value}`;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return example;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Generate markdown table for inputs or outputs
|
|
136
|
+
*/
|
|
137
|
+
generateTable(items, type = 'inputs') {
|
|
138
|
+
if (!items || Object.keys(items).length === 0) {
|
|
139
|
+
return `*No ${type}*`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const headers = type === 'inputs' ? '| Input | Description | Required | Default |' : '| Output | Description |';
|
|
143
|
+
|
|
144
|
+
const separator = type === 'inputs' ? '|-------|-------------|----------|---------|' : '|--------|-------------|';
|
|
145
|
+
|
|
146
|
+
let table = `${headers}\n${separator}`;
|
|
147
|
+
|
|
148
|
+
Object.entries(items).forEach(([key, config]) => {
|
|
149
|
+
const description = config.description || 'No description';
|
|
150
|
+
|
|
151
|
+
if (type === 'inputs') {
|
|
152
|
+
const required = config.required ? 'Yes' : 'No';
|
|
153
|
+
const defaultValue = config.default ? `\`${config.default}\` ` : '-';
|
|
154
|
+
table += `\n| \`${key}\` | ${description} | ${required} | ${defaultValue} |`;
|
|
155
|
+
} else {
|
|
156
|
+
table += `\n| \`${key}\` | ${description} |`;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return table;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Generate markdown section for a single action
|
|
165
|
+
*/
|
|
166
|
+
generateActionSection(action) {
|
|
167
|
+
const { directory, name, description, inputs, outputs } = action;
|
|
168
|
+
const usageExample = this.extractUsageExample(directory);
|
|
169
|
+
|
|
170
|
+
let section = `### [${name}](./${directory}/)\n\n`;
|
|
171
|
+
section += `${description}\n\n`;
|
|
172
|
+
|
|
173
|
+
// Add inputs table
|
|
174
|
+
section += `**Inputs:**\n\n${this.generateTable(inputs, 'inputs')}\n\n`;
|
|
175
|
+
|
|
176
|
+
// Add outputs table if there are outputs
|
|
177
|
+
if (outputs && Object.keys(outputs).length > 0) {
|
|
178
|
+
section += `**Outputs:**\n\n${this.generateTable(outputs, 'outputs')}\n\n`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Add usage example
|
|
182
|
+
if (usageExample) {
|
|
183
|
+
section += `**Usage:**\n\n\`\`\`yml\n${usageExample}\n\`\`\`\n\n`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return section;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Generate just the content for actions (without the section header)
|
|
191
|
+
*/
|
|
192
|
+
generateAvailableActionsContent() {
|
|
193
|
+
const actionDirs = this.findActionDirectories();
|
|
194
|
+
|
|
195
|
+
console.log(`Found ${actionDirs.length} action directories:`, actionDirs);
|
|
196
|
+
|
|
197
|
+
this.actions = actionDirs
|
|
198
|
+
.map(dir => this.parseActionFile(dir))
|
|
199
|
+
.filter(action => action !== null)
|
|
200
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
201
|
+
|
|
202
|
+
let content = '';
|
|
203
|
+
|
|
204
|
+
this.actions.forEach(action => {
|
|
205
|
+
content += this.generateActionSection(action);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return content.trim(); // Remove trailing newlines
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Update the main README.md file using file descriptors for security
|
|
213
|
+
*/
|
|
214
|
+
updateReadme() {
|
|
215
|
+
const readmePath = path.join(this.rootDir, 'README.md');
|
|
216
|
+
|
|
217
|
+
if (!fs.existsSync(readmePath)) {
|
|
218
|
+
console.error('README.md not found');
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let fd;
|
|
223
|
+
try {
|
|
224
|
+
// Open file descriptor for reading and writing
|
|
225
|
+
fd = fs.openSync(readmePath, 'r+');
|
|
226
|
+
|
|
227
|
+
// Read content using file descriptor
|
|
228
|
+
const stats = fs.fstatSync(fd);
|
|
229
|
+
const buffer = Buffer.alloc(stats.size);
|
|
230
|
+
fs.readSync(fd, buffer, 0, stats.size, 0);
|
|
231
|
+
let content = buffer.toString('utf8');
|
|
232
|
+
|
|
233
|
+
// Find the action docs markers
|
|
234
|
+
const startMarker = '<!-- start action docs -->';
|
|
235
|
+
const endMarker = '<!-- end action docs -->';
|
|
236
|
+
|
|
237
|
+
const startIndex = content.indexOf(startMarker);
|
|
238
|
+
const endIndex = content.indexOf(endMarker);
|
|
239
|
+
|
|
240
|
+
if (startIndex === -1) {
|
|
241
|
+
console.error(`Could not find "${startMarker}" in README.md`);
|
|
242
|
+
console.error('Please add the marker where you want action documentation to be generated');
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (endIndex === -1) {
|
|
247
|
+
console.error(`Could not find "${endMarker}" in README.md`);
|
|
248
|
+
console.error('Please add the end marker after the start marker');
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (endIndex <= startIndex) {
|
|
253
|
+
console.error('End marker must come after start marker');
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Replace content between markers
|
|
258
|
+
const beforeMarker = content.substring(0, startIndex + startMarker.length);
|
|
259
|
+
const afterMarker = content.substring(endIndex);
|
|
260
|
+
|
|
261
|
+
const newContent = this.generateAvailableActionsContent();
|
|
262
|
+
const updatedContent = beforeMarker + '\n' + newContent + '\n' + afterMarker;
|
|
263
|
+
|
|
264
|
+
// Truncate and write back using file descriptor
|
|
265
|
+
fs.ftruncateSync(fd, 0);
|
|
266
|
+
fs.writeSync(fd, updatedContent, 0, 'utf8');
|
|
267
|
+
|
|
268
|
+
console.log('✅ README.md updated successfully!');
|
|
269
|
+
console.log(`📝 Generated documentation for ${this.actions.length} actions`);
|
|
270
|
+
|
|
271
|
+
return true;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error('Error updating README.md:', error.message);
|
|
274
|
+
return false;
|
|
275
|
+
} finally {
|
|
276
|
+
// Always close the file descriptor
|
|
277
|
+
if (fd !== undefined) {
|
|
278
|
+
try {
|
|
279
|
+
fs.closeSync(fd);
|
|
280
|
+
} catch (closeError) {
|
|
281
|
+
console.error('Error closing README.md file descriptor:', closeError.message);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Update individual action README files with inputs/outputs using file descriptors for security
|
|
289
|
+
*/
|
|
290
|
+
updateActionReadmes() {
|
|
291
|
+
const actionDirs = this.findActionDirectories();
|
|
292
|
+
let updatedCount = 0;
|
|
293
|
+
|
|
294
|
+
actionDirs.forEach(dirName => {
|
|
295
|
+
const readmePath = path.join(this.rootDir, dirName, 'README.md');
|
|
296
|
+
|
|
297
|
+
if (!fs.existsSync(readmePath)) {
|
|
298
|
+
console.log(`⚠️ No README.md found in ${dirName}, skipping`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const actionData = this.parseActionFile(dirName);
|
|
303
|
+
if (!actionData) {
|
|
304
|
+
console.log(`⚠️ Could not parse action.yml for ${dirName}, skipping`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let fd;
|
|
309
|
+
try {
|
|
310
|
+
// Open file descriptor for reading and writing
|
|
311
|
+
fd = fs.openSync(readmePath, 'r+');
|
|
312
|
+
|
|
313
|
+
// Read content using file descriptor
|
|
314
|
+
const stats = fs.fstatSync(fd);
|
|
315
|
+
const buffer = Buffer.alloc(stats.size);
|
|
316
|
+
fs.readSync(fd, buffer, 0, stats.size, 0);
|
|
317
|
+
let content = buffer.toString('utf8');
|
|
318
|
+
let modified = false;
|
|
319
|
+
|
|
320
|
+
// Update inputs section
|
|
321
|
+
const inputsStartMarker = '<!-- start inputs -->';
|
|
322
|
+
const inputsEndMarker = '<!-- end inputs -->';
|
|
323
|
+
const inputsStart = content.indexOf(inputsStartMarker);
|
|
324
|
+
const inputsEnd = content.indexOf(inputsEndMarker);
|
|
325
|
+
|
|
326
|
+
if (inputsStart !== -1 && inputsEnd !== -1 && inputsEnd > inputsStart) {
|
|
327
|
+
const inputsTable = this.generateTable(actionData.inputs, 'inputs');
|
|
328
|
+
const beforeInputs = content.substring(0, inputsStart + inputsStartMarker.length);
|
|
329
|
+
const afterInputs = content.substring(inputsEnd);
|
|
330
|
+
content = beforeInputs + '\n\n' + inputsTable + '\n\n' + afterInputs;
|
|
331
|
+
modified = true;
|
|
332
|
+
console.log(`✅ Updated inputs section in ${dirName}/README.md`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Update outputs section
|
|
336
|
+
const outputsStartMarker = '<!-- start outputs -->';
|
|
337
|
+
const outputsEndMarker = '<!-- end outputs -->';
|
|
338
|
+
const outputsStart = content.indexOf(outputsStartMarker);
|
|
339
|
+
const outputsEnd = content.indexOf(outputsEndMarker);
|
|
340
|
+
|
|
341
|
+
if (outputsStart !== -1 && outputsEnd !== -1 && outputsEnd > outputsStart) {
|
|
342
|
+
const outputsTable = this.generateTable(actionData.outputs, 'outputs');
|
|
343
|
+
const beforeOutputs = content.substring(0, outputsStart + outputsStartMarker.length);
|
|
344
|
+
const afterOutputs = content.substring(outputsEnd);
|
|
345
|
+
content = beforeOutputs + '\n\n' + outputsTable + '\n\n' + afterOutputs;
|
|
346
|
+
modified = true;
|
|
347
|
+
console.log(`✅ Updated outputs section in ${dirName}/README.md`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (modified) {
|
|
351
|
+
// Truncate and write back using file descriptor
|
|
352
|
+
fs.ftruncateSync(fd, 0);
|
|
353
|
+
fs.writeSync(fd, content, 0, 'utf8');
|
|
354
|
+
updatedCount++;
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.error(`Error updating ${dirName}/README.md:`, error.message);
|
|
358
|
+
} finally {
|
|
359
|
+
// Always close the file descriptor
|
|
360
|
+
if (fd !== undefined) {
|
|
361
|
+
try {
|
|
362
|
+
fs.closeSync(fd);
|
|
363
|
+
} catch (closeError) {
|
|
364
|
+
console.error(`Error closing ${dirName}/README.md file descriptor:`, closeError.message);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return updatedCount;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Run prettier formatting on all documentation files
|
|
375
|
+
*/
|
|
376
|
+
async formatDocs() {
|
|
377
|
+
const { execSync } = await import('child_process');
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
console.log('\n🎨 Formatting documentation with prettier...');
|
|
381
|
+
execSync('pnpm format', {
|
|
382
|
+
stdio: 'inherit',
|
|
383
|
+
cwd: this.rootDir,
|
|
384
|
+
});
|
|
385
|
+
console.log('✅ Documentation formatting complete!');
|
|
386
|
+
return true;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error('❌ Prettier formatting failed:', error.message);
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Run the documentation generation
|
|
395
|
+
*/
|
|
396
|
+
async run() {
|
|
397
|
+
console.log('🔍 Scanning for GitHub Actions...');
|
|
398
|
+
|
|
399
|
+
// Update main README
|
|
400
|
+
const mainSuccess = this.updateReadme();
|
|
401
|
+
|
|
402
|
+
// Update individual action READMEs
|
|
403
|
+
console.log('\n🔍 Updating individual action README files...');
|
|
404
|
+
const updatedActionCount = this.updateActionReadmes();
|
|
405
|
+
|
|
406
|
+
if (mainSuccess) {
|
|
407
|
+
console.log(`\n📚 Documentation generation complete!`);
|
|
408
|
+
console.log(`📝 Updated main README.md with ${this.actions.length} actions`);
|
|
409
|
+
if (updatedActionCount > 0) {
|
|
410
|
+
console.log(`📝 Updated ${updatedActionCount} action README files`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Format the documentation
|
|
414
|
+
const formatSuccess = await this.formatDocs();
|
|
415
|
+
|
|
416
|
+
if (formatSuccess) {
|
|
417
|
+
console.log('\n🎉 All documentation updated and formatted successfully!');
|
|
418
|
+
console.log('Run `git diff` to see all changes.');
|
|
419
|
+
} else {
|
|
420
|
+
console.log('\n⚠️ Documentation updated but formatting failed.');
|
|
421
|
+
console.log('You may want to run `pnpm format` manually.');
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
console.error('\n❌ Main README documentation generation failed!');
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Run the generator
|
|
431
|
+
const generator = new DocumentationGenerator();
|
|
432
|
+
generator.run();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# comment
|
|
2
|
+
|
|
3
|
+
Creates or updates pull request comments with intelligent upsert functionality using unique tags.
|
|
4
|
+
|
|
5
|
+
<!-- DOCTOC SKIP -->
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
See [action.yml](action.yml).
|
|
10
|
+
|
|
11
|
+
```yml
|
|
12
|
+
- name: Comment on PR
|
|
13
|
+
uses: codfish/actions/comment@v3
|
|
14
|
+
with:
|
|
15
|
+
message: '✅ Build successful!'
|
|
16
|
+
tag: 'build-status'
|
|
17
|
+
upsert: true
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Inputs
|
|
21
|
+
|
|
22
|
+
<!-- start inputs -->
|
|
23
|
+
|
|
24
|
+
| Input | Description | Required | Default |
|
|
25
|
+
| --------- | ------------------------------------------------------------------------------------- | -------- | ------- |
|
|
26
|
+
| `message` | The comment message content (supports markdown formatting) | Yes | - |
|
|
27
|
+
| `tag` | Unique identifier to find and update existing comments (required when upsert is true) | No | - |
|
|
28
|
+
| `upsert` | Update existing comment with matching tag instead of creating new comment | No | `false` |
|
|
29
|
+
|
|
30
|
+
<!-- end inputs -->
|
|
31
|
+
|
|
32
|
+
## Examples
|
|
33
|
+
|
|
34
|
+
### Basic comment
|
|
35
|
+
|
|
36
|
+
```yml
|
|
37
|
+
- uses: codfish/actions/comment@v3
|
|
38
|
+
with:
|
|
39
|
+
message: 'Hello from GitHub Actions! 👋'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Updating comments with upsert
|
|
43
|
+
|
|
44
|
+
Use the `upsert` feature to update the same comment instead of creating multiple comments:
|
|
45
|
+
|
|
46
|
+
```yml
|
|
47
|
+
- name: Update build status
|
|
48
|
+
uses: codfish/actions/comment@v3
|
|
49
|
+
with:
|
|
50
|
+
message: |
|
|
51
|
+
## Build Status
|
|
52
|
+
⏳ Build in progress...
|
|
53
|
+
tag: 'build-status'
|
|
54
|
+
upsert: true
|
|
55
|
+
|
|
56
|
+
# Later in the workflow...
|
|
57
|
+
- name: Update build status
|
|
58
|
+
uses: codfish/actions/comment@v3
|
|
59
|
+
with:
|
|
60
|
+
message: |
|
|
61
|
+
## Build Status
|
|
62
|
+
✅ Build completed successfully!
|
|
63
|
+
tag: 'build-status'
|
|
64
|
+
upsert: true
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Multi-line markdown comment
|
|
68
|
+
|
|
69
|
+
```yml
|
|
70
|
+
- uses: codfish/actions/comment@v3
|
|
71
|
+
with:
|
|
72
|
+
message: |
|
|
73
|
+
## 📊 Test Results
|
|
74
|
+
|
|
75
|
+
- ✅ Unit tests: 42 passed
|
|
76
|
+
- ✅ Integration tests: 12 passed
|
|
77
|
+
- 📦 Coverage: 98%
|
|
78
|
+
|
|
79
|
+
Great work! 🎉
|
|
80
|
+
tag: 'test-results'
|
|
81
|
+
upsert: true
|
|
82
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
name: comment
|
|
2
|
+
|
|
3
|
+
description: Creates or updates a comment in a pull request with optional tagging for upsert functionality
|
|
4
|
+
|
|
5
|
+
inputs:
|
|
6
|
+
message:
|
|
7
|
+
description: The comment message content (supports markdown formatting)
|
|
8
|
+
required: true
|
|
9
|
+
tag:
|
|
10
|
+
description: Unique identifier to find and update existing comments (required when upsert is true)
|
|
11
|
+
required: false
|
|
12
|
+
upsert:
|
|
13
|
+
description: Update existing comment with matching tag instead of creating new comment
|
|
14
|
+
required: false
|
|
15
|
+
default: 'false'
|
|
16
|
+
|
|
17
|
+
runs:
|
|
18
|
+
using: composite
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- name: Validate inputs and set globals
|
|
22
|
+
id: globals
|
|
23
|
+
shell: bash
|
|
24
|
+
run: |
|
|
25
|
+
# Validate required inputs
|
|
26
|
+
if [ -z "${{ inputs.message }}" ]; then
|
|
27
|
+
echo "❌ ERROR: 'message' input is required"
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Validate upsert logic
|
|
32
|
+
if [ "${{ inputs.upsert }}" = "true" ] && [ -z "${{ inputs.tag }}" ]; then
|
|
33
|
+
echo "❌ ERROR: 'tag' input is required when upsert is true"
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Create dynamic tag based on repository name
|
|
38
|
+
repo_namespace="${{ github.repository }}"
|
|
39
|
+
tag="<!-- ${repo_namespace}/comment ${{ inputs.tag }} -->"
|
|
40
|
+
body=$(printf '${{ inputs.message }}')
|
|
41
|
+
|
|
42
|
+
echo "💬 Comment tag: $tag"
|
|
43
|
+
echo "tag=$tag" >> $GITHUB_OUTPUT
|
|
44
|
+
echo "body<<EOF"$'\n'"$body"'\n'"$tag"$'\n'EOF >> "$GITHUB_OUTPUT"
|
|
45
|
+
|
|
46
|
+
- name: Check existing comments
|
|
47
|
+
id: check-comments
|
|
48
|
+
if: inputs.upsert == 'true'
|
|
49
|
+
uses: actions/github-script@v8
|
|
50
|
+
with:
|
|
51
|
+
script: |
|
|
52
|
+
try {
|
|
53
|
+
const comments = await github.rest.issues.listComments({
|
|
54
|
+
owner: context.repo.owner,
|
|
55
|
+
repo: context.repo.repo,
|
|
56
|
+
issue_number: context.issue.number,
|
|
57
|
+
});
|
|
58
|
+
const existingComment = comments.data.find(comment => comment.body.includes('${{ steps.globals.outputs.tag }}'));
|
|
59
|
+
core.setOutput('comment-id', existingComment ? existingComment.id : null);
|
|
60
|
+
|
|
61
|
+
if (existingComment) {
|
|
62
|
+
console.log(`Found existing comment with ID: ${existingComment.id}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.log('No existing comment found, will create new one');
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
core.setFailed(`Failed to check existing comments: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
- name: Update existing comment
|
|
71
|
+
if: steps.check-comments.outputs.comment-id != null
|
|
72
|
+
uses: actions/github-script@v8
|
|
73
|
+
with:
|
|
74
|
+
script: |
|
|
75
|
+
try {
|
|
76
|
+
await github.rest.issues.updateComment({
|
|
77
|
+
owner: context.repo.owner,
|
|
78
|
+
repo: context.repo.repo,
|
|
79
|
+
comment_id: ${{ steps.check-comments.outputs.comment-id }},
|
|
80
|
+
body: `${{ steps.globals.outputs.body }}`,
|
|
81
|
+
});
|
|
82
|
+
console.log('✅ Successfully updated existing comment');
|
|
83
|
+
} catch (error) {
|
|
84
|
+
core.setFailed(`Failed to update comment: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
- name: Create new comment
|
|
88
|
+
if: steps.check-comments.outputs.comment-id == null
|
|
89
|
+
uses: actions/github-script@v8
|
|
90
|
+
with:
|
|
91
|
+
script: |
|
|
92
|
+
try {
|
|
93
|
+
const response = await github.rest.issues.createComment({
|
|
94
|
+
owner: context.repo.owner,
|
|
95
|
+
repo: context.repo.repo,
|
|
96
|
+
issue_number: context.issue.number,
|
|
97
|
+
body: `${{ steps.globals.outputs.body }}`,
|
|
98
|
+
});
|
|
99
|
+
console.log(`✅ Successfully created new comment with ID: ${response.data.id}`);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
core.setFailed(`Failed to create comment: ${error.message}`);
|
|
102
|
+
}
|