Comment Analysis → Feature Messaging
Scenario
Issue comments contain raw voice-of-the-customer language — how they describe problems, what they wish for. This job pulls comments from recent customer-reported issues, feeds the language into Mavera’s Brand Voice engine, then generates feature messaging that mirrors how customers actually talk. Flow: JiraGET /issue/{id}/comment → aggregate language → Mavera POST /api/v1/brand-voices → POST /api/v1/generations
Code
import os, requests, time, base64
DOMAIN, EMAIL = os.environ["JIRA_DOMAIN"], os.environ["JIRA_EMAIL"]
TOKEN, MV = os.environ["JIRA_API_TOKEN"], os.environ["MAVERA_API_KEY"]
JB, MB = f"https://{DOMAIN}.atlassian.net/rest/api/3", "https://app.mavera.io/api/v1"
cred = base64.b64encode(f"{EMAIL}:{TOKEN}".encode()).decode()
JH = {"Authorization": f"Basic {cred}", "Content-Type": "application/json"}
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
r = requests.post(f"{JB}/search", headers=JH, json={
"jql": 'project = PROJ AND labels = "customer-reported" AND comment IS NOT EMPTY ORDER BY updated DESC',
"startAt": 0, "maxResults": 30, "fields": ["summary"],
})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 30)))
r.raise_for_status()
issues = r.json().get("issues", [])
print(f"Found {len(issues)} issues with comments")
all_comments = []
for iss in issues:
time.sleep(0.5)
cr = requests.get(f"{JB}/issue/{iss['key']}/comment", headers=JH,
params={"maxResults": 20, "orderBy": "-created"})
if cr.status_code == 429:
time.sleep(int(cr.headers.get("Retry-After", 30)))
continue
for c in cr.json().get("comments", []):
text = ""
if c.get("body") and c["body"].get("content"):
for block in c["body"]["content"]:
for item in block.get("content", []):
if item.get("text"):
text += item["text"] + " "
if len(text) > 20:
all_comments.append(f"[{iss['key']}: {iss['fields']['summary']}]\n"
f"By {c.get('author',{}).get('displayName','Unknown')}:\n{text.strip()[:500]}")
print(f"Extracted {len(all_comments)} comments")
bv = requests.post(f"{MB}/brand-voices", headers=MH, json={
"name": "Voice of the Customer — Jira Comments",
"extracted_content": "\n\n---\n\n".join(all_comments[:80])[:50000],
}).json()
print(f"Brand Voice: {bv['id']}")
time.sleep(0.3)
messaging = requests.post(f"{MB}/generations", headers=MH, json={
"brand_voice_id": bv["id"],
"prompt": "Using the customer voice from Jira comments, generate:\n"
"1. Homepage hero — headline + subheadline in customer language\n"
"2. 3 feature descriptions written how customers describe value\n"
"3. 3 testimonial-style quotes echoing actual language patterns\n"
"4. 5 email subject lines using phrases customers actually use\n"
"5. 4 pain-to-solution pairs — customer quote → product answer",
}).json()
print(messaging.get("output", messaging.get("content", ""))[:3000])
const DOMAIN = process.env.JIRA_DOMAIN, EMAIL = process.env.JIRA_EMAIL;
const TOKEN = process.env.JIRA_API_TOKEN, MV = process.env.MAVERA_API_KEY;
const JB = `https://${DOMAIN}.atlassian.net/rest/api/3`, MB = "https://app.mavera.io/api/v1";
const cred = btoa(`${EMAIL}:${TOKEN}`);
const JH = { Authorization: `Basic ${cred}`, "Content-Type": "application/json" };
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
let res = await fetch(`${JB}/search`, { method: "POST", headers: JH,
body: JSON.stringify({
jql: 'project = PROJ AND labels = "customer-reported" AND comment IS NOT EMPTY ORDER BY updated DESC',
startAt: 0, maxResults: 30, fields: ["summary"],
}),
});
if (res.status === 429) await new Promise(r => setTimeout(r, 30000));
const issues = (await res.json()).issues || [];
const allComments = [];
for (const iss of issues) {
await new Promise(r => setTimeout(r, 500));
const cr = await fetch(`${JB}/issue/${iss.key}/comment?maxResults=20&orderBy=-created`, { headers: JH });
if (cr.status === 429) continue;
for (const c of (await cr.json()).comments || []) {
let text = "";
for (const b of (c.body?.content || []))
for (const i of (b.content || [])) if (i.text) text += i.text + " ";
if (text.length > 20)
allComments.push(`[${iss.key}: ${iss.fields.summary}]\nBy ${c.author?.displayName || "?"}:\n${text.trim().slice(0,500)}`);
}
}
const bv = await fetch(`${MB}/brand-voices`, { method: "POST", headers: MH,
body: JSON.stringify({ name: "Voice of the Customer — Jira Comments",
extracted_content: allComments.slice(0,80).join("\n\n---\n\n").slice(0,50000) }),
}).then(r => r.json());
await new Promise(r => setTimeout(r, 300));
const msg = await fetch(`${MB}/generations`, { method: "POST", headers: MH,
body: JSON.stringify({ brand_voice_id: bv.id,
prompt: "Using customer voice from Jira comments:\n1) Homepage hero\n2) 3 feature descriptions\n3) 3 testimonial quotes\n4) 5 email subjects\n5) 4 pain-to-solution pairs" }),
}).then(r => r.json());
console.log((msg.output || msg.content || "").slice(0, 3000));
Example Output
Found 28 issues with comments
Extracted 142 comments
Brand Voice: bv_9pR3x
## Homepage Hero
Headline: "Stop fighting your tools. Start shipping."
Subheadline: "Teams switch to us when they're tired of workarounds."
## Feature Descriptions
### Real-Time Sync
"Everything just stays in sync" — changes propagate instantly
across boards, dashboards, and integrations. No stale data.
### Bulk Operations
"I shouldn't need 45 clicks to update 45 issues." Select, update,
done. Bulk edit fields and transitions in seconds.
## Email Subject Lines
1. "Your team deserves tools that don't fight back"
2. "What if bulk edits actually worked?"
3. "Stop context-switching. Start shipping."
4. "The sync problem your team complains about — solved"
5. "We built what Jira comments kept asking for"
Error Handling
Comment body format (ADF)
Comment body format (ADF)
Bot comment pollution
Bot comment pollution
Automation bots post comments too. Filter by
author.accountType === "atlassian" to include only humans, or maintain a bot exclusion list.Pagination on comment threads
Pagination on comment threads
Issues with 100+ comments require pagination via
startAt. The code fetches 20 most recent per issue. For thorough analysis, page through all comments.
body.content[].content[].text. Inline code, mentions, and media nodes need type-specific handling.