Skip to content

Commit 01f756c

Browse files
julianknutsenclaude
andcommitted
Add DoltHub REST API fork so users can fork without session token
Replace the exists-check → ForkRequiredError path with a REST API call to POST /api/v1alpha1/fork using the standard DOLTHUB_TOKEN. Polls for async completion with exponential backoff. Falls back to the previous exists-check behavior on auth/permission errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2d53d53 commit 01f756c

2 files changed

Lines changed: 270 additions & 8 deletions

File tree

internal/remote/dolthub.go

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,137 @@ func (d *DoltHubProvider) DatabaseURL(org, db string) string {
5757
//
5858
// If DOLTHUB_SESSION_TOKEN is set (browser session cookie), uses the GraphQL
5959
// createFork mutation which preserves DoltHub fork metadata (parent link, PR
60-
// support). Otherwise checks if the fork already exists on DoltHub — if it
61-
// does, continues silently; if not, returns a ForkRequiredError with
62-
// instructions for the user to fork manually on dolthub.com.
60+
// support). Otherwise attempts the REST API fork endpoint using the standard
61+
// DOLTHUB_TOKEN. If the REST API fails due to auth/permission errors, falls
62+
// back to checking if the fork already exists and returns a ForkRequiredError
63+
// if not.
6364
func (d *DoltHubProvider) Fork(fromOrg, fromDB, toOrg string) error {
6465
sessionToken := os.Getenv("DOLTHUB_SESSION_TOKEN")
6566
if sessionToken != "" {
6667
return d.forkGraphQL(fromOrg, fromDB, toOrg, sessionToken)
6768
}
69+
return d.forkREST(fromOrg, fromDB, toOrg)
70+
}
6871

69-
// No session token — check if fork already exists.
70-
if d.databaseExists(toOrg, fromDB) {
72+
// forkREST uses the DoltHub REST API to create a fork. It POSTs to the fork
73+
// endpoint and polls until the operation completes. Falls back to an
74+
// exists-check with ForkRequiredError if the API returns an auth error.
75+
func (d *DoltHubProvider) forkREST(fromOrg, fromDB, toOrg string) error {
76+
reqBody, err := json.Marshal(map[string]string{
77+
"ownerName": toOrg,
78+
"parentOwnerName": fromOrg,
79+
"parentDatabaseName": fromDB,
80+
})
81+
if err != nil {
82+
return fmt.Errorf("marshaling REST fork request: %w", err)
83+
}
84+
85+
req, err := http.NewRequest("POST", dolthubAPIBase+"/fork", bytes.NewReader(reqBody))
86+
if err != nil {
87+
return fmt.Errorf("creating REST fork request: %w", err)
88+
}
89+
req.Header.Set("Content-Type", "application/json")
90+
req.Header.Set("authorization", "token "+d.token)
91+
92+
client := &http.Client{Timeout: 60 * time.Second}
93+
resp, err := client.Do(req)
94+
if err != nil {
95+
return d.forkRESTFallback(fromOrg, fromDB, toOrg,
96+
fmt.Errorf("REST fork request failed: %w", err))
97+
}
98+
defer func() { _ = resp.Body.Close() }()
99+
100+
body, err := io.ReadAll(resp.Body)
101+
if err != nil {
102+
return fmt.Errorf("reading REST fork response: %w", err)
103+
}
104+
105+
if resp.StatusCode == 401 || resp.StatusCode == 403 {
106+
return d.forkRESTFallback(fromOrg, fromDB, toOrg,
107+
fmt.Errorf("REST fork auth error (HTTP %d): %s", resp.StatusCode, string(body)))
108+
}
109+
110+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
111+
// Check for "already exists" in error responses.
112+
if strings.Contains(strings.ToLower(string(body)), "already exists") {
113+
return nil
114+
}
115+
return d.forkRESTFallback(fromOrg, fromDB, toOrg,
116+
fmt.Errorf("REST fork error (HTTP %d): %s", resp.StatusCode, string(body)))
117+
}
118+
119+
var forkResp struct {
120+
Status string `json:"status"`
121+
OperationName string `json:"operation_name"`
122+
}
123+
if err := json.Unmarshal(body, &forkResp); err != nil {
124+
return fmt.Errorf("parsing REST fork response: %w", err)
125+
}
126+
127+
// If the response already has a success status with no operation to poll, we're done.
128+
if forkResp.OperationName == "" {
71129
return nil
72130
}
73131

132+
// Poll until the fork operation completes.
133+
return d.pollForkOperation(forkResp.OperationName)
134+
}
135+
136+
// pollForkOperation polls the fork endpoint until the operation completes.
137+
func (d *DoltHubProvider) pollForkOperation(operationName string) error {
138+
client := &http.Client{Timeout: 30 * time.Second}
139+
backoff := 500 * time.Millisecond
140+
deadline := time.Now().Add(60 * time.Second)
141+
142+
for time.Now().Before(deadline) {
143+
time.Sleep(backoff)
144+
145+
url := fmt.Sprintf("%s/fork?operationName=%s", dolthubAPIBase, operationName)
146+
req, err := http.NewRequest("GET", url, nil)
147+
if err != nil {
148+
return fmt.Errorf("creating fork poll request: %w", err)
149+
}
150+
req.Header.Set("authorization", "token "+d.token)
151+
152+
resp, err := client.Do(req)
153+
if err != nil {
154+
if backoff < 8*time.Second {
155+
backoff *= 2
156+
}
157+
continue
158+
}
159+
160+
body, err := io.ReadAll(resp.Body)
161+
_ = resp.Body.Close()
162+
if err != nil {
163+
if backoff < 8*time.Second {
164+
backoff *= 2
165+
}
166+
continue
167+
}
168+
169+
var pollResp struct {
170+
OwnerName string `json:"owner_name"`
171+
DatabaseName string `json:"database_name"`
172+
}
173+
if err := json.Unmarshal(body, &pollResp); err == nil &&
174+
pollResp.OwnerName != "" && pollResp.DatabaseName != "" {
175+
return nil
176+
}
177+
178+
if backoff < 8*time.Second {
179+
backoff *= 2
180+
}
181+
}
182+
183+
return fmt.Errorf("timed out waiting for fork operation %q to complete", operationName)
184+
}
185+
186+
// forkRESTFallback falls back to the exists-check when the REST API fork fails.
187+
func (d *DoltHubProvider) forkRESTFallback(fromOrg, fromDB, toOrg string, _ error) error {
188+
if d.databaseExists(toOrg, fromDB) {
189+
return nil
190+
}
74191
return &ForkRequiredError{
75192
UpstreamOrg: fromOrg,
76193
UpstreamDB: fromDB,

internal/remote/dolthub_test.go

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,148 @@ func TestDoltHubProvider_ForkDispatch_WithSessionToken(t *testing.T) {
112112
}
113113
}
114114

115+
func TestDoltHubProvider_ForkREST_Success(t *testing.T) {
116+
// REST fork: POST returns operation_name, poll returns success.
117+
pollCount := 0
118+
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
119+
if r.Header.Get("authorization") != "token api-token" {
120+
t.Errorf("expected auth header, got %q", r.Header.Get("authorization"))
121+
}
122+
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/fork") {
123+
var body map[string]string
124+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
125+
t.Errorf("decoding request: %v", err)
126+
}
127+
if body["ownerName"] != "alice-dev" {
128+
t.Errorf("ownerName = %q, want %q", body["ownerName"], "alice-dev")
129+
}
130+
if body["parentOwnerName"] != "steveyegge" {
131+
t.Errorf("parentOwnerName = %q, want %q", body["parentOwnerName"], "steveyegge")
132+
}
133+
if body["parentDatabaseName"] != "wl-commons" {
134+
t.Errorf("parentDatabaseName = %q, want %q", body["parentDatabaseName"], "wl-commons")
135+
}
136+
w.WriteHeader(200)
137+
_, _ = w.Write([]byte(`{"status":"Success","operation_name":"fork-op-123"}`))
138+
return
139+
}
140+
if r.Method == "GET" && r.URL.Query().Get("operationName") == "fork-op-123" {
141+
pollCount++
142+
if pollCount < 2 {
143+
w.WriteHeader(200)
144+
_, _ = w.Write([]byte(`{"status":"Pending"}`))
145+
return
146+
}
147+
w.WriteHeader(200)
148+
_, _ = w.Write([]byte(`{"owner_name":"alice-dev","database_name":"wl-commons"}`))
149+
return
150+
}
151+
w.WriteHeader(404)
152+
}))
153+
defer apiServer.Close()
154+
155+
oldAPI := dolthubAPIBase
156+
dolthubAPIBase = apiServer.URL
157+
defer func() { dolthubAPIBase = oldAPI }()
158+
159+
provider := NewDoltHubProvider("api-token")
160+
err := provider.forkREST("steveyegge", "wl-commons", "alice-dev")
161+
if err != nil {
162+
t.Errorf("forkREST should succeed: %v", err)
163+
}
164+
}
165+
166+
func TestDoltHubProvider_ForkREST_AlreadyExists(t *testing.T) {
167+
// REST fork: POST returns "already exists" error → treated as success.
168+
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169+
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/fork") {
170+
w.WriteHeader(400)
171+
_, _ = w.Write([]byte(`{"status":"Error","message":"database already exists"}`))
172+
return
173+
}
174+
w.WriteHeader(404)
175+
}))
176+
defer apiServer.Close()
177+
178+
oldAPI := dolthubAPIBase
179+
dolthubAPIBase = apiServer.URL
180+
defer func() { dolthubAPIBase = oldAPI }()
181+
182+
provider := NewDoltHubProvider("api-token")
183+
err := provider.forkREST("steveyegge", "wl-commons", "alice-dev")
184+
if err != nil {
185+
t.Errorf("forkREST should succeed for already-exists: %v", err)
186+
}
187+
}
188+
189+
func TestDoltHubProvider_ForkREST_AuthError(t *testing.T) {
190+
// REST fork: auth error → falls back to exists-check → ForkRequiredError.
191+
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192+
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/fork") {
193+
w.WriteHeader(401)
194+
_, _ = w.Write([]byte(`{"status":"Error","message":"unauthorized"}`))
195+
return
196+
}
197+
// Exists-check for fallback: fork doesn't exist.
198+
w.WriteHeader(400)
199+
_, _ = w.Write([]byte(`{"query_execution_status":"Error"}`))
200+
}))
201+
defer apiServer.Close()
202+
203+
oldAPI := dolthubAPIBase
204+
dolthubAPIBase = apiServer.URL
205+
defer func() { dolthubAPIBase = oldAPI }()
206+
207+
provider := NewDoltHubProvider("bad-token")
208+
err := provider.forkREST("steveyegge", "wl-commons", "alice-dev")
209+
if err == nil {
210+
t.Fatal("expected ForkRequiredError, got nil")
211+
}
212+
var forkErr *ForkRequiredError
213+
if !errors.As(err, &forkErr) {
214+
t.Fatalf("expected ForkRequiredError, got %T: %v", err, err)
215+
}
216+
}
217+
218+
func TestDoltHubProvider_Fork_NoSession_UsesREST(t *testing.T) {
219+
// When no session token, Fork dispatches to forkREST (not ForkRequiredError).
220+
gotRESTFork := false
221+
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222+
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/fork") {
223+
gotRESTFork = true
224+
w.WriteHeader(200)
225+
_, _ = w.Write([]byte(`{"status":"Success","operation_name":""}`))
226+
return
227+
}
228+
w.WriteHeader(404)
229+
}))
230+
defer apiServer.Close()
231+
232+
oldAPI := dolthubAPIBase
233+
dolthubAPIBase = apiServer.URL
234+
defer func() { dolthubAPIBase = oldAPI }()
235+
236+
t.Setenv("DOLTHUB_SESSION_TOKEN", "")
237+
238+
provider := NewDoltHubProvider("api-token")
239+
err := provider.Fork("steveyegge", "wl-commons", "alice-dev")
240+
if err != nil {
241+
t.Errorf("Fork should succeed via REST: %v", err)
242+
}
243+
if !gotRESTFork {
244+
t.Error("expected Fork to use REST API, but no POST /fork was received")
245+
}
246+
}
247+
115248
func TestDoltHubProvider_Fork_NoSession_ForkExists(t *testing.T) {
116-
// When fork database already exists on DoltHub, Fork returns nil.
249+
// REST fork fails with auth error, but fork already exists → success.
117250
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
251+
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/fork") {
252+
w.WriteHeader(403)
253+
_, _ = w.Write([]byte(`{"status":"Error","message":"forbidden"}`))
254+
return
255+
}
256+
// Exists-check fallback: fork exists.
118257
if r.Header.Get("authorization") != "token api-token" {
119258
t.Errorf("expected auth header, got %q", r.Header.Get("authorization"))
120259
}
@@ -137,8 +276,14 @@ func TestDoltHubProvider_Fork_NoSession_ForkExists(t *testing.T) {
137276
}
138277

139278
func TestDoltHubProvider_Fork_NoSession_ForkNotFound(t *testing.T) {
140-
// When fork database does not exist, Fork returns ForkRequiredError.
141-
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
279+
// REST fork fails, fork doesn't exist → ForkRequiredError.
280+
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
281+
if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/fork") {
282+
w.WriteHeader(403)
283+
_, _ = w.Write([]byte(`{"status":"Error","message":"forbidden"}`))
284+
return
285+
}
286+
// Exists-check fallback: fork not found.
142287
w.WriteHeader(400)
143288
_, _ = w.Write([]byte(`{"query_execution_status":"Error","query_execution_message":"no such repository"}`))
144289
}))

0 commit comments

Comments
 (0)