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") }}
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")
}
}