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
-
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, ...}"""
-
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
-
Add sync to StackAPI (app/backend/src/molecules/apis/stack_api.py):
async def sync_stack(self, stack_id: UUID, branches: list[BranchSyncInput]) -> dict:
-
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:
-
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
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 afterst push, after merges, or manually from the UI "Sync" button.Current State
The backend already has:
StackEntity.update_branch_sha()atapp/backend/src/molecules/entities/stack_entity.py:135-139— updates a single branch SHAStackEntity.get_stack_with_branches()at line 73 — loads full stack + branches + PRsBranchService.get_by_name()atapp/backend/src/features/branches/service.py:23-28— lookup by name within a stackGitHubAdapteratapp/backend/src/molecules/providers/github_adapter.py— already calls GitHub REST API (diffs, trees, file content) with cachingPullRequestmodel atapp/backend/src/features/pull_requests/models.py— hasexternal_id(int) andexternal_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
Add GitHub refs/PR reading to GitHubAdapter (
app/backend/src/molecules/providers/github_adapter.py):Add sync method to StackEntity (
app/backend/src/molecules/entities/stack_entity.py):pr_number: fetch PR state from GitHub, updatePullRequest.statesynced_count,created_countAdd sync to StackAPI (
app/backend/src/molecules/apis/stack_api.py):Add router endpoint (
app/backend/src/organisms/api/routers/stacks.py):Add request/response schemas (in the router file, following the existing convention of router-local schemas):
API Design
Key Files
app/backend/src/organisms/api/routers/stacks.pyPOST /{stack_id}/syncendpointapp/backend/src/molecules/apis/stack_api.pysync_stack()methodapp/backend/src/molecules/entities/stack_entity.pysync_stack()with upsert + PR reconciliationapp/backend/src/molecules/providers/github_adapter.pyget_ref_sha()andget_pull_request()app/backend/src/features/branches/service.pyget_by_name()already exists for lookupapp/backend/src/features/pull_requests/service.pyget_by_branch()(may already exist)app/backend/src/features/branches/models.pyhead_sha,position,namefieldsapp/backend/src/features/pull_requests/models.pyexternal_id,external_url, state machineSync Logic Detail
Dependencies
st pushcommand — push branches and sync stack state to backend #99 (SB-062 —st pushcalls this after pushing)Acceptance Criteria
POST /api/v1/stacks/{id}/synccreates/updates branches in DBhead_shaupdated to match request payloadexternal_idandexternal_urlwhenpr_numberprovided