From 66e0bd913ae5d4062b72ce90830f3de64cb236b9 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 11 Aug 2026 17:13:46 +0200 Subject: [PATCH] feat(activity): add resource activity history - query oCIS activitylog across visible personal and project Spaces - add resource scoping with depth, limit, and sort filters - render human and structured output with permission-aware errors Signed-off-by: Matteo --- .agents/skills/use-ocis-cli/SKILL.md | 9 +- .../use-ocis-cli/references/commands.md | 12 + .claude/skills/use-ocis-cli/SKILL.md | 9 +- .../use-ocis-cli/references/commands.md | 12 + ARCHITECTURE.md | 8 +- Makefile | 2 +- README.md | 38 +++ internal/activities/client.go | 133 ++++++++++ internal/activities/client_test.go | 96 +++++++ internal/app/activity_api.go | 26 ++ internal/app/activity_service.go | 247 ++++++++++++++++++ internal/app/activity_service_test.go | 153 +++++++++++ internal/app/runtime.go | 9 + internal/command/activity.go | 49 ++++ internal/command/root.go | 1 + internal/command/root_test.go | 35 +++ 16 files changed, 830 insertions(+), 9 deletions(-) create mode 100644 internal/activities/client.go create mode 100644 internal/activities/client_test.go create mode 100644 internal/app/activity_api.go create mode 100644 internal/app/activity_service.go create mode 100644 internal/app/activity_service_test.go create mode 100644 internal/command/activity.go diff --git a/.agents/skills/use-ocis-cli/SKILL.md b/.agents/skills/use-ocis-cli/SKILL.md index 4f6f1f0..811afdf 100644 --- a/.agents/skills/use-ocis-cli/SKILL.md +++ b/.agents/skills/use-ocis-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: use-ocis-cli -description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, manage notifications, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. +description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, inspect activity history, manage notifications, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. --- # Use oCIS CLI @@ -44,8 +44,8 @@ protocol-level work. parse human-readable tables when structured output is available. - Use read-only discovery commands such as `ls`, `stat`, `search`, `tree`, `space list`, `share overview`, `federation connection list`, `trash list`, - `notification list`, and admin `list` or `info` commands to resolve names and - IDs before changing anything. + `activity list`, `notification list`, and admin `list` or `info` commands to + resolve names and IDs before changing anything. - Interpret a remote path in the selected Space. Keep local filesystem paths and remote oCIS paths distinct according to the command help. @@ -61,6 +61,9 @@ protocol-level work. file or folder is a separate explicit operation. - Use `trash` for recoverable deletion management and `version` for historical file versions. +- Use `activity list` for read-only account-wide or resource-scoped history. + Pass a remote path or `--space` when the requested scope is narrower than the + account. - Use `notification list` and `notification info` to inspect unread events. In oCIS, `notification dismiss` is the server's mark-as-read operation; it does not delete the resource referenced by the notification. diff --git a/.agents/skills/use-ocis-cli/references/commands.md b/.agents/skills/use-ocis-cli/references/commands.md index c363c50..2d0d873 100644 --- a/.agents/skills/use-ocis-cli/references/commands.md +++ b/.agents/skills/use-ocis-cli/references/commands.md @@ -19,6 +19,7 @@ same command. - Spaces - Sharing - Federation +- Activity history - Notifications - Metadata, trash, and versions - Administration @@ -117,6 +118,17 @@ An invitation token establishes identity trust; it does not share a resource. Treat invitation tokens as secrets and never accept one without an explicit user request. +## Activity history + +| Command | Purpose | +| --- | --- | +| `activity list, activity ls` | List account-wide or resource-scoped activity history with server-enforced permissions. | + +With no path or explicit `--space`, `activity list` is account-wide. Pass a +remote path to use the current file root, or pass `--space SPACE` to scope the +query to that Space. Use `--depth`, `--limit`, and `--sort` for server-side +filtering. Activity history is read-only. + ## Notifications | Command | Purpose | diff --git a/.claude/skills/use-ocis-cli/SKILL.md b/.claude/skills/use-ocis-cli/SKILL.md index 4f6f1f0..811afdf 100644 --- a/.claude/skills/use-ocis-cli/SKILL.md +++ b/.claude/skills/use-ocis-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: use-ocis-cli -description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, manage notifications, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. +description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, inspect activity history, manage notifications, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. --- # Use oCIS CLI @@ -44,8 +44,8 @@ protocol-level work. parse human-readable tables when structured output is available. - Use read-only discovery commands such as `ls`, `stat`, `search`, `tree`, `space list`, `share overview`, `federation connection list`, `trash list`, - `notification list`, and admin `list` or `info` commands to resolve names and - IDs before changing anything. + `activity list`, `notification list`, and admin `list` or `info` commands to + resolve names and IDs before changing anything. - Interpret a remote path in the selected Space. Keep local filesystem paths and remote oCIS paths distinct according to the command help. @@ -61,6 +61,9 @@ protocol-level work. file or folder is a separate explicit operation. - Use `trash` for recoverable deletion management and `version` for historical file versions. +- Use `activity list` for read-only account-wide or resource-scoped history. + Pass a remote path or `--space` when the requested scope is narrower than the + account. - Use `notification list` and `notification info` to inspect unread events. In oCIS, `notification dismiss` is the server's mark-as-read operation; it does not delete the resource referenced by the notification. diff --git a/.claude/skills/use-ocis-cli/references/commands.md b/.claude/skills/use-ocis-cli/references/commands.md index c363c50..2d0d873 100644 --- a/.claude/skills/use-ocis-cli/references/commands.md +++ b/.claude/skills/use-ocis-cli/references/commands.md @@ -19,6 +19,7 @@ same command. - Spaces - Sharing - Federation +- Activity history - Notifications - Metadata, trash, and versions - Administration @@ -117,6 +118,17 @@ An invitation token establishes identity trust; it does not share a resource. Treat invitation tokens as secrets and never accept one without an explicit user request. +## Activity history + +| Command | Purpose | +| --- | --- | +| `activity list, activity ls` | List account-wide or resource-scoped activity history with server-enforced permissions. | + +With no path or explicit `--space`, `activity list` is account-wide. Pass a +remote path to use the current file root, or pass `--space SPACE` to scope the +query to that Space. Use `--depth`, `--limit`, and `--sort` for server-side +filtering. Activity history is read-only. + ## Notifications | Command | Purpose | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ac83d4e..4bc7282 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,6 +9,7 @@ The repository follows the standard Go command layout: cmd/ ocis/ executable entrypoint only internal/ + activities/ authenticated oCIS Graph activity-history client command/ Cobra command tree and input validation app/ application use-case orchestration apperror/ stable error categories and exit-code mapping @@ -58,7 +59,7 @@ without starting a subprocess. `batch_service.go`, `filesystem_service.go`, `filesystem_tree_service.go`, `filesystem_du_service.go`, `filesystem_touch_service.go`, `filesystem_walk.go`, `metadata_service.go`, - `notification_service.go`, + `activity_service.go`, `notification_service.go`, `share_overview_service.go`, `space_member_service.go`, `space_update_service.go`, `space_lifecycle_service.go`, and @@ -67,6 +68,9 @@ without starting a subprocess. contains shared application wiring. - `internal/apperror`: classify usage, authentication, not-found, and conflict errors without coupling application services to Cobra. +- `internal/activities`: query account-wide or resource-scoped activity + history through the bounded oCIS Graph extension, preserving the localized + message template and structured variables returned by the server. - `internal/auth`: implement OIDC discovery, dynamic native-client registration, token exchange, refresh, and userinfo. - `internal/config`: validate server URLs, requiring `https` unless the caller @@ -135,7 +139,7 @@ without starting a subprocess. authentication headers, metadata and checksum response mapping, and safe scalar custom-property `PROPFIND`/`PROPPATCH` operations. -Protocol-specific behavior belongs in dedicated `internal/auth`, +Protocol-specific behavior belongs in dedicated `internal/activities`, `internal/auth`, `internal/federation`, `internal/graph`, `internal/notifications`, `internal/search`, `internal/sharing`, `internal/trash`, `internal/versions`, and `internal/webdav` adapters. Recursive local/remote diff --git a/Makefile b/Makefile index e293d38..b47ac7c 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ check: fmt coverage: go run ./tools/covercheck -min $(COVERAGE_MIN) \ - app auth federation graph httpapi notifications retry search sharing sync trash transfer versions \ + activities app auth federation graph httpapi notifications retry search sharing sync trash transfer versions \ webdav fmt: diff --git a/README.md b/README.md index 4ce0d45..7cac272 100644 --- a/README.md +++ b/README.md @@ -1204,6 +1204,44 @@ sharing restrictions, and resource permissions. A user may be able to read a file without being allowed to share it, update a share, or remove another user's permission. +## Activity history + +Inspect changes recorded by the oCIS activity service. With no path or explicit +`--space`, the command returns account-wide activity visible to the current +user: + +```sh +ocis activity list +ocis activity list --limit 50 --sort desc +ocis --json activity list +``` + +Pass a remote path to scope the history to a file or folder. Path resolution +uses the profile's saved default Space, or the personal file root when no Space +is selected. An explicit `--space` without a path scopes the query to that +Space's root: + +```sh +ocis activity list /reports/report.pdf +ocis activity list /projects --depth 1 +ocis --space Engineering activity list +ocis --space Engineering activity list /reports --depth -1 +``` + +`--depth 0` selects only the resolved resource, positive values include that +many descendant levels, and `--depth -1` includes the complete recorded +subtree. The default limit is 100; `--limit -1` requests all history retained +by the server. Sorting accepts `asc` or `desc` and defaults to newest first. + +Human output substitutes the structured activity variables into the server's +localized message. JSON and JSONL preserve the activity ID, recorded time, +message template, and complete variables for automation. + +Activity history is read-only. The server requires the authenticated user to +have permission to list grants on the selected resource, so access can differ +between users and Spaces. The CLI reports that authorization decision instead +of assuming that every authenticated user can inspect every activity. + ## Notifications List and inspect the authenticated user's unread in-app notifications: diff --git a/internal/activities/client.go b/internal/activities/client.go new file mode 100644 index 0000000..bead17c --- /dev/null +++ b/internal/activities/client.go @@ -0,0 +1,133 @@ +// Package activities implements the authenticated oCIS Graph activity API. +package activities + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +const ( + endpoint = "/graph/v1beta1/extensions/org.libregraph/activities" + maxResponseBytes = 8 << 20 + maxLimit = 1000 +) + +// Activity is one server-recorded resource activity. +type Activity struct { + ID string `json:"id"` + Times Times `json:"times"` + Template Template `json:"template"` +} + +// Times contains server timestamps associated with an activity. +type Times struct { + RecordedTime string `json:"recordedTime"` +} + +// Template contains the localized activity message and its structured values. +type Template struct { + Message string `json:"message"` + Variables map[string]any `json:"variables,omitempty"` +} + +// ListRequest describes server-side activity filters. A nil Depth omits the +// filter; -1 explicitly requests the complete subtree. +type ListRequest struct { + ItemID string + Depth *int + Limit int + Sort string +} + +// Client reads activities visible to the authenticated user. +type Client struct { + api *httpapi.Client +} + +// NewClient constructs an activities client. +func NewClient(config httpapi.Config, httpClient *http.Client) *Client { + return &Client{api: httpapi.NewClient(config, httpClient)} +} + +// List returns activities matching the requested resource and bounds. +func (client *Client) List( + ctx context.Context, request ListRequest, +) ([]Activity, error) { + resource, err := listResource(request) + if err != nil { + return nil, err + } + response, err := client.api.Do( + ctx, http.MethodGet, resource, nil, + http.Header{"Accept": {"application/json"}}, + ) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, httpapi.ResponseError(response) + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("read activities response: %w", err) + } + var payload struct { + Value []Activity `json:"value"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("decode activities response: %w", err) + } + if payload.Value == nil { + return []Activity{}, nil + } + return payload.Value, nil +} + +func listResource(request ListRequest) (string, error) { + filters := make([]string, 0, 4) + itemID := strings.TrimSpace(request.ItemID) + if strings.ContainsAny(itemID, "\"\r\n") { + return "", errors.New("activity item ID contains unsupported characters") + } + if itemID != "" { + filters = append(filters, `itemid:"`+itemID+`"`) + } + if request.Depth != nil { + if *request.Depth < -1 { + return "", errors.New("activity depth must be -1 or greater") + } + filters = append(filters, "depth:"+strconv.Itoa(*request.Depth)) + } + if request.Limit != 0 { + if request.Limit != -1 && + (request.Limit < 1 || request.Limit > maxLimit) { + return "", fmt.Errorf( + "activity limit must be -1 or between 1 and %d", maxLimit, + ) + } + filters = append(filters, "limit:"+strconv.Itoa(request.Limit)) + } + sortOrder := strings.ToLower(strings.TrimSpace(request.Sort)) + if sortOrder != "" { + if sortOrder != "asc" && sortOrder != "desc" { + return "", errors.New("activity sort must be asc or desc") + } + filters = append(filters, "sort:"+sortOrder) + } + if len(filters) == 0 { + return endpoint, nil + } + query := url.Values{} + query.Set("kql", strings.Join(filters, " AND ")) + return endpoint + "?" + query.Encode(), nil +} diff --git a/internal/activities/client_test.go b/internal/activities/client_test.go new file mode 100644 index 0000000..298a4bd --- /dev/null +++ b/internal/activities/client_test.go @@ -0,0 +1,96 @@ +package activities + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +func TestListActivities(t *testing.T) { + depth := 1 + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + if request.Method != http.MethodGet || request.URL.Path != endpoint { + t.Fatalf("request: %s %s", request.Method, request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer token" { + t.Fatalf("authorization: %q", request.Header.Get("Authorization")) + } + if request.Header.Get("Accept") != "application/json" { + t.Fatalf("accept: %q", request.Header.Get("Accept")) + } + wantQuery := `itemid:"storage$space!report" AND depth:1 AND limit:50 AND sort:desc` + if got := request.URL.Query().Get("kql"); got != wantQuery { + t.Fatalf("kql: got %q, want %q", got, wantQuery) + } + _, _ = io.WriteString(writer, `{"value":[{ + "id":"event-1","times":{"recordedTime":"2026-08-11T08:00:00Z"}, + "template":{"message":"{user} added {resource} to {folder}", + "variables":{"user":{"id":"alice","displayName":"Alice"}, + "resource":{"id":"file-1","name":"report.txt"}, + "folder":{"id":"folder-1","name":"Reports"}}} + }]}`) + })) + defer server.Close() + + client := NewClient(httpapi.Config{ + Server: server.URL, AuthType: "oidc", AccessToken: "token", + }, server.Client()) + values, err := client.List(context.Background(), ListRequest{ + ItemID: "storage$space!report", Depth: &depth, Limit: 50, Sort: "DESC", + }) + if err != nil || len(values) != 1 || values[0].ID != "event-1" || + values[0].Template.Variables["resource"] == nil { + t.Fatalf("activities: %#v, %v", values, err) + } +} + +func TestListActivitiesAcceptsNullCollection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, _ *http.Request, + ) { + _, _ = io.WriteString(writer, `{"value":null}`) + })) + defer server.Close() + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + values, err := client.List(context.Background(), ListRequest{}) + if err != nil || values == nil || len(values) != 0 { + t.Fatalf("activities: %#v, %v", values, err) + } +} + +func TestListActivitiesValidatesFilters(t *testing.T) { + invalidDepth := -2 + client := NewClient(httpapi.Config{Server: "http://127.0.0.1:1"}, nil) + for _, request := range []ListRequest{ + {ItemID: "invalid\"id"}, + {Depth: &invalidDepth}, + {Limit: 1001}, + {Sort: "newest"}, + } { + if _, err := client.List(context.Background(), request); err == nil { + t.Fatalf("accepted invalid request: %#v", request) + } + } +} + +func TestListActivitiesReturnsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, _ *http.Request, + ) { + writer.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(writer, "activity access denied") + })) + defer server.Close() + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + _, err := client.List(context.Background(), ListRequest{Limit: 100}) + if err == nil || !strings.Contains(err.Error(), "activity access denied") { + t.Fatalf("error: %v", err) + } +} diff --git a/internal/app/activity_api.go b/internal/app/activity_api.go new file mode 100644 index 0000000..5467ab9 --- /dev/null +++ b/internal/app/activity_api.go @@ -0,0 +1,26 @@ +package app + +import "context" + +// ActivityRequest describes one activity-history query. +type ActivityRequest struct { + Path string + Depth int + DepthSet bool + Limit int + Sort string +} + +// RunActivityWithOptions lists activity history visible to the authenticated +// user. +func RunActivityWithOptions( + ctx context.Context, + request ActivityRequest, + selectedProfile string, + options RunOptions, +) error { + return classifyProtocolError( + "activity list", + runActivity(ctx, request, selectedProfile, options.normalized()), + ) +} diff --git a/internal/app/activity_service.go b/internal/app/activity_service.go new file mode 100644 index 0000000..da797a3 --- /dev/null +++ b/internal/app/activity_service.go @@ -0,0 +1,247 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/mzner/ocis-cli/internal/activities" + "github.com/mzner/ocis-cli/internal/apperror" + appoutput "github.com/mzner/ocis-cli/internal/output" +) + +func runActivity( + ctx context.Context, + request ActivityRequest, + selectedProfile string, + options RunOptions, +) error { + if err := validateActivityRequest(request); err != nil { + return apperror.Wrap(apperror.KindUsage, "activity list", err) + } + client, err := newClientWithOptions(ctx, selectedProfile, options) + if err != nil { + return err + } + + remote := strings.TrimSpace(request.Path) + itemID := "" + if remote != "" || options.Space != "" { + if err := client.selectSpace(options.Space); err != nil { + return err + } + if remote == "" { + remote = "/" + } + metadata, err := client.stat(remote) + if err != nil { + return err + } + if metadata.ResourceID == "" { + return errors.New( + "server did not return a stable resource ID; scoped activity history is unsupported", + ) + } + itemID = metadata.ResourceID + } + + var depth *int + if request.DepthSet { + depth = &request.Depth + } + activityRequest := activities.ListRequest{ + ItemID: itemID, Depth: depth, Limit: request.Limit, Sort: request.Sort, + } + var values []activities.Activity + if itemID == "" { + values, err = listAccountActivities( + ctx, client, activityRequest, options, + ) + } else { + values, err = client.activitiesClient().List(ctx, activityRequest) + } + if err != nil { + return activityListError(remote, err) + } + return writeActivities(values, options) +} + +func listAccountActivities( + ctx context.Context, + client *client, + request activities.ListRequest, + options RunOptions, +) ([]activities.Activity, error) { + drives, err := client.graphClient().ListMyDrives(ctx) + if err != nil { + return nil, err + } + values := make([]activities.Activity, 0) + seen := make(map[string]struct{}) + permissionFailures := 0 + successfulDrives := 0 + var permissionErr error + for _, drive := range drives { + driveType := strings.ToLower(strings.TrimSpace(drive.DriveType)) + if drive.ID == "" || drive.Root.Deleted != nil || + (driveType != "personal" && driveType != "project") { + continue + } + request.ItemID = drive.ID + driveActivities, err := client.activitiesClient().List(ctx, request) + if protocolStatus(err) == http.StatusForbidden { + permissionFailures++ + permissionErr = err + continue + } + if err != nil { + return nil, fmt.Errorf( + "list activity history for Space %q: %w", drive.Name, err, + ) + } + successfulDrives++ + for _, value := range driveActivities { + if value.ID != "" { + if _, exists := seen[value.ID]; exists { + continue + } + seen[value.ID] = struct{}{} + } + values = append(values, value) + } + } + if successfulDrives == 0 && permissionFailures > 0 { + return nil, permissionErr + } + if permissionFailures > 0 { + _, _ = fmt.Fprintf( + options.Err, + "Warning: activity history is unavailable for %d Space(s) due to server permissions\n", + permissionFailures, + ) + } + sortActivities(values, request.Sort) + if request.Limit > 0 && len(values) > request.Limit { + values = values[:request.Limit] + } + return values, nil +} + +func sortActivities(values []activities.Activity, order string) { + descending := !strings.EqualFold(strings.TrimSpace(order), "asc") + sort.SliceStable(values, func(left, right int) bool { + leftTime, leftErr := time.Parse( + time.RFC3339Nano, values[left].Times.RecordedTime, + ) + rightTime, rightErr := time.Parse( + time.RFC3339Nano, values[right].Times.RecordedTime, + ) + if leftErr == nil && rightErr == nil && !leftTime.Equal(rightTime) { + if descending { + return leftTime.After(rightTime) + } + return leftTime.Before(rightTime) + } + if values[left].Times.RecordedTime != values[right].Times.RecordedTime { + if descending { + return values[left].Times.RecordedTime > values[right].Times.RecordedTime + } + return values[left].Times.RecordedTime < values[right].Times.RecordedTime + } + return values[left].ID < values[right].ID + }) +} + +func validateActivityRequest(request ActivityRequest) error { + if request.DepthSet && request.Depth < -1 { + return errors.New("--depth must be -1 or greater") + } + if request.Limit != -1 && + (request.Limit < 1 || request.Limit > 1000) { + return errors.New("--limit must be -1 or between 1 and 1000") + } + switch strings.ToLower(strings.TrimSpace(request.Sort)) { + case "asc", "desc": + return nil + default: + return errors.New("--sort must be asc or desc") + } +} + +func activityListError(remote string, err error) error { + scope := "account-wide activity history" + if remote != "" { + scope = fmt.Sprintf("activity history for %s", cleanRemote(remote)) + } + switch protocolStatus(err) { + case http.StatusForbidden: + return fmt.Errorf( + "the current user is not allowed to list %s: %w", scope, err, + ) + case http.StatusNotFound, http.StatusMethodNotAllowed, + http.StatusNotImplemented: + return fmt.Errorf( + "server does not expose the oCIS activity-history service: %w", err, + ) + default: + return err + } +} + +func writeActivities(values []activities.Activity, options RunOptions) error { + if options.OutputMode != appoutput.Human { + return writeOutput(options, "activity", values) + } + if len(values) == 0 { + _, err := fmt.Fprintln(options.Out, "No activities found") + return err + } + writer := tabwriter.NewWriter(options.Out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(writer, "DATE\tMESSAGE\tID"); err != nil { + return err + } + for _, value := range values { + if _, err := fmt.Fprintf( + writer, "%s\t%s\t%s\n", value.Times.RecordedTime, + activityLine(renderActivityMessage(value)), value.ID, + ); err != nil { + return err + } + } + return writer.Flush() +} + +func renderActivityMessage(value activities.Activity) string { + message := value.Template.Message + for name, variable := range value.Template.Variables { + replacement := activityVariableText(variable) + if replacement == "" { + continue + } + message = strings.ReplaceAll(message, "{"+name+"}", replacement) + } + return message +} + +func activityVariableText(value any) string { + switch value := value.(type) { + case string: + return value + case map[string]any: + for _, key := range []string{"displayName", "name", "value", "id"} { + if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" { + return text + } + } + } + return "" +} + +func activityLine(value string) string { + return strings.Join(strings.Fields(value), " ") +} diff --git a/internal/app/activity_service_test.go b/internal/app/activity_service_test.go new file mode 100644 index 0000000..1e50b1b --- /dev/null +++ b/internal/app/activity_service_test.go @@ -0,0 +1,153 @@ +package app + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/mzner/ocis-cli/internal/apperror" + appoutput "github.com/mzner/ocis-cli/internal/output" +) + +const appActivityFile = ` + + + /remote.php/dav/files/alice/report.txt + HTTP/1.1 200 OK + report.txt + 8 + storage$space!report + + +` + +const appActivityResponse = `{"value":[{ + "id":"event-1","times":{"recordedTime":"2026-08-11T08:00:00Z"}, + "template":{"message":"{user} added {resource} to {folder}", + "variables":{"user":{"id":"alice","displayName":"Alice Hansen"}, + "resource":{"id":"file-1","name":"report.txt"}, + "folder":{"id":"folder-1","name":"Reports"}}} +}]}` + +func TestActivityUseCases(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + switch { + case request.Method == http.MethodGet && + request.URL.Path == "/graph/v1.0/me/drives": + _, _ = io.WriteString(writer, `{"value":[ + {"id":"storage$space-a","name":"Personal","driveType":"personal"}, + {"id":"storage$space-b","name":"Engineering","driveType":"project"}, + {"id":"shares","name":"Shares","driveType":"virtual"} + ]}`) + case request.Method == "PROPFIND": + writer.WriteHeader(http.StatusMultiStatus) + _, _ = io.WriteString(writer, appActivityFile) + case request.Method == http.MethodGet: + query := request.URL.Query().Get("kql") + switch { + case strings.Contains(query, "!report"): + want := `itemid:"storage$space!report" AND depth:0 AND limit:10 AND sort:asc` + if query != want { + t.Fatalf("scoped kql: got %q, want %q", query, want) + } + _, _ = io.WriteString(writer, appActivityResponse) + case strings.Contains(query, "storage$space-a"): + want := `itemid:"storage$space-a" AND limit:100 AND sort:desc` + if query != want { + t.Fatalf("personal kql: got %q, want %q", query, want) + } + _, _ = io.WriteString(writer, appActivityResponse) + case strings.Contains(query, "storage$space-b"): + want := `itemid:"storage$space-b" AND limit:100 AND sort:desc` + if query != want { + t.Fatalf("project kql: got %q, want %q", query, want) + } + _, _ = io.WriteString(writer, strings.ReplaceAll( + strings.ReplaceAll(appActivityResponse, "event-1", "event-2"), + "2026-08-11T08:00:00Z", "2026-08-11T09:00:00Z", + )) + default: + t.Fatalf("unexpected activity kql: %q", query) + } + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + })) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + + var global bytes.Buffer + if err := RunActivityWithOptions( + context.Background(), ActivityRequest{Limit: 100, Sort: "desc"}, + "", RunOptions{Out: &global, OutputMode: appoutput.JSON}, + ); err != nil || !strings.Contains(global.String(), `"id": "event-1"`) || + !strings.Contains(global.String(), `"id": "event-2"`) || + strings.Index(global.String(), "event-2") > + strings.Index(global.String(), "event-1") { + t.Fatalf("global=%q error=%v", global.String(), err) + } + + var scoped bytes.Buffer + if err := RunActivityWithOptions( + context.Background(), ActivityRequest{ + Path: "/report.txt", Depth: 0, DepthSet: true, + Limit: 10, Sort: "asc", + }, "", RunOptions{Out: &scoped}, + ); err != nil || !strings.Contains( + scoped.String(), "Alice Hansen added report.txt to Reports", + ) || !strings.Contains(scoped.String(), "event-1") { + t.Fatalf("scoped=%q error=%v", scoped.String(), err) + } +} + +func TestActivityValidationFailsBeforeProfileLoad(t *testing.T) { + t.Setenv("OCIS_CONFIG", filepath.Join(t.TempDir(), "missing", "config.json")) + for _, request := range []ActivityRequest{ + {Depth: -2, DepthSet: true, Limit: 100, Sort: "desc"}, + {Limit: 0, Sort: "desc"}, + {Limit: 100, Sort: "newest"}, + } { + err := RunActivityWithOptions( + context.Background(), request, "", RunOptions{Out: io.Discard}, + ) + if !apperror.IsKind(err, apperror.KindUsage) { + t.Fatalf("request=%#v error=%v", request, err) + } + } +} + +func TestActivityPermissionErrorIsActionable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, _ *http.Request, + ) { + writer.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(writer, "Forbidden") + })) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + err := RunActivityWithOptions( + context.Background(), ActivityRequest{Limit: 100, Sort: "desc"}, + "", RunOptions{Out: io.Discard}, + ) + if !apperror.IsKind(err, apperror.KindAuthentication) || + !strings.Contains(err.Error(), "current user is not allowed") { + t.Fatalf("error: %v", err) + } +} + +func TestWriteActivitiesReportsEmptyHistory(t *testing.T) { + var output bytes.Buffer + if err := writeActivities( + nil, RunOptions{Out: &output}.normalized(), + ); err != nil || + output.String() != "No activities found\n" { + t.Fatalf("output=%q error=%v", output.String(), err) + } +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 0f5d854..fe0eb60 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -9,6 +9,7 @@ import ( "os" "time" + "github.com/mzner/ocis-cli/internal/activities" "github.com/mzner/ocis-cli/internal/apperror" "github.com/mzner/ocis-cli/internal/auth" appconfig "github.com/mzner/ocis-cli/internal/config" @@ -38,6 +39,7 @@ type client struct { http *http.Client store *store ctx context.Context + activities *activities.Client dav *webdav.Client graph *graph.Client search *search.Client @@ -52,6 +54,13 @@ type client struct { dependencies Dependencies } +func (client *client) activitiesClient() *activities.Client { + if client.activities == nil { + client.activities = activities.NewClient(client.apiConfig(), client.http) + } + return client.activities +} + func (client *client) apiConfig() httpapi.Config { return httpapi.Config{ Server: client.profile.Server, Username: client.profile.Username, diff --git a/internal/command/activity.go b/internal/command/activity.go new file mode 100644 index 0000000..08c36a0 --- /dev/null +++ b/internal/command/activity.go @@ -0,0 +1,49 @@ +package command + +import ( + "github.com/mzner/ocis-cli/internal/app" + "github.com/spf13/cobra" +) + +func newActivityCommand(options *globalOptions) *cobra.Command { + command := &cobra.Command{ + Use: "activity", Aliases: []string{"activities"}, + Short: "Inspect file, folder, and Space activity history", + } + command.AddCommand(newActivityListCommand(options)) + return command +} + +func newActivityListCommand(options *globalOptions) *cobra.Command { + var depth, limit int + var sortOrder string + command := &cobra.Command{ + Use: "list [REMOTE_PATH]", Aliases: []string{"ls"}, + Short: "List activity history", Args: maximumArgs(1), + RunE: func(command *cobra.Command, args []string) error { + remote := "" + if len(args) == 1 { + remote = args[0] + } + return app.RunActivityWithOptions( + command.Context(), app.ActivityRequest{ + Path: remote, Depth: depth, + DepthSet: command.Flags().Changed("depth"), + Limit: limit, Sort: sortOrder, + }, options.profile, options.runOptions(command), + ) + }, + } + command.Flags().IntVar( + &depth, "depth", -1, + "include descendants to this depth (-1 for the complete subtree)", + ) + command.Flags().IntVar( + &limit, "limit", 100, + "maximum activities to return (-1 for the server limit)", + ) + command.Flags().StringVar( + &sortOrder, "sort", "desc", "sort by recorded time (asc or desc)", + ) + return command +} diff --git a/internal/command/root.go b/internal/command/root.go index 1a1ee55..9fbf35d 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -128,6 +128,7 @@ func NewRootCommand() *cobra.Command { newShareCommand(options), newFederationCommand(options), newNotificationCommand(options), + newActivityCommand(options), newSearchCommand(options), newSyncCommand(options), newTagCommand(options), diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 1fa4b8c..3d72d0b 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -71,6 +71,7 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { "sync", "config", "cat", "tree", "du", "batch", "touch", "federation, federated, ocm", "notification, notifications", + "activity, activities", } { if !strings.Contains(help, expected) { t.Fatalf("help does not contain %q:\n%s", expected, help) @@ -78,6 +79,40 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { } } +func TestActivityCommandsAndAliasesAreDiscoverable(t *testing.T) { + root := NewRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"activity", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "list, ls") { + t.Fatalf("activity help:\n%s", output.String()) + } + + output.Reset() + root = NewRootCommand() + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"activity", "list", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, expected := range []string{"--depth", "--limit", "--sort"} { + if !strings.Contains(output.String(), expected) { + t.Fatalf("activity list help missing %q:\n%s", expected, output.String()) + } + } + for _, args := range [][]string{{"activities", "ls"}, {"activity", "list"}} { + root = NewRootCommand() + if _, _, err := root.Find(args); err != nil { + t.Fatalf("%v: %v", args, err) + } + } +} + func TestNotificationCommandsAndAliasesAreDiscoverable(t *testing.T) { root := NewRootCommand() var output bytes.Buffer