415 lines · 13.3 KB · Go
RawDownload
package cli import (	"bytes"	"context"	"encoding/json"	"fmt"	"net/http"	"net/http/httptest"	"path/filepath"	"strings"	"testing"	"time" 	gitcrawlopenai "github.com/openclaw/gitcrawl/internal/openai"	"github.com/openclaw/gitcrawl/internal/store") func TestSummarizeCommandPersistsCurrentRevisionSummary(t *testing.T) {	ctx := context.Background()	dir := t.TempDir()	configPath := filepath.Join(dir, "config.toml")	dbPath := filepath.Join(dir, "gitcrawl.db")	if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {		t.Fatalf("init: %v", err)	}	st, err := store.Open(ctx, dbPath)	if err != nil {		t.Fatalf("open store: %v", err)	}	repoID, err := st.UpsertRepository(ctx, store.Repository{		Owner:     "openclaw",		Name:      "openclaw",		FullName:  "openclaw/openclaw",		RawJSON:   "{}",		UpdatedAt: "2026-07-12T00:00:00Z",	})	if err != nil {		t.Fatalf("repository: %v", err)	}	thread := store.Thread{		RepoID:          repoID,		GitHubID:        "42",		Number:          42,		Kind:            "issue",		State:           "open",		Title:           "Gateway reconnect loop",		Body:            "Reconnect loops after websocket timeout.",		HTMLURL:         "https://github.com/openclaw/openclaw/issues/42",		LabelsJSON:      "[]",		AssigneesJSON:   "[]",		RawJSON:         "{}",		ContentHash:     "thread",		UpdatedAtGitHub: "2026-07-12T00:00:00Z",		UpdatedAt:       "2026-07-12T00:00:00Z",	}	threadID, err := st.UpsertThread(ctx, thread)	if err != nil {		t.Fatalf("thread: %v", err)	}	thread.ID = threadID	if _, err := st.UpsertDocument(ctx, store.Document{		ThreadID:   threadID,		Title:      thread.Title,		Body:       thread.Body,		RawText:    thread.Title + "\n\n" + thread.Body,		DedupeText: thread.Title + " " + thread.Body,		UpdatedAt:  thread.UpdatedAt,	}); err != nil {		t.Fatalf("document: %v", err)	}	if _, err := st.UpsertThreadRevisionAndFingerprint(ctx, store.ThreadEvidence{Thread: thread}, "2026-07-12T00:00:00Z"); err != nil {		t.Fatalf("enrichment: %v", err)	}	if err := st.Close(); err != nil {		t.Fatalf("close store: %v", err)	} 	var calls int	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		calls++		if r.URL.Path != "/responses" {			t.Fatalf("path = %q", r.URL.Path)		}		var payload struct {			Input string `json:"input"`		}		if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {			t.Fatalf("decode request: %v", err)		}		if !strings.Contains(payload.Input, "Gateway reconnect loop") {			t.Fatalf("input = %q", payload.Input)		}		_ = json.NewEncoder(w).Encode(map[string]any{			"output": []map[string]any{{				"type": "message",				"content": []map[string]any{{					"type": "output_text",					"text": "Gateway reconnect loops after websocket timeout.",				}},			}},		})	}))	defer server.Close()	t.Setenv("OPENAI_API_KEY", "test-key")	t.Setenv("GITCRAWL_OPENAI_BASE_URL", server.URL)	t.Setenv("GITCRAWL_OPENAI_RETRY_DISABLED", "1") 	app := New()	var stdout bytes.Buffer	app.Stdout = &stdout	if err := app.Run(ctx, []string{"--config", configPath, "summarize", "openclaw/openclaw", "--json"}); err != nil {		t.Fatalf("summarize: %v", err)	}	var result summaryResult	if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {		t.Fatalf("decode result: %v\n%s", err, stdout.String())	}	if result.Selected != 1 || result.Summarized != 1 || result.Status != "success" {		t.Fatalf("result = %+v", result)	} 	st, err = store.Open(ctx, dbPath)	if err != nil {		t.Fatalf("reopen store: %v", err)	}	defer st.Close()	coverage, err := st.ArchiveCoverage(ctx, store.ArchiveCoverageOptions{})	if err != nil {		t.Fatalf("coverage: %v", err)	}	if len(coverage.Rows) != 1 || coverage.Rows[0].Enrichment.Summaries.Fresh != 1 {		t.Fatalf("summary coverage = %+v", coverage.Rows)	}	runs, err := st.ListRuns(ctx, repoID, "summary", 1)	if err != nil {		t.Fatalf("summary runs: %v", err)	}	if len(runs) != 1 || runs[0].Status != "success" {		t.Fatalf("summary runs = %+v", runs)	}	if err := st.Close(); err != nil {		t.Fatalf("close before rerun: %v", err)	}	stdout.Reset()	if err := app.Run(ctx, []string{"--config", configPath, "summarize", "openclaw/openclaw", "--json"}); err != nil {		t.Fatalf("summarize current: %v", err)	}	if calls != 1 {		t.Fatalf("current summary triggered another API call: %d", calls)	}} func TestSummaryInputIncludesThreadIdentity(t *testing.T) {	input := summaryInput(store.SummaryTask{		Number: 7,		Kind:   "pull_request",		Title:  "Fix reconnect",		Text:   "Evidence body",	})	for _, want := range []string{"kind: pull_request", "number: #7", "title: Fix reconnect", "Evidence body"} {		if !strings.Contains(input, want) {			t.Fatalf("input missing %q: %s", want, input)		}	}} func TestSummarizeCommandRecordsPartialAPIResults(t *testing.T) {	ctx := context.Background()	dir := t.TempDir()	configPath := filepath.Join(dir, "config.toml")	dbPath := filepath.Join(dir, "gitcrawl.db")	if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {		t.Fatalf("init: %v", err)	}	st, err := store.Open(ctx, dbPath)	if err != nil {		t.Fatalf("open store: %v", err)	}	repoID, err := st.UpsertRepository(ctx, store.Repository{		Owner:     "openclaw",		Name:      "openclaw",		FullName:  "openclaw/openclaw",		RawJSON:   "{}",		UpdatedAt: "2026-07-12T00:00:00Z",	})	if err != nil {		t.Fatalf("repository: %v", err)	}	for _, number := range []int{42, 43} {		thread := store.Thread{			RepoID:          repoID,			GitHubID:        fmt.Sprintf("%d", number),			Number:          number,			Kind:            "issue",			State:           "open",			Title:           fmt.Sprintf("Summary case %d", number),			Body:            "Distinct evidence.",			HTMLURL:         fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", number),			LabelsJSON:      "[]",			AssigneesJSON:   "[]",			RawJSON:         "{}",			ContentHash:     fmt.Sprintf("thread-%d", number),			UpdatedAtGitHub: "2026-07-12T00:00:00Z",			UpdatedAt:       "2026-07-12T00:00:00Z",		}		threadID, err := st.UpsertThread(ctx, thread)		if err != nil {			t.Fatalf("thread %d: %v", number, err)		}		thread.ID = threadID		if _, err := st.UpsertDocument(ctx, store.Document{			ThreadID:   threadID,			Title:      thread.Title,			Body:       thread.Body,			RawText:    thread.Title + "\n\n" + thread.Body,			DedupeText: thread.Title + " " + thread.Body,			UpdatedAt:  thread.UpdatedAt,		}); err != nil {			t.Fatalf("document %d: %v", number, err)		}		if _, err := st.UpsertThreadRevisionAndFingerprint(ctx, store.ThreadEvidence{Thread: thread}, "2026-07-12T00:00:00Z"); err != nil {			t.Fatalf("enrichment %d: %v", number, err)		}	}	if err := st.Close(); err != nil {		t.Fatalf("close store: %v", err)	} 	failAll := false	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		var payload struct {			Input string `json:"input"`		}		if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {			t.Fatalf("decode request: %v", err)		}		if failAll || strings.Contains(payload.Input, "number: #43") {			w.WriteHeader(http.StatusBadRequest)			_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{				"message": "summary rejected",				"type":    "invalid_request_error",				"code":    "bad_summary",			}})			return		}		_ = json.NewEncoder(w).Encode(map[string]any{"output_text": "Stable summary."})	}))	defer server.Close()	t.Setenv("OPENAI_API_KEY", "test-key")	t.Setenv("GITCRAWL_OPENAI_BASE_URL", server.URL)	t.Setenv("GITCRAWL_OPENAI_RETRY_DISABLED", "1") 	app := New()	var stdout bytes.Buffer	app.Stdout = &stdout	if err := app.Run(ctx, []string{"--config", configPath, "summarize", "openclaw/openclaw", "--json"}); err != nil {		t.Fatalf("summarize: %v", err)	}	var result summaryResult	if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {		t.Fatalf("decode result: %v", err)	}	if result.Selected != 2 || result.Summarized != 1 || result.Failed != 1 || result.Status != "partial" {		t.Fatalf("partial result = %+v", result)	}	if len(result.Failures) != 1 || result.Failures[0].Status != http.StatusBadRequest ||		result.Failures[0].Type != "invalid_request_error" || result.Failures[0].Code != "bad_summary" {		t.Fatalf("failure metadata = %+v", result.Failures)	} 	st, err = store.Open(ctx, dbPath)	if err != nil {		t.Fatalf("reopen store: %v", err)	}	defer st.Close()	runs, err := st.ListRuns(ctx, repoID, "summary", 1)	if err != nil {		t.Fatalf("summary runs: %v", err)	}	if len(runs) != 1 || runs[0].Status != "partial" || !strings.Contains(runs[0].ErrorText, "summary rejected") {		t.Fatalf("summary runs = %+v", runs)	}	if err := st.Close(); err != nil {		t.Fatalf("close before failed rerun: %v", err)	}	failAll = true	stdout.Reset()	err = app.Run(ctx, []string{"--config", configPath, "summarize", "openclaw/openclaw", "--force", "--json"})	if err == nil || !strings.Contains(err.Error(), "summary rejected") {		t.Fatalf("failed summarize error = %v", err)	}	st, err = store.Open(ctx, dbPath)	if err != nil {		t.Fatalf("reopen after failed run: %v", err)	}	defer st.Close()	runs, err = st.ListRuns(ctx, repoID, "summary", 1)	if err != nil {		t.Fatalf("failed summary runs: %v", err)	}	if len(runs) != 1 || runs[0].Status != "error" {		t.Fatalf("failed summary run = %+v", runs)	}} func TestSummaryFailurePreservesGenericErrors(t *testing.T) {	failure := summaryFailure(7, fmt.Errorf("network unavailable"))	if failure.Number != 7 || failure.Message != "network unavailable" || failure.Status != 0 {		t.Fatalf("generic failure = %+v", failure)	}	apiFailure := summaryFailure(8, &gitcrawlopenai.APIError{		Status:  http.StatusTooManyRequests,		Type:    "rate_limit_error",		Code:    "rate_limit",		Message: "slow down",	})	if apiFailure.Status != http.StatusTooManyRequests || apiFailure.Type != "rate_limit_error" ||		apiFailure.Code != "rate_limit" || !strings.Contains(apiFailure.Message, "slow down") {		t.Fatalf("API failure = %+v", apiFailure)	}} func TestSummarizeCancelsWorkersAfterPersistenceFailure(t *testing.T) {	ctx := context.Background()	dir := t.TempDir()	configPath := filepath.Join(dir, "config.toml")	dbPath := filepath.Join(dir, "gitcrawl.db")	if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {		t.Fatalf("init: %v", err)	}	st, err := store.Open(ctx, dbPath)	if err != nil {		t.Fatalf("open store: %v", err)	}	repoID, err := st.UpsertRepository(ctx, store.Repository{		Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-07-12T00:00:00Z",	})	if err != nil {		t.Fatalf("repository: %v", err)	}	for _, number := range []int{42, 43} {		thread := store.Thread{			RepoID: repoID, GitHubID: fmt.Sprintf("%d", number), Number: number, Kind: "issue", State: "open",			Title: fmt.Sprintf("Persistence failure %d", number), Body: "evidence",			HTMLURL:    fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", number),			LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: fmt.Sprintf("thread-%d", number),			UpdatedAtGitHub: "2026-07-12T00:00:00Z", UpdatedAt: "2026-07-12T00:00:00Z",		}		thread.ID, err = st.UpsertThread(ctx, thread)		if err != nil {			t.Fatalf("thread %d: %v", number, err)		}		if _, err := st.UpsertDocument(ctx, store.Document{			ThreadID: thread.ID, Title: thread.Title, Body: thread.Body,			RawText: thread.Title + "\n\n" + thread.Body, DedupeText: thread.Title + " " + thread.Body,			UpdatedAt: thread.UpdatedAt,		}); err != nil {			t.Fatalf("document %d: %v", number, err)		}		if _, err := st.UpsertThreadRevisionAndFingerprint(ctx, store.ThreadEvidence{Thread: thread}, thread.UpdatedAt); err != nil {			t.Fatalf("enrichment %d: %v", number, err)		}	}	if _, err := st.DB().ExecContext(ctx, `		create trigger reject_summary_insert		before insert on thread_key_summaries		begin			select raise(abort, 'summary persistence rejected');		end	`); err != nil {		t.Fatalf("trigger: %v", err)	}	if err := st.Close(); err != nil {		t.Fatalf("close store: %v", err)	} 	blockedStarted := make(chan struct{})	blockedCancelled := make(chan struct{})	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		var payload struct {			Input string `json:"input"`		}		if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {			t.Errorf("decode request: %v", err)			return		}		if strings.Contains(payload.Input, "number: #42") {			close(blockedStarted)			<-r.Context().Done()			close(blockedCancelled)			return		}		select {		case <-blockedStarted:		case <-time.After(2 * time.Second):			t.Error("second summary worker did not start")		}		_ = json.NewEncoder(w).Encode(map[string]any{"status": "completed", "output_text": "Fast summary."})	}))	defer server.Close()	t.Setenv("OPENAI_API_KEY", "test-key")	t.Setenv("GITCRAWL_OPENAI_BASE_URL", server.URL)	t.Setenv("GITCRAWL_OPENAI_RETRY_DISABLED", "1") 	err = New().Run(ctx, []string{"--config", configPath, "summarize", "openclaw/openclaw", "--json"})	if err == nil || !strings.Contains(err.Error(), "summary persistence rejected") {		t.Fatalf("summarize error = %v", err)	}	select {	case <-blockedCancelled:	case <-time.After(2 * time.Second):		t.Fatal("blocked summary worker was not cancelled and joined")	}}