Restore private prompt history with idempotent user-scoped imports

Hop-State: A_06FNBBSMHQ9HVEQCC70EPA8
Hop-Proposal: R_06FNBBRHSSJTCFN79G3KWJ0
Hop-Task: T_06FNB93F0NJANQV08BA6VAG
Hop-Attempt: AT_06FNB93F0PSZWAGR81JCWBG
This commit is contained in:
2026-07-12 02:36:13 -07:00
committed by Hop
parent ff25f26b6c
commit 65da71bb80
4 changed files with 76 additions and 17 deletions
@@ -91,6 +91,7 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
}
var input struct {
UserLogin string `json:"user_login"`
GiteaUserID int64 `json:"gitea_user_id"`
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
StateID string `json:"state_id"`
@@ -100,6 +101,7 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
Status string `json:"status"`
ResponseSummary string `json:"response_summary"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt *time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
}
if err := decodeJSON(w, r, &input); err != nil {
@@ -107,8 +109,13 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
return
}
input.UserLogin = strings.TrimSpace(input.UserLogin)
if !validSlug(input.UserLogin) {
writeError(w, http.StatusBadRequest, "user_login is required")
if input.GiteaUserID < 0 || (input.GiteaUserID == 0 && !validSlug(input.UserLogin)) || (input.GiteaUserID > 0 && input.UserLogin != "") {
writeError(w, http.StatusBadRequest, "exactly one of gitea_user_id or user_login is required")
return
}
input.StateID = strings.TrimSpace(input.StateID)
if input.StateID == "" || len(input.StateID) > 128 {
writeError(w, http.StatusBadRequest, "state_id is required and must be at most 128 characters")
return
}
input.Prompt = strings.TrimSpace(input.Prompt)
@@ -129,21 +136,25 @@ func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "metadata must be valid JSON and at most 16 KiB")
return
}
promptOwner, err := s.gitea.User(r.Context(), input.UserLogin)
if err != nil {
if errors.Is(err, gitea.ErrTokenNotConfigured) {
writeError(w, http.StatusServiceUnavailable, err.Error())
promptOwnerID := input.GiteaUserID
if promptOwnerID == 0 {
promptOwner, err := s.gitea.User(r.Context(), input.UserLogin)
if err != nil {
if errors.Is(err, gitea.ErrTokenNotConfigured) {
writeError(w, http.StatusServiceUnavailable, err.Error())
return
}
s.logger.Error("resolve prompt owner", "user_login", input.UserLogin, "error", err)
writeError(w, http.StatusBadGateway, "could not resolve prompt owner")
return
}
s.logger.Error("resolve prompt owner", "user_login", input.UserLogin, "error", err)
writeError(w, http.StatusBadGateway, "could not resolve prompt owner")
return
promptOwnerID = promptOwner.ID
}
saved, err := s.store.CreatePrompt(r.Context(), store.CreatePromptInput{
Owner: owner, Repository: name, TaskID: input.TaskID, AttemptID: input.AttemptID, StateID: input.StateID,
GiteaUserID: promptOwner.ID,
GiteaUserID: promptOwnerID,
Prompt: input.Prompt, AgentName: input.AgentName, AgentModel: input.AgentModel, Status: input.Status,
ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CompletedAt: input.CompletedAt,
ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CreatedAt: input.CreatedAt, CompletedAt: input.CompletedAt,
})
if err != nil {
s.logger.Error("create prompt", "owner", owner, "name", name, "error", err)
@@ -169,7 +169,7 @@ func TestListPromptsRejectsUnauthenticatedViewer(t *testing.T) {
func TestCreatePrompt(t *testing.T) {
data := &fakeStore{}
handler := testServer(data, fakeGitea{user: gitea.User{ID: 42, Login: "alice"}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"user_login":"alice","prompt":"Ship the prompt view","agent_name":"codex","status":"completed","metadata":{"duration_ms":1200}}`))
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"user_login":"alice","state_id":"P_TEST","prompt":"Ship the prompt view","agent_name":"codex","status":"completed","metadata":{"duration_ms":1200}}`))
req.Header.Set("Authorization", "Bearer admin-secret")
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)
@@ -184,9 +184,46 @@ func TestCreatePrompt(t *testing.T) {
}
}
func TestCreatePromptRequiresOwner(t *testing.T) {
func TestCreatePromptWithImmutableGiteaUserID(t *testing.T) {
data := &fakeStore{}
handler := testServer(data, fakeGitea{err: errors.New("user lookup must not run")})
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"gitea_user_id":42,"state_id":"P_TEST","prompt":"Restore my prompt history","status":"completed","created_at":"2026-07-11T19:26:01Z"}`))
req.Header.Set("Authorization", "Bearer admin-secret")
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)
if response.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusCreated, response.Body.String())
}
if len(data.prompts) != 1 || data.prompts[0].GiteaUserID != 42 {
t.Fatalf("prompt not attributed to immutable user ID: %#v", data.prompts)
}
}
func TestCreatePromptRejectsAmbiguousOwner(t *testing.T) {
handler := testServer(&fakeStore{}, fakeGitea{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"prompt":"private work","status":"completed"}`))
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"gitea_user_id":42,"user_login":"alice","prompt":"private work","status":"completed"}`))
req.Header.Set("Authorization", "Bearer admin-secret")
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
}
}
func TestCreatePromptRequiresOwner(t *testing.T) {
handler := testServer(&fakeStore{}, fakeGitea{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"state_id":"P_TEST","prompt":"private work","status":"completed"}`))
req.Header.Set("Authorization", "Bearer admin-secret")
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
}
}
func TestCreatePromptRequiresStateID(t *testing.T) {
handler := testServer(&fakeStore{}, fakeGitea{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"gitea_user_id":42,"prompt":"private work","status":"completed"}`))
req.Header.Set("Authorization", "Bearer admin-secret")
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)