feat(api): inbound task-sync protocol — POST /projects/{id}/task-sync/ (Jira/Linear/GitHub push)
Implements gap 3 of ADR-0065 (Hybrid Bridge v1.1).
Why
ADR-0049 defined outbound webhook extension points. No inbound push protocol exists. Teams using Linear, GitHub Issues, or Jira should be able to push tasks into TruePPM via a lightweight authenticated webhook, with TruePPM creating/updating tasks and assigning them to a backlog sprint. This is import-only (one-way push) — two-way sync with conflict resolution is Enterprise scope.
Scope
Backend — new endpoint POST /projects/{id}/task-sync/
Payload:
{
"source": "jira | linear | github | custom",
"external_id": "PROJ-123",
"name": "...",
"description": "...",
"assignee_email": "...",
"story_points": 3,
"labels": ["backend"],
"external_url": "https://...",
"parent_external_id": "PROJ-001"
}Behavior:
- If a task with matching
external_id+source+projectexists (viaInboundTaskLink) → update name, description, story_points, and map external status toTaskStatususing the token'sstatus_map. Default map:{"todo": "NOT_STARTED", "in_progress": "IN_PROGRESS", "done": "COMPLETE"}. - If no match → create task in project with
status=BACKLOG,sprint=null. - Assignee resolved by email against project members; unmatched email stored in
pending_assignee_emailonInboundTaskLink, resolved on next project member sync. - If
parent_external_idis provided and a matching parentInboundTaskLinkexists → attach the created task under that parent (is_subtask=True, WBS path under parent). Preserves Jira epic → story hierarchy. No match → flat BACKLOG item. - Synchronous response:
201 {"task_id": "...", "short_id": "PRJ-0ab", "created": true|false}.
New models
class InboundTaskLink(Model):
project = FK(Project, CASCADE)
task = FK(Task, CASCADE, related_name="inbound_links")
source = CharField(32)
external_id = CharField(255)
external_url = URLField(nullable)
parent_external_id = CharField(255, nullable)
pending_assignee_email = EmailField(nullable)
last_synced_at = DateTimeField(auto_now=True)
class Meta:
unique_together = [("project", "source", "external_id")]
class ProjectApiToken(Model):
id = UUIDField(PK)
project = FK(Project, CASCADE)
name = CharField(128)
token_hash = CharField(64) # SHA-256 hex; raw token shown once
status_map = JSONField(default=dict)
created_by = FK(User, SET_NULL, nullable)
created_at = DateTimeField(auto_now_add=True)
last_used_at = DateTimeField(nullable)
revoked_at = DateTimeField(nullable)Auth and rate limiting
- Project-scoped API token. 256-bit hex, hashed at rest, shown once on creation.
- New endpoints:
POST /projects/{id}/api-tokens/andDELETE /projects/{id}/api-tokens/{token_id}/. - RBAC: role ≥ 3 (Admin/PM) can create tokens. Confirm with rbac-check agent before merge.
- Per-project rate limit: 100 req/min on
/task-sync/to prevent DoS via flood.
Out of scope
- Two-way status sync (status changes in TruePPM do not flow back to the external source). Enterprise scope.
- OAuth handshake / webhook subscription on the external side. Enterprise scope.
- Web UI for token management — file as follow-up once API stabilizes. v1.1 ships API + curl docs.
Tests
- pytest: token auth (valid, revoked, missing), idempotent upsert by
unique_together, parent attach byparent_external_id, default status_map applied when none configured, rate limit (100/min) enforced, RBAC role ≥ 3 for token creation. - vitest: N/A (no UI in this issue).
- Playwright: N/A (no UI in this issue).
Docs
docs/integrations/inbound-sync.md(new) — auth, payload, examples for Jira/Linear/GitHub Actions.- Document explicitly: teams must designate one source of truth for status before setup — TruePPM does not write back. This is the most likely source of post-launch support tickets.
docs/api/— new endpoints.
References
- ADR-0065 (this work)
- ADR-0049 (outbound webhook extension points — companion direction)