diff --git a/README.md b/README.md index e7fcb62..3a33f1e 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ shelltime codex install | `shelltime cc uninstall` | Remove Claude Code OTEL configuration from `~/.claude/settings.json` | | `shelltime cc statusline` | Emit statusline JSON for Claude Code | | `shelltime cc backfill` | Upload past Claude Code usage from local transcripts | +| `shelltime cc pr --session-id ...` | Link pull requests to a Claude Code session (called by the ShellTime Claude Code mod) | | `shelltime codex install` | Add ShellTime OTEL config to `~/.codex/config.toml` | | `shelltime codex uninstall` | Remove ShellTime OTEL config from `~/.codex/config.toml` | | `shelltime codex backfill` | Upload past Codex usage from local session files | @@ -207,6 +208,16 @@ shelltime codex backfill # upload Codex sessions - Flags: `--since` / `--until` (`YYYY-MM-DD`) limit the range, `--no-prompts` uploads prompt lengths without the text, and `--ai-summary` also generates AI session summaries, which use your monthly AI credits. - Claude Code deletes transcripts after 30 days by default (`cleanupPeriodDays`), so only recent history may be available. +## Linking Pull Requests to AI Sessions + +The [ShellTime Claude Code mod](https://github.com/shelltime/claude-code-mods) watches for `gh pr create` in Claude Code's Bash tool. When it sees one, it runs: + +```bash +shelltime cc pr --session-id https://github.com/owner/repo/pull/123 [more URLs...] +``` + +The command hands the URLs to the daemon, and the daemon sends them to ShellTime, where they appear on the session. If no daemon is running, the CLI sends them itself. A session can link any number of PRs. Sending the same URL again does nothing. The command does nothing if you are not logged in. + ## Security and Privacy - **Data masking** redacts sensitive command content before it leaves your machine. diff --git a/commands/cc.go b/commands/cc.go index 7cea073..dd1d56d 100644 --- a/commands/cc.go +++ b/commands/cc.go @@ -14,6 +14,7 @@ var CCCommand = &cli.Command{ CCUninstallCommand, CCStatuslineCommand, CCBackfillCommand, + CCPullRequestCommand, }, } diff --git a/commands/cc_pr.go b/commands/cc_pr.go new file mode 100644 index 0000000..c522d9d --- /dev/null +++ b/commands/cc_pr.go @@ -0,0 +1,84 @@ +package commands + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/malamtime/cli/daemon" + "github.com/malamtime/cli/model" + "github.com/urfave/cli/v2" + "go.opentelemetry.io/otel/trace" +) + +var CCPullRequestCommand = &cli.Command{ + Name: "pr", + Usage: "Link pull requests opened in a Claude Code session to it (called by the ShellTime Claude Code mod after `gh pr create`)", + ArgsUsage: " [pr-url...]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "session-id", + Usage: "Claude Code session id", + Required: true, + }, + }, + Action: commandCCPullRequest, +} + +func commandCCPullRequest(c *cli.Context) error { + ctx, span := commandTracer.Start(c.Context, "cc.pr", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)) + + sessionID := strings.TrimSpace(c.String("session-id")) + urls := uniqueNonEmpty(c.Args().Slice()) + if sessionID == "" || len(urls) == 0 { + return fmt.Errorf("usage: shelltime cc pr --session-id [pr-url...]") + } + + config, err := configService.ReadConfigFile(ctx) + if err != nil { + return err + } + if config.Token == "" { + slog.Debug("cc pr: not logged in, skipping") + return nil + } + + socketPath := config.SocketPath + if socketPath == "" { + socketPath = model.DefaultSocketPath + } + // The daemon sends them in the background; with no daemon, send them here. + err = daemon.SendSessionPullRequests(socketPath, sessionID, urls) + if err == nil { + return nil + } + slog.Debug("cc pr: daemon unreachable, sending directly", slog.Any("err", err)) + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := model.SendSessionPullRequests(ctx, config, sessionID, urls); err != nil { + slog.Error("cc pr: failed to send pull requests", slog.String("sessionId", sessionID), slog.Any("err", err)) + return err + } + return nil +} + +// uniqueNonEmpty trims each value and drops blanks and repeats, keeping order. +func uniqueNonEmpty(values []string) []string { + seen := make(map[string]bool, len(values)) + result := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" || seen[v] { + continue + } + seen[v] = true + result = append(result, v) + } + return result +} diff --git a/commands/cc_pr_test.go b/commands/cc_pr_test.go new file mode 100644 index 0000000..1188b73 --- /dev/null +++ b/commands/cc_pr_test.go @@ -0,0 +1,146 @@ +package commands + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/malamtime/cli/daemon" + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace/noop" +) + +func setupCCPullRequest(t *testing.T) *model.MockConfigService { + t.Helper() + otel.SetTracerProvider(noop.NewTracerProvider()) + SKIP_LOGGER_SETTINGS = true + orig := configService + mc := model.NewMockConfigService(t) + configService = mc + t.Cleanup(func() { configService = orig }) + return mc +} + +func runCCPullRequest(args ...string) error { + app := &cli.App{Name: "t", Commands: []*cli.Command{CCCommand}} + return app.Run(append([]string{"t", "cc", "pr"}, args...)) +} + +func TestCCPullRequest_SendsToDaemon(t *testing.T) { + mc := setupCCPullRequest(t) + + socketPath := filepath.Join(t.TempDir(), "daemon.sock") + ln, err := net.Listen("unix", socketPath) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + got := make(chan daemon.SocketMessage, 1) + go func() { + conn, aerr := ln.Accept() + if aerr != nil { + return + } + defer conn.Close() + var msg daemon.SocketMessage + if derr := json.NewDecoder(conn).Decode(&msg); derr == nil { + got <- msg + } + }() + + mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{ + Token: "tok", + SocketPath: socketPath, + }, nil) + + require.NoError(t, runCCPullRequest( + "--session-id", "sess-1", + "https://github.com/o/r/pull/1", + " https://github.com/o/r/pull/1 ", + "https://github.com/o/r2/pull/2", + )) + + select { + case msg := <-got: + assert.Equal(t, daemon.SocketMessageTypeSessionPullRequests, msg.Type) + payload, ok := msg.Payload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "sess-1", payload["sessionId"]) + assert.Equal(t, []interface{}{"https://github.com/o/r/pull/1", "https://github.com/o/r2/pull/2"}, payload["urls"]) + case <-time.After(time.Second): + t.Fatal("daemon did not receive the pull requests") + } +} + +func TestCCPullRequest_FallsBackToServerWithoutDaemon(t *testing.T) { + mc := setupCCPullRequest(t) + + var gotPath string + var body struct { + SessionID string `json:"sessionId"` + URLs []string `json:"urls"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{ + Token: "tok", + APIEndpoint: server.URL, + SocketPath: filepath.Join(t.TempDir(), "absent.sock"), + }, nil) + + require.NoError(t, runCCPullRequest("--session-id", "sess-1", "https://github.com/o/r/pull/1")) + assert.Equal(t, "/api/v1/cc/session-pull-requests", gotPath) + assert.Equal(t, "sess-1", body.SessionID) + assert.Equal(t, []string{"https://github.com/o/r/pull/1"}, body.URLs) +} + +func TestCCPullRequest_ServerErrorIsReturned(t *testing.T) { + mc := setupCCPullRequest(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"Invalid request body"}`)) + })) + t.Cleanup(server.Close) + + mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{ + Token: "tok", + APIEndpoint: server.URL, + SocketPath: filepath.Join(t.TempDir(), "absent.sock"), + }, nil) + + err := runCCPullRequest("--session-id", "sess-1", "not-a-pr") + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid request body") +} + +func TestCCPullRequest_SkipsWithoutToken(t *testing.T) { + mc := setupCCPullRequest(t) + mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{ + SocketPath: filepath.Join(t.TempDir(), "absent.sock"), + }, nil) + + require.NoError(t, runCCPullRequest("--session-id", "sess-1", "https://github.com/o/r/pull/1")) +} + +func TestCCPullRequest_RequiresSessionAndURL(t *testing.T) { + setupCCPullRequest(t) + + assert.Error(t, runCCPullRequest("https://github.com/o/r/pull/1"), "no --session-id") + assert.Error(t, runCCPullRequest("--session-id", "sess-1"), "no url") + assert.Error(t, runCCPullRequest("--session-id", " ", "https://github.com/o/r/pull/1"), "blank session id") + assert.Error(t, runCCPullRequest("--session-id", "sess-1", " "), "blank url") +} diff --git a/daemon/client.go b/daemon/client.go index 08e4fa3..72942a4 100644 --- a/daemon/client.go +++ b/daemon/client.go @@ -103,6 +103,27 @@ func SendSessionProject(socketPath string, sessionID, projectPath string) { json.NewEncoder(conn).Encode(msg) } +// SendSessionPullRequests hands pull request URLs opened in a Claude Code +// session to the daemon, which sends them to the server. It returns the dial or +// write error so the caller can send them directly when no daemon is running. +func SendSessionPullRequests(socketPath string, sessionID string, urls []string) error { + conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond) + if err != nil { + return err + } + defer conn.Close() + + msg := SocketMessage{ + Type: SocketMessageTypeSessionPullRequests, + Payload: SessionPullRequestsRequest{ + SessionID: sessionID, + URLs: urls, + }, + } + + return json.NewEncoder(conn).Encode(msg) +} + // RequestListCommands asks the daemon for the locally buffered commands (used // by `shelltime ls` in bolt mode, since the CLI can't open the locked DB). func RequestListCommands(socketPath string, timeout time.Duration) (*ListCommandsResponse, error) { diff --git a/daemon/client_socket_cov_test.go b/daemon/client_socket_cov_test.go index 7bbfc59..512867e 100644 --- a/daemon/client_socket_cov_test.go +++ b/daemon/client_socket_cov_test.go @@ -65,6 +65,45 @@ func TestX3SendSessionProject_DeliversToServer(t *testing.T) { } } +func TestSendSessionPullRequests_DialFailureReturnsError(t *testing.T) { + err := SendSessionPullRequests(filepath.Join(t.TempDir(), "absent.sock"), "sess", []string{"https://github.com/o/r/pull/1"}) + require.Error(t, err) +} + +func TestSendSessionPullRequests_DeliversToDaemon(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "spr.sock") + ln, err := net.Listen("unix", socketPath) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + got := make(chan SocketMessage, 1) + go func() { + conn, aerr := ln.Accept() + if aerr != nil { + return + } + defer conn.Close() + var msg SocketMessage + if derr := json.NewDecoder(conn).Decode(&msg); derr == nil { + got <- msg + } + }() + + urls := []string{"https://github.com/o/r/pull/1", "https://github.com/o/r2/pull/2"} + require.NoError(t, SendSessionPullRequests(socketPath, "sess-1", urls)) + + select { + case msg := <-got: + assert.Equal(t, SocketMessageTypeSessionPullRequests, msg.Type) + payload, ok := msg.Payload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "sess-1", payload["sessionId"]) + assert.Equal(t, []interface{}{urls[0], urls[1]}, payload["urls"]) + case <-time.After(time.Second): + t.Fatal("session_pull_requests message not delivered") + } +} + // TestX3SocketHandler_StartListenError covers the net.Listen failure branch of // SocketHandler.Start: a socket path inside a non-existent directory cannot be // bound. diff --git a/daemon/socket.go b/daemon/socket.go index a48907d..e0fa92f 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -23,6 +23,9 @@ const ( SocketMessageTypeStatus SocketMessageType = "status" SocketMessageTypeCCInfo SocketMessageType = "cc_info" SocketMessageTypeSessionProject SocketMessageType = "session_project" + // SocketMessageTypeSessionPullRequests links pull requests opened by + // `gh pr create` in a Claude Code session to it (fire-and-forget). + SocketMessageTypeSessionPullRequests SocketMessageType = "session_pull_requests" // SocketMessageTypeTrackPre / TrackPost carry a single raw command event the // daemon persists to its bolt-backed CommandStore (used when the bolt storage // engine is enabled). @@ -51,6 +54,11 @@ type SessionProjectRequest struct { ProjectPath string `json:"projectPath"` } +type SessionPullRequestsRequest struct { + SessionID string `json:"sessionId"` + URLs []string `json:"urls"` +} + type CCInfoTimeRange string const ( @@ -236,11 +244,36 @@ func (p *SocketHandler) handleConnection(conn net.Conn) { slog.Debug("session_project update dispatched", slog.String("sessionId", sessionID)) } } + case SocketMessageTypeSessionPullRequests: + p.handleSessionPullRequests(msg) default: slog.Error("Unknown message type:", slog.String("messageType", string(msg.Type))) } } +func (p *SocketHandler) handleSessionPullRequests(msg SocketMessage) { + buf, err := json.Marshal(msg.Payload) + if err != nil { + slog.Error("Error encoding session_pull_requests payload", slog.Any("err", err)) + return + } + var req SessionPullRequestsRequest + if err := json.Unmarshal(buf, &req); err != nil { + slog.Error("Error decoding session_pull_requests payload", slog.Any("err", err)) + return + } + if req.SessionID == "" || len(req.URLs) == 0 { + return + } + go func() { + if err := model.SendSessionPullRequests(context.Background(), *p.config, req.SessionID, req.URLs); err != nil { + slog.Warn("Failed to send session pull requests", slog.String("sessionId", req.SessionID), slog.Any("err", err)) + return + } + slog.Debug("session_pull_requests sent", slog.String("sessionId", req.SessionID), slog.Int("count", len(req.URLs))) + }() +} + func (p *SocketHandler) handleStatus(conn net.Conn) { uptime := time.Since(startedAt) response := StatusResponse{ diff --git a/daemon/socket_extra_test.go b/daemon/socket_extra_test.go index d1ccf70..de79ca6 100644 --- a/daemon/socket_extra_test.go +++ b/daemon/socket_extra_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -165,6 +167,54 @@ func TestSocketHandler_SessionProject(t *testing.T) { }) } +func TestSocketHandler_SessionPullRequestsSendsToServer(t *testing.T) { + type request struct { + path, auth string + body map[string]interface{} + } + got := make(chan request, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + got <- request{path: r.URL.Path, auth: r.Header.Get("Authorization"), body: body} + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + _, socketPath := startHandler(t, &model.ShellTimeConfig{Token: "tok", APIEndpoint: server.URL}) + urls := []string{"https://github.com/o/r/pull/1", "https://github.com/o/r2/pull/2"} + require.NoError(t, SendSessionPullRequests(socketPath, "sess-1", urls)) + + select { + case req := <-got: + assert.Equal(t, "/api/v1/cc/session-pull-requests", req.path) + assert.Equal(t, "CLI tok", req.auth) + assert.Equal(t, "sess-1", req.body["sessionId"]) + assert.Equal(t, []interface{}{urls[0], urls[1]}, req.body["urls"]) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not send session pull requests to the server") + } +} + +func TestSocketHandler_SessionPullRequestsIgnoresEmpty(t *testing.T) { + requests := make(chan struct{}, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- struct{}{} + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + _, socketPath := startHandler(t, &model.ShellTimeConfig{Token: "tok", APIEndpoint: server.URL}) + require.NoError(t, SendSessionPullRequests(socketPath, "", []string{"https://github.com/o/r/pull/1"})) + require.NoError(t, SendSessionPullRequests(socketPath, "sess-1", nil)) + + select { + case <-requests: + t.Fatal("daemon sent a request without a session id or urls") + case <-time.After(200 * time.Millisecond): + } +} + func TestSocketHandler_UnknownMessageType(t *testing.T) { _, socketPath := startHandler(t, &model.ShellTimeConfig{}) diff --git a/daemon/socket_test.go b/daemon/socket_test.go index d29f8ae..a8ff571 100644 --- a/daemon/socket_test.go +++ b/daemon/socket_test.go @@ -193,6 +193,7 @@ func TestSocketMessageType_Constants(t *testing.T) { {SocketMessageTypeHeartbeat, "heartbeat"}, {SocketMessageTypeStatus, "status"}, {SocketMessageTypeCCInfo, "cc_info"}, + {SocketMessageTypeSessionPullRequests, "session_pull_requests"}, } for _, tc := range testCases { diff --git a/model/api_senders_test.go b/model/api_senders_test.go index b105003..51c8ece 100644 --- a/model/api_senders_test.go +++ b/model/api_senders_test.go @@ -213,3 +213,39 @@ func TestSendAliasesToServer(t *testing.T) { assert.Contains(t, err.Error(), "failed to send aliases to server") }) } + +func TestSendSessionPullRequests(t *testing.T) { + t.Run("happy path posts session and urls", func(t *testing.T) { + var gotPath, gotAuth string + var body sessionPullRequestsRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + readJSONBody(t, r, &body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + cfg := ShellTimeConfig{Token: "tok123", APIEndpoint: server.URL} + urls := []string{"https://github.com/o/r/pull/1", "https://github.com/o/r2/pull/2"} + err := SendSessionPullRequests(context.Background(), cfg, "sess-1", urls) + require.NoError(t, err) + assert.Equal(t, "/api/v1/cc/session-pull-requests", gotPath) + assert.Equal(t, "CLI tok123", gotAuth) + assert.Equal(t, "sess-1", body.SessionID) + assert.Equal(t, urls, body.URLs) + }) + + t.Run("error path returns error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"Invalid request body"}`)) + })) + defer server.Close() + + cfg := ShellTimeConfig{Token: "t", APIEndpoint: server.URL} + err := SendSessionPullRequests(context.Background(), cfg, "s", []string{"nope"}) + require.Error(t, err) + assert.Equal(t, "Invalid request body", err.Error()) + }) +} diff --git a/model/api_session_pull_requests.go b/model/api_session_pull_requests.go new file mode 100644 index 0000000..f1f40c2 --- /dev/null +++ b/model/api_session_pull_requests.go @@ -0,0 +1,38 @@ +package model + +import ( + "context" + "net/http" + "time" +) + +type sessionPullRequestsRequest struct { + SessionID string `json:"sessionId"` + URLs []string `json:"urls"` +} + +type sessionPullRequestsResponse struct{} + +// SendSessionPullRequests links pull requests opened in a Claude Code session +// (`gh pr create`) to that session on the server +func SendSessionPullRequests(ctx context.Context, config ShellTimeConfig, sessionID string, urls []string) error { + ctx, span := modelTracer.Start(ctx, "session_pull_requests.send") + defer span.End() + + var resp sessionPullRequestsResponse + return SendHTTPRequestJSON(HTTPRequestOptions[*sessionPullRequestsRequest, sessionPullRequestsResponse]{ + Context: ctx, + Endpoint: Endpoint{ + APIEndpoint: config.APIEndpoint, + Token: config.Token, + }, + Method: http.MethodPost, + Path: "/api/v1/cc/session-pull-requests", + Payload: &sessionPullRequestsRequest{ + SessionID: sessionID, + URLs: urls, + }, + Response: &resp, + Timeout: 5 * time.Second, + }) +}