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
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,9 @@ git-review feedback
git-review feedback --json
```

This read-only command returns notes persisted by git-review, including reviews completed outside an agent session.
This read-only command returns commit-review notes persisted by git-review and file-review notes from tuicr, including existing saved sessions.
It does not launch tuicr, recover an attempt, or change either review store.
Interrupted sessions whose notes were not captured by git-review still require explicit interactive recovery.
Interrupted commit-review sessions whose notes were not captured by git-review still require explicit interactive recovery.

Feedback results use the versioned result envelope, with `"command":"feedback"`.
The optional `feedback` array contains records with these fields:
Expand All @@ -189,9 +189,16 @@ Superseded attempts, changed or removed stacks, and addressed attempts remain hi
The command does not infer that historical feedback needs another fix or that a changed stack resolved it.
Malformed stored review data is an error rather than silently omitted feedback.

The result is `pending` when current saved feedback exists and `idle` otherwise.
The optional `fileFeedback` array contains file-review results with `"command":"files"`, the saved session path, comments, and session notes.
It includes local sessions in the feature checkout and its annotation directories, not commit-review sessions in the detached checkout.
These records are repository-scoped and remain visible after branch changes while their tuicr sessions contain notes.
They do not imply current stack feedback or authorize fixup commits.
Address them as new commits, logically organized and sized.
Resolve or remove obsolete notes in tuicr when they no longer apply.

The result is `pending` when current commit feedback or any saved file feedback exists and `idle` otherwise.
An `idle` result can still include historical records.
The `feedback` field is omitted when no saved notes exist, and `entries` is empty.
Each feedback array is omitted when it has no records, and `entries` is empty.
Active attempts remain visible in `active`, but collecting feedback does not resume them.

### Review a stack
Expand Down Expand Up @@ -305,7 +312,8 @@ Use `--session` to select an exact saved session when several file reviews match
File reviews run in the feature checkout, not the detached review worktree.
`--all`, explicit paths, and `--untracked` do not require a configured upstream.
They hold the same repository-wide worktree lock as `commits` reviews, so `status --is-reviewing` reports true during either review.
They do not create git-review attempts and do not affect `status` counts or `feedback` records.
They do not create git-review attempts or affect `status` counts.
Retrieve their comments and session notes with `git-review feedback`, in the separate `fileFeedback` JSON array or the terminal “File feedback” section.
`approve` applies only to commit stacks, not file reviews.

Successful `files` output uses `"command":"files"` to distinguish it from `"command":"commits"`.
Expand Down Expand Up @@ -368,7 +376,8 @@ Successful results other than `status` contain:
| `entries` | Unresolved stacks, or the stacks selected by approval/review |
| `active` | Persisted active attempts |
| `decision` | Required explicit choice, when present |
| `feedback` | Current and historical saved feedback records for the feedback command, when notes exist |
| `feedback` | Current and historical commit-review feedback records, when notes exist |
| `fileFeedback` | Saved file-review results from tuicr, when notes exist |

Needs-review outcomes are `pending` or `idle`.
Review outcomes include `approved`, `feedback`, `incomplete`, `decision_required`, `cancelled`, `idle`, `range_complete`, and `no_waiting`.
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ func git(t *testing.T, dir string, args ...string) string {

func featureRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
git(t, dir, "init", "-b", "main")
git(t, dir, "config", "user.name", "Test")
git(t, dir, "config", "user.email", "test@example.com")
Expand Down
46 changes: 42 additions & 4 deletions internal/cli/files_functional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ func TestRealTuicrFileScopes(t *testing.T) {
env := realTuicrEnvironment(t)
binary := buildGitReview(t)
for _, tc := range []struct {
name string
args []string
cwd string
previous bool
name string
args []string
cwd string
previous bool
noUpstream bool
}{
{name: "nested file", args: []string{"src/nested.txt"}},
{name: "annotation without upstream", args: []string{"src/nested.txt"}, noUpstream: true},
{name: "nested file after root review", args: []string{"src/nested.txt"}, previous: true},
{name: "nested directory", args: []string{"src"}, previous: true},
{name: "untracked from subdirectory", args: []string{"--untracked"}, cwd: "src", previous: true},
Expand All @@ -37,11 +39,15 @@ func TestRealTuicrFileScopes(t *testing.T) {
if tc.previous {
reviewRealFile(t, binary, dir, env, "feature.txt", "Prior root feedback", "feature.txt")
}
if tc.noUpstream {
git(t, dir, "branch", "--unset-upstream")
}
result := reviewRealFile(t, binary, filepath.Join(dir, tc.cwd), env, "nested.txt", "Nested feedback", tc.args...)
require.Equal(t, dir, result.Repo)
require.Len(t, result.Review.Comments, 1)
require.Equal(t, "Nested feedback", result.Review.Comments[0]["content"])
require.Equal(t, "src/nested.txt", result.Review.Comments[0]["path"])
checkRealFileFeedback(t, binary, filepath.Join(dir, tc.cwd), env, result)
})
}
}
Expand Down Expand Up @@ -88,10 +94,42 @@ func TestRealTuicrBranchFiles(t *testing.T) {
require.Len(t, result.Review.Comments, 1)
require.Equal(t, "Branch feedback", result.Review.Comments[0]["content"])
require.Equal(t, "feature.txt", result.Review.Comments[0]["path"])
git(t, dir, "commit", "--allow-empty", "-m", "Advance branch after file review")
checkRealFileFeedback(t, binary, filepath.Join(dir, tc.cwd), env, result)
})
}
}

func checkRealFileFeedback(t *testing.T, binary, dir string, env []string, reviewed review.FilesResult) {
t.Helper()
before, err := os.ReadFile(reviewed.Session)
require.NoError(t, err)
out := runRealCommand(t, binary, dir, env, "feedback", "--json")
var result struct {
Status string `json:"status"`
Feedback []review.FeedbackRecord `json:"feedback"`
FileFeedback []review.FilesResult `json:"fileFeedback"`
}
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, "pending", result.Status)
require.Empty(t, result.Feedback, "file feedback must not become a stack attempt")
var found bool
for _, file := range result.FileFeedback {
if file.Session == reviewed.Session {
found = true
require.Equal(t, "files", file.Command)
require.Equal(t, reviewed.Review, file.Review)
}
}
require.True(t, found, "feedback must include the saved file review")
terminal := runRealCommand(t, binary, dir, env, "feedback")
require.Contains(t, terminal, "File feedback:")
require.Contains(t, terminal, reviewed.Review.Comments[0]["content"])
after, err := os.ReadFile(reviewed.Session)
require.NoError(t, err)
require.Equal(t, before, after, "feedback must not change the tuicr session")
}

func reviewRealFile(t *testing.T, binary, dir string, env []string, file, feedback string, args ...string) review.FilesResult {
t.Helper()
command := append([]string{"files", "--json"}, args...)
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/tuicr_functional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ func realTuicrEnvironment(t *testing.T) []string {
}
_, err := exec.LookPath("tuicr")
require.NoError(t, err, "functional tests require tuicr on PATH")
home := t.TempDir()
home, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
overrides := map[string]string{
"HOME": home, "XDG_DATA_HOME": filepath.Join(home, "data"), "XDG_CACHE_HOME": filepath.Join(home, "cache"), "XDG_CONFIG_HOME": filepath.Join(home, "config"),
"TERM": "xterm-256color", "COLORTERM": "truecolor", "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1",
Expand Down
8 changes: 8 additions & 0 deletions internal/display/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ func renderFeedbackResult(result review.Result) (string, error) {
if err := renderFeedback(&out, result.Feedback); err != nil {
return "", err
}
for _, file := range result.FileFeedback {
fmt.Fprintln(&out, "File feedback:")
text, err := renderFilesResult(file)
if err != nil {
return "", err
}
fmt.Fprintln(&out, text)
}
if result.Status == "idle" {
fmt.Fprintln(&out, "No current saved feedback.")
} else {
Expand Down
122 changes: 122 additions & 0 deletions internal/review/file_feedback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package review

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

func readFileFeedbackEntry(store, repo string, entry object) (*FilesResult, error) {
canonical, relativeScope, err := localFileFeedbackScope(repo, entry)
if err != nil {
return nil, err
}
if canonical == "" {
return nil, nil
}
session, path, err := readFileFeedbackSession(store, entry)
if err != nil {
return nil, err
}
source, err := fileFeedbackSource(session, relativeScope)
if err != nil {
return nil, err
}
if source == "" {
return nil, nil
}
if err := validateSessionRepo(session, filepath.Clean(canonical)); err != nil {
return nil, err
}
saved, err := decodeFileFeedbackSession(session, canonical, source)
if err != nil {
return nil, err
}
if saved.Status() != "feedback" {
return nil, nil
}
result := FilesResult{Version: 1, Command: "files", Repo: repo, Session: path, AllFiles: source == "pristine"}
if relativeScope != "." {
result.Path = filepath.ToSlash(relativeScope)
}
result, err = fileReviewResult(result, saved)
return &result, err
}

func localFileFeedbackScope(repo string, entry object) (string, string, error) {
kind, err := decodeRequired[object](entry, "kind")
if err != nil {
return "", "", err
}
entryType, err := decodeRequired[string](kind, "type")
if err != nil {
return "", "", err
}
if entryType != "local" {
return "", "", nil
}
canonical, err := decodeRequired[string](entry, "canonical_repo_path")
if err != nil {
return "", "", err
}
relativeScope, err := filepath.Rel(repo, canonical)
if err != nil {
return "", "", err
}
if relativeScope == ".." || strings.HasPrefix(relativeScope, ".."+string(filepath.Separator)) {
return "", "", nil
}
return canonical, relativeScope, nil
}

func readFileFeedbackSession(store string, entry object) (object, string, error) {
relative, err := decodeRequired[string](entry, "path")
if err != nil {
return nil, "", err
}
if !sessionPathPattern.MatchString(relative) {
return nil, "", fmt.Errorf("unsafe session path %q", relative)
}
path, err := safeSessionPath(store, relative)
if err != nil {
return nil, "", err
}
session, err := readObject(path)
return session, path, err
}

func fileFeedbackSource(session object, relativeScope string) (string, error) {
if raw, ok := session["pr_session_key"]; ok {
if string(raw) != "null" {
return "", nil
}
}
source, err := decodeRequired[string](session, "diff_source")
if err != nil {
return "", err
}
switch source {
case "pristine", "working_tree":
return source, nil
case "commit_range":
// Branch diffs launched by files always use the feature repository root.
if relativeScope == "." {
return source, nil
}
}
return "", nil
}

func decodeFileFeedbackSession(session object, canonical, source string) (*SavedReview, error) {
if source != "commit_range" {
return decodeFileSession(session, canonical)
}
commits, err := decodeRequired[[]string](session, "commit_range")
if err != nil {
return nil, err
}
if err := validateCommits(commits); err != nil {
return nil, err
}
return decodeSession(session, Stack{Repo: canonical, Commits: commits})
}
55 changes: 55 additions & 0 deletions internal/review/file_feedback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package review

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestFileFeedbackDiscoversNotesWithoutChangingSessions(t *testing.T) {
r := testRepo(t)
store := t.TempDir()
path := filepath.Join(store, "sessions/0123456789abcdef.json")
session := fileFixture(t, r.Dir, "working_tree", "file", store, false, "sessions/0123456789abcdef.json")
session["session_notes"] = "Check the public API"
writeJSON(t, path, session)
before, err := os.ReadFile(path)
require.NoError(t, err)
reader := SavedReader{Stores: []string{store, store}}
feedback, err := reader.FileFeedback(r.Dir)
require.NoError(t, err)
require.Len(t, feedback, 1, "duplicate index discovery must not repeat feedback")
require.Equal(t, "feedback", feedback[0].Status)
require.Empty(t, feedback[0].Review.Comments)
require.Equal(t, "Check the public API", *feedback[0].Review.SessionNotes)
after, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, before, after)

// A different repository must not inherit this annotation, even if its
// directory name starts with the same prefix.
other := r.Dir + "-other"
require.NoError(t, os.Mkdir(other, 0o700))
t.Cleanup(func() { _ = os.Remove(other) })
otherStore := t.TempDir()
otherSession := fileFixture(t, other, "working_tree", "file", otherStore, false, "sessions/0123456789abcdef.json")
otherSession["session_notes"] = "Unrelated feedback"
writeJSON(t, filepath.Join(otherStore, "sessions/0123456789abcdef.json"), otherSession)
feedback, err = (SavedReader{Stores: []string{otherStore}}).FileFeedback(r.Dir)
require.NoError(t, err)
require.Empty(t, feedback)

delete(session, "session_notes")
writeJSON(t, path, session)
feedback, err = reader.FileFeedback(r.Dir)
require.NoError(t, err)
require.Empty(t, feedback, "incomplete reviews without notes are not feedback")

// Corrupt notes must be reported, not mistaken for absent feedback.
session["session_notes"] = 42
writeJSON(t, path, session)
_, err = reader.FileFeedback(r.Dir)
require.ErrorContains(t, err, "session_notes")
}
Loading
Loading