Skip to content

SB-066: Stack sync API endpoint — update branch SHAs and PR state from GitHub #103

@dugshub

Description

@dugshub

SB-066: Stack sync API endpoint — update branch SHAs and PR state from GitHub

Epic: EP-010 (Remote-First Stack Management)
Complexity: M (Medium)
Depends on: None (foundational endpoint)


Goal

Build POST /api/v1/stacks/{stack_id}/sync — the central endpoint that reconciles backend DB state with GitHub. Called after st push, after merges, or manually from the UI "Sync" button.

Current State

The backend already has:

  • StackEntity.update_branch_sha() at app/backend/src/molecules/entities/stack_entity.py:135-139 — updates a single branch SHA
  • StackEntity.get_stack_with_branches() at line 73 — loads full stack + branches + PRs
  • BranchService.get_by_name() at app/backend/src/features/branches/service.py:23-28 — lookup by name within a stack
  • GitHubAdapter at app/backend/src/molecules/providers/github_adapter.py — already calls GitHub REST API (diffs, trees, file content) with caching
  • PullRequest model at app/backend/src/features/pull_requests/models.py — has external_id (int) and external_url (str) fields, plus state machine (draft → open → approved → merged/closed)

What's missing: a sync method that batch-updates branches and reconciles PR state from GitHub.

Implementation Steps

  1. Add GitHub refs/PR reading to GitHubAdapter (app/backend/src/molecules/providers/github_adapter.py):

    async def get_ref_sha(self, owner: str, repo: str, branch: str) -> str:
        """GET /repos/{owner}/{repo}/git/ref/heads/{branch} → SHA"""
    
    async def get_pull_request(self, owner: str, repo: str, pr_number: int) -> dict:
        """GET /repos/{owner}/{repo}/pulls/{pr_number} → {state, head.sha, ...}"""
  2. Add sync method to StackEntity (app/backend/src/molecules/entities/stack_entity.py):

    async def sync_stack(self, stack_id: UUID, branch_updates: list[BranchSyncInput]) -> SyncResult:
        """For each branch: upsert branch record, update SHA, reconcile PR state."""
    • Upsert branches by name (create if new, update SHA if existing)
    • For branches with pr_number: fetch PR state from GitHub, update PullRequest.state
    • Return counts: synced_count, created_count
  3. Add sync to StackAPI (app/backend/src/molecules/apis/stack_api.py):

    async def sync_stack(self, stack_id: UUID, branches: list[BranchSyncInput]) -> dict:
  4. Add router endpoint (app/backend/src/organisms/api/routers/stacks.py):

    @router.post("/{stack_id}/sync")
    async def sync_stack(stack_id: UUID, data: SyncStackRequest, api: StackAPIDep) -> dict:
  5. Add request/response schemas (in the router file, following the existing convention of router-local schemas):

    class BranchSyncItem(BaseModel):
        name: str
        position: int
        head_sha: str
        pr_number: int | None = None
        pr_url: str | None = None
    
    class SyncStackRequest(BaseModel):
        branches: list[BranchSyncItem]
    
    class SyncStackResponse(BaseModel):
        stack: StackResponse
        branches: list[dict]  # branch + PR pairs
        synced_count: int
        created_count: int

API Design

POST /api/v1/stacks/{stack_id}/sync

Request:
{
  "branches": [
    {
      "name": "dugshub/my-stack/1-feature",
      "position": 1,
      "head_sha": "abc123def456...",
      "pr_number": 42,
      "pr_url": "https://github.com/owner/repo/pull/42"
    },
    {
      "name": "dugshub/my-stack/2-tests",
      "position": 2,
      "head_sha": "789abc...",
      "pr_number": null,
      "pr_url": null
    }
  ]
}

Response:
{
  "stack": { "id": "...", "name": "my-stack", "state": "active", ... },
  "branches": [
    {
      "branch": { "id": "...", "name": "...", "head_sha": "abc123...", "position": 1 },
      "pull_request": { "id": "...", "external_id": 42, "state": "open", ... }
    }
  ],
  "synced_count": 2,
  "created_count": 0
}

Key Files

File Role
app/backend/src/organisms/api/routers/stacks.py Router — add POST /{stack_id}/sync endpoint
app/backend/src/molecules/apis/stack_api.py API facade — add sync_stack() method
app/backend/src/molecules/entities/stack_entity.py Entity — add sync_stack() with upsert + PR reconciliation
app/backend/src/molecules/providers/github_adapter.py Adapter — add get_ref_sha() and get_pull_request()
app/backend/src/features/branches/service.py Service — get_by_name() already exists for lookup
app/backend/src/features/pull_requests/service.py Service — needs get_by_branch() (may already exist)
app/backend/src/features/branches/models.py Model — Branch has head_sha, position, name fields
app/backend/src/features/pull_requests/models.py Model — PullRequest has external_id, external_url, state machine

Sync Logic Detail

For each branch in request:
  1. branch = BranchService.get_by_name(stack_id, name)
  2. If branch is None:
       branch = StackEntity.add_branch(stack_id, workspace_id, name, position, head_sha)
       created_count += 1
  3. Else:
       StackEntity.update_branch_sha(branch.id, head_sha)
       synced_count += 1
  4. If pr_number is not None:
       pr = PullRequestService.get_by_branch(branch.id)
       If pr is None:
         StackEntity.create_pull_request(branch.id, title=name)
         StackEntity.link_external_pr(pr.id, pr_number, pr_url)
       Else if pr.external_id != pr_number:
         StackEntity.link_external_pr(pr.id, pr_number, pr_url)
       # Optionally: fetch PR state from GitHub and update

Dependencies

Acceptance Criteria

  • POST /api/v1/stacks/{id}/sync creates/updates branches in DB
  • Branch head_sha updated to match request payload
  • PRs linked via external_id and external_url when pr_number provided
  • PR state transitions updated from GitHub (open/merged/closed)
  • Response includes full stack detail with updated branches and PRs
  • Idempotent — calling sync twice with same data produces same result
  • Branch positions re-ordered if positions in payload differ from DB
  • Unit tests for sync logic (create vs update paths)
  • Integration test: sync → verify DB state → re-sync → verify idempotent

Metadata

Metadata

Assignees

No one assigned

    Labels

    epic:EP-010EP-010: Remote-First Stack ManagementinfrastructureInfrastructure and architecture

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions