Sprint Completion → Release Content
Scenario
When a sprint closes, marketing needs release notes and a changelog, not a list of Jira keys. This job pulls the most recently completed sprint, fetches all resolved issues, categorizes by type, then chains two Mavera Generate calls for polished release notes and a changelog. Flow: JiraGET /board/{id}/sprint (completed) → GET /sprint/{id}/issue (done) → categorize → Mavera POST /api/v1/generations (release notes + changelog)
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"]
AB, MB = f"https://{DOMAIN}.atlassian.net/rest/agile/1.0", "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"}
BOARD_ID = 42
r = requests.get(f"{AB}/board/{BOARD_ID}/sprint", headers=JH, params={"state": "closed", "maxResults": 5})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 30)))
r = requests.get(f"{AB}/board/{BOARD_ID}/sprint", headers=JH, params={"state": "closed", "maxResults": 5})
r.raise_for_status()
sprints = r.json().get("values", [])
if not sprints:
raise SystemExit("No completed sprints found")
sprint = sprints[-1]
print(f"Sprint: {sprint['name']}")
issues, start = [], 0
while True:
r = requests.get(f"{AB}/sprint/{sprint['id']}/issue", headers=JH,
params={"startAt": start, "maxResults": 100, "fields": "summary,issuetype,priority,resolution"})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 30)))
continue
r.raise_for_status()
data = r.json()
issues.extend(data.get("issues", []))
if start + data["maxResults"] >= data["total"]:
break
start += data["maxResults"]
time.sleep(0.5)
resolved = [i for i in issues if i["fields"].get("resolution")]
cats = {"features": [], "bugs": [], "improvements": []}
for iss in resolved:
name = iss["fields"]["issuetype"]["name"].lower()
entry = f"- [{iss['key']}] {iss['fields']['summary']} ({iss['fields']['priority']['name']})"
if "story" in name or "feature" in name: cats["features"].append(entry)
elif "bug" in name: cats["bugs"].append(entry)
else: cats["improvements"].append(entry)
cat_block = "\n".join(f"{k.upper()} ({len(v)}):\n" + "\n".join(v) for k, v in cats.items() if v)
time.sleep(0.3)
notes = requests.post(f"{MB}/generations", headers=MH, json={
"prompt": f"Release notes for '{sprint['name']}'.\n\n{cat_block[:5000]}\n\n"
"Group by New Features/Bug Fixes/Improvements. User benefit first. Include Jira keys.",
}).json()
release_notes = notes.get("output", notes.get("content", ""))
time.sleep(0.3)
changelog = requests.post(f"{MB}/generations", headers=MH, json={
"prompt": f"CHANGELOG.md for '{sprint['name']}'.\n\n{release_notes[:3000]}\n\n"
"Keep-a-Changelog format (Added/Changed/Fixed). One line per item.",
}).json()
print(f"\nRELEASE NOTES:\n{release_notes[:2000]}")
print(f"\nCHANGELOG:\n{changelog.get('output', changelog.get('content', ''))[:1500]}")
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 AB = `https://${DOMAIN}.atlassian.net/rest/agile/1.0`, 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" };
const BOARD_ID = 42;
let res = await fetch(`${AB}/board/${BOARD_ID}/sprint?state=closed&maxResults=5`, { headers: JH });
if (res.status === 429) {
await new Promise(r => setTimeout(r, 30000));
res = await fetch(`${AB}/board/${BOARD_ID}/sprint?state=closed&maxResults=5`, { headers: JH });
}
const sprints = (await res.json()).values || [];
if (!sprints.length) { console.log("No completed sprints"); process.exit(1); }
const sprint = sprints.at(-1);
const issues = [];
let start = 0;
while (true) {
const r = await fetch(
`${AB}/sprint/${sprint.id}/issue?startAt=${start}&maxResults=100&fields=summary,issuetype,priority,resolution`,
{ headers: JH });
if (r.status === 429) { await new Promise(w => setTimeout(w, 30000)); continue; }
const data = await r.json();
issues.push(...(data.issues || []));
if (start + data.maxResults >= data.total) break;
start += data.maxResults;
await new Promise(w => setTimeout(w, 500));
}
const resolved = issues.filter(i => i.fields.resolution);
const cats = { features: [], bugs: [], improvements: [] };
for (const iss of resolved) {
const n = iss.fields.issuetype.name.toLowerCase();
const e = `- [${iss.key}] ${iss.fields.summary} (${iss.fields.priority.name})`;
if (n.includes("story") || n.includes("feature")) cats.features.push(e);
else if (n.includes("bug")) cats.bugs.push(e);
else cats.improvements.push(e);
}
const catBlock = Object.entries(cats).filter(([,v]) => v.length)
.map(([k,v]) => `${k.toUpperCase()} (${v.length}):\n${v.join("\n")}`).join("\n\n");
await new Promise(r => setTimeout(r, 300));
const notes = await fetch(`${MB}/generations`, { method: "POST", headers: MH,
body: JSON.stringify({ prompt: `Release notes for '${sprint.name}'.\n\n${catBlock.slice(0,5000)}\n\nGroup by type. User benefit first. Jira keys.` }),
}).then(r => r.json());
const releaseNotes = notes.output || notes.content || "";
await new Promise(r => setTimeout(r, 300));
const cl = await fetch(`${MB}/generations`, { method: "POST", headers: MH,
body: JSON.stringify({ prompt: `CHANGELOG.md for '${sprint.name}'.\n\n${releaseNotes.slice(0,3000)}\n\nKeep-a-Changelog (Added/Changed/Fixed).` }),
}).then(r => r.json());
console.log(`RELEASE NOTES:\n${releaseNotes.slice(0,2000)}`);
console.log(`\nCHANGELOG:\n${(cl.output||cl.content||"").slice(0,1500)}`);
Example Output
Sprint: Sprint 24 — API Hardening (2026-03-03 → 2026-03-14)
Resolved: 19 / 23 total issues
RELEASE NOTES:
## New Features
- Bulk issue import supports CSV up to 10K rows (PROJ-412)
- Real-time webhook status dashboard (PROJ-398)
## Bug Fixes
- Fixed 502 errors on boards with 500+ issues (PROJ-401)
- Email notifications work for plus-sign addresses (PROJ-389)
CHANGELOG:
## [2026-03-14] Sprint 24
### Added
- Bulk CSV import up to 10K rows (PROJ-412)
### Fixed
- 502 errors on large boards (PROJ-401)
Error Handling
Board not found (404)
Board not found (404)
The Agile API returns 404 if the board ID is wrong or the token lacks access. Verify via
GET /rest/agile/1.0/board.No resolved issues
No resolved issues
Sprints can close with unresolved issues (moved to backlog). The code filters on
resolution != null. If all issues were moved, skip generation.Agile API vs REST API
Agile API vs REST API
Sprint and board endpoints use
/rest/agile/1.0, not /rest/api/3. Mixing them causes 404s.