Skip to content

Instantly share code, notes, and snippets.

@shaal
Created August 11, 2026 00:38
Show Gist options
  • Select an option

  • Save shaal/ee985f6aa0898a8faa52ebd78e8f24ba to your computer and use it in GitHub Desktop.

Select an option

Save shaal/ee985f6aa0898a8faa52ebd78e8f24ba to your computer and use it in GitHub Desktop.
jira-image-comment — Claude Code skill: post a Jira comment with images that actually render inline (v2 wiki-markup upload workflow)
#!/usr/bin/env bash
#
# Post a Jira comment with inline images: upload -> comment -> verify.
#
# jira-image-comment.sh ISSUE-KEY BODY_FILE IMAGE [IMAGE...]
#
# BODY_FILE is v2 wiki markup and should already reference each image by its
# BASENAME, e.g. !before-after-1440.png!
#
# Exits non-zero if verification finds unrendered markup, so a broken comment
# fails the caller instead of looking like a success.
set -euo pipefail
die() { printf '\n\033[31mERROR\033[0m %s\n' "$*" >&2; exit 1; }
info() { printf '\033[36m%s\033[0m %s\n' "==>" "$*"; }
[ $# -ge 3 ] || die "usage: $0 ISSUE-KEY BODY_FILE IMAGE [IMAGE...]"
ISSUE="$1"; shift
BODY_FILE="$1"; shift
: "${JIRA_BASE_URL:?JIRA_BASE_URL is not set}"
: "${JIRA_USERNAME:?JIRA_USERNAME is not set}"
: "${JIRA_API_TOKEN:?JIRA_API_TOKEN is not set}"
BASE="${JIRA_BASE_URL%/}"
AUTH=(-u "$JIRA_USERNAME:$JIRA_API_TOKEN")
[ -f "$BODY_FILE" ] || die "body file not found: $BODY_FILE"
# --- 1. upload -------------------------------------------------------------
# X-Atlassian-Token: no-check is REQUIRED; without it Jira rejects the upload
# as a possible XSRF attempt.
for img in "$@"; do
[ -f "$img" ] || die "image not found: $img"
base=$(basename "$img")
grep -qF "!${base}" "$BODY_FILE" \
|| die "$BODY_FILE never references !${base}! — the upload would be orphaned"
info "uploading $base"
resp=$(curl -sS --fail-with-body "${AUTH[@]}" \
-X POST \
-H "X-Atlassian-Token: no-check" \
-F "file=@${img}" \
"$BASE/rest/api/3/issue/$ISSUE/attachments") \
|| die "upload failed for $base: $resp"
printf ' %s\n' "$(printf '%s' "$resp" | python3 -c \
'import sys,json; a=json.load(sys.stdin); print(", ".join(f"{x[\"filename\"]} (id {x[\"id\"]})" for x in a))')"
done
# --- 2. comment ------------------------------------------------------------
# v2, wiki markup. Jira converts !file.png! into a real ADF media node and
# mints the Media Services UUID itself -- which is the one thing you cannot do
# from the outside. Hand-built v3/ADF returns 201 and renders a grey box.
info "posting comment to $ISSUE"
payload=$(python3 -c 'import json,sys; print(json.dumps({"body": open(sys.argv[1]).read()}))' "$BODY_FILE")
created=$(curl -sS --fail-with-body "${AUTH[@]}" \
-X POST \
-H "Content-Type: application/json" \
--data "$payload" \
"$BASE/rest/api/2/issue/$ISSUE/comment") \
|| die "comment failed: $created"
CID=$(printf '%s' "$created" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
info "comment id $CID"
# --- 3. verify -------------------------------------------------------------
# 201 means "comment created", not "image visible". Read it back.
info "verifying"
curl -sS --fail-with-body "${AUTH[@]}" \
"$BASE/rest/api/3/issue/$ISSUE/comment/$CID?expand=renderedBody" \
| python3 -c "
import sys, json, re
d = json.load(sys.stdin)
rb = d.get('renderedBody', '')
imgs = len(re.findall(r'<img[^>]+attachment/content/\d+', rb))
bad = re.findall(r'!\S+\.(?:png|jpe?g|gif|webp)!', rb)
want = int('${#}')
print(f' inline images : {imgs} (expected {want})')
print(f' unrendered : {len(bad)} {bad if bad else \"\"}')
if bad:
sys.exit(' FAIL: markup did not resolve -- filenames must match the attachments exactly')
if imgs < want:
sys.exit(f' FAIL: only {imgs} of {want} images rendered')
print(' OK')
"
printf '\n\033[32mPosted\033[0m %s/browse/%s?focusedCommentId=%s\n' "$BASE" "$ISSUE" "$CID"
name jira-image-comment
description Post a Jira comment with images that actually render inline — screenshots, before/after strips, charts, diagrams. Use when asked to attach a screenshot to a ticket, add visual evidence to a Jira issue, put a before/after comparison on a ticket, or when a comment needs a picture rather than a link. Also use when images were posted to Jira but show as broken, grey, or as literal text.

Posting images into Jira comments

Jira has two comment APIs and only one of them will render your image. The obvious route silently fails: it returns 201 Created and shows a grey placeholder box. This skill is the route that works, plus the check that proves it worked.

🔴 Before you post anything

Posting to Jira writes to a shared system under the user's own identity. Teammates get notified. Comments cannot be un-sent, only deleted, and the notification has already gone out by then.

So: have an explicit instruction to post. "How would I get an image into a Jira ticket?" is a question about feasibility — answer it, do not act on it. "Post this to DONI-123" is an instruction.

If you have prepared a comment and are not certain you were told to send it, show the user the text and ask. That costs one turn. Getting it wrong costs them a retraction in front of their team.

Never invent the authorization. If you cannot point at the message that told you to post, you were not told to post.

Auth

Three environment variables, already set in most of this user's projects:

JIRA_BASE_URL     https://<site>.atlassian.net
JIRA_USERNAME     the account email
JIRA_API_TOKEN    an Atlassian API token

Check them first — env | grep -i ^JIRA — and if they are missing, say so rather than guessing at credentials. Never echo the token value.

If the Atlassian MCP tools are available, prefer them for text-only comments. They cannot upload attachments, which is the whole reason this skill exists.

The two steps

1. Upload the attachment — v3

curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \
  -X POST \
  -H "X-Atlassian-Token: no-check" \
  -F "file=@/path/to/shot.png" \
  "$JIRA_BASE_URL/rest/api/3/issue/PROJ-123/attachments"

X-Atlassian-Token: no-check is required — without it Jira rejects the upload as a possible XSRF attempt. The form field must be named file.

The response is a JSON array; keep each filename. You do not need the id — see the trap below.

2. Post the comment — v2 wiki markup

curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{"body": "Before and after:\n\n!shot.png!\n\nThe gap above the heading is the fix."}' \
  "$JIRA_BASE_URL/rest/api/2/issue/PROJ-123/comment"

!filename.png! is wiki markup for "embed this attachment". Jira converts it server-side into a proper ADF media node and mints the Media Services UUID for you. That minting is the part you cannot do yourself, and it is the entire reason to use v2 here.

The filename must match the uploaded attachment exactly, including extension. Sizing works too: !shot.png|width=800!.

Other v2 wiki markup that survives the conversion:

heading h3. Title
bold / italic *bold* · _italic_
inline code {{code}}
code block {code}{code}
table ||head||head|| then |cell|cell|
bullet / number * item · # item
quote bq. text

🔴 The trap: do not build the ADF yourself

The natural-looking approach is v3 with an Atlassian Document Format body containing a mediaSinglemedia node. It does not work from automation.

The media node's id must be a Media Services UUID, not the attachment id you got back from the upload. There is no public endpoint that mints one. The only way to obtain it is to scrape the 303 Location header from /rest/api/3/attachment/content/{id}.

Put the attachment id in there instead and Jira answers 201 Created and renders a grey placeholder box. Success status, broken comment — the worst possible failure mode, because nothing tells you until a human looks at the ticket.

Let Jira mint the UUID. Use v2.

3. Verify — the step people skip

201 means "comment created", not "image visible". Read it back:

curl -s -u "$JIRA_USERNAME:$JIRA_API_TOKEN" \
  "$JIRA_BASE_URL/rest/api/3/issue/PROJ-123/comment/<COMMENT_ID>?expand=renderedBody" \
  | python3 -c "
import sys, json, re
d = json.load(sys.stdin)
rb = d.get('renderedBody', '')
print('inline images   :', len(re.findall(r'<img[^>]+attachment/content/\d+', rb)))
print('unrendered text :', len(re.findall(r'!\S+\.(png|jpe?g|gif|webp)!', rb)))
"

Pass = inline images equals the number you embedded, AND unrendered text is zero.

A non-zero "unrendered text" count means the filename did not match an attachment on that issue and your markup is sitting there as literal !x.png! for everyone to see. Fix it with a PUT to the same comment id rather than posting a second one.

Make the picture carry its own meaning

A screenshot on a ticket is read by someone with none of your context, often on a phone, often weeks later.

  • Compose pairs into one image. Two side-by-side panels in a single PNG beat two separate attachments — the reader cannot accidentally see one without the other, and it survives being viewed in a notification email.
  • Label inside the pixels. A BEFORE / AFTER bar burned into the image keeps working when the caption is scrolled off.
  • Print the measurement on the panel. padding-top: 0px40px above the relevant shot. A 40px change is invisible without a number next to it.
  • Crop to the change. A full-page screenshot of a 70px band is a picture of everything except the point.
  • Say what the image does not prove. If a value was seeded directly into the database rather than entered through the UI, the screenshot proves the render path and nothing about the save path. Put that on the ticket, not just in your own notes — an unqualified "fixed and verified" that turns out to be narrower than it read is how QA loses trust in the evidence.

Helper

scripts/jira-image-comment.sh in this skill directory does upload → comment → verify in one call:

bash scripts/jira-image-comment.sh PROJ-123 comment.md shot1.png shot2.png

comment.md is v2 wiki markup and should already contain the !shot1.png! references. The script fails loudly if verification finds unrendered markup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment