From e49675e83740b2a1ddf65fb377045b8f228289e9 Mon Sep 17 00:00:00 2001 From: Jan Krivanek Date: Tue, 31 Mar 2026 07:54:29 +0200 Subject: [PATCH] Nightly report: post details as follow-up comments instead of truncating 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. --- .github/workflows/skill-quality-report.yml | 194 ++++++++++++--------- 1 file changed, 112 insertions(+), 82 deletions(-) diff --git a/.github/workflows/skill-quality-report.yml b/.github/workflows/skill-quality-report.yml index ad3cc927b..61889ff33 100644 --- a/.github/workflows/skill-quality-report.yml +++ b/.github/workflows/skill-quality-report.yml @@ -206,93 +206,95 @@ jobs: const title = `Skill Quality Report — ${today}`; - const body = [ - `# ${title}`, - '', - `**${skillDirs.length} skills** and **${agentFiles.length} agents** scanned.`, - '', - `| Severity | Count |`, - `|----------|-------|`, + const annotatedSkills = annotateWithAuthors(skillsOutput, 'skill'); + const annotatedAgents = annotateWithAuthors(agentsOutput, 'agent'); + + // ── Body size management ────────────────────────────── + // GitHub body limit is ~65536 UTF-8 bytes for both + // Discussions and Issues. When the full report fits, we + // inline everything. When it doesn't, the body gets a + // compact summary and the verbose sections are written to + // separate files that get posted as follow-up comments. + const MAX_BYTES = 65000; // leave margin + + function makeDetailsBlock(heading, summary, content) { + return [ + `## ${heading}`, '', + '
', + `${summary}`, '', + '```', content, '```', '', + '
', + ].join('\n'); + } + + const summaryLines = [ + `# ${title}`, '', + `**${skillDirs.length} skills** and **${agentFiles.length} agents** scanned.`, '', + '| Severity | Count |', + '|----------|-------|', `| ⛔ Errors | ${errorCount} |`, `| ⚠️ Warnings | ${warningCount} |`, - `| ℹ️ Advisories | ${advisoryCount} |`, - '', - '---', - '', - '## Skills', - '', - '
', - 'Full skill-validator output for skills', - '', - '```', - annotateWithAuthors(skillsOutput, 'skill'), - '```', - '', - '
', - '', - '## Agents', - '', - '
', - 'Full skill-validator output for agents', - '', - '```', - annotateWithAuthors(agentsOutput, 'agent'), - '```', - '', - '
', - '', + `| ℹ️ Advisories | ${advisoryCount} |`, '', '---', - '', - `_Generated by the [Skill Validator nightly scan](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/skill-quality-report.yml)._`, - ].join('\n'); - - core.setOutput('title', title); - core.setOutput('body_file', 'report-body.md'); - - // GitHub Issues/Discussions enforce a body size limit on the - // UTF-8 payload (~65536 bytes). Use byte-based limits and prefer - // shrinking verbose
sections to keep markdown valid. - const MAX_BODY_BYTES = 65000; // leave some margin - - function shrinkDetailsSections(markdown) { - return markdown.replace( - /[\s\S]*?<\/details>/g, - (match, attrs) => { - const placeholder = '\nDetails truncated\n\n' + - "> Full output was truncated to fit GitHub's body size limit. " + - 'See the workflow run for complete output.\n'; - return `${placeholder}
`; + ]; + 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)._`; + + const skillsBlock = makeDetailsBlock('Skills', 'Full skill-validator output for skills', annotatedSkills); + const agentsBlock = makeDetailsBlock('Agents', 'Full skill-validator output for agents', annotatedAgents); + + // Try full inline body first + const fullBody = summaryLines.join('\n') + '\n\n' + skillsBlock + '\n\n' + agentsBlock + footer; + + const commentParts = []; // overflow comment files + + let finalBody; + if (Buffer.byteLength(fullBody, 'utf8') <= MAX_BYTES) { + finalBody = fullBody; + } else { + // Details won't fit inline — move them to follow-up comments + const bodyNote = '\n\n> **Note:** Detailed output is posted in the comments below (too large for the discussion body).\n'; + finalBody = summaryLines.join('\n') + bodyNote + footer; + + // Split each section into ≤65 KB chunks + function chunkContent(label, content) { + const prefix = `## ${label}\n\n\`\`\`\n`; + const suffix = '\n```'; + const overhead = Buffer.byteLength(prefix + suffix, 'utf8'); + const budget = MAX_BYTES - overhead; + + const buf = Buffer.from(content, 'utf8'); + if (buf.length <= budget) { + return [prefix + content + suffix]; } - ); - } + const parts = []; + let offset = 0; + let partNum = 1; + while (offset < buf.length) { + const slice = buf.slice(offset, offset + budget).toString('utf8'); + // Remove trailing replacement char from mid-codepoint cut + const clean = slice.replace(/\uFFFD$/, ''); + const hdr = `## ${label} (part ${partNum})\n\n\`\`\`\n`; + parts.push(hdr + clean + suffix); + offset += Buffer.byteLength(clean, 'utf8'); + partNum++; + } + return parts; + } - function trimToByteLimit(str, maxBytes) { - const buf = Buffer.from(str, 'utf8'); - if (buf.length <= maxBytes) return str; - // Slice bytes and decode, which safely handles multi-byte chars - return buf.slice(0, maxBytes).toString('utf8').replace(/\uFFFD$/, ''); + commentParts.push(...chunkContent('Skills', annotatedSkills)); + commentParts.push(...chunkContent('Agents', annotatedAgents)); } - 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'; - const truncNoteBytes = Buffer.byteLength(truncNote, 'utf8'); - - let finalBody = body; - - if (Buffer.byteLength(finalBody, 'utf8') > MAX_BODY_BYTES) { - // First try: collapse
sections to reduce size - finalBody = shrinkDetailsSections(finalBody); - } + core.setOutput('title', title); + core.setOutput('body_file', 'report-body.md'); - if (Buffer.byteLength(finalBody, 'utf8') > MAX_BODY_BYTES) { - // Last resort: hard byte-trim + truncation note - finalBody = trimToByteLimit(finalBody, MAX_BODY_BYTES - truncNoteBytes); - } + fs.writeFileSync('report-body.md', finalBody); - if (Buffer.byteLength(finalBody, 'utf8') < Buffer.byteLength(body, 'utf8')) { - finalBody += truncNote; + // Write overflow comment parts as numbered files + for (let i = 0; i < commentParts.length; i++) { + fs.writeFileSync(`report-comment-${i}.md`, commentParts[i]); } - - fs.writeFileSync('report-body.md', finalBody); + core.setOutput('comment_count', String(commentParts.length)); # ── Create Discussion (preferred) or Issue (fallback) ──────── - name: Create Discussion @@ -304,6 +306,7 @@ jobs: const fs = require('fs'); const title = '${{ steps.report.outputs.title }}'.replace(/'/g, "\\'"); const body = fs.readFileSync('report-body.md', 'utf8'); + const commentCount = parseInt('${{ steps.report.outputs.comment_count }}' || '0', 10); // Find the "Skill Quality Reports" category const categoriesResult = await github.graphql(` @@ -331,7 +334,7 @@ jobs: return; } - await github.graphql(` + const result = await github.graphql(` mutation($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) { createDiscussion(input: { repositoryId: $repoId, @@ -339,7 +342,7 @@ jobs: title: $title, body: $body }) { - discussion { url } + discussion { id url } } } `, { @@ -349,7 +352,24 @@ jobs: body: body, }); - console.log('Discussion created successfully.'); + const discussionId = result.createDiscussion.discussion.id; + console.log(`Discussion created: ${result.createDiscussion.discussion.url}`); + + // Post overflow detail comments + for (let i = 0; i < commentCount; i++) { + const commentBody = fs.readFileSync(`report-comment-${i}.md`, 'utf8'); + await github.graphql(` + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { + discussionId: $discussionId, + body: $body + }) { + comment { id } + } + } + `, { discussionId, body: commentBody }); + console.log(`Posted detail comment ${i + 1}/${commentCount}`); + } - name: Fallback — Create Issue if: steps.create-discussion.outcome == 'failure' @@ -358,7 +378,17 @@ jobs: run: | # Create label if it doesn't exist (ignore errors if it already exists) gh label create "skill-quality" --description "Automated skill quality reports" --color "d4c5f9" 2>/dev/null || true - gh issue create \ + ISSUE_URL=$(gh issue create \ --title "${{ steps.report.outputs.title }}" \ --body-file report-body.md \ - --label "skill-quality" + --label "skill-quality") + echo "Created issue: $ISSUE_URL" + + # Post overflow detail comments on the issue + COMMENT_COUNT=${{ steps.report.outputs.comment_count }} + for i in $(seq 0 $(( ${COMMENT_COUNT:-0} - 1 ))); do + if [ -f "report-comment-${i}.md" ]; then + gh issue comment "$ISSUE_URL" --body-file "report-comment-${i}.md" + echo "Posted detail comment $((i+1))/${COMMENT_COUNT}" + fi + done