Microsoft Planner ↔ Linear Bridge
Reference: https://learn.microsoft.com/en-us/graph/api/resources/planner-overview
Auth
App-only (client credentials) flow:
GRAPH_CLIENT_ID,GRAPH_CLIENT_SECRET,GRAPH_TENANT_ID- Token endpoint:
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token - Scopes (admin-consented):
Tasks.ReadWrite.All,Group.Read.All,User.Read.All,Files.ReadWrite.All(for attachment mirror)
const tokenRes = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
method: "POST",
body: new URLSearchParams({
client_id, client_secret,
grant_type: "client_credentials",
scope: "https://graph.microsoft.com/.default"
})
});
Tokens expire at 60 min; refresh proactively at 50 min.
Delta queries (efficient incremental sync)
Initial bootstrap:
GET /planner/plans/{planId}/tasks/delta
Returns first batch + @odata.nextLink (for paging) and eventually @odata.deltaLink (the cursor).
Subsequent polls:
GET <deltaLink>
Returns only changed tasks since the last call.
The bridge stores deltaLink per plan in lib/state.ts. TTL is 30 days; on expiry, full re-bootstrap.
Mapping table
| Planner field | Linear field |
|---|---|
title |
title |
details.description |
description (markdown) |
bucketId |
state (via bucket → state map) |
assignments (AAD user IDs) |
assignee (via email) |
dueDateTime |
dueDate |
percentComplete |
state (0=Triage, 50=In Progress, 100=Done) |
appliedCategories |
priority + first label |
details.checklist |
sub-issues (mapped 1:1) |
details.references |
attachments (link-only) |
Sync direction
| Linear → Planner | Planner → Linear |
|---|---|
Webhook on Issue create/update → POST/PATCH /planner/tasks/{id} |
Delta poll → GraphQL mutations |
Webhook on Comment create → no direct equivalent; appended to task description with timestamp |
Task description change → re-fetch issue, reconcile |
Webhook on Reaction → ignored |
Task appliedCategories change → label add/remove |
User mapping
By email (case-insensitive). If Linear user's email isn't in AAD, the bridge:
- Logs a warning the first time
- Stores the mapping as "unmapped"
- On Planner side, leaves task unassigned
GET /users?$filter=mail eq 'alice@acme.com' returns the AAD user. Cache in SQLite for 24h.
Sub-issue ↔ checklist
Planner has no real sub-tasks. Linear sub-issues map to entries in details.checklist (max 20 per task). The bridge:
- Creates a checklist entry per sub-issue
- Maintains the link via
customAttributeson the Planner side - On checklist toggle (
isChecked: true), transitions the corresponding Linear sub-issue to "Done"
Priority encoding
Planner has 4 priority slots (1-10 mapped to "Urgent", "Important", "Medium", "Low"). Linear's 5 levels (None, Urgent, High, Medium, Low) collapse to:
- Linear None → Planner 5 (Medium)
- Linear Urgent → Planner 1 (Urgent)
- Linear High → Planner 3 (Important)
- Linear Medium → Planner 5 (Medium)
- Linear Low → Planner 9 (Low)
Plus an appliedCategory for color coding (red/orange/yellow/green/blue).
Cycle / project encoding
Planner has no cycle concept. The bridge encodes cycle membership in the task title prefix:
[Cycle: Sprint 23] Fix login bug
A bridge utility re-titles tasks on cycle transitions (updateCycle webhook).
Linear projects map to Planner plans: one Linear project ↔ one Planner plan. The mapping is stored in plugin state; users configure via /linear:planner-sync map.
Attachments
When a Linear issue's attachment is uploaded:
- Bridge fetches the asset bytes (auth via
Authorization: Bearer <linear_key>) - Uploads to OneDrive/SharePoint via
PUT /drives/{driveId}/items/{path}:content - Adds reference to Planner task:
PATCH /planner/tasks/{id}/detailssettingreferences["<onedrive-url>"]
For Planner → Linear, references are link-only attachments (no byte copy).
Throttling
Microsoft Graph: 600 reqs / 30s per app per tenant. Bridge implements:
- Token bucket (capacity 600, refill 20/s)
- Retry on 429 / 503 with
Retry-Afterheader - Backoff up to 3 attempts → DLQ
Failure modes
| Failure | Behaviour |
|---|---|
| Graph 401 | Refresh token; on second 401, pause bridge + alert |
| Graph 403 | Likely missing admin consent on scope; alert with required scope name |
| Graph 429 | Honor Retry-After, backoff |
| Delta token expired | Re-bootstrap (full scan) |
| AAD user not found | Skip assignment, log warning |
| Planner plan deleted | Disable mapping; preserve historic links |
Limitations
- Planner has no native @-mentions in task descriptions — Linear mentions render as plain
@email - Planner doesn't support markdown — descriptions are plain text. The bridge strips markdown on outbound and converts inbound to literal text (preserving links).
- Planner caps tasks per plan at 7,500 — bridge alerts at 90% capacity.
- Planner doesn't track activity history — bridge keeps its own audit trail in SQLite.