Skip to content

Commit 35d73e0

Browse files
authored
Nightly report: post details as follow-up comments instead of truncating (#1239)
When the full report exceeds GitHub's 65K body limit, the summary table stays in the discussion/issue body and the verbose skill/agent output is posted as follow-up comments (split into chunks if needed). This ensures no output is lost.
1 parent 74208f6 commit 35d73e0

1 file changed

Lines changed: 112 additions & 82 deletions

File tree

.github/workflows/skill-quality-report.yml

Lines changed: 112 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -206,93 +206,95 @@ jobs:
206206
207207
const title = `Skill Quality Report — ${today}`;
208208
209-
const body = [
210-
`# ${title}`,
211-
'',
212-
`**${skillDirs.length} skills** and **${agentFiles.length} agents** scanned.`,
213-
'',
214-
`| Severity | Count |`,
215-
`|----------|-------|`,
209+
const annotatedSkills = annotateWithAuthors(skillsOutput, 'skill');
210+
const annotatedAgents = annotateWithAuthors(agentsOutput, 'agent');
211+
212+
// ── Body size management ──────────────────────────────
213+
// GitHub body limit is ~65536 UTF-8 bytes for both
214+
// Discussions and Issues. When the full report fits, we
215+
// inline everything. When it doesn't, the body gets a
216+
// compact summary and the verbose sections are written to
217+
// separate files that get posted as follow-up comments.
218+
const MAX_BYTES = 65000; // leave margin
219+
220+
function makeDetailsBlock(heading, summary, content) {
221+
return [
222+
`## ${heading}`, '',
223+
'<details>',
224+
`<summary>${summary}</summary>`, '',
225+
'```', content, '```', '',
226+
'</details>',
227+
].join('\n');
228+
}
229+
230+
const summaryLines = [
231+
`# ${title}`, '',
232+
`**${skillDirs.length} skills** and **${agentFiles.length} agents** scanned.`, '',
233+
'| Severity | Count |',
234+
'|----------|-------|',
216235
`| ⛔ Errors | ${errorCount} |`,
217236
`| ⚠️ Warnings | ${warningCount} |`,
218-
`| ℹ️ Advisories | ${advisoryCount} |`,
219-
'',
220-
'---',
221-
'',
222-
'## Skills',
223-
'',
224-
'<details>',
225-
'<summary>Full skill-validator output for skills</summary>',
226-
'',
227-
'```',
228-
annotateWithAuthors(skillsOutput, 'skill'),
229-
'```',
230-
'',
231-
'</details>',
232-
'',
233-
'## Agents',
234-
'',
235-
'<details>',
236-
'<summary>Full skill-validator output for agents</summary>',
237-
'',
238-
'```',
239-
annotateWithAuthors(agentsOutput, 'agent'),
240-
'```',
241-
'',
242-
'</details>',
243-
'',
237+
`| ℹ️ Advisories | ${advisoryCount} |`, '',
244238
'---',
245-
'',
246-
`_Generated by the [Skill Validator nightly scan](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/skill-quality-report.yml)._`,
247-
].join('\n');
248-
249-
core.setOutput('title', title);
250-
core.setOutput('body_file', 'report-body.md');
251-
252-
// GitHub Issues/Discussions enforce a body size limit on the
253-
// UTF-8 payload (~65536 bytes). Use byte-based limits and prefer
254-
// shrinking verbose <details> sections to keep markdown valid.
255-
const MAX_BODY_BYTES = 65000; // leave some margin
256-
257-
function shrinkDetailsSections(markdown) {
258-
return markdown.replace(
259-
/<details([\s\S]*?)>[\s\S]*?<\/details>/g,
260-
(match, attrs) => {
261-
const placeholder = '\n<summary>Details truncated</summary>\n\n' +
262-
"> Full output was truncated to fit GitHub's body size limit. " +
263-
'See the workflow run for complete output.\n';
264-
return `<details${attrs}>${placeholder}</details>`;
239+
];
240+
const footer = `\n---\n\n_Generated by the [Skill Validator nightly scan](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/skill-quality-report.yml)._`;
241+
242+
const skillsBlock = makeDetailsBlock('Skills', 'Full skill-validator output for skills', annotatedSkills);
243+
const agentsBlock = makeDetailsBlock('Agents', 'Full skill-validator output for agents', annotatedAgents);
244+
245+
// Try full inline body first
246+
const fullBody = summaryLines.join('\n') + '\n\n' + skillsBlock + '\n\n' + agentsBlock + footer;
247+
248+
const commentParts = []; // overflow comment files
249+
250+
let finalBody;
251+
if (Buffer.byteLength(fullBody, 'utf8') <= MAX_BYTES) {
252+
finalBody = fullBody;
253+
} else {
254+
// Details won't fit inline — move them to follow-up comments
255+
const bodyNote = '\n\n> **Note:** Detailed output is posted in the comments below (too large for the discussion body).\n';
256+
finalBody = summaryLines.join('\n') + bodyNote + footer;
257+
258+
// Split each section into ≤65 KB chunks
259+
function chunkContent(label, content) {
260+
const prefix = `## ${label}\n\n\`\`\`\n`;
261+
const suffix = '\n```';
262+
const overhead = Buffer.byteLength(prefix + suffix, 'utf8');
263+
const budget = MAX_BYTES - overhead;
264+
265+
const buf = Buffer.from(content, 'utf8');
266+
if (buf.length <= budget) {
267+
return [prefix + content + suffix];
265268
}
266-
);
267-
}
269+
const parts = [];
270+
let offset = 0;
271+
let partNum = 1;
272+
while (offset < buf.length) {
273+
const slice = buf.slice(offset, offset + budget).toString('utf8');
274+
// Remove trailing replacement char from mid-codepoint cut
275+
const clean = slice.replace(/\uFFFD$/, '');
276+
const hdr = `## ${label} (part ${partNum})\n\n\`\`\`\n`;
277+
parts.push(hdr + clean + suffix);
278+
offset += Buffer.byteLength(clean, 'utf8');
279+
partNum++;
280+
}
281+
return parts;
282+
}
268283
269-
function trimToByteLimit(str, maxBytes) {
270-
const buf = Buffer.from(str, 'utf8');
271-
if (buf.length <= maxBytes) return str;
272-
// Slice bytes and decode, which safely handles multi-byte chars
273-
return buf.slice(0, maxBytes).toString('utf8').replace(/\uFFFD$/, '');
284+
commentParts.push(...chunkContent('Skills', annotatedSkills));
285+
commentParts.push(...chunkContent('Agents', annotatedAgents));
274286
}
275287
276-
const truncNote = '\n\n> **Note:** Output was truncated to fit GitHub\'s body size limit. See the [workflow run](https://github.com/' + context.repo.owner + '/' + context.repo.repo + '/actions/workflows/skill-quality-report.yml) for full output.\n';
277-
const truncNoteBytes = Buffer.byteLength(truncNote, 'utf8');
278-
279-
let finalBody = body;
280-
281-
if (Buffer.byteLength(finalBody, 'utf8') > MAX_BODY_BYTES) {
282-
// First try: collapse <details> sections to reduce size
283-
finalBody = shrinkDetailsSections(finalBody);
284-
}
288+
core.setOutput('title', title);
289+
core.setOutput('body_file', 'report-body.md');
285290
286-
if (Buffer.byteLength(finalBody, 'utf8') > MAX_BODY_BYTES) {
287-
// Last resort: hard byte-trim + truncation note
288-
finalBody = trimToByteLimit(finalBody, MAX_BODY_BYTES - truncNoteBytes);
289-
}
291+
fs.writeFileSync('report-body.md', finalBody);
290292
291-
if (Buffer.byteLength(finalBody, 'utf8') < Buffer.byteLength(body, 'utf8')) {
292-
finalBody += truncNote;
293+
// Write overflow comment parts as numbered files
294+
for (let i = 0; i < commentParts.length; i++) {
295+
fs.writeFileSync(`report-comment-${i}.md`, commentParts[i]);
293296
}
294-
295-
fs.writeFileSync('report-body.md', finalBody);
297+
core.setOutput('comment_count', String(commentParts.length));
296298
297299
# ── Create Discussion (preferred) or Issue (fallback) ────────
298300
- name: Create Discussion
@@ -304,6 +306,7 @@ jobs:
304306
const fs = require('fs');
305307
const title = '${{ steps.report.outputs.title }}'.replace(/'/g, "\\'");
306308
const body = fs.readFileSync('report-body.md', 'utf8');
309+
const commentCount = parseInt('${{ steps.report.outputs.comment_count }}' || '0', 10);
307310
308311
// Find the "Skill Quality Reports" category
309312
const categoriesResult = await github.graphql(`
@@ -331,15 +334,15 @@ jobs:
331334
return;
332335
}
333336
334-
await github.graphql(`
337+
const result = await github.graphql(`
335338
mutation($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
336339
createDiscussion(input: {
337340
repositoryId: $repoId,
338341
categoryId: $categoryId,
339342
title: $title,
340343
body: $body
341344
}) {
342-
discussion { url }
345+
discussion { id url }
343346
}
344347
}
345348
`, {
@@ -349,7 +352,24 @@ jobs:
349352
body: body,
350353
});
351354
352-
console.log('Discussion created successfully.');
355+
const discussionId = result.createDiscussion.discussion.id;
356+
console.log(`Discussion created: ${result.createDiscussion.discussion.url}`);
357+
358+
// Post overflow detail comments
359+
for (let i = 0; i < commentCount; i++) {
360+
const commentBody = fs.readFileSync(`report-comment-${i}.md`, 'utf8');
361+
await github.graphql(`
362+
mutation($discussionId: ID!, $body: String!) {
363+
addDiscussionComment(input: {
364+
discussionId: $discussionId,
365+
body: $body
366+
}) {
367+
comment { id }
368+
}
369+
}
370+
`, { discussionId, body: commentBody });
371+
console.log(`Posted detail comment ${i + 1}/${commentCount}`);
372+
}
353373
354374
- name: Fallback — Create Issue
355375
if: steps.create-discussion.outcome == 'failure'
@@ -358,7 +378,17 @@ jobs:
358378
run: |
359379
# Create label if it doesn't exist (ignore errors if it already exists)
360380
gh label create "skill-quality" --description "Automated skill quality reports" --color "d4c5f9" 2>/dev/null || true
361-
gh issue create \
381+
ISSUE_URL=$(gh issue create \
362382
--title "${{ steps.report.outputs.title }}" \
363383
--body-file report-body.md \
364-
--label "skill-quality"
384+
--label "skill-quality")
385+
echo "Created issue: $ISSUE_URL"
386+
387+
# Post overflow detail comments on the issue
388+
COMMENT_COUNT=${{ steps.report.outputs.comment_count }}
389+
for i in $(seq 0 $(( ${COMMENT_COUNT:-0} - 1 ))); do
390+
if [ -f "report-comment-${i}.md" ]; then
391+
gh issue comment "$ISSUE_URL" --body-file "report-comment-${i}.md"
392+
echo "Posted detail comment $((i+1))/${COMMENT_COUNT}"
393+
fi
394+
done

0 commit comments

Comments
 (0)