feat(sessions): ensure personal repo on POST /api/sessions

Merged
SW
5mo ago
+634-10

Summary

Unblocks chat.recoupable.com's Path C cutover (recoupable/chat#1748) by moving personal-repo provisioning into recoup-api. When POST /api/sessions is called without a cloneUrl and without an org bound to the caller's auth context, the handler now provisions (or reuses) the user's personal Recoupable workspace repo before returning the session row.

Previously this code lived only in open-agents (apps/web/lib/recoupable/ensure-personal-repo.ts), so chat.recoupable.com had no path to a personal repo without round-tripping through open-agents. Chat tried to build the cloneUrl client-side from account_info.name — but that field is the user's display name (e.g. sweetman.eth → kebabed to sweetman-eth), not the open-agents-canonical name (sweetman). Result: POST /api/sandbox 502'd because the constructed repo URL didn't exist on GitHub.

What changed

Ported from open-agents (matching naming + behavior, but using recoup-api's plain-fetch style instead of Octokit to avoid a new dep):

  • lib/recoupable/githubOwner.ts — RECOUPABLE_GITHUB_OWNER constant
  • lib/string/toKebabCase.ts — kebab helper
  • lib/recoupable/buildPersonalRepoIdentifier.ts — { owner, repo } pair
  • lib/recoupable/buildPersonalRepoUrl.ts — full GitHub URL
  • lib/github/repositoryExists.ts — GET /repos/:owner/:repo (true / false / null)
  • lib/github/createRepository.ts — POST /orgs/:owner/repos with auto_init: true
  • lib/recoupable/ensurePersonalRepo.ts — idempotent check-then-create

New session-side helper:

  • lib/sessions/resolveSessionCloneUrl.ts — picks bodyCloneUrl > org-no-op > ensurePersonalRepo. Falls back to email.split("@")[0] if account_info.name is missing (matches open-agents' fetchOrCreateAccount fallback).

Modified:

  • lib/sessions/buildSessionInsertRow.ts — accepts the resolved cloneUrl instead of reading body.cloneUrl directly
  • lib/sessions/createSessionHandler.ts — calls resolveSessionCloneUrl, returns 502 if it fails

Tests

  • lib/string/__tests__/toKebabCase.test.ts (6 tests — including the sweetman.eth → sweetman-eth regression case)
  • lib/recoupable/__tests__/buildPersonalRepoIdentifier.test.ts (3 tests)
  • lib/github/__tests__/repositoryExists.test.ts (4 tests — 200 / 404 / 403 / network)
  • lib/github/__tests__/createRepository.test.ts (5 tests — bad name / 201 / 422 / 403 / network)
  • lib/recoupable/__tests__/ensurePersonalRepo.test.ts (5 tests — missing token / already-exists / check-failed / create-success / create-failed)
  • lib/sessions/__tests__/resolveSessionCloneUrl.test.ts (7 tests — body cloneUrl / org / personal / email fallback / no-name+no-email / missing account / ensure-failed)
  • lib/sessions/__tests__/buildSessionInsertRow.test.ts (updated for new cloneUrl prop)
  • lib/sessions/__tests__/createSessionHandler.persistence.test.ts (added 502 + cloneUrl-forwarding cases)
  • Full suite: 3,331 / 3,331 pass

Test plan

  • On preview deployment, log in as a user without an existing repo, call POST /api/sessions with body {} — verify a recoupable/<slug>-<account_id> repo is created and the response session's clone_url matches it
  • Re-call with the same account — verify idempotency: no new repo, same clone_url returned
  • Call with body { cloneUrl: "..." } — verify the provided URL is used verbatim (no repo-creation side effects)
  • Call from a user with an org bound (Bearer token resolves to orgId != null) and no body cloneUrl — verify clone_url is null (narrow scope; org-URL derivation stays client-side for now)
  • If GITHUB_TOKEN is unset — verify the handler returns 502 with a clean error, not a 500

Follow-ups (not in this PR)

  • Once this ships, chat.recoupable.com PR #1748 can stop constructing the personal cloneUrl client-side and instead omit cloneUrl from its POST /api/sessions body — the api will resolve it correctly.
  • lib/github/findOrgReposByAccountId.ts and friends use process.env.GITHUB_TOKEN directly. They could migrate to getServiceGithubToken() for consistency. Out of scope here.

🤖 Generated with Claude Code


Summary by cubic

Automatically provisions or reuses a workspace repo at github.com/recoupable/ on POST /api/sessions when cloneUrl is absent, for both personal and org-bound sessions. Standardizes repo names to the account UUID and returns the resolved clone_url; legacy <slug>-<accountId> URLs keep working via GitHub redirects.

  • New Features

    • resolveSessionCloneUrl provisions for org sessions too (accountId = auth.orgId ?? auth.accountId), persists the URL, and createSessionHandler returns 502 on provisioning failure.
    • GitHub helpers: repositoryExists and createRepository (plain fetch, owner recoupable, private: true, auto_init: true, token via getServiceGithubToken); createRepository now returns { success, repoUrl, error }.
    • ensurePersonalRepo returns the final URL string; extractOrgId accepts both legacy and new repo shapes; buildSessionInsertRow takes the resolved cloneUrl.
    • Flattened ResolveSessionCloneUrlResult to { ok, cloneUrl, error? } to fix Next.js 16 type-narrowing build errors.
  • Migration

    • One-time repo rename to <accountId> was run; the migration script was removed. No action needed.

Summary by CodeRabbit

Release Notes

  • New Features

    • Sessions now automatically provision workspace repositories and resolve appropriate clone URLs during creation.
  • Improvements

    • Enhanced repository validation, existence checking, and error handling with improved fallback mechanisms for better reliability.

Review Change Stack