Scenario
Marketing needs to know when features will actually ship — not the optimistic roadmap date, but a timeline grounded in engineering velocity. This job pulls Linear cycle history (completed story points per cycle), calculates velocity trends and remaining capacity, then sends the analysis to Mave Agent for a marketing launch timeline aligned with engineering reality. Flow: Linear GraphQL (cycles withcompletedIssueCountHistory) → calculate velocity → Mavera POST /api/v1/mave/chat → Marketing timeline
Code
import os, requests, time
LN, MV = os.environ["LINEAR_API_KEY"], os.environ["MAVERA_API_KEY"]
LB, MB = "https://api.linear.app/graphql", "https://app.mavera.io/api/v1"
LH = {"Authorization": LN, "Content-Type": "application/json"}
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
TEAM_KEY = "ENG"
cycle_q = """query($tk: String!) {
cycles(filter: { team: { key: { eq: $tk } } }, first: 12, orderBy: startsAt) {
nodes { number name startsAt endsAt progress completedScopeHistory scopeHistory }
}
}"""
r = requests.post(LB, headers=LH, json={"query": cycle_q, "variables": {"tk": TEAM_KEY}})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 60)))
r = requests.post(LB, headers=LH, json={"query": cycle_q, "variables": {"tk": TEAM_KEY}})
r.raise_for_status()
cycles = r.json()["data"]["cycles"]["nodes"]
time.sleep(0.3)
pipe_q = """query($tk: String!) {
issues(filter: { team: { key: { eq: $tk } },
state: { type: { in: ["started", "unstarted"] } } }, first: 100) {
nodes { identifier title estimate project { name } }
}
}"""
pipe_issues = requests.post(LB, headers=LH,
json={"query": pipe_q, "variables": {"tk": TEAM_KEY}}).json()["data"]["issues"]["nodes"]
vel = [{"cycle": c.get("number", "?"),
"dates": f"{(c.get('startsAt') or '')[:10]} → {(c.get('endsAt') or '')[:10]}",
"pts": (c.get("completedScopeHistory") or [0])[-1],
"scope": (c.get("scopeHistory") or [0])[-1],
"pct": round((c.get("progress") or 0) * 100, 1)} for c in cycles]
recent = vel[-6:] if len(vel) >= 6 else vel
avg = sum(v["pts"] for v in recent) / max(len(recent), 1)
trend = "increasing" if len(recent) >= 3 and recent[-1]["pts"] > recent[0]["pts"] else "decreasing"
by_proj, total = {}, 0
for i in pipe_issues:
p = (i.get("project") or {}).get("name", "Unassigned")
pts = i.get("estimate") or 0
by_proj.setdefault(p, {"n": 0, "pts": 0})
by_proj[p]["n"] += 1; by_proj[p]["pts"] += pts; total += pts
vel_txt = "\n".join(f" Cycle {v['cycle']} ({v['dates']}): {v['pts']}/{v['scope']} pts ({v['pct']}%)" for v in vel)
pipe_txt = "\n".join(f" {p}: {d['n']} issues, {d['pts']} pts" for p, d in sorted(by_proj.items(), key=lambda x: -x[1]["pts"]))
print(f"Avg velocity: {avg:.1f} pts/cycle ({trend}), Pipeline: {total} pts")
time.sleep(0.3)
plan = requests.post(f"{MB}/mave/chat", headers=MH, json={
"message": f"Marketing launch planner.\n\nVELOCITY ({len(vel)} cycles):\n{vel_txt}\n\n"
f"AVG: {avg:.1f} pts/cycle ({trend})\n\nPIPELINE:\n{pipe_txt}\nTotal: {total} pts\n\n"
"Produce: 1) Delivery forecast per project 2) Confidence levels 3) Marketing milestone "
"timeline (prep, teaser, launch, follow-up) 4) Risk factors 5) Big bang vs stagger "
"recommendation 6) Content production schedule. Use measured velocity, not optimism.",
}).json()
print(f"\n{'='*60}\nMARKETING LAUNCH TIMELINE\n{'='*60}")
print(plan.get("content", "")[:3000])
const LN = process.env.LINEAR_API_KEY, MV = process.env.MAVERA_API_KEY;
const LB = "https://api.linear.app/graphql", MB = "https://app.mavera.io/api/v1";
const LH = { Authorization: LN, "Content-Type": "application/json" };
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const TEAM_KEY = "ENG";
const cycleQ = `query($tk: String!) {
cycles(filter: { team: { key: { eq: $tk } } }, first: 12, orderBy: startsAt) {
nodes { number name startsAt endsAt progress completedScopeHistory scopeHistory }
}
}`;
let res = await fetch(LB, { method: "POST", headers: LH,
body: JSON.stringify({ query: cycleQ, variables: { tk: TEAM_KEY } }) });
if (res.status === 429) {
await new Promise(r => setTimeout(r, parseInt(res.headers.get("Retry-After") || "60") * 1000));
res = await fetch(LB, { method: "POST", headers: LH,
body: JSON.stringify({ query: cycleQ, variables: { tk: TEAM_KEY } }) });
}
const cycles = (await res.json()).data?.cycles?.nodes || [];
await new Promise(r => setTimeout(r, 300));
const pipeQ = `query($tk: String!) {
issues(filter: { team: { key: { eq: $tk } },
state: { type: { in: ["started","unstarted"] } } }, first: 100) {
nodes { identifier title estimate project { name } }
}
}`;
const pipeIssues = (await (await fetch(LB, { method: "POST", headers: LH,
body: JSON.stringify({ query: pipeQ, variables: { tk: TEAM_KEY } }) })).json()).data?.issues?.nodes || [];
const vel = cycles.map(c => ({
cycle: c.number || c.name, dates: `${(c.startsAt||"").slice(0,10)} → ${(c.endsAt||"").slice(0,10)}`,
pts: (c.completedScopeHistory||[0]).slice(-1)[0], scope: (c.scopeHistory||[0]).slice(-1)[0],
pct: Math.round((c.progress||0)*1000)/10 }));
const recent = vel.slice(-6);
const avg = recent.reduce((s,v) => s+v.pts, 0) / Math.max(recent.length, 1);
const trend = recent.length >= 3 && recent.at(-1).pts > recent[0].pts ? "increasing" : "decreasing";
const byProj = {}; let total = 0;
for (const i of pipeIssues) {
const p = i.project?.name || "Unassigned";
byProj[p] = byProj[p] || { n: 0, pts: 0 };
byProj[p].n++; byProj[p].pts += i.estimate || 0; total += i.estimate || 0;
}
const velTxt = vel.map(v => ` Cycle ${v.cycle} (${v.dates}): ${v.pts}/${v.scope} (${v.pct}%)`).join("\n");
const pipeTxt = Object.entries(byProj).sort(([,a],[,b])=>b.pts-a.pts)
.map(([p,d])=>` ${p}: ${d.n} issues, ${d.pts} pts`).join("\n");
await new Promise(r => setTimeout(r, 300));
const plan = await fetch(`${MB}/mave/chat`, { method: "POST", headers: MH,
body: JSON.stringify({
message: `Launch planner.\n\nVELOCITY (${vel.length} cycles):\n${velTxt}\n\nAVG: ${avg.toFixed(1)} (${trend})\n\nPIPELINE:\n${pipeTxt}\nTotal: ${total} pts\n\n1) Forecast 2) Confidence 3) Timeline 4) Risks 5) Strategy 6) Content schedule. Measured velocity only.`,
}),
}).then(r => r.json());
console.log(`\n${"=".repeat(60)}\nMARKETING LAUNCH TIMELINE`);
console.log((plan.content || "").slice(0, 3000));
Example Output
Avg velocity: 34.2 pts/cycle (increasing), Pipeline: 142 pts
MARKETING LAUNCH TIMELINE
============================================================
## Delivery Forecast
| Project | Remaining | Est. Cycles | Ship Date | Confidence |
|---------|-----------|-------------|-----------|------------|
| API v3 | 48 pts | 1.4 cycles | Mar 28 | High |
| Dashboard Redesign | 62 pts | 1.8 cycles | Apr 11 | Medium |
| Mobile App | 32 pts | 0.9 cycles | Mar 21 | High |
## Marketing Milestones — API v3 (ship ~Mar 28)
- Mar 14: Begin blog draft + API migration guide
- Mar 21: Teaser on Twitter
- Mar 28: Launch — blog, changelog, developer email
- Apr 4: Follow-up case study with beta tester
## Recommended Strategy
STAGGER. Ship Mobile App first (smallest), then API v3 one week
later, then Dashboard. 1 week between launches for content prep.
## Content Schedule
| Content | API v3 | Dashboard | Mobile |
|---------|--------|-----------|--------|
| Blog draft | Mar 14 | Mar 28 | Mar 7 |
| Email copy | Mar 21 | Apr 4 | Mar 14 |
| Social | Mar 25 | Apr 8 | Mar 18 |
Error Handling
Missing scope history
Missing scope history
Cycles without estimates return empty
completedScopeHistory. Enable estimates in Linear (Settings → Teams → Estimation) for accurate velocity tracking.Cycle naming
Cycle naming
Linear auto-names cycles by date range. The code uses
number as identifier. Adjust if your team uses custom names.Velocity outliers
Velocity outliers
Holidays or crunch sprints skew averages. The code uses the last 6 cycles — consider trimming the highest and lowest values for robustness.