Skip to content
Closed
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
101 changes: 101 additions & 0 deletions internal/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,104 @@ func (c *Client) Profiles(ctx context.Context) ([]AdminProfile, error) {
}
return out.Profiles, nil
}

// AdminFeed is a subscribed feed source as the admin API returns it
// (GET/POST/PATCH /api/admin/feeds). authorHandle and listingSlug are joined
// display labels; the server stores profile/listing ids after resolving those
// handles. There is no DELETE verb — pause with enabled=false.
type AdminFeed struct {
ID string `json:"id"`
Label string `json:"label"`
FeedURL string `json:"feedUrl"`
Kind string `json:"kind"` // github_release | rss
AuthorProfileID *string `json:"authorProfileId"`
AuthorHandle *string `json:"authorHandle"`
ListingID *string `json:"listingId"`
ListingSlug *string `json:"listingSlug"`
DefaultCategory string `json:"defaultCategory"`
DefaultTags []string `json:"defaultTags"`
AutoPublish bool `json:"autoPublish"`
Enabled bool `json:"enabled"`
LastFetchedAt *string `json:"lastFetchedAt"`
LastStatus *string `json:"lastStatus"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}

// FeedSyncSummary is the result of POST /api/admin/feeds/{id}/sync (or one
// entry of the bulk ingest run). Error is non-empty when the fetch/parse failed.
type FeedSyncSummary struct {
FeedID string `json:"feedId"`
Label string `json:"label"`
Fetched int `json:"fetched"`
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
ItemErrors []string `json:"itemErrors"`
Error string `json:"error,omitempty"`
}

// Feeds lists every feed source: GET /api/admin/feeds. Admin only.
func (c *Client) Feeds(ctx context.Context) ([]AdminFeed, error) {
var out struct {
Feeds []AdminFeed `json:"feeds"`
}
if err := c.do(ctx, http.MethodGet, "/api/admin/feeds", nil, nil, &out); err != nil {
return nil, err
}
return out.Feeds, nil
}

// AdminFeed fetches one feed source by id: GET /api/admin/feeds/{id}.
func (c *Client) AdminFeed(ctx context.Context, id string) (*AdminFeed, error) {
var out struct {
Feed AdminFeed `json:"feed"`
}
path := "/api/admin/feeds/" + url.PathEscape(id)
if err := c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil {
return nil, err
}
return &out.Feed, nil
}

// CreateFeed creates a feed source: POST /api/admin/feeds. fields is sent
// verbatim — the server validates kind/defaultCategory and resolves
// authorHandle/listingSlug to ids (422 unknown_profile / invalid_input when
// missing). The id is server-assigned.
func (c *Client) CreateFeed(ctx context.Context, fields map[string]any) (*AdminFeed, error) {
var out struct {
Feed AdminFeed `json:"feed"`
}
if err := c.do(ctx, http.MethodPost, "/api/admin/feeds", nil, fields, &out); err != nil {
return nil, err
}
return &out.Feed, nil
}

// UpdateFeed patches a feed source: PATCH /api/admin/feeds/{id}. No DELETE —
// pause with {"enabled":false}. authorHandle/listingSlug are re-resolved
// server-side when present; empty string clears the attribution.
func (c *Client) UpdateFeed(ctx context.Context, id string, patch map[string]any) (*AdminFeed, error) {
var out struct {
Feed AdminFeed `json:"feed"`
}
path := "/api/admin/feeds/" + url.PathEscape(id)
if err := c.do(ctx, http.MethodPatch, path, nil, patch, &out); err != nil {
return nil, err
}
return &out.Feed, nil
}

// SyncFeed ingests one feed now: POST /api/admin/feeds/{id}/sync. A failed
// fetch/parse is a 502 carrying the summary — the typed *APIError still
// surfaces; callers that need the summary on success use this path.
func (c *Client) SyncFeed(ctx context.Context, id string) (*FeedSyncSummary, error) {
var out struct {
Summary FeedSyncSummary `json:"summary"`
}
path := "/api/admin/feeds/" + url.PathEscape(id) + "/sync"
if err := c.do(ctx, http.MethodPost, path, nil, nil, &out); err != nil {
return nil, err
}
return &out.Summary, nil
}
98 changes: 98 additions & 0 deletions internal/api/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,104 @@ func TestAdminProfileMethods(t *testing.T) {
})
}

// Feed equivalents follow the same wire contract under /api/admin/feeds:
// list GETs every feed source; create POSTs the field map; get/update use the
// id path; sync is POST …/sync and decodes the summary.
func TestAdminFeedMethods(t *testing.T) {
t.Run("list", func(t *testing.T) {
e := &adminEcho{status: http.StatusOK,
answer: `{"feeds":[{"id":"01F","label":"Buzz","feedUrl":"https://github.com/block/buzz","kind":"github_release","defaultCategory":"Releases","defaultTags":["buzz"],"autoPublish":true,"enabled":true,"createdAt":"t","updatedAt":"t"}]}`}
c := adminClient(t, e)
feeds, err := c.Feeds(context.Background())
if err != nil {
t.Fatalf("Feeds: %v", err)
}
if e.method != http.MethodGet || e.path != "/api/admin/feeds" {
t.Errorf("request = %s %s, want GET /api/admin/feeds", e.method, e.path)
}
if len(feeds) != 1 || feeds[0].Label != "Buzz" {
t.Errorf("feeds = %+v, want the decoded feed list", feeds)
}
})

t.Run("create", func(t *testing.T) {
e := &adminEcho{status: http.StatusCreated,
answer: `{"feed":{"id":"01F","label":"Buzz","feedUrl":"https://github.com/block/buzz","kind":"github_release","defaultCategory":"Releases","defaultTags":[],"autoPublish":true,"enabled":true,"createdAt":"t","updatedAt":"t"}}`}
c := adminClient(t, e)
feed, err := c.CreateFeed(context.Background(), map[string]any{
"label": "Buzz", "feedUrl": "https://github.com/block/buzz",
"kind": "github_release", "defaultCategory": "Releases", "autoPublish": true,
})
if err != nil {
t.Fatalf("CreateFeed: %v", err)
}
if e.method != http.MethodPost || e.path != "/api/admin/feeds" {
t.Errorf("request = %s %s, want POST /api/admin/feeds", e.method, e.path)
}
var sent map[string]any
if err := json.Unmarshal(e.body, &sent); err != nil {
t.Fatalf("request body is not JSON: %v (%q)", err, e.body)
}
if sent["label"] != "Buzz" || sent["autoPublish"] != true {
t.Errorf("sent body = %v, want the field map verbatim", sent)
}
if feed.ID != "01F" || feed.Label != "Buzz" {
t.Errorf("feed = %+v, want the decoded created feed", feed)
}
})

t.Run("get", func(t *testing.T) {
e := &adminEcho{status: http.StatusOK,
answer: `{"feed":{"id":"01F","label":"Buzz","feedUrl":"u","kind":"github_release","defaultCategory":"Releases","defaultTags":[],"autoPublish":false,"enabled":true,"createdAt":"t","updatedAt":"t"}}`}
c := adminClient(t, e)
feed, err := c.AdminFeed(context.Background(), "01F")
if err != nil {
t.Fatalf("AdminFeed: %v", err)
}
if e.method != http.MethodGet || e.path != "/api/admin/feeds/01F" {
t.Errorf("request = %s %s, want GET /api/admin/feeds/01F", e.method, e.path)
}
if feed.Label != "Buzz" {
t.Errorf("feed = %+v, want the decoded feed", feed)
}
})

t.Run("update", func(t *testing.T) {
e := &adminEcho{status: http.StatusOK,
answer: `{"feed":{"id":"01F","label":"Buzz","feedUrl":"u","kind":"github_release","defaultCategory":"Releases","defaultTags":[],"autoPublish":true,"enabled":false,"createdAt":"t","updatedAt":"t"}}`}
c := adminClient(t, e)
feed, err := c.UpdateFeed(context.Background(), "01F", map[string]any{"enabled": false})
if err != nil {
t.Fatalf("UpdateFeed: %v", err)
}
if e.method != http.MethodPatch || e.path != "/api/admin/feeds/01F" {
t.Errorf("request = %s %s, want PATCH /api/admin/feeds/01F", e.method, e.path)
}
if string(e.body) != `{"enabled":false}` {
t.Errorf("sent body = %s, want only the patched field", e.body)
}
if feed.Enabled {
t.Errorf("feed.Enabled = true, want false")
}
})

t.Run("sync", func(t *testing.T) {
e := &adminEcho{status: http.StatusOK,
answer: `{"summary":{"feedId":"01F","label":"Buzz","fetched":2,"created":2,"updated":0,"skipped":0,"itemErrors":[]}}`}
c := adminClient(t, e)
sum, err := c.SyncFeed(context.Background(), "01F")
if err != nil {
t.Fatalf("SyncFeed: %v", err)
}
if e.method != http.MethodPost || e.path != "/api/admin/feeds/01F/sync" {
t.Errorf("request = %s %s, want POST /api/admin/feeds/01F/sync", e.method, e.path)
}
if sum.FeedID != "01F" || sum.Created != 2 {
t.Errorf("summary = %+v, want the decoded sync summary", sum)
}
})
}

// A non-2xx admin response must surface as the typed *APIError carrying the
// server's envelope verbatim — the CLI prints validator messages raw.
func TestAdminErrorPassthrough(t *testing.T) {
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ const defaultLoopSourceURL = "https://positronick.com/registry/loop"

// registerAdminCommands attaches the hidden admin commands to the existing
// tree by lookup — soul create/update under the soul noun, loop create under
// the loop noun, plus a hidden `listing` parent for the type-agnostic
// create/update — then reveals them all if the cached login is an admin.
// the loop noun, plus hidden `listing`/`profile`/`feed` parents — then
// reveals them all if the cached login is an admin.
func registerAdminCommands(root *cobra.Command) {
if soul := findCommand(root, "soul"); soul != nil {
soul.AddCommand(newSoulCreateCmd(), newSoulUpdateCmd())
Expand All @@ -50,6 +50,7 @@ func registerAdminCommands(root *cobra.Command) {
}
root.AddCommand(newListingCmd())
root.AddCommand(newProfileCmd())
root.AddCommand(newFeedCmd())
revealAdminCommands(root)
}

Expand Down
7 changes: 7 additions & 0 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ var adminCommandPaths = [][]string{
{"profile"},
{"profile", "create"},
{"profile", "list"},
{"feed"},
{"feed", "list"},
{"feed", "create"},
{"feed", "update"},
{"feed", "sync"},
}

// The admin commands are hidden by default and flip visible from the CACHED
Expand Down Expand Up @@ -246,6 +251,8 @@ func TestRevealAdminCommands(t *testing.T) {
"positronick soul create", "positronick soul update",
"positronick listing create", "positronick loop create",
"positronick profile create", "positronick profile list",
"positronick feed list", "positronick feed create",
"positronick feed update", "positronick feed sync",
} {
if !strings.Contains(stdout, "## "+want) {
t.Errorf("agent-docs for an admin must document %q", want)
Expand Down
Loading