Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/debug_fix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
//go:build ignore
package main
117 changes: 117 additions & 0 deletions cmd/fix_responses.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"

"github.com/pcr-developers/cli/internal/projects"
"github.com/pcr-developers/cli/internal/sources/claudecode"
"github.com/pcr-developers/cli/internal/store"
)

var fixResponsesCmd = &cobra.Command{
Use: "fix-responses",
Short: "Backfill response_text for drafts captured without Claude's response",
RunE: func(cmd *cobra.Command, args []string) error {
repush, _ := cmd.Flags().GetBool("repush")

home, _ := os.UserHomeDir()
claudeProjectsDir := filepath.Join(home, ".claude", "projects")

// Walk all Claude project directories, match each JSONL to a registered project
// (same ancestor-matching logic the watcher uses), and backfill response_text.
// Group prompts by session_id for the fuzzy updater.
type sessionEntry struct {
prompts map[string]string // promptText → responseText
}
sessions := map[string]*sessionEntry{}

projDirs, err := os.ReadDir(claudeProjectsDir)
if err != nil {
return fmt.Errorf("cannot read %s: %w", claudeProjectsDir, err)
}

for _, projDir := range projDirs {
if !projDir.IsDir() {
continue
}
slug := projDir.Name()
project := projects.GetProjectForClaudeSlug(slug)
if project == nil {
continue
}

sessionDir := filepath.Join(claudeProjectsDir, slug)
entries, err := os.ReadDir(sessionDir)
if err != nil {
continue
}

for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
filePath := filepath.Join(sessionDir, entry.Name())
content, err := os.ReadFile(filePath)
if err != nil {
continue
}

session := claudecode.ParseClaudeCodeSession(string(content), project.Name, filePath)
for _, p := range session.Prompts {
if p.ResponseText == "" {
continue
}
e, ok := sessions[p.SessionID]
if !ok {
e = &sessionEntry{prompts: map[string]string{}}
sessions[p.SessionID] = e
}
e.prompts[p.PromptText] = p.ResponseText
}
}
}

updated := 0
for sessionID, e := range sessions {
n, err := store.UpdateDraftResponseFuzzy(sessionID, e.prompts)
if err != nil {
fmt.Fprintf(os.Stderr, "error updating session %s: %v\n", sessionID, err)
}
updated += n
}

fmt.Printf("Updated response text for %d drafts.\n", updated)

if repush {
pushed, err := store.ListPushedCommits()
if err != nil {
return fmt.Errorf("could not list pushed bundles: %w", err)
}
if len(pushed) == 0 {
fmt.Println("No pushed bundles to reset.")
return nil
}
for _, c := range pushed {
if err := store.UnmarkPushed(c.ID); err != nil {
fmt.Fprintf(os.Stderr, "error resetting bundle %q: %v\n", c.Message, err)
} else {
fmt.Printf("Reset bundle %q — run `pcr push` to re-push with updated responses.\n", c.Message)
}
}
} else {
fmt.Println("Run with --repush to reset pushed bundles so `pcr push` re-sends them.")
}

return nil
},
}

func init() {
fixResponsesCmd.Flags().Bool("repush", false, "Reset pushed bundles so `pcr push` re-sends them with updated response text")
rootCmd.AddCommand(fixResponsesCmd)
}
6 changes: 5 additions & 1 deletion internal/sources/claudecode/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,11 @@ func ParseClaudeCodeSession(fileContent, projectName, filePath string) ParsedSes
}
continue
}
break
// Skip auxiliary message types (attachment, file-history-snapshot, etc.)
// that don't represent conversation boundaries.
if next.Type == "system" {
break
}
}

fileContext := map[string]any{}
Expand Down
2 changes: 2 additions & 0 deletions internal/sources/claudecode/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,12 @@ func (w *Watcher) processFile(filePath string, forceFullScan bool) {
for _, p := range session.Prompts {
hash := supabase.PromptContentHash(p.SessionID, p.PromptText, "")
if w.dedup.IsDuplicate(p.SessionID, hash) {
_ = store.UpdateDraftResponse(p.SessionID, p.PromptText, p.ResponseText)
continue
}
if store.IsDraftSaved(p.SessionID, p.PromptText) {
w.dedup.Mark(p.SessionID, hash)
_ = store.UpdateDraftResponse(p.SessionID, p.PromptText, p.ResponseText)
continue
}
w.dedup.Mark(p.SessionID, hash)
Expand Down
21 changes: 21 additions & 0 deletions internal/store/commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,27 @@ func GetCommitBySha(headSha string) (*PromptCommit, error) {
return &commits[0], nil
}

// UnmarkPushed resets a pushed commit back to unpushed so it can be re-pushed.
func UnmarkPushed(commitID string) error {
db := Open()
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec("UPDATE prompt_commits SET pushed_at = NULL, remote_id = NULL WHERE id = ?", commitID); err != nil {
return err
}
if _, err := tx.Exec(`
UPDATE drafts SET status = 'committed'
WHERE id IN (SELECT draft_id FROM prompt_commit_items WHERE prompt_commit_id = ?)
AND status = 'pushed'
`, commitID); err != nil {
return err
}
return tx.Commit()
}

// RelinkCommit updates a commit's HEAD SHA (for git amend).
func RelinkCommit(commitID, newHeadSha string) error {
db := Open()
Expand Down
71 changes: 71 additions & 0 deletions internal/store/drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,77 @@ func IsDraftSaved(sessionID, promptText string) bool {
return exists == 1
}

// UpdateDraftResponse fills in response_text for an existing draft that has none.
// Uses exact content hash match.
func UpdateDraftResponse(sessionID, promptText, responseText string) error {
if responseText == "" {
return nil
}
db := Open()
hash := supabase.PromptContentHash(sessionID, promptText, "")
_, err := db.Exec(
"UPDATE drafts SET response_text = ? WHERE content_hash = ? AND (response_text IS NULL OR response_text = '')",
responseText, hash,
)
return err
}

// UpdateDraftResponseFuzzy fills in response_text by matching session_id + prompt prefix
// in Go (not SQL). Handles cases where the prompt was captured from a partially-written
// JSONL line, meaning the stored prompt_text is a prefix of the full parsed prompt.
// Returns the number of rows updated.
func UpdateDraftResponseFuzzy(sessionID string, prompts map[string]string) (int, error) {
if len(prompts) == 0 {
return 0, nil
}
db := Open()

// Fetch all drafts for the session with missing response_text
rows, err := db.Query(
"SELECT id, prompt_text FROM drafts WHERE session_id = ? AND (response_text IS NULL OR response_text = '')",
sessionID,
)
if err != nil {
return 0, err
}
type row struct{ id, text string }
var drafts []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.text); err != nil {
rows.Close()
return 0, err
}
drafts = append(drafts, r)
}
rows.Close()

// For each draft, find the JSONL prompt that starts with (or equals) the stored text.
updated := 0
for _, d := range drafts {
for promptText, responseText := range prompts {
if responseText == "" {
continue
}
// Match: JSONL prompt starts with stored text, or exact match.
if strings.HasPrefix(promptText, d.text) || promptText == d.text {
res, err := db.Exec(
"UPDATE drafts SET response_text = ? WHERE id = ? AND (response_text IS NULL OR response_text = '')",
responseText, d.id,
)
if err != nil {
return updated, err
}
if n, _ := res.RowsAffected(); n > 0 {
updated++
}
break
}
}
}
return updated, nil
}

// GetDraftsByStatus returns drafts filtered by status and optionally by project.
// projectIDs and projectNames are OR-combined; pass nil slices to return all.
func GetDraftsByStatus(status DraftStatus, projectIDs, projectNames []string) ([]DraftRecord, error) {
Expand Down