🐛 Screenshots as compressed comments + GHA processing#4342
Conversation
The previous base64-in-issue-body approach hit GitHub's 65K character body limit. Screenshots are now: 1. Compressed on the frontend (JPEG, max 1024px, quality 60%) to ~30KB 2. Added as separate issue comments (one per screenshot) — not in the body 3. Processed by GHA workflow (process-screenshots.yml) which triggers on issue_comment, decodes base64, commits image, replaces comment with rendered  This avoids both the body size limit and the push access requirement. Also updated: - Token permissions: Contents scope no longer needed (only Issues R/W) - github-token.ts constants updated to reflect reduced requirements - Frontend messages reflect the new async processing flow Signed-off-by: Andrew Anderson <andy@clubanderson.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
✅ Deploy Preview for kubestellarconsole ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
👋 Hey @clubanderson — thanks for opening this PR!
This is an automated message. |
|
Thank you for your contribution! Your PR has been merged. Check out what's new:
Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey |
There was a problem hiding this comment.
Pull request overview
Addresses GitHub’s 65K issue body limit by moving screenshot payloads out of the issue body and into compressed, per-screenshot issue comments that a GitHub Actions workflow later converts into committed images.
Changes:
- Add client-side screenshot compression (JPEG + resize) before submitting feedback.
- Backend now creates issues with a small body and posts each screenshot as a separate issue comment with a marker for automation.
- Update GitHub Actions workflow to trigger on
issue_comment.createdand replace the base64 comment with a rendered image after committing it to the repo.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
web/src/lib/imageCompression.ts |
Adds client-side compression to keep base64 under GitHub limits. |
web/src/lib/constants/github-token.ts |
Updates fine-grained PAT permission guidance (removes Contents scope requirement for users). |
web/src/components/feedback/FeedbackModal.tsx |
Uses compressScreenshot() prior to submitting screenshots. |
web/src/components/feedback/FeatureRequestModal.tsx |
Uses compressScreenshot() prior to submitting screenshots. |
pkg/api/handlers/feedback.go |
Moves screenshot payloads from issue body into per-screenshot issue comments. |
.github/workflows/process-screenshots.yml |
Processes screenshot marker comments, commits decoded images, and updates the comment to a markdown image link. |
Comments suppressed due to low confidence (1)
web/src/components/feedback/FeedbackModal.tsx:182
- The inline comments still mention fitting within the issue body limit and that screenshots are “uploaded server-side and embedded as images”, but the new design is “base64 in issue comments processed by GHA”. Please update these comments to reflect the new comment-based pipeline to avoid misleading future edits.
// Compress screenshots to fit within GitHub's 65K issue body limit.
// Images are embedded as base64 and processed into rendered images
// by a GitHub Actions workflow after the issue is created.
const screenshotDataURIs: string[] = []
for (const s of screenshots) {
const compressed = await compressScreenshot(s.preview)
if (compressed) screenshotDataURIs.push(compressed)
}
// Submit via backend API — creates GitHub issue directly using the
// server-side token. No GitHub login required from the user.
// Screenshots are uploaded server-side and embedded as images.
const hasScreenshots = screenshotDataURIs.length > 0
| * Compress an image data URI to fit within GitHub's issue body limit. | ||
| * | ||
| * Screenshots are embedded as base64 in issue bodies and processed into | ||
| * rendered images by a GitHub Actions workflow. GitHub limits issue bodies | ||
| * to 65,536 characters, so we compress images to JPEG and resize if needed. | ||
| * | ||
| * Target: ~30KB per screenshot (~40K base64 chars), leaving room for | ||
| * the issue template and multiple screenshots. |
There was a problem hiding this comment.
The module docstring still describes screenshots being embedded in issue bodies, but this PR’s approach embeds base64 in issue comments (and GitHub has similar ~65K limits on both bodies and comments). Updating the top-level comment to match the new flow will prevent confusion for future maintainers.
| * Compress an image data URI to fit within GitHub's issue body limit. | |
| * | |
| * Screenshots are embedded as base64 in issue bodies and processed into | |
| * rendered images by a GitHub Actions workflow. GitHub limits issue bodies | |
| * to 65,536 characters, so we compress images to JPEG and resize if needed. | |
| * | |
| * Target: ~30KB per screenshot (~40K base64 chars), leaving room for | |
| * the issue template and multiple screenshots. | |
| * Compress an image data URI to fit within GitHub's issue comment limit. | |
| * | |
| * Screenshots are embedded as base64 in issue comments and processed into | |
| * rendered images by a GitHub Actions workflow. GitHub limits issue comments | |
| * to about 65,536 characters, so we compress images to JPEG and resize if needed. | |
| * | |
| * Target: ~30KB per screenshot (~40K base64 chars), leaving room for | |
| * other comment content and multiple screenshots. |
| for (const s of screenshots) { | ||
| const compressed = await compressScreenshot(s.preview) | ||
| if (compressed) screenshotDataURIs.push(compressed) | ||
| } | ||
|
|
There was a problem hiding this comment.
Screenshots that fail client-side compression are silently dropped (compressScreenshot returns null and the screenshot is not included), but the UI still shows the screenshot thumbnails and no warning is displayed. Consider tracking a client-side failed count and surfacing a message (or blocking submit) when any screenshot cannot be compressed so users know it won’t be attached.
| for (const s of screenshots) { | |
| const compressed = await compressScreenshot(s.preview) | |
| if (compressed) screenshotDataURIs.push(compressed) | |
| } | |
| let failedCompressionCount = 0 | |
| for (const s of screenshots) { | |
| const compressed = await compressScreenshot(s.preview) | |
| if (compressed) { | |
| screenshotDataURIs.push(compressed) | |
| } else { | |
| failedCompressionCount += 1 | |
| } | |
| } | |
| if (failedCompressionCount > 0) { | |
| const message = failedCompressionCount === 1 | |
| ? '1 screenshot could not be compressed and will not be attached. Please remove it or try again.' | |
| : `${failedCompressionCount} screenshots could not be compressed and will not be attached. Please remove them or try again.` | |
| emitScreenshotUploadFailed(message, failedCompressionCount) | |
| setSubmitError(message) | |
| showToast(message, 'error') | |
| return | |
| } |
| for (const s of screenshots) { | ||
| const compressed = await compressScreenshot(s.preview) | ||
| if (compressed) screenshotDataURIs.push(compressed) | ||
| } | ||
|
|
There was a problem hiding this comment.
Screenshots that fail client-side compression are currently omitted without any user-visible warning (compressScreenshot can return null, and those screenshots are just not sent). Consider counting compression failures and showing a warning (or preventing submit) so users don’t assume all selected screenshots were attached.
| for (const s of screenshots) { | |
| const compressed = await compressScreenshot(s.preview) | |
| if (compressed) screenshotDataURIs.push(compressed) | |
| } | |
| let screenshotCompressionFailures = 0 | |
| for (const s of screenshots) { | |
| const compressed = await compressScreenshot(s.preview) | |
| if (compressed) { | |
| screenshotDataURIs.push(compressed) | |
| } else { | |
| screenshotCompressionFailures += 1 | |
| } | |
| } | |
| if (screenshotCompressionFailures > 0) { | |
| setError( | |
| `Failed to process ${screenshotCompressionFailures} screenshot${screenshotCompressionFailures === 1 ? '' : 's'}. Please remove or re-add the affected screenshot${screenshotCompressionFailures === 1 ? '' : 's'} and try again.` | |
| ) | |
| return | |
| } |
| // Validate screenshots upfront so we can report accurate counts. | ||
| // Screenshots are NOT embedded in the issue body (GitHub limits bodies to | ||
| // 65,536 chars and base64 screenshots easily exceed that). Instead, they | ||
| // are added as separate comments after issue creation. A GitHub Actions | ||
| // workflow (process-screenshots.yml) then decodes the base64, commits | ||
| // images to the repo, and replaces the comment with a rendered image. | ||
| var validScreenshots []string | ||
| var ssResult screenshotUploadResult | ||
| if len(screenshots) > 0 { | ||
| var blocks []string | ||
| for i, dataURI := range screenshots { | ||
| // Validate the data URI format | ||
| parts := strings.SplitN(dataURI, ",", 2) | ||
| if len(parts) != 2 { | ||
| ssResult.Failed++ | ||
| log.Printf("[Feedback] Screenshot %d: invalid data URI format", i+1) | ||
| continue | ||
| } | ||
| ssResult.Uploaded++ | ||
| // Wrap in a collapsible <details> block with a machine-readable marker | ||
| // that the GHA workflow can find and process. | ||
| blocks = append(blocks, fmt.Sprintf( | ||
| "<!-- screenshot-base64:%d -->\n<details>\n<summary>Screenshot %d (processing...)</summary>\n\n```\n%s\n```\n\n</details>", | ||
| i+1, i+1, dataURI)) | ||
| } | ||
| if len(blocks) > 0 { | ||
| screenshotMarkdown = "\n\n## Screenshots\n\n" + strings.Join(blocks, "\n\n") | ||
| for i, dataURI := range screenshots { | ||
| parts := strings.SplitN(dataURI, ",", 2) | ||
| if len(parts) != 2 { | ||
| ssResult.Failed++ | ||
| log.Printf("[Feedback] Screenshot %d: invalid data URI format", i+1) | ||
| continue | ||
| } | ||
| validScreenshots = append(validScreenshots, dataURI) | ||
| ssResult.Uploaded++ | ||
| } |
There was a problem hiding this comment.
screenshotUploadResult is now incremented based only on data-URI parsing, not on whether the screenshot comment was actually created successfully. Since addIssueComment only logs failures and returns void, the API can report screenshots_uploaded even when GitHub rejects the comment (permissions/size/rate-limit). Consider making addIssueComment return an error (or status code) and update ssResult.Uploaded/Failed based on the POST result so the frontend message stays accurate.
| // Add screenshots as separate comments (one per screenshot) so they | ||
| // don't blow up the 65K issue body limit. Each comment contains a | ||
| // base64 data URI in a collapsible <details> block with a marker | ||
| // that the process-screenshots GHA workflow can find and process. | ||
| if err == nil && len(validScreenshots) > 0 { | ||
| for i, dataURI := range validScreenshots { | ||
| commentBody := fmt.Sprintf( | ||
| "<!-- screenshot-base64:%d -->\n<details>\n<summary>Screenshot %d (processing...)</summary>\n\n```\n%s\n```\n\n</details>", | ||
| i+1, i+1, dataURI) | ||
| h.addIssueComment(number, commentBody, repoName) | ||
| } | ||
| log.Printf("[Feedback] Added %d screenshot comment(s) to issue #%d", len(validScreenshots), number) |
There was a problem hiding this comment.
Screenshot comments are posted synchronously and any failures are ignored (addIssueComment doesn’t return an error, and its result isn’t checked). This can both slow down the request (multiple GitHub API calls) and cause “success” responses even when no screenshot comments were created. Consider (1) capturing failures and reflecting them in ssResult, and (2) deciding whether comment posting should be async (e.g., goroutines with bounded concurrency) or must be best-effort but explicitly reflected back to the caller.
| on: | ||
| issues: | ||
| types: [opened] | ||
| issue_comment: | ||
| types: [created] | ||
|
|
||
| permissions: | ||
| contents: write | ||
| issues: write | ||
|
|
||
| jobs: | ||
| process-screenshots: | ||
| # Only run if the issue body contains our base64 screenshot marker | ||
| if: contains(github.event.issue.body, '<!-- screenshot-base64:') | ||
| process-screenshot: | ||
| # Only run on issue comments (not PR comments) with our marker | ||
| if: > | ||
| !github.event.issue.pull_request && | ||
| contains(github.event.comment.body, '<!-- screenshot-base64:') |
There was a problem hiding this comment.
This workflow will run for any newly created issue comment containing the marker, and it has contents: write, meaning any user who can comment on issues could potentially cause arbitrary files to be committed to the repo. Add a hard gate to only process comments from a trusted actor (e.g., the bot/service account used by the backend token, repo members, or comments on issues created by the console) before decoding/committing.
| // Commit the image to the repo | ||
| const filePath = `.github/screenshots/${requestId}/screenshot-${index}.${ext}`; | ||
| console.log(`Uploading ${filePath}...`); | ||
|
|
||
| const { data } = await github.rest.repos.createOrUpdateFileContents({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| path: filePath, | ||
| message: `Add screenshot ${index} for issue #${issueNumber}`, | ||
| content: b64Content, | ||
| committer: { |
There was a problem hiding this comment.
createOrUpdateFileContents will fail with a 422 when updating an existing path unless sha is provided. Since filePath is deterministic (screenshot-{index}.{ext}), re-processing a screenshot (duplicate comment, retries, multiple submits on same issue) can hit this and replace the comment with an error. Consider using a unique filename (e.g., include commentId or a timestamp) or first fetching the existing file’s sha and passing it when updating.
🔄 Auto-Applying Copilot Code ReviewCopilot code review found 3 code suggestion(s) and 4 general comment(s). @copilot Please apply all of the following code review suggestions:
Also address these general comments:
Push all fixes in a single commit. Run Auto-generated by copilot-review-apply workflow. |
Summary
Fixes the 422 "body is too long" error from #4220. Base64 screenshots easily exceed GitHub's 65K issue body limit.
New approach
<!-- screenshot-base64:N -->marker. Issue body stays clean and small.process-screenshots.yml) triggers onissue_comment.created, decodes base64, commits image to.github/screenshots/, replaces the comment withToken permissions simplified
Since screenshots go via issue comments (not Contents API), the token no longer needs
Contents: Read and write. Updatedgithub-token.tsconstants — onlyIssues: Read and writeis needed for fine-grained PATs.Files changed
pkg/api/handlers/feedback.go— screenshots as comments instead of in body.github/workflows/process-screenshots.yml— triggers onissue_commentinstead ofissues.openedweb/src/lib/imageCompression.ts— new: client-side JPEG compressionweb/src/components/feedback/*.tsx— use compression, updated messagesweb/src/lib/constants/github-token.ts— removed Contents permission requirementTest plan
<details>blockreposcope only — works without Contents permission