> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Sprint Completion → Release Content

> Pull completed sprint data from Jira, categorize resolved issues, and generate release notes plus changelog with Mavera

## 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:** Jira `GET /board/{id}/sprint` (completed) → `GET /sprint/{id}/issue` (done) → categorize → Mavera `POST /api/v1/generations` (release notes + changelog)

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  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]}")
  ```

  ```javascript JavaScript theme={"dark"}
  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)}`);
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
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

<AccordionGroup>
  <Accordion title="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`.</Accordion>
  <Accordion title="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.</Accordion>
  <Accordion title="Agile API vs REST API">Sprint and board endpoints use `/rest/agile/1.0`, not `/rest/api/3`. Mixing them causes 404s.</Accordion>
</AccordionGroup>
