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:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE UNIQUE INDEX prompts_repository_user_state_idx
|
||||
ON prompts (repository_id, gitea_user_id, state_id)
|
||||
WHERE gitea_user_id IS NOT NULL AND state_id <> '';
|
||||
@@ -67,6 +67,7 @@ type CreatePromptInput struct {
|
||||
Status string
|
||||
ResponseSummary string
|
||||
Metadata json.RawMessage
|
||||
CreatedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
@@ -199,11 +200,18 @@ func (p *Postgres) CreatePrompt(ctx context.Context, input CreatePromptInput) (P
|
||||
}
|
||||
var prompt Prompt
|
||||
err = p.pool.QueryRow(ctx, `
|
||||
INSERT INTO prompts (id, repository_id, gitea_user_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, completed_at)
|
||||
SELECT $1, r.id, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
INSERT INTO prompts (id, repository_id, gitea_user_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, created_at, completed_at)
|
||||
SELECT $1, r.id, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, COALESCE($14, now()), $15
|
||||
FROM repositories r WHERE r.owner=$2 AND r.name=$3
|
||||
ON CONFLICT (repository_id, gitea_user_id, state_id)
|
||||
WHERE gitea_user_id IS NOT NULL AND state_id <> ''
|
||||
DO UPDATE SET
|
||||
task_id=EXCLUDED.task_id, attempt_id=EXCLUDED.attempt_id,
|
||||
prompt=EXCLUDED.prompt, agent_name=EXCLUDED.agent_name, agent_model=EXCLUDED.agent_model,
|
||||
status=EXCLUDED.status, response_summary=EXCLUDED.response_summary, metadata=EXCLUDED.metadata,
|
||||
created_at=EXCLUDED.created_at, completed_at=EXCLUDED.completed_at
|
||||
RETURNING id, gitea_user_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, created_at, completed_at`,
|
||||
id, input.Owner, input.Repository, input.GiteaUserID, input.TaskID, input.AttemptID, input.StateID, input.Prompt, input.AgentName, input.AgentModel, input.Status, input.ResponseSummary, metadata, input.CompletedAt,
|
||||
id, input.Owner, input.Repository, input.GiteaUserID, input.TaskID, input.AttemptID, input.StateID, input.Prompt, input.AgentName, input.AgentModel, input.Status, input.ResponseSummary, metadata, input.CreatedAt, input.CompletedAt,
|
||||
).Scan(&prompt.ID, &prompt.GiteaUserID, &prompt.TaskID, &prompt.AttemptID, &prompt.StateID, &prompt.Prompt, &prompt.AgentName, &prompt.AgentModel, &prompt.Status, &prompt.ResponseSummary, &prompt.Metadata, &prompt.CreatedAt, &prompt.CompletedAt)
|
||||
if err != nil {
|
||||
return Prompt{}, err
|
||||
|
||||
Reference in New Issue
Block a user