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 + project exists (via InboundTaskLink) → update name, description, story_points, and map external status to TaskStatus using the token's status_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_email on InboundTaskLink, resolved on next project member sync.
  • If parent_external_id is provided and a matching parent InboundTaskLink exists → 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/ and DELETE /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 by parent_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)