diff --git a/.clanker.example.yaml b/.clanker.example.yaml index 430f1a1..b86e2b7 100644 --- a/.clanker.example.yaml +++ b/.clanker.example.yaml @@ -227,6 +227,29 @@ infra: # Conversation history: ~/.clanker/linear-{workspaceID}.json (or {team} when # workspace_id is empty). Delete to start fresh. +# Notion (for `clanker notion ask ...` and `clanker notion search ...`): +# notion: +# integration_token: "" # Internal Integration token (or set NOTION_API_KEY) +# # Create at https://www.notion.so/profile/integrations +# # IMPORTANT: tokens start with ZERO access. You must +# # explicitly share each page/database with the +# # integration via "..." → "Connections" in Notion. +# # This is Notion's #1 UX papercut — surface it when +# # search returns empty. +# # Auth header IS "Authorization: Bearer " +# # (opposite of Linear — Notion does use the prefix). +# default_database_id: "" # Optional default database UUID (or set NOTION_DATABASE_ID) +# # Used by `clanker notion db row create --db ` +# # so you don't have to retype the id every time. +# +# Conversation history: ~/.clanker/notion-{workspaceName}.json. The workspace +# name comes from `GET /v1/users/me` → `bot.workspace_name`. Delete to start +# fresh. +# +# Rate limit: Notion enforces ~3 req/sec average per integration — tighter +# than Linear's. The client backs off exponentially on 429 with the +# Retry-After header, so heavy parallel ask flows degrade gracefully. + # Verda Cloud (for `clanker verda ...` and `clanker ask --verda ...`): # verda: # client_id: "" # Verda OAuth2 client ID (or set VERDA_CLIENT_ID, or run `verda auth login`) diff --git a/cmd/mcp.go b/cmd/mcp.go index 621e3ba..7dd90d0 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -360,6 +360,7 @@ func newClankerMCPServer() *mcptransport.MCPServer { registerSentryMCPTools(server) registerTencentMCPTools(server) registerLinearMCPTools(server) + registerNotionMCPTools(server) registerK8sMCPTools(server) return server diff --git a/cmd/mcp_notion.go b/cmd/mcp_notion.go new file mode 100644 index 0000000..67b7cc8 --- /dev/null +++ b/cmd/mcp_notion.go @@ -0,0 +1,394 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/notion" + "github.com/mark3labs/mcp-go/mcp" + mcptransport "github.com/mark3labs/mcp-go/server" + "github.com/spf13/viper" +) + +// Notion MCP tools — explicit schema declarations (same pattern as +// Linear/Tencent — struct-tag reflection in WithInputSchema[T]() is +// broken in this version of mark3labs/mcp-go). + +func registerNotionMCPTools(server *mcptransport.MCPServer) { + server.AddTool( + mcp.NewTool( + "clanker_notion_ask", + mcp.WithDescription("Ask a natural-language question about a Notion workspace. Fetches matching pages, databases, and users then answers via the configured AI provider."), + mcp.WithString("question", mcp.Required(), mcp.Description("The natural-language question")), + mcp.WithString("token", mcp.Description("Notion integration token (falls back to config/env)")), + mcp.WithReadOnlyHintAnnotation(true), + ), + handleMCPNotionAsk, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_search", + mcp.WithDescription("Search Notion by title. Returns matching pages and databases. NOTE: Notion's search indexes titles only — block content is NOT searched."), + mcp.WithString("query", mcp.Required(), mcp.Description("Search terms (matched against page/database titles)")), + mcp.WithString("filter", mcp.Description("page | database (omit for both)")), + mcp.WithNumber("limit", mcp.DefaultNumber(25), mcp.Description("Max results (1..100)")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithReadOnlyHintAnnotation(true), + ), + handleMCPNotionSearch, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_get_page", + mcp.WithDescription("Fetch a single Notion page by ID (metadata + properties, NOT content)."), + mcp.WithString("pageId", mcp.Required(), mcp.Description("Page UUID")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithReadOnlyHintAnnotation(true), + ), + handleMCPNotionGetPage, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_get_page_blocks", + mcp.WithDescription("Fetch a page's block content tree (capped at depth 3 / 200 blocks). Returns markdown rendering plus the raw tree."), + mcp.WithString("pageId", mcp.Required(), mcp.Description("Page UUID")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithReadOnlyHintAnnotation(true), + ), + handleMCPNotionGetPageBlocks, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_query_database", + mcp.WithDescription("Query rows in a Notion database. Filter and sorts use Notion's typed filter language — pass as JSON strings."), + mcp.WithString("databaseId", mcp.Required(), mcp.Description("Database UUID")), + mcp.WithString("filterJSON", mcp.Description("Filter object as a JSON string (Notion filter spec)")), + mcp.WithString("sortsJSON", mcp.Description("Sorts array as a JSON string")), + mcp.WithNumber("limit", mcp.DefaultNumber(25), mcp.Description("Max rows per page (1..100)")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithReadOnlyHintAnnotation(true), + ), + handleMCPNotionQueryDatabase, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_list_databases", + mcp.WithDescription("List databases the integration has access to."), + mcp.WithString("query", mcp.Description("Optional title filter")), + mcp.WithNumber("limit", mcp.DefaultNumber(25), mcp.Description("Max results (1..100)")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithReadOnlyHintAnnotation(true), + ), + handleMCPNotionListDatabases, + ) + + // Mutations — no read-only hint; cautious MCP clients should prompt. + server.AddTool( + mcp.NewTool( + "clanker_notion_create_page", + mcp.WithDescription("Create a new Notion page under a parent (page or database). The body is markdown converted into Notion blocks."), + mcp.WithString("parentId", mcp.Required(), mcp.Description("Parent page or database UUID")), + mcp.WithString("parentType", mcp.DefaultString("page_id"), mcp.Description("page_id | database_id")), + mcp.WithString("title", mcp.Required(), mcp.Description("Page title")), + mcp.WithString("markdown", mcp.Description("Markdown body (converted to Notion blocks)")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithDestructiveHintAnnotation(true), + ), + handleMCPNotionCreatePage, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_append_blocks", + mcp.WithDescription("Append markdown content to an existing Notion page (converted to blocks). Useful for runbook updates, post-incident notes."), + mcp.WithString("pageId", mcp.Required(), mcp.Description("Target page UUID")), + mcp.WithString("markdown", mcp.Required(), mcp.Description("Markdown content to append")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithDestructiveHintAnnotation(true), + ), + handleMCPNotionAppendBlocks, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_update_page_properties", + mcp.WithDescription("Patch a page's typed property values (DB rows). Properties payload is the Notion typed property shape — pass as JSON string."), + mcp.WithString("pageId", mcp.Required(), mcp.Description("Page UUID (DB row)")), + mcp.WithString("propertiesJSON", mcp.Required(), mcp.Description("Properties patch as JSON string")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithDestructiveHintAnnotation(true), + ), + handleMCPNotionUpdatePageProperties, + ) + + server.AddTool( + mcp.NewTool( + "clanker_notion_create_database_row", + mcp.WithDescription("Create a row in a Notion database. Properties must satisfy the database schema; pass as JSON string."), + mcp.WithString("databaseId", mcp.Required(), mcp.Description("Database UUID")), + mcp.WithString("propertiesJSON", mcp.Required(), mcp.Description("Properties payload as JSON string (Notion typed property shape)")), + mcp.WithString("token", mcp.Description("Notion integration token")), + mcp.WithDestructiveHintAnnotation(true), + ), + handleMCPNotionCreateDatabaseRow, + ) +} + +// --- Helpers ---------------------------------------------------------------- + +func mcpNotionClient(req mcp.CallToolRequest) (*notion.Client, error) { + token := strParam(req, "token") + if token == "" { + token = notion.ResolveToken() + } + if token == "" { + return nil, fmt.Errorf("notion integration token not configured (set notion.integration_token in ~/.clanker.yaml or NOTION_API_KEY)") + } + return notion.NewClient(token, notion.ResolveDefaultDatabaseID(), false) +} + +// --- Handlers ---------------------------------------------------------------- + +func handleMCPNotionAsk(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + question := strParam(req, "question") + if question == "" { + return mcp.NewToolResultError("question is required"), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + contextStr, _ := gatherNotionContext(ctx, client, question, false) + status, _ := notion.GatherAccountStatus(ctx, client) + statusStr := "" + if status != nil { + statusStr = fmt.Sprintf("Workspace: %s — Accessible pages: %d — Databases: %d", + status.WorkspaceName, status.AccessiblePages, status.DatabaseCount) + } + prompt := buildNotionPrompt(question, contextStr, "", statusStr) + + aiProfile := viper.GetString("ai.default_provider") + apiKey := resolveAIKeyForProfile(aiProfile) + aiClient := ai.NewClient(aiProfile, apiKey, false) + answer, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("AI query failed: %v", err)), nil + } + return mcp.NewToolResultText(answer), nil +} + +func handleMCPNotionSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + results, _, _, err := client.Search(ctx, notion.SearchOptions{ + Query: strParam(req, "query"), + FilterObject: strParam(req, "filter"), + PageSize: intParam(req, "limit", 25), + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(results) +} + +func handleMCPNotionGetPage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := strParam(req, "pageId") + if id == "" { + return mcp.NewToolResultError("pageId is required"), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + p, err := client.GetPage(ctx, id) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(p) +} + +func handleMCPNotionGetPageBlocks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := strParam(req, "pageId") + if id == "" { + return mcp.NewToolResultError("pageId is required"), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + tree, count, err := client.GetPageBlocks(ctx, id) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(map[string]any{ + "markdown": notion.BlocksToMarkdown(tree), + "blockCount": count, + "tree": tree, + }) +} + +func handleMCPNotionQueryDatabase(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := strParam(req, "databaseId") + if id == "" { + return mcp.NewToolResultError("databaseId is required"), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + var filter map[string]any + if raw := strParam(req, "filterJSON"); raw != "" { + if err := json.Unmarshal([]byte(raw), &filter); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("parse filterJSON: %v", err)), nil + } + } + var sorts []map[string]any + if raw := strParam(req, "sortsJSON"); raw != "" { + if err := json.Unmarshal([]byte(raw), &sorts); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("parse sortsJSON: %v", err)), nil + } + } + rows, next, more, err := client.QueryDatabase(ctx, id, notion.QueryDatabaseOptions{ + Filter: filter, + Sorts: sorts, + PageSize: intParam(req, "limit", 25), + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(map[string]any{ + "rows": rows, + "nextCursor": next, + "hasMore": more, + }) +} + +func handleMCPNotionListDatabases(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + dbs, err := client.ListDatabases(ctx, strParam(req, "query"), intParam(req, "limit", 25)) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(dbs) +} + +func handleMCPNotionCreatePage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + parentID := strParam(req, "parentId") + if parentID == "" { + return mcp.NewToolResultError("parentId is required"), nil + } + parentType := strParam(req, "parentType") + if parentType == "" { + parentType = notion.ParentTypePage + } + title := strParam(req, "title") + if title == "" { + return mcp.NewToolResultError("title is required"), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + props := notion.TitleProperty(title) + var children []map[string]any + if md := strParam(req, "markdown"); md != "" { + children = notion.MarkdownToBlocks(md) + } + p, err := client.CreatePage(ctx, parentType, parentID, props, children) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(p) +} + +func handleMCPNotionAppendBlocks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + pageID := strParam(req, "pageId") + if pageID == "" { + return mcp.NewToolResultError("pageId is required"), nil + } + md := strParam(req, "markdown") + if md == "" { + return mcp.NewToolResultError("markdown is required"), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + blocks := notion.MarkdownToBlocks(md) + if len(blocks) == 0 { + return mcp.NewToolResultError("markdown produced zero blocks"), nil + } + appended, err := client.AppendBlockChildren(ctx, pageID, blocks) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(appended) +} + +func handleMCPNotionUpdatePageProperties(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + pageID := strParam(req, "pageId") + if pageID == "" { + return mcp.NewToolResultError("pageId is required"), nil + } + raw := strParam(req, "propertiesJSON") + if raw == "" { + return mcp.NewToolResultError("propertiesJSON is required"), nil + } + var props map[string]any + if err := json.Unmarshal([]byte(raw), &props); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("parse propertiesJSON: %v", err)), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + p, err := client.UpdatePageProperties(ctx, pageID, props) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(p) +} + +func handleMCPNotionCreateDatabaseRow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + dbID := strParam(req, "databaseId") + if dbID == "" { + return mcp.NewToolResultError("databaseId is required"), nil + } + raw := strParam(req, "propertiesJSON") + if raw == "" { + return mcp.NewToolResultError("propertiesJSON is required"), nil + } + var props map[string]any + if err := json.Unmarshal([]byte(raw), &props); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("parse propertiesJSON: %v", err)), nil + } + client, err := mcpNotionClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + p, err := client.CreateDatabaseRow(ctx, dbID, props) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return jsonResult(p) +} + +func jsonResult(v any) (*mcp.CallToolResult, error) { + raw, err := json.MarshalIndent(v, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("encode result: %v", err)), nil + } + return mcp.NewToolResultText(string(raw)), nil +} diff --git a/cmd/notion.go b/cmd/notion.go new file mode 100644 index 0000000..fcd3c2b --- /dev/null +++ b/cmd/notion.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/notion" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/sync/errgroup" +) + +var notionAskCmd = &cobra.Command{ + Use: "ask [question]", + Short: "Ask natural-language questions about your Notion workspace", + Long: `Ask natural-language questions about your Notion workspace using AI. + +The assistant fetches accessible pages and databases based on the question and +replies in markdown. Conversation history is maintained per-workspace so +follow-up questions land in context. + +Note: Notion integrations only see content explicitly shared with them. +If results come back empty, share the relevant pages or databases with the +integration via "..." → "Connections" in the Notion UI. + +Examples: + clanker notion ask "where is the prod-RDS-snapshot policy?" + clanker notion ask "what databases do we track incidents in?" + clanker notion ask "summarise our last 5 runbooks"`, + Args: cobra.ExactArgs(1), + RunE: runNotionAsk, +} + +var ( + notionAskToken string + notionAskDatabase string + notionAskAIProfile string + notionAskDebug bool +) + +func init() { + notionAskCmd.Flags().StringVar(¬ionAskToken, "token", "", "Notion integration token") + notionAskCmd.Flags().StringVar(¬ionAskDatabase, "database", "", "Default database id") + notionAskCmd.Flags().StringVar(¬ionAskAIProfile, "ai-profile", "", "AI profile to use for LLM queries") + notionAskCmd.Flags().BoolVar(¬ionAskDebug, "debug", false, "Enable debug output") +} + +// AddNotionAskCommand wires the ask subcommand onto the base notion command. +func AddNotionAskCommand(notionCmd *cobra.Command) { + notionCmd.AddCommand(notionAskCmd) +} + +func runNotionAsk(cmd *cobra.Command, args []string) error { + question := strings.TrimSpace(args[0]) + if question == "" { + return fmt.Errorf("question cannot be empty") + } + + debug := notionAskDebug || viper.GetBool("debug") + + token := notionAskToken + if token == "" { + token = notion.ResolveToken() + } + if token == "" { + return fmt.Errorf("notion integration token is required (set --token, NOTION_API_KEY, or notion.integration_token in config)") + } + + database := notionAskDatabase + if database == "" { + database = notion.ResolveDefaultDatabaseID() + } + + client, err := notion.NewClient(token, database, debug) + if err != nil { + return fmt.Errorf("create notion client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + status, _ := notion.GatherAccountStatus(ctx, client) + workspaceName := "" + if status != nil { + workspaceName = status.WorkspaceName + } + if workspaceName == "" { + workspaceName = "default" + } + + history := notion.NewConversationHistory(workspaceName) + if err := history.Load(); err != nil && debug { + fmt.Printf("[debug] load history: %v\n", err) + } + if status != nil { + history.UpdateAccountStatus(status) + } + + dataContext, err := gatherNotionContext(ctx, client, question, debug) + if err != nil && debug { + fmt.Printf("[debug] gather context: %v\n", err) + } + + prompt := buildNotionPrompt(question, dataContext, history.GetRecentContext(5), history.GetAccountStatusContext()) + + aiProfile := notionAskAIProfile + if aiProfile == "" { + aiProfile = viper.GetString("ai.default_provider") + } + apiKey := resolveAIKeyForProfile(aiProfile) + aiClient := ai.NewClient(aiProfile, apiKey, debug) + + answer, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return fmt.Errorf("AI query failed: %w", err) + } + + fmt.Println(answer) + + history.AddEntry(question, answer, workspaceName) + if err := history.Save(); err != nil && debug { + fmt.Printf("[debug] save history: %v\n", err) + } + + return nil +} + +// gatherNotionContext fetches Notion data relevant to the question. The +// matched sections run concurrently — empty results often mean the +// integration hasn't been shared yet, so the prompt below tells the LLM +// to suggest sharing as the next step. +func gatherNotionContext(ctx context.Context, client *notion.Client, question string, debug bool) (string, error) { + q := strings.ToLower(question) + + wantPages := containsAny(q, []string{"page", "doc", "document", "runbook", "spec", "design", "rfc", "wiki"}) + wantDatabases := containsAny(q, []string{"database", "db", "table", "row", "tracker", "list"}) + wantUsers := containsAny(q, []string{"user", "who", "person", "owner", "assignee"}) + + // Default behaviour: pull a small page sample so the LLM has something + // to ground its answer in even when the question is vague. + if !wantPages && !wantDatabases && !wantUsers { + wantPages = true + wantDatabases = true + } + + g, gctx := errgroup.WithContext(ctx) + var pagesBlock, dbsBlock, usersBlock string + + if wantPages { + g.Go(func() error { + pages, err := client.SearchPages(gctx, question, 25) + if err != nil { + if debug { + fmt.Printf("[debug] search pages: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Accessible pages matching the question:\n") + if len(pages) == 0 { + b.WriteString(" (none — has the integration been shared with the relevant pages?)\n") + } + for _, p := range pages { + fmt.Fprintf(&b, " - %s (id=%s, url=%s, edited=%s)\n", + strings.TrimSpace(notion.TitleOfPage(&p)), + p.ID, + p.URL, + p.LastEditedTime.Format(time.RFC3339), + ) + } + b.WriteString("\n") + pagesBlock = b.String() + return nil + }) + } + + if wantDatabases { + g.Go(func() error { + dbs, err := client.ListDatabases(gctx, "", 25) + if err != nil { + if debug { + fmt.Printf("[debug] list databases: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Accessible databases:\n") + if len(dbs) == 0 { + b.WriteString(" (none — share at least one database with the integration to query rows)\n") + } + for _, db := range dbs { + fmt.Fprintf(&b, " - %s (id=%s, url=%s)\n", notion.TitleOfDatabase(&db), db.ID, db.URL) + } + b.WriteString("\n") + dbsBlock = b.String() + return nil + }) + } + + if wantUsers { + g.Go(func() error { + users, err := client.ListUsers(gctx, 25) + if err != nil { + if debug { + fmt.Printf("[debug] list users: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Workspace users:\n") + for _, u := range users { + email := "" + if u.Person != nil { + email = u.Person.Email + } + fmt.Fprintf(&b, " - %s (%s, %s)\n", u.Name, u.Type, email) + } + b.WriteString("\n") + usersBlock = b.String() + return nil + }) + } + + _ = g.Wait() + + var sb strings.Builder + sb.WriteString(pagesBlock) + sb.WriteString(dbsBlock) + sb.WriteString(usersBlock) + + if sb.Len() == 0 { + return "No Notion data fetched (check the integration token and that pages have been shared with the integration).", nil + } + return sb.String(), nil +} + +func buildNotionPrompt(question, dataContext, historyContext, statusContext string) string { + var sb strings.Builder + + sb.WriteString("You are a Notion knowledge-base assistant. ") + sb.WriteString("Help the user navigate their Notion workspace and answer questions about pages, databases, and users.\n\n") + sb.WriteString("Vocabulary cheat-sheet: a *page* is a document (long-form prose). ") + sb.WriteString("A *database* is a typed table whose rows are themselves pages. ") + sb.WriteString("A *block* is a content atom (paragraph, heading, list item).\n\n") + sb.WriteString("Important — Notion integrations only see content explicitly shared with them. ") + sb.WriteString("If the data section is empty or the user's target isn't there, suggest they share the relevant page or database with the integration via \"...\" → \"Connections\" in Notion.\n\n") + + if statusContext != "" { + sb.WriteString("Workspace status:\n") + sb.WriteString(statusContext) + sb.WriteString("\n\n") + } + + if dataContext != "" { + sb.WriteString("Notion data:\n") + sb.WriteString(dataContext) + sb.WriteString("\n") + } + + if historyContext != "" { + sb.WriteString("Recent conversation:\n") + sb.WriteString(historyContext) + sb.WriteString("\n") + } + + sb.WriteString("User question: ") + sb.WriteString(question) + sb.WriteString("\n\n") + + sb.WriteString("Respond in concise markdown. Cite page titles plus URLs when referencing specific pages.") + return sb.String() +} diff --git a/cmd/root.go b/cmd/root.go index 38cca31..0b88b86 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/bgdnvk/clanker/internal/gcp" "github.com/bgdnvk/clanker/internal/hetzner" "github.com/bgdnvk/clanker/internal/linear" + "github.com/bgdnvk/clanker/internal/notion" "github.com/bgdnvk/clanker/internal/railway" "github.com/bgdnvk/clanker/internal/sentry" "github.com/bgdnvk/clanker/internal/tencent" @@ -124,6 +125,13 @@ func init() { AddLinearAskCommand(linearCmd) rootCmd.AddCommand(linearCmd) + // Register Notion static commands + ask command. `clanker notion ask "..."` + // for natural language; list/get/search/page/db on the same root via + // internal/notion.CreateNotionCommands(). + notionCmd := notion.CreateNotionCommands() + AddNotionAskCommand(notionCmd) + rootCmd.AddCommand(notionCmd) + // Register Digital Ocean static commands doCmd := digitalocean.CreateDigitalOceanCommands() rootCmd.AddCommand(doCmd) diff --git a/internal/notion/blocks.go b/internal/notion/blocks.go new file mode 100644 index 0000000..446e343 --- /dev/null +++ b/internal/notion/blocks.go @@ -0,0 +1,316 @@ +package notion + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" +) + +// Block-tree fetching is recursive. The walk is bounded to keep "get +// blocks for a huge page" calls predictable — Notion has no API-level +// safeguard against runaway depth. +const ( + maxBlockDepth = 3 + maxBlocksPerPage = 200 + getChildrenMaxSize = 100 +) + +// GetBlockChildren fetches one page (up to 100 nodes) of a block's +// direct children. Pagination is handled by GetBlockChildrenAll. +func (c *Client) GetBlockChildren(ctx context.Context, blockID, startCursor string, pageSize int) ([]Block, string, bool, error) { + if strings.TrimSpace(blockID) == "" { + return nil, "", false, errors.New("block id is required") + } + if pageSize <= 0 { + pageSize = getChildrenMaxSize + } + pageSize = min(pageSize, getChildrenMaxSize) + path := fmt.Sprintf("/blocks/%s/children?page_size=%d", url.PathEscape(blockID), pageSize) + if startCursor != "" { + path += "&start_cursor=" + url.QueryEscape(startCursor) + } + var resp PaginatedResponse + if err := c.Do(ctx, "GET", path, nil, &resp); err != nil { + return nil, "", false, err + } + blocks := make([]Block, 0, len(resp.Results)) + for _, raw := range resp.Results { + var b Block + if err := json.Unmarshal(raw, &b); err != nil { + return nil, "", false, fmt.Errorf("decode block: %w", err) + } + blocks = append(blocks, b) + } + return blocks, resp.NextCursor, resp.HasMore, nil +} + +// GetBlockChildrenAll paginates through every direct child of a block. +// Bounded by maxBlocksPerPage so a single huge page can't fan into 100+ +// sequential requests at Notion's 3 req/s ceiling. Returns partial +// results when the cap is reached. +func (c *Client) GetBlockChildrenAll(ctx context.Context, blockID string) ([]Block, error) { + var all []Block + cursor := "" + for { + page, next, more, err := c.GetBlockChildren(ctx, blockID, cursor, 0) + if err != nil { + return nil, err + } + all = append(all, page...) + if len(all) >= maxBlocksPerPage { + if len(all) > maxBlocksPerPage { + all = all[:maxBlocksPerPage] + } + break + } + if !more || next == "" { + break + } + cursor = next + } + return all, nil +} + +// PageBlockTree is one node in the recursively-fetched tree. Depth=0 is +// the top-level page; child blocks are nested via Children. +type PageBlockTree struct { + Block + Depth int `json:"depth"` + Children []PageBlockTree `json:"children,omitempty"` + // Truncated is true when we stopped descending (depth cap, count cap, + // or fetch error). UI surfaces this so users know to load more. + Truncated bool `json:"truncated,omitempty"` +} + +// GetPageBlocks fetches a page's block tree recursively. Bounded by +// maxBlockDepth + maxBlocksPerPage to keep latency + memory predictable. +// Returns partial results when the cap kicks in (Truncated=true on the +// node where descent stopped). +func (c *Client) GetPageBlocks(ctx context.Context, pageID string) ([]PageBlockTree, int, error) { + count := 0 + trees, err := c.fetchTree(ctx, pageID, 0, &count) + return trees, count, err +} + +func (c *Client) fetchTree(ctx context.Context, parentID string, depth int, count *int) ([]PageBlockTree, error) { + if *count >= maxBlocksPerPage { + return nil, nil + } + children, err := c.GetBlockChildrenAll(ctx, parentID) + if err != nil { + return nil, err + } + trees := make([]PageBlockTree, 0, len(children)) + for _, b := range children { + if *count >= maxBlocksPerPage { + break + } + *count++ + node := PageBlockTree{Block: b, Depth: depth} + if b.HasChildren { + if depth+1 >= maxBlockDepth { + node.Truncated = true + } else { + sub, subErr := c.fetchTree(ctx, b.ID, depth+1, count) + if subErr != nil { + node.Truncated = true + } else { + node.Children = sub + } + } + } + trees = append(trees, node) + } + return trees, nil +} + +// AppendBlockChildren appends new children to a parent (page or block). +// Returns the appended blocks with their server-assigned IDs. +func (c *Client) AppendBlockChildren(ctx context.Context, parentID string, children []map[string]any) ([]Block, error) { + if strings.TrimSpace(parentID) == "" { + return nil, errors.New("parent id is required") + } + if len(children) == 0 { + return nil, errors.New("at least one child block is required") + } + body := map[string]any{"children": children} + var resp PaginatedResponse + if err := c.Do(ctx, "PATCH", "/blocks/"+url.PathEscape(parentID)+"/children", body, &resp); err != nil { + return nil, err + } + out := make([]Block, 0, len(resp.Results)) + for _, raw := range resp.Results { + var b Block + if err := json.Unmarshal(raw, &b); err != nil { + return nil, fmt.Errorf("decode appended block: %w", err) + } + out = append(out, b) + } + return out, nil +} + +// DeleteBlock soft-deletes (archives) a block. +func (c *Client) DeleteBlock(ctx context.Context, id string) error { + if strings.TrimSpace(id) == "" { + return errors.New("block id is required") + } + return c.Do(ctx, "DELETE", "/blocks/"+url.PathEscape(id), nil, nil) +} + +// Block builders return map[string]any rather than concrete structs because +// Notion's payload is sparse — each block has a single populated key +// matching its type, and every other type key is absent. + +func richTextChunk(text string) []map[string]any { + if text == "" { + return []map[string]any{} + } + // Notion caps a single rich_text item at 2000 characters; longer + // content must be split into multiple spans of the same paragraph. + const limit = 2000 + chunks := []map[string]any{} + for len(text) > 0 { + n := min(len(text), limit) + chunks = append(chunks, map[string]any{ + "type": "text", + "text": map[string]any{"content": text[:n]}, + }) + text = text[n:] + } + return chunks +} + +func ParagraphBlock(text string) map[string]any { + return map[string]any{ + "object": "block", + "type": "paragraph", + "paragraph": map[string]any{ + "rich_text": richTextChunk(text), + }, + } +} + +func HeadingBlock(level int, text string) map[string]any { + switch level { + case 1, 2, 3: + default: + level = 1 + } + t := fmt.Sprintf("heading_%d", level) + return map[string]any{ + "object": "block", + "type": t, + t: map[string]any{ + "rich_text": richTextChunk(text), + }, + } +} + +func BulletedListItemBlock(text string) map[string]any { + return map[string]any{ + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": map[string]any{ + "rich_text": richTextChunk(text), + }, + } +} + +func NumberedListItemBlock(text string) map[string]any { + return map[string]any{ + "object": "block", + "type": "numbered_list_item", + "numbered_list_item": map[string]any{ + "rich_text": richTextChunk(text), + }, + } +} + +func ToDoBlock(text string, checked bool) map[string]any { + return map[string]any{ + "object": "block", + "type": "to_do", + "to_do": map[string]any{ + "rich_text": richTextChunk(text), + "checked": checked, + }, + } +} + +func QuoteBlock(text string) map[string]any { + return map[string]any{ + "object": "block", + "type": "quote", + "quote": map[string]any{ + "rich_text": richTextChunk(text), + }, + } +} + +func CodeBlock(language, text string) map[string]any { + lang := strings.ToLower(strings.TrimSpace(language)) + if lang == "" { + lang = "plain text" + } + return map[string]any{ + "object": "block", + "type": "code", + "code": map[string]any{ + "rich_text": richTextChunk(text), + "language": lang, + }, + } +} + +func DividerBlock() map[string]any { + return map[string]any{ + "object": "block", + "type": "divider", + "divider": map[string]any{}, + } +} + +// RichTextPlain extracts plain text from a block's payload by reading +// the `rich_text` array under the type-named key. Returns empty string +// for non-textual blocks (divider, image, child_page, child_database). +func (b *Block) RichTextPlain() string { + if b == nil { + return "" + } + var payload json.RawMessage + switch b.Type { + case "paragraph": + payload = b.Paragraph + case "heading_1": + payload = b.Heading1 + case "heading_2": + payload = b.Heading2 + case "heading_3": + payload = b.Heading3 + case "bulleted_list_item": + payload = b.BulletedListItem + case "numbered_list_item": + payload = b.NumberedListItem + case "to_do": + payload = b.ToDo + case "code": + payload = b.Code + case "quote": + payload = b.Quote + case "callout": + payload = b.Callout + } + if len(payload) == 0 { + return "" + } + var probe struct { + RichText []RichTextSpan `json:"rich_text"` + } + if err := json.Unmarshal(payload, &probe); err != nil { + return "" + } + return joinRichText(probe.RichText) +} diff --git a/internal/notion/client.go b/internal/notion/client.go new file mode 100644 index 0000000..ba27c72 --- /dev/null +++ b/internal/notion/client.go @@ -0,0 +1,249 @@ +package notion + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/viper" +) + +const ( + baseURL = "https://api.notion.com/v1" + notionVersion = "2022-06-28" + userAgent = "clanker-cli" +) + +// Client wraps Notion's REST API. Auth is an Internal Integration token +// passed as a standard Bearer header — Notion DOES use the Bearer prefix +// (unlike Linear, which is the inverse footgun). +// +// IMPORTANT — Notion tokens start out with ZERO access. The user must +// explicitly share each page/database with the integration via "..." → +// "Connections". Surface this in empty-state copy; agents should suggest +// sharing when search returns no results. +type Client struct { + token string + defaultDatabaseID string + httpClient *http.Client + debug bool +} + +func ResolveToken() string { + if v := strings.TrimSpace(viper.GetString("notion.integration_token")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("NOTION_API_KEY")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("NOTION_TOKEN")); v != "" { + return v + } + return "" +} + +func ResolveDefaultDatabaseID() string { + if v := strings.TrimSpace(viper.GetString("notion.default_database_id")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("NOTION_DATABASE_ID")); v != "" { + return v + } + return "" +} + +func NewClient(token, defaultDatabaseID string, debug bool) (*Client, error) { + if strings.TrimSpace(token) == "" { + return nil, errors.New("notion integration_token is required") + } + return &Client{ + token: token, + defaultDatabaseID: strings.TrimSpace(defaultDatabaseID), + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + debug: debug, + }, nil +} + +func (c *Client) SetHTTPClient(hc *http.Client) { + if hc != nil { + c.httpClient = hc + } +} + +func (c *Client) DefaultDatabaseID() string { return c.defaultDatabaseID } +func (c *Client) Debug() bool { return c.debug } + +// APIError carries the HTTP status + Notion's `code` + `message`. +type APIError struct { + Status int + Code string + Message string + Body string +} + +func (e *APIError) Error() string { + if e.Message != "" { + return fmt.Sprintf("notion api error %d [%s]: %s", e.Status, e.Code, e.Message) + } + return fmt.Sprintf("notion api error %d: %s", e.Status, e.Body) +} + +// IsAuthError reports whether err is an auth failure (401, 403, or +// Notion's `unauthorized` / `restricted_resource` error codes). +func IsAuthError(err error) bool { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return false + } + if apiErr.Status == http.StatusUnauthorized || apiErr.Status == http.StatusForbidden { + return true + } + return apiErr.Code == "unauthorized" || apiErr.Code == "restricted_resource" +} + +// Do executes a Notion request with 429 backoff. method is GET/POST/PATCH; +// path is the path AFTER /v1 (e.g. "/search"); body is marshalled if +// non-nil. out is the response decoding target. +func (c *Client) Do(ctx context.Context, method, path string, body any, out any) error { + const maxAttempts = 4 + for attempt := range maxAttempts { + resp, respBody, err := c.doOnce(ctx, method, path, body) + if err != nil { + if !isRetryableNetErr(err) || attempt == maxAttempts-1 { + return err + } + sleepWithJitter(ctx, time.Duration(200<= 400 { + return parseAPIError(resp, respBody) + } + if out == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + preview := string(respBody) + if len(preview) > 300 { + preview = preview[:300] + "..." + } + return fmt.Errorf("decode notion response: %w (body: %s)", err, preview) + } + return nil + } + return errors.New("notion api: exhausted retries") +} + +func (c *Client) doOnce(ctx context.Context, method, path string, body any) (*http.Response, []byte, error) { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, nil, fmt.Errorf("marshal body: %w", err) + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, baseURL+path, reader) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Notion-Version", notionVersion) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.debug { + fmt.Fprintf(os.Stderr, "[notion] %s %s\n", method, path) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp, nil, fmt.Errorf("read body: %w", err) + } + return resp, respBody, nil +} + +func parseAPIError(resp *http.Response, body []byte) error { + var env struct { + Object string `json:"object"` + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal(body, &env) + preview := string(body) + if len(preview) > 512 { + preview = preview[:512] + "..." + } + return &APIError{ + Status: resp.StatusCode, + Code: env.Code, + Message: env.Message, + Body: preview, + } +} + +func parseRetryWait(resp *http.Response) time.Duration { + if h := resp.Header.Get("Retry-After"); h != "" { + if secs, err := strconv.ParseFloat(h, 64); err == nil && secs > 0 { + return min(time.Duration(secs*float64(time.Second)), 30*time.Second) + } + } + return 2 * time.Second +} + +func sleepWithJitter(ctx context.Context, base time.Duration) { + if base <= 0 { + return + } + jitter := time.Duration(rand.Int63n(int64(base)/2 + 1)) + select { + case <-ctx.Done(): + case <-time.After(base + jitter): + } +} + +// isRetryableNetErr matches the post-review behaviour from Linear: +// timeouts and connection-reset are transient; DNS / refused-connection +// are permanent for the request lifetime so we don't waste retries. +func isRetryableNetErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "timeout") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "temporarily unavailable") || + strings.Contains(msg, "eof") +} diff --git a/internal/notion/client_test.go b/internal/notion/client_test.go new file mode 100644 index 0000000..e9cc432 --- /dev/null +++ b/internal/notion/client_test.go @@ -0,0 +1,196 @@ +package notion + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// newTestClient wires a Client at an httptest server. We rewrite the +// destination URL via a custom transport so production header building +// (Authorization: Bearer + Notion-Version) stays in scope of the test +// path — handlers assert what arrived. +func newTestClient(t *testing.T, ts *httptest.Server) *Client { + t.Helper() + c, err := NewClient("secret_test_token", "", false) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + c.SetHTTPClient(&http.Client{ + Transport: rewritingTransport{target: ts.URL}, + Timeout: 5 * time.Second, + }) + return c +} + +type rewritingTransport struct { + target string +} + +func (rt rewritingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + cloned := req.Clone(req.Context()) + newReq, err := http.NewRequestWithContext(req.Context(), req.Method, rt.target+req.URL.RequestURI(), req.Body) + if err != nil { + return nil, err + } + newReq.Header = cloned.Header + return http.DefaultTransport.RoundTrip(newReq) +} + +func TestClientDo_HappyPath(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer secret_test_token" { + t.Errorf("Authorization should be 'Bearer ', got %q (Bearer prefix is required for Notion, unlike Linear)", got) + } + if got := r.Header.Get("Notion-Version"); got != "2022-06-28" { + t.Errorf("Notion-Version header missing or wrong: %q", got) + } + if !strings.HasSuffix(r.URL.Path, "/users/me") { + t.Errorf("path mismatch: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"object":"user","id":"bot-id","type":"bot","name":"test bot","bot":{"workspace_name":"Test WS"}}`)) + })) + defer ts.Close() + + c := newTestClient(t, ts) + user, ws, err := c.Me(context.Background()) + if err != nil { + t.Fatalf("Me: %v", err) + } + if user.ID != "bot-id" { + t.Errorf("ID: got %s, want bot-id", user.ID) + } + if ws.WorkspaceName != "Test WS" { + t.Errorf("WorkspaceName: got %q", ws.WorkspaceName) + } +} + +func TestClientDo_RateLimitBackoff(t *testing.T) { + var attempts int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&attempts, 1) + if n == 1 { + w.Header().Set("Retry-After", "0.05") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"object":"error","status":429,"code":"rate_limited","message":"slow down"}`)) + return + } + _, _ = w.Write([]byte(`{"object":"list","results":[],"has_more":false,"next_cursor":null}`)) + })) + defer ts.Close() + + c := newTestClient(t, ts) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, _, _, err := c.Search(ctx, SearchOptions{Query: "x"}); err != nil { + t.Fatalf("Search: %v", err) + } + if got := atomic.LoadInt32(&attempts); got != 2 { + t.Errorf("attempts: got %d, want 2 (1 throttle + 1 success)", got) + } +} + +func TestClientDo_APIError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"object":"error","status":401,"code":"unauthorized","message":"API token is invalid"}`)) + })) + defer ts.Close() + + c := newTestClient(t, ts) + _, _, err := c.Me(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Status != 401 || apiErr.Code != "unauthorized" { + t.Errorf("unexpected envelope: %+v", apiErr) + } + if !IsAuthError(err) { + t.Error("IsAuthError(401/unauthorized) should be true") + } +} + +func TestClientDo_ShareFailureEmptyResults(t *testing.T) { + // When no pages have been shared with the integration, search returns + // an empty list — NOT an error. This is the Notion share gotcha. Our + // `ask` flow surfaces guidance to the user when this happens; the + // client itself must treat the response as successful so callers can + // distinguish "no access" from "transport failure". + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"object":"list","results":[],"has_more":false,"next_cursor":null}`)) + })) + defer ts.Close() + + c := newTestClient(t, ts) + pages, err := c.SearchPages(context.Background(), "anything", 25) + if err != nil { + t.Fatalf("SearchPages: %v", err) + } + if len(pages) != 0 { + t.Errorf("expected empty result, got %d", len(pages)) + } +} + +func TestParseRetryWait_NumericHeader(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Retry-After", "1.5") + got := parseRetryWait(resp) + if got < 1500*time.Millisecond || got > 1600*time.Millisecond { + t.Errorf("Retry-After=1.5 should parse near 1.5s, got %s", got) + } +} + +func TestIsRetryableNetErr_PermanentDrop(t *testing.T) { + // Permanent errors (DNS, refused-connection) should NOT retry — + // matches the post-review Linear behaviour. + cases := []struct { + msg string + expected bool + }{ + {"context deadline exceeded (timeout)", true}, + {"read: connection reset by peer", true}, + {"unexpected EOF", true}, + {"dial tcp: lookup foo: no such host", false}, + {"connect: connection refused", false}, + } + for _, tc := range cases { + if got := isRetryableNetErr(errFromString(tc.msg)); got != tc.expected { + t.Errorf("isRetryableNetErr(%q): got %v, want %v", tc.msg, got, tc.expected) + } + } +} + +func errFromString(s string) error { return &simpleErr{s} } + +type simpleErr struct{ s string } + +func (e *simpleErr) Error() string { return e.s } + +func TestClientDo_DecodeErrorBubblesPreview(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`not-json`)) + })) + defer ts.Close() + + c := newTestClient(t, ts) + _, _, err := c.Me(context.Background()) + if err == nil { + t.Fatal("expected decode error, got nil") + } + if !strings.Contains(err.Error(), "decode notion response") { + t.Errorf("error should mention decode: %v", err) + } +} + +// silence the io import unused warning when running with -count=1 +var _ = io.Discard diff --git a/internal/notion/conversation.go b/internal/notion/conversation.go new file mode 100644 index 0000000..12829e1 --- /dev/null +++ b/internal/notion/conversation.go @@ -0,0 +1,166 @@ +package notion + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + MaxHistoryEntries = 20 + MaxAnswerLengthInContext = 500 +) + +// ConversationEntry is a single Q&A turn against the Notion ask agent. +type ConversationEntry struct { + Timestamp time.Time `json:"timestamp"` + Question string `json:"question"` + Answer string `json:"answer"` + WorkspaceName string `json:"workspace_name"` +} + +// ConversationHistory persists Notion ask sessions per-workspace under +// ~/.clanker/notion-{safeSlug(workspace_name)}.json — same pattern as +// the Sentry history. Workspace names contain spaces and punctuation, +// so safeSlug enforces the [A-Za-z0-9_-] charset. +type ConversationHistory struct { + Entries []ConversationEntry `json:"entries"` + WorkspaceName string `json:"workspace_name"` + LastStatus *AccountStatus `json:"last_status,omitempty"` + mu sync.RWMutex +} + +func NewConversationHistory(workspaceName string) *ConversationHistory { + return &ConversationHistory{ + Entries: make([]ConversationEntry, 0), + WorkspaceName: workspaceName, + } +} + +func (h *ConversationHistory) AddEntry(question, answer, workspaceName string) { + h.mu.Lock() + defer h.mu.Unlock() + h.Entries = append(h.Entries, ConversationEntry{ + Timestamp: time.Now(), + Question: question, + Answer: answer, + WorkspaceName: workspaceName, + }) + if len(h.Entries) > MaxHistoryEntries { + h.Entries = h.Entries[len(h.Entries)-MaxHistoryEntries:] + } +} + +func (h *ConversationHistory) UpdateAccountStatus(status *AccountStatus) { + h.mu.Lock() + defer h.mu.Unlock() + h.LastStatus = status +} + +func (h *ConversationHistory) GetRecentContext(maxEntries int) string { + h.mu.RLock() + defer h.mu.RUnlock() + if len(h.Entries) == 0 { + return "" + } + start := 0 + if len(h.Entries) > maxEntries { + start = len(h.Entries) - maxEntries + } + var sb strings.Builder + for _, e := range h.Entries[start:] { + sb.WriteString("Q: ") + sb.WriteString(e.Question) + sb.WriteString("\nA: ") + ans := e.Answer + if len(ans) > MaxAnswerLengthInContext { + ans = ans[:MaxAnswerLengthInContext] + "..." + } + sb.WriteString(ans) + sb.WriteString("\n\n") + } + return sb.String() +} + +func (h *ConversationHistory) GetAccountStatusContext() string { + h.mu.RLock() + defer h.mu.RUnlock() + if h.LastStatus == nil { + return "" + } + return fmt.Sprintf( + "Workspace: %s — Accessible pages: %d — Databases: %d (snapshot at %s)", + h.LastStatus.WorkspaceName, + h.LastStatus.AccessiblePages, + h.LastStatus.DatabaseCount, + h.LastStatus.Timestamp.Format(time.RFC3339), + ) +} + +// safeSlug strips anything outside [A-Za-z0-9_-] so a malicious workspace +// name (e.g. "../../etc/passwd") cannot escape the ~/.clanker directory +// when filepath.Join resolves the path. +func safeSlug(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', + c >= 'A' && c <= 'Z', + c >= '0' && c <= '9', + c == '-' || c == '_': + out = append(out, c) + } + } + if len(out) == 0 { + return "default" + } + return string(out) +} + +func historyPath(workspaceName string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".clanker") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return filepath.Join(dir, fmt.Sprintf("notion-%s.json", safeSlug(workspaceName))), nil +} + +func (h *ConversationHistory) Load() error { + path, err := historyPath(h.WorkspaceName) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + h.mu.Lock() + defer h.mu.Unlock() + return json.Unmarshal(data, h) +} + +func (h *ConversationHistory) Save() error { + path, err := historyPath(h.WorkspaceName) + if err != nil { + return err + } + h.mu.RLock() + defer h.mu.RUnlock() + data, err := json.MarshalIndent(h, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/notion/conversation_test.go b/internal/notion/conversation_test.go new file mode 100644 index 0000000..7ba0b51 --- /dev/null +++ b/internal/notion/conversation_test.go @@ -0,0 +1,100 @@ +package notion + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestConversationHistory_RoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + h := NewConversationHistory("My Workspace!!") + h.AddEntry("what tables exist?", "Three databases: Incidents, Runbooks, OnCall.", "My Workspace!!") + h.UpdateAccountStatus(&AccountStatus{ + Timestamp: time.Now(), + WorkspaceName: "My Workspace!!", + AccessiblePages: 7, + DatabaseCount: 3, + }) + if err := h.Save(); err != nil { + t.Fatalf("save: %v", err) + } + + // File should land under ~/.clanker/notion-MyWorkspace.json (safeSlug + // strips spaces + punctuation). + matches, _ := filepath.Glob(filepath.Join(tmp, ".clanker", "notion-*.json")) + if len(matches) != 1 { + t.Fatalf("expected one history file, got %v", matches) + } + if got := filepath.Base(matches[0]); got != "notion-MyWorkspace.json" { + t.Errorf("unexpected file name: %s", got) + } + + // Reload and verify content. + h2 := NewConversationHistory("My Workspace!!") + if err := h2.Load(); err != nil { + t.Fatalf("load: %v", err) + } + if len(h2.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(h2.Entries)) + } + if h2.Entries[0].Question != "what tables exist?" { + t.Errorf("question: %q", h2.Entries[0].Question) + } + if h2.LastStatus == nil || h2.LastStatus.AccessiblePages != 7 { + t.Errorf("status not persisted: %+v", h2.LastStatus) + } + + // File permissions should be 0600 (history may contain question text + // against sensitive workspaces). + info, err := os.Stat(matches[0]) + if err != nil { + t.Fatalf("stat: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("file mode: got %o, want 600", mode) + } +} + +func TestSafeSlug_PathTraversalDefense(t *testing.T) { + cases := map[string]string{ + "normal-workspace": "normal-workspace", + "My Workspace": "MyWorkspace", + "../../etc/passwd": "etcpasswd", + "with/slashes": "withslashes", + "!!!": "default", + "": "default", + "abc123_DEF": "abc123_DEF", + } + for in, want := range cases { + if got := safeSlug(in); got != want { + t.Errorf("safeSlug(%q) = %q, want %q", in, got, want) + } + } +} + +func TestConversationHistory_TrimsToMaxEntries(t *testing.T) { + h := NewConversationHistory("ws") + for range MaxHistoryEntries + 5 { + h.AddEntry("q", "a", "ws") + } + if len(h.Entries) != MaxHistoryEntries { + t.Errorf("entries: got %d, want %d", len(h.Entries), MaxHistoryEntries) + } +} + +func TestGetRecentContext_TruncatesLongAnswer(t *testing.T) { + h := NewConversationHistory("ws") + long := make([]byte, MaxAnswerLengthInContext+200) + for i := range long { + long[i] = 'x' + } + h.AddEntry("q", string(long), "ws") + got := h.GetRecentContext(5) + if len(got) >= len(long) { + t.Errorf("answer should have been truncated; output length %d (vs %d)", len(got), len(long)) + } +} diff --git a/internal/notion/databases.go b/internal/notion/databases.go new file mode 100644 index 0000000..cda9bdd --- /dev/null +++ b/internal/notion/databases.go @@ -0,0 +1,105 @@ +package notion + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" +) + +// ListDatabases enumerates databases via the search endpoint with the +// `object=database` filter. Notion's "list databases" endpoint is +// deprecated; search is the supported path. +func (c *Client) ListDatabases(ctx context.Context, query string, pageSize int) ([]Database, error) { + results, _, _, err := c.Search(ctx, SearchOptions{ + Query: query, + FilterObject: FilterObjectDatabase, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + out := make([]Database, 0, len(results)) + for _, raw := range results { + var db Database + if err := json.Unmarshal(raw, &db); err != nil { + return nil, fmt.Errorf("decode database: %w", err) + } + out = append(out, db) + } + return out, nil +} + +// GetDatabase fetches a database by ID. +func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) { + if strings.TrimSpace(id) == "" { + return nil, errors.New("database id is required") + } + var db Database + if err := c.Do(ctx, "GET", "/databases/"+url.PathEscape(id), nil, &db); err != nil { + return nil, err + } + return &db, nil +} + +// QueryDatabaseOptions captures the subset of `POST /v1/databases/{id}/query` +// we exercise. Filter and Sorts are passed through as-is — Notion's +// filter language is too rich to type cleanly here; callers should +// construct map[string]any per the upstream docs. +type QueryDatabaseOptions struct { + Filter map[string]any // upstream filter object (Notion's spec) + Sorts []map[string]any // upstream sorts array + StartCursor string + PageSize int +} + +// QueryDatabase executes a typed query against a database. Each row in +// the result is a Page (Notion models DB rows as pages). +func (c *Client) QueryDatabase(ctx context.Context, id string, opts QueryDatabaseOptions) ([]Page, string, bool, error) { + if strings.TrimSpace(id) == "" { + return nil, "", false, errors.New("database id is required") + } + body := map[string]any{} + if opts.Filter != nil { + body["filter"] = opts.Filter + } + if len(opts.Sorts) > 0 { + body["sorts"] = opts.Sorts + } + if opts.StartCursor != "" { + body["start_cursor"] = opts.StartCursor + } + if opts.PageSize > 0 { + body["page_size"] = opts.PageSize + } + var resp PaginatedResponse + if err := c.Do(ctx, "POST", "/databases/"+url.PathEscape(id)+"/query", body, &resp); err != nil { + return nil, "", false, err + } + rows := make([]Page, 0, len(resp.Results)) + for _, raw := range resp.Results { + var p Page + if err := json.Unmarshal(raw, &p); err != nil { + return nil, "", false, fmt.Errorf("decode database row: %w", err) + } + rows = append(rows, p) + } + return rows, resp.NextCursor, resp.HasMore, nil +} + +// CreateDatabaseRow is a thin wrapper over CreatePage with the parent +// type pinned to database_id. Kept as a named API because the agent +// surfaces "create row" rather than "create page with database parent". +func (c *Client) CreateDatabaseRow(ctx context.Context, databaseID string, properties map[string]any) (*Page, error) { + return c.CreatePage(ctx, ParentTypeDatabase, databaseID, properties, nil) +} + +// TitleOfDatabase joins the database's title rich-text spans. +func TitleOfDatabase(db *Database) string { + if db == nil { + return "" + } + return joinRichText(db.Title) +} diff --git a/internal/notion/markdown.go b/internal/notion/markdown.go new file mode 100644 index 0000000..35cd695 --- /dev/null +++ b/internal/notion/markdown.go @@ -0,0 +1,255 @@ +package notion + +import ( + "strings" +) + +// MarkdownToBlocks converts a minimal subset of CommonMark into the +// block array Notion expects on `PATCH /v1/blocks/{id}/children`. +// +// Supported syntax (intentionally narrow — the use case is "agent +// emits markdown, we round-trip it into Notion blocks", not full +// CommonMark fidelity): +// +// - `# h1`, `## h2`, `### h3` (h4+ collapse to h3 since Notion only +// has three heading levels) +// - `- item` and `* item` → bulleted list item +// - `1. item` (and any digit prefix + `.`) → numbered list item +// - `- [ ] todo` / `- [x] done` → to-do block +// - `> quote` → quote block +// - ```` ```lang ```` fenced code blocks (single-line `lang` opener) +// - `---` / `***` (line of three or more) → divider +// - blank lines → paragraph breaks +// - everything else → paragraph +// +// Inline annotations (bold/italic/code/links) are NOT preserved — the +// whole line becomes plain text. Worth revisiting once an agent +// actually needs bold output; until then, paragraph + plain text keeps +// the converter trivial and lossless to round-trip back to text. +func MarkdownToBlocks(md string) []map[string]any { + lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n") + blocks := []map[string]any{} + var paragraph strings.Builder + + flushParagraph := func() { + text := strings.TrimSpace(paragraph.String()) + paragraph.Reset() + if text != "" { + blocks = append(blocks, ParagraphBlock(text)) + } + } + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + + if trimmed == "" { + flushParagraph() + continue + } + + // Fenced code: collect until matching close. + if strings.HasPrefix(trimmed, "```") { + flushParagraph() + lang := strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) + var code strings.Builder + j := i + 1 + for ; j < len(lines); j++ { + if strings.HasPrefix(strings.TrimSpace(lines[j]), "```") { + break + } + if code.Len() > 0 { + code.WriteByte('\n') + } + code.WriteString(lines[j]) + } + blocks = append(blocks, CodeBlock(lang, code.String())) + i = j + continue + } + + if isDividerLine(trimmed) { + flushParagraph() + blocks = append(blocks, DividerBlock()) + continue + } + + if level, rest, ok := matchHeading(trimmed); ok { + flushParagraph() + blocks = append(blocks, HeadingBlock(level, rest)) + continue + } + + if rest, checked, ok := matchTodo(trimmed); ok { + flushParagraph() + blocks = append(blocks, ToDoBlock(rest, checked)) + continue + } + + if rest, ok := matchBullet(trimmed); ok { + flushParagraph() + blocks = append(blocks, BulletedListItemBlock(rest)) + continue + } + + if rest, ok := matchNumbered(trimmed); ok { + flushParagraph() + blocks = append(blocks, NumberedListItemBlock(rest)) + continue + } + + if strings.HasPrefix(trimmed, "> ") { + flushParagraph() + blocks = append(blocks, QuoteBlock(strings.TrimSpace(strings.TrimPrefix(trimmed, ">")))) + continue + } + + if paragraph.Len() > 0 { + paragraph.WriteByte('\n') + } + paragraph.WriteString(trimmed) + } + flushParagraph() + return blocks +} + +func matchHeading(s string) (int, string, bool) { + hashes := 0 + for hashes < len(s) && s[hashes] == '#' { + hashes++ + } + if hashes == 0 || hashes >= len(s) || s[hashes] != ' ' { + return 0, "", false + } + // Notion has only three heading levels; H4+ collapse to H3. + level := min(hashes, 3) + return level, strings.TrimSpace(s[hashes+1:]), true +} + +func matchBullet(s string) (string, bool) { + for _, p := range []string{"- ", "* ", "+ "} { + if strings.HasPrefix(s, p) { + return strings.TrimSpace(s[len(p):]), true + } + } + return "", false +} + +func matchNumbered(s string) (string, bool) { + // e.g. "1. foo", "12. bar" + idx := strings.Index(s, ". ") + if idx <= 0 || idx > 4 { + return "", false + } + for i := range idx { + if s[i] < '0' || s[i] > '9' { + return "", false + } + } + return strings.TrimSpace(s[idx+2:]), true +} + +func matchTodo(s string) (string, bool, bool) { + // "- [ ] foo" or "- [x] foo" / "* [X] foo" — handle both the dash + // and asterisk prefixes plus upper/lower-case x. + for _, p := range []string{"- ", "* ", "+ "} { + if !strings.HasPrefix(s, p) { + continue + } + rest := s[len(p):] + if len(rest) < 3 || rest[0] != '[' || rest[2] != ']' { + continue + } + marker := rest[1] + if marker != ' ' && marker != 'x' && marker != 'X' { + continue + } + body := strings.TrimSpace(rest[3:]) + return body, marker == 'x' || marker == 'X', true + } + return "", false, false +} + +func isDividerLine(s string) bool { + if len(s) < 3 { + return false + } + if !(s[0] == '-' || s[0] == '*' || s[0] == '_') { + return false + } + for i := 1; i < len(s); i++ { + if s[i] != s[0] { + return false + } + } + return true +} + +// BlocksToMarkdown renders a Notion block tree back to a markdown string. +// Used by the desktop "doc viewer" and by `clanker notion get page` to +// give operators a copy-pasteable form. Round-trips the subset that +// MarkdownToBlocks emits; richer block types render to a best-effort +// approximation (callouts as quotes, child pages as placeholders). +func BlocksToMarkdown(blocks []PageBlockTree) string { + var sb strings.Builder + renderBlocks(&sb, blocks, 0) + return sb.String() +} + +func renderBlocks(sb *strings.Builder, nodes []PageBlockTree, indent int) { + ind := strings.Repeat(" ", indent) + for _, n := range nodes { + text := (&n.Block).RichTextPlain() + switch n.Type { + case "heading_1": + sb.WriteString("# ") + sb.WriteString(text) + sb.WriteString("\n\n") + case "heading_2": + sb.WriteString("## ") + sb.WriteString(text) + sb.WriteString("\n\n") + case "heading_3": + sb.WriteString("### ") + sb.WriteString(text) + sb.WriteString("\n\n") + case "bulleted_list_item": + sb.WriteString(ind) + sb.WriteString("- ") + sb.WriteString(text) + sb.WriteByte('\n') + case "numbered_list_item": + sb.WriteString(ind) + sb.WriteString("1. ") + sb.WriteString(text) + sb.WriteByte('\n') + case "to_do": + sb.WriteString(ind) + sb.WriteString("- [ ] ") + sb.WriteString(text) + sb.WriteByte('\n') + case "quote": + sb.WriteString("> ") + sb.WriteString(text) + sb.WriteString("\n\n") + case "code": + sb.WriteString("```\n") + sb.WriteString(text) + sb.WriteString("\n```\n\n") + case "divider": + sb.WriteString("---\n\n") + case "child_page": + sb.WriteString("[child page]\n\n") + case "child_database": + sb.WriteString("[child database]\n\n") + default: + if text != "" { + sb.WriteString(text) + sb.WriteString("\n\n") + } + } + if len(n.Children) > 0 { + renderBlocks(sb, n.Children, indent+1) + } + } +} diff --git a/internal/notion/markdown_test.go b/internal/notion/markdown_test.go new file mode 100644 index 0000000..53d4a63 --- /dev/null +++ b/internal/notion/markdown_test.go @@ -0,0 +1,179 @@ +package notion + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestMarkdownToBlocks_Paragraphs(t *testing.T) { + blocks := MarkdownToBlocks("first paragraph\nstill first\n\nsecond paragraph") + if len(blocks) != 2 { + t.Fatalf("want 2 blocks, got %d", len(blocks)) + } + if blocks[0]["type"] != "paragraph" || blocks[1]["type"] != "paragraph" { + t.Errorf("unexpected types: %v / %v", blocks[0]["type"], blocks[1]["type"]) + } +} + +func TestMarkdownToBlocks_Headings(t *testing.T) { + blocks := MarkdownToBlocks("# H1\n## H2\n### H3\n#### deeper") + if len(blocks) != 4 { + t.Fatalf("want 4 blocks, got %d", len(blocks)) + } + want := []string{"heading_1", "heading_2", "heading_3", "heading_3"} + for i, b := range blocks { + if b["type"] != want[i] { + t.Errorf("block[%d] type: got %v, want %s", i, b["type"], want[i]) + } + } +} + +func TestMarkdownToBlocks_Lists(t *testing.T) { + blocks := MarkdownToBlocks("- bullet 1\n- bullet 2\n\n1. one\n2. two") + if len(blocks) != 4 { + t.Fatalf("want 4 blocks, got %d", len(blocks)) + } + want := []string{"bulleted_list_item", "bulleted_list_item", "numbered_list_item", "numbered_list_item"} + for i, b := range blocks { + if b["type"] != want[i] { + t.Errorf("block[%d] type: got %v, want %s", i, b["type"], want[i]) + } + } +} + +func TestMarkdownToBlocks_Todos(t *testing.T) { + blocks := MarkdownToBlocks("- [ ] open\n- [x] done\n* [X] also done") + if len(blocks) != 3 { + t.Fatalf("want 3 blocks, got %d", len(blocks)) + } + checked := []bool{false, true, true} + for i, b := range blocks { + if b["type"] != "to_do" { + t.Errorf("block[%d] type: got %v", i, b["type"]) + } + todo := b["to_do"].(map[string]any) + if todo["checked"].(bool) != checked[i] { + t.Errorf("block[%d] checked: got %v, want %v", i, todo["checked"], checked[i]) + } + } +} + +func TestMarkdownToBlocks_CodeFence(t *testing.T) { + md := "before\n\n```go\nfunc x() {}\n```\n\nafter" + blocks := MarkdownToBlocks(md) + if len(blocks) != 3 { + t.Fatalf("want 3 blocks (para + code + para), got %d", len(blocks)) + } + if blocks[1]["type"] != "code" { + t.Fatalf("middle block should be code, got %v", blocks[1]["type"]) + } + code := blocks[1]["code"].(map[string]any) + if code["language"] != "go" { + t.Errorf("language: got %v, want go", code["language"]) + } + rt := code["rich_text"].([]map[string]any) + if len(rt) == 0 { + t.Fatal("code rich_text is empty") + } + body := rt[0]["text"].(map[string]any)["content"].(string) + if !strings.Contains(body, "func x()") { + t.Errorf("code body: %q", body) + } +} + +func TestMarkdownToBlocks_Divider(t *testing.T) { + blocks := MarkdownToBlocks("above\n\n---\n\nbelow") + if len(blocks) != 3 { + t.Fatalf("want 3 blocks, got %d", len(blocks)) + } + if blocks[1]["type"] != "divider" { + t.Errorf("middle should be divider, got %v", blocks[1]["type"]) + } +} + +func TestMarkdownToBlocks_QuoteAndEmpty(t *testing.T) { + blocks := MarkdownToBlocks("> a quote\n\n") + if len(blocks) != 1 { + t.Fatalf("want 1 block, got %d", len(blocks)) + } + if blocks[0]["type"] != "quote" { + t.Errorf("got %v, want quote", blocks[0]["type"]) + } +} + +func TestRichTextChunk_LongInputSplits(t *testing.T) { + long := strings.Repeat("a", 4500) + chunks := richTextChunk(long) + if len(chunks) != 3 { + t.Errorf("want 3 chunks for 4500 chars, got %d", len(chunks)) + } + for _, c := range chunks { + txt := c["text"].(map[string]any)["content"].(string) + if len(txt) > 2000 { + t.Errorf("chunk exceeds 2000 chars: %d", len(txt)) + } + } +} + +func TestBlocksToMarkdown_RoundTripSubset(t *testing.T) { + // Build a synthetic block tree that mimics what GetPageBlocks returns, + // then render to markdown and verify the structure (we don't require + // byte-identical round-trip — the converter explicitly drops inline + // annotations). + mkRichText := func(s string) json.RawMessage { + raw, _ := json.Marshal(map[string]any{ + "rich_text": []map[string]any{{"plain_text": s}}, + }) + return raw + } + tree := []PageBlockTree{ + {Block: Block{Type: "heading_1", Heading1: mkRichText("Title")}}, + {Block: Block{Type: "paragraph", Paragraph: mkRichText("body para")}}, + {Block: Block{Type: "bulleted_list_item", BulletedListItem: mkRichText("first")}}, + {Block: Block{Type: "bulleted_list_item", BulletedListItem: mkRichText("second")}}, + {Block: Block{Type: "divider"}}, + {Block: Block{Type: "code", Code: mkRichText("echo hi")}}, + } + md := BlocksToMarkdown(tree) + for _, must := range []string{"# Title", "body para", "- first", "- second", "---", "```", "echo hi"} { + if !strings.Contains(md, must) { + t.Errorf("rendered markdown missing %q. got:\n%s", must, md) + } + } +} + +func TestPageBlockTree_RichTextPlain(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "rich_text": []map[string]any{ + {"plain_text": "hello "}, + {"plain_text": "world"}, + }, + }) + b := &Block{Type: "paragraph", Paragraph: raw} + if got := b.RichTextPlain(); got != "hello world" { + t.Errorf("RichTextPlain: got %q", got) + } + + if (&Block{Type: "divider"}).RichTextPlain() != "" { + t.Error("divider should render to empty plain text") + } +} + +func TestParagraphBlock_Shape(t *testing.T) { + got := ParagraphBlock("hello") + want := map[string]any{ + "object": "block", + "type": "paragraph", + "paragraph": map[string]any{ + "rich_text": []map[string]any{{ + "type": "text", + "text": map[string]any{"content": "hello"}, + }}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("ParagraphBlock shape mismatch.\ngot: %#v\nwant: %#v", got, want) + } +} diff --git a/internal/notion/pages.go b/internal/notion/pages.go new file mode 100644 index 0000000..791e9da --- /dev/null +++ b/internal/notion/pages.go @@ -0,0 +1,185 @@ +package notion + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" +) + +// SearchOptions mirrors Notion's `POST /v1/search` body. Filter restricts +// to pages or databases; Sort orders by last_edited_time (the only sort +// the search endpoint accepts). +type SearchOptions struct { + Query string // free-text — matches titles only, NOT block content + FilterObject string // "page" | "database" | "" (both) + PageSize int // 1..100 +} + +// Search wraps Notion's `POST /v1/search`. Returns RawMessage so callers +// can dispatch on `object` ("page"|"database"). Pagination is opt-out: +// pass PageSize=0 to fetch one page (default 25), or call again with +// the returned NextCursor. +func (c *Client) Search(ctx context.Context, opts SearchOptions) ([]json.RawMessage, string, bool, error) { + body := map[string]any{} + if q := strings.TrimSpace(opts.Query); q != "" { + body["query"] = q + } + if opts.FilterObject != "" { + body["filter"] = map[string]string{"property": "object", "value": opts.FilterObject} + } + if opts.PageSize > 0 { + body["page_size"] = opts.PageSize + } + body["sort"] = map[string]string{"direction": "descending", "timestamp": "last_edited_time"} + + var resp PaginatedResponse + if err := c.Do(ctx, "POST", "/search", body, &resp); err != nil { + return nil, "", false, err + } + return resp.Results, resp.NextCursor, resp.HasMore, nil +} + +// SearchPages is a typed convenience over Search filtered to pages. +func (c *Client) SearchPages(ctx context.Context, query string, pageSize int) ([]Page, error) { + results, _, _, err := c.Search(ctx, SearchOptions{ + Query: query, + FilterObject: FilterObjectPage, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + pages := make([]Page, 0, len(results)) + for _, raw := range results { + var p Page + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("decode page: %w", err) + } + pages = append(pages, p) + } + return pages, nil +} + +// GetPage fetches a single page by ID. Notion accepts both +// dash-separated UUIDs and the compact form; we pass through as-is. +func (c *Client) GetPage(ctx context.Context, id string) (*Page, error) { + if strings.TrimSpace(id) == "" { + return nil, errors.New("page id is required") + } + var p Page + if err := c.Do(ctx, "GET", "/pages/"+url.PathEscape(id), nil, &p); err != nil { + return nil, err + } + return &p, nil +} + +// CreatePage creates a page under a parent (page or database). For +// database parents, properties must satisfy the database schema. For +// page parents, only `title` is meaningful. children is an optional +// block array (use the helpers in blocks.go / markdown.go). +// +// parentType must be "page_id" or "database_id". +func (c *Client) CreatePage(ctx context.Context, parentType, parentID string, properties map[string]any, children []map[string]any) (*Page, error) { + if parentType != ParentTypePage && parentType != ParentTypeDatabase { + return nil, fmt.Errorf("parent type must be %q or %q, got %q", ParentTypePage, ParentTypeDatabase, parentType) + } + if strings.TrimSpace(parentID) == "" { + return nil, errors.New("parent id is required") + } + body := map[string]any{ + "parent": map[string]string{parentType: parentID}, + "properties": properties, + } + if len(children) > 0 { + body["children"] = children + } + var p Page + if err := c.Do(ctx, "POST", "/pages", body, &p); err != nil { + return nil, err + } + return &p, nil +} + +// UpdatePageProperties patches a page's typed property values. Use for +// database rows (free-form pages only have `title`). Pass only the +// properties you want to change — Notion merges the patch. +func (c *Client) UpdatePageProperties(ctx context.Context, id string, properties map[string]any) (*Page, error) { + if strings.TrimSpace(id) == "" { + return nil, errors.New("page id is required") + } + body := map[string]any{"properties": properties} + var p Page + if err := c.Do(ctx, "PATCH", "/pages/"+url.PathEscape(id), body, &p); err != nil { + return nil, err + } + return &p, nil +} + +// ArchivePage soft-deletes a page (Notion has no hard delete via API). +func (c *Client) ArchivePage(ctx context.Context, id string) error { + if strings.TrimSpace(id) == "" { + return errors.New("page id is required") + } + body := map[string]any{"archived": true} + return c.Do(ctx, "PATCH", "/pages/"+url.PathEscape(id), body, nil) +} + +// TitleProperty builds the typed `title` property payload Notion expects +// in CreatePage requests. Database rows whose title column is named +// something other than "title" should patch the result before sending. +func TitleProperty(title string) map[string]any { + return map[string]any{ + "title": map[string]any{ + "title": []map[string]any{{ + "type": "text", + "text": map[string]any{"content": title}, + }}, + }, + } +} + +// TitleOfPage extracts a best-effort display title from a page's +// properties. Database rows store the title under a property whose name +// is workspace-defined ("Name", "Task", etc); we scan for any property +// with type=title. Free-form pages always use the literal key "title". +func TitleOfPage(p *Page) string { + if p == nil { + return "" + } + if raw, ok := p.Properties["title"]; ok { + if s := extractRichTextTitle(raw); s != "" { + return s + } + } + for _, raw := range p.Properties { + var probe struct { + Type string `json:"type"` + Title []RichTextSpan `json:"title"` + } + if err := json.Unmarshal(raw, &probe); err == nil && probe.Type == "title" { + return joinRichText(probe.Title) + } + } + return "" +} + +func extractRichTextTitle(raw json.RawMessage) string { + var probe struct { + Title []RichTextSpan `json:"title"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return "" + } + return joinRichText(probe.Title) +} + +func joinRichText(spans []RichTextSpan) string { + var sb strings.Builder + for _, s := range spans { + sb.WriteString(s.PlainText) + } + return sb.String() +} diff --git a/internal/notion/static_commands.go b/internal/notion/static_commands.go new file mode 100644 index 0000000..3374bf3 --- /dev/null +++ b/internal/notion/static_commands.go @@ -0,0 +1,470 @@ +package notion + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" +) + +// CreateNotionCommands builds the `clanker notion` command tree. The ask +// subcommand is wired up by cmd/notion.go so this package stays free of +// the AI provider dependencies. +func CreateNotionCommands() *cobra.Command { + cmd := &cobra.Command{ + Use: "notion", + Short: "Query and write to your Notion workspace", + Long: "Browse pages, query databases, append blocks, and manage Notion content directly.", + Aliases: []string{"nt"}, + } + + cmd.PersistentFlags().String("token", "", "Notion integration token (overrides config)") + cmd.PersistentFlags().String("database", "", "Default database id (overrides config)") + cmd.PersistentFlags().String("format", "table", "Output format: table | json") + cmd.PersistentFlags().Bool("debug", false, "Enable debug output") + + cmd.AddCommand(buildListCommand()) + cmd.AddCommand(buildGetCommand()) + cmd.AddCommand(buildSearchCommand()) + cmd.AddCommand(buildPageCommand()) + cmd.AddCommand(buildDBCommand()) + + return cmd +} + +func clientFromCmd(cmd *cobra.Command) (*Client, error) { + token, _ := cmd.Flags().GetString("token") + if token == "" { + token = ResolveToken() + } + if token == "" { + return nil, fmt.Errorf("notion integration token is required (set --token, NOTION_API_KEY, or notion.integration_token)") + } + db, _ := cmd.Flags().GetString("database") + if db == "" { + db = ResolveDefaultDatabaseID() + } + debug, _ := cmd.Flags().GetBool("debug") + return NewClient(token, db, debug) +} + +func outputFormat(cmd *cobra.Command) string { + f, _ := cmd.Flags().GetString("format") + if f == "json" { + return "json" + } + return "table" +} + +func writeJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) +} + +func buildListCommand() *cobra.Command { + c := &cobra.Command{ + Use: "list ", + Short: "List Notion resources", + Long: `List Notion resources of a specific type. + +Supported resources: + pages - Top search results across the workspace (filter by --query) + databases - Databases the integration can access + users - Workspace users (people + integration bots)`, + Args: cobra.ExactArgs(1), + RunE: runList, + } + c.Flags().String("query", "", "Free-text query (matches titles, NOT block content)") + c.Flags().Int("limit", 25, "Page size (1..100)") + return c +} + +func runList(cmd *cobra.Command, args []string) error { + client, err := clientFromCmd(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + q, _ := cmd.Flags().GetString("query") + limit, _ := cmd.Flags().GetInt("limit") + + switch strings.ToLower(args[0]) { + case "pages", "page": + pages, err := client.SearchPages(ctx, q, limit) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(pages) + } + return renderPages(pages) + case "databases", "database", "dbs", "db": + dbs, err := client.ListDatabases(ctx, q, limit) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(dbs) + } + return renderDatabases(dbs) + case "users", "user": + users, err := client.ListUsers(ctx, limit) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(users) + } + return renderUsers(users) + default: + return fmt.Errorf("unknown resource %q (supported: pages, databases, users)", args[0]) + } +} + +func renderPages(pages []Page) error { + w := newTabWriter() + fmt.Fprintln(w, "ID\tTITLE\tPARENT\tEDITED") + for _, p := range pages { + parent := p.Parent.Type + switch p.Parent.Type { + case ParentTypeDatabase: + parent = "db:" + shorten(p.Parent.DatabaseID) + case ParentTypePage: + parent = "page:" + shorten(p.Parent.PageID) + case ParentTypeWorkspace: + parent = "workspace" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", shorten(p.ID), TitleOfPage(&p), parent, p.LastEditedTime.Format(time.RFC3339)) + } + return w.Flush() +} + +func renderDatabases(dbs []Database) error { + w := newTabWriter() + fmt.Fprintln(w, "ID\tTITLE\tEDITED") + for _, db := range dbs { + fmt.Fprintf(w, "%s\t%s\t%s\n", shorten(db.ID), TitleOfDatabase(&db), db.LastEditedTime.Format(time.RFC3339)) + } + return w.Flush() +} + +func renderUsers(users []User) error { + w := newTabWriter() + fmt.Fprintln(w, "ID\tNAME\tTYPE\tEMAIL") + for _, u := range users { + email := "" + if u.Person != nil { + email = u.Person.Email + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", shorten(u.ID), u.Name, u.Type, email) + } + return w.Flush() +} + +func shorten(id string) string { + if len(id) <= 12 { + return id + } + return id[:8] + "…" +} + +func buildGetCommand() *cobra.Command { + c := &cobra.Command{ + Use: "get ", + Short: "Get a single Notion resource", + Long: `Get a single Notion resource by ID. + +Examples: + clanker notion get page + clanker notion get database + clanker notion get blocks # recursive block tree (capped)`, + Args: cobra.ExactArgs(2), + RunE: runGet, + } + return c +} + +func runGet(cmd *cobra.Command, args []string) error { + client, err := clientFromCmd(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + resource := strings.ToLower(args[0]) + id := args[1] + switch resource { + case "page": + p, err := client.GetPage(ctx, id) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(p) + } + fmt.Printf("ID: %s\nTitle: %s\nURL: %s\nEdited: %s\n", p.ID, TitleOfPage(p), p.URL, p.LastEditedTime.Format(time.RFC3339)) + return nil + case "database", "db": + db, err := client.GetDatabase(ctx, id) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(db) + } + fmt.Printf("ID: %s\nTitle: %s\nURL: %s\n", db.ID, TitleOfDatabase(db), db.URL) + return nil + case "blocks", "block": + tree, count, err := client.GetPageBlocks(ctx, id) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(tree) + } + fmt.Println(BlocksToMarkdown(tree)) + fmt.Fprintf(os.Stderr, "(%d blocks)\n", count) + return nil + } + return fmt.Errorf("unknown resource %q (supported: page, database, blocks)", resource) +} + +func buildSearchCommand() *cobra.Command { + c := &cobra.Command{ + Use: "search ", + Short: "Full-title search across the workspace", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := clientFromCmd(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + q := strings.Join(args, " ") + pages, err := client.SearchPages(ctx, q, 25) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(pages) + } + if len(pages) == 0 { + fmt.Fprintln(os.Stderr, "no results — has the integration been shared with the relevant pages?") + return nil + } + return renderPages(pages) + }, + } + return c +} + +func buildPageCommand() *cobra.Command { + page := &cobra.Command{ + Use: "page ", + Short: "Create pages and append blocks", + } + + create := &cobra.Command{ + Use: "create", + Short: "Create a page under a parent page or database", + RunE: runPageCreate, + } + create.Flags().String("parent", "", "Parent page or database id (required)") + create.Flags().String("parent-type", "page_id", "Parent type: page_id | database_id") + create.Flags().String("title", "", "Page title") + create.Flags().String("markdown", "", "Path to a markdown file with the page body") + create.Flags().String("text", "", "Inline markdown body (alternative to --markdown)") + _ = create.MarkFlagRequired("parent") + _ = create.MarkFlagRequired("title") + + append := &cobra.Command{ + Use: "append", + Short: "Append blocks to an existing page", + RunE: runPageAppend, + } + append.Flags().String("page", "", "Target page id (required)") + append.Flags().String("markdown", "", "Path to a markdown file with the content to append") + append.Flags().String("text", "", "Inline markdown content to append") + _ = append.MarkFlagRequired("page") + + page.AddCommand(create, append) + return page +} + +func runPageCreate(cmd *cobra.Command, args []string) error { + client, err := clientFromCmd(cmd) + if err != nil { + return err + } + parent, _ := cmd.Flags().GetString("parent") + parentType, _ := cmd.Flags().GetString("parent-type") + title, _ := cmd.Flags().GetString("title") + mdPath, _ := cmd.Flags().GetString("markdown") + mdText, _ := cmd.Flags().GetString("text") + + md, err := readMarkdownInput(mdPath, mdText) + if err != nil { + return err + } + children := MarkdownToBlocks(md) + + // Both database rows and free-form pages use a "title" property; for + // database parents the user must ensure that column exists. + properties := TitleProperty(title) + + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + p, err := client.CreatePage(ctx, parentType, parent, properties, children) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(p) + } + fmt.Printf("Created %s\n%s\n", p.ID, p.URL) + return nil +} + +func runPageAppend(cmd *cobra.Command, args []string) error { + client, err := clientFromCmd(cmd) + if err != nil { + return err + } + pageID, _ := cmd.Flags().GetString("page") + mdPath, _ := cmd.Flags().GetString("markdown") + mdText, _ := cmd.Flags().GetString("text") + + md, err := readMarkdownInput(mdPath, mdText) + if err != nil { + return err + } + children := MarkdownToBlocks(md) + if len(children) == 0 { + return fmt.Errorf("no blocks parsed from input") + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + appended, err := client.AppendBlockChildren(ctx, pageID, children) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(appended) + } + fmt.Printf("Appended %d blocks to %s\n", len(appended), pageID) + return nil +} + +func readMarkdownInput(path, inline string) (string, error) { + if path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read markdown: %w", err) + } + return string(raw), nil + } + if inline != "" { + return inline, nil + } + return "", fmt.Errorf("either --markdown or --text is required") +} + +func buildDBCommand() *cobra.Command { + db := &cobra.Command{ + Use: "db ", + Short: "Database row operations", + } + row := &cobra.Command{ + Use: "row ", + Short: "Create or update database rows", + } + create := &cobra.Command{ + Use: "create", + Short: "Create a row in a database (properties supplied as JSON)", + RunE: runRowCreate, + } + create.Flags().String("db", "", "Database id (defaults to notion.default_database_id)") + create.Flags().String("json", "", "Properties payload (Notion's typed property shape)") + _ = create.MarkFlagRequired("json") + + update := &cobra.Command{ + Use: "update", + Short: "Patch a row's properties", + RunE: runRowUpdate, + } + update.Flags().String("row", "", "Row (page) id") + update.Flags().String("json", "", "Properties patch") + _ = update.MarkFlagRequired("row") + _ = update.MarkFlagRequired("json") + + row.AddCommand(create, update) + db.AddCommand(row) + return db +} + +func runRowCreate(cmd *cobra.Command, args []string) error { + client, err := clientFromCmd(cmd) + if err != nil { + return err + } + dbID, _ := cmd.Flags().GetString("db") + if dbID == "" { + dbID = client.DefaultDatabaseID() + } + if dbID == "" { + return fmt.Errorf("database id is required (--db or notion.default_database_id)") + } + raw, _ := cmd.Flags().GetString("json") + var props map[string]any + if err := json.Unmarshal([]byte(raw), &props); err != nil { + return fmt.Errorf("parse properties JSON: %w", err) + } + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + p, err := client.CreateDatabaseRow(ctx, dbID, props) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(p) + } + fmt.Printf("Created row %s\n%s\n", p.ID, p.URL) + return nil +} + +func runRowUpdate(cmd *cobra.Command, args []string) error { + client, err := clientFromCmd(cmd) + if err != nil { + return err + } + rowID, _ := cmd.Flags().GetString("row") + raw, _ := cmd.Flags().GetString("json") + var props map[string]any + if err := json.Unmarshal([]byte(raw), &props); err != nil { + return fmt.Errorf("parse properties JSON: %w", err) + } + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + p, err := client.UpdatePageProperties(ctx, rowID, props) + if err != nil { + return err + } + if outputFormat(cmd) == "json" { + return writeJSON(p) + } + fmt.Printf("Updated row %s\n", p.ID) + return nil +} diff --git a/internal/notion/status.go b/internal/notion/status.go new file mode 100644 index 0000000..9a5699f --- /dev/null +++ b/internal/notion/status.go @@ -0,0 +1,63 @@ +package notion + +import ( + "context" + "encoding/json" + "sync" + "time" +) + +// GatherAccountStatus collects an at-a-glance snapshot for the conversation +// history. Two Notion calls in parallel: `/users/me` for the workspace +// name, and an unfiltered `/search` whose results we bucket by object +// type to derive page + database counts in a single round-trip. +// +// AccessiblePages stays at zero when the user hasn't shared anything with +// the integration — surfacing the share gotcha at the prompt level. +func GatherAccountStatus(ctx context.Context, c *Client) (*AccountStatus, error) { + status := &AccountStatus{ + Timestamp: time.Now(), + } + + var ( + wg sync.WaitGroup + workspaceName string + pageCount int + dbCount int + ) + + wg.Add(2) + go func() { + defer wg.Done() + if _, ws, err := c.Me(ctx); err == nil && ws != nil { + workspaceName = ws.WorkspaceName + } + }() + go func() { + defer wg.Done() + results, _, _, err := c.Search(ctx, SearchOptions{PageSize: 100}) + if err != nil { + return + } + for _, raw := range results { + var probe struct { + Object string `json:"object"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + continue + } + switch probe.Object { + case "page": + pageCount++ + case "database": + dbCount++ + } + } + }() + wg.Wait() + + status.WorkspaceName = workspaceName + status.AccessiblePages = pageCount + status.DatabaseCount = dbCount + return status, nil +} diff --git a/internal/notion/types.go b/internal/notion/types.go new file mode 100644 index 0000000..23ba0ed --- /dev/null +++ b/internal/notion/types.go @@ -0,0 +1,164 @@ +package notion + +import ( + "encoding/json" + "time" +) + +// Notion API responses use snake_case keys and ISO-8601 timestamp strings. +// The block tree is polymorphic — each Block carries a Type discriminator +// plus a typed-per-Type body; we keep the typed payload as RawMessage and +// let callers dispatch on Type. + +// Workspace identifies the integration's accessible scope. Notion's API +// does not expose a "current workspace" endpoint — we infer name/id from +// `GET /v1/users/me` → `bot.workspace_name`. +type Workspace struct { + BotID string `json:"bot_id"` + WorkspaceName string `json:"workspace_name"` +} + +// User is a workspace member (`type=person`) or an integration bot +// (`type=bot`). Both share Name + AvatarURL; Person carries Email. +type User struct { + ID string `json:"id"` + Type string `json:"type"` // "person" | "bot" + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + Person *struct { + Email string `json:"email"` + } `json:"person,omitempty"` + Bot *struct { + Owner json.RawMessage `json:"owner,omitempty"` + WorkspaceName string `json:"workspace_name,omitempty"` + } `json:"bot,omitempty"` +} + +// ParentType values for CreatePage and friends. Kept as typed string so +// callers get a compile-time error on typos (Notion silently 400s on +// unknown parent types). +const ( + ParentTypePage = "page_id" + ParentTypeDatabase = "database_id" + ParentTypeWorkspace = "workspace" + ParentTypeBlock = "block_id" +) + +// FilterObject values for SearchOptions.FilterObject — restricts the +// `POST /v1/search` result set to one object kind. +const ( + FilterObjectPage = "page" + FilterObjectDatabase = "database" +) + +// Parent identifies the owner of a Page/Database/Block. Exactly one of +// the fields is populated based on Type ("database_id" | "page_id" | +// "workspace" | "block_id"). +type Parent struct { + Type string `json:"type"` + DatabaseID string `json:"database_id,omitempty"` + PageID string `json:"page_id,omitempty"` + BlockID string `json:"block_id,omitempty"` + Workspace bool `json:"workspace,omitempty"` +} + +// Page can be a free-form document OR a database row — the discriminator +// is Parent.Type. Database rows have typed Properties; free-form pages +// only have a `title` property. +type Page struct { + Object string `json:"object"` // always "page" + ID string `json:"id"` + CreatedTime time.Time `json:"created_time"` + LastEditedTime time.Time `json:"last_edited_time"` + Archived bool `json:"archived"` + URL string `json:"url"` + Parent Parent `json:"parent"` + Properties map[string]json.RawMessage `json:"properties"` + Icon json.RawMessage `json:"icon,omitempty"` + Cover json.RawMessage `json:"cover,omitempty"` +} + +// Database is a Notion table. Properties define the schema (column types). +type Database struct { + Object string `json:"object"` // always "database" + ID string `json:"id"` + CreatedTime time.Time `json:"created_time"` + LastEditedTime time.Time `json:"last_edited_time"` + Archived bool `json:"archived"` + URL string `json:"url"` + Title []RichTextSpan `json:"title"` + Description []RichTextSpan `json:"description"` + Properties map[string]json.RawMessage `json:"properties"` + Parent Parent `json:"parent"` +} + +// Block is one node in a page's content tree. The polymorphic payload +// lives in the field named after Type (e.g. Type="paragraph" → field +// `paragraph: {rich_text, color}`). We unmarshal everything into +// RawMessage and dispatch lazily. +type Block struct { + Object string `json:"object"` // always "block" + ID string `json:"id"` + Type string `json:"type"` + HasChildren bool `json:"has_children"` + Archived bool `json:"archived"` + CreatedTime time.Time `json:"created_time"` + LastEditedTime time.Time `json:"last_edited_time"` + Parent Parent `json:"parent"` + + // The per-type payload sits at top level under a key matching Type. + // We capture the whole envelope so block renderers can pull any + // fields they need without re-defining every variant struct here. + Paragraph json.RawMessage `json:"paragraph,omitempty"` + Heading1 json.RawMessage `json:"heading_1,omitempty"` + Heading2 json.RawMessage `json:"heading_2,omitempty"` + Heading3 json.RawMessage `json:"heading_3,omitempty"` + BulletedListItem json.RawMessage `json:"bulleted_list_item,omitempty"` + NumberedListItem json.RawMessage `json:"numbered_list_item,omitempty"` + ToDo json.RawMessage `json:"to_do,omitempty"` + Code json.RawMessage `json:"code,omitempty"` + Quote json.RawMessage `json:"quote,omitempty"` + Callout json.RawMessage `json:"callout,omitempty"` + Divider json.RawMessage `json:"divider,omitempty"` + Image json.RawMessage `json:"image,omitempty"` + ChildPage json.RawMessage `json:"child_page,omitempty"` + ChildDatabase json.RawMessage `json:"child_database,omitempty"` +} + +// RichTextSpan is the atom inside a rich-text array. Notion uses these +// for prose with inline annotations (bold/italic/code/links). For most +// machine-rendered prose we only care about PlainText. +type RichTextSpan struct { + Type string `json:"type"` + PlainText string `json:"plain_text"` + Href string `json:"href,omitempty"` + Annotations struct { + Bold bool `json:"bold"` + Italic bool `json:"italic"` + Strikethrough bool `json:"strikethrough"` + Underline bool `json:"underline"` + Code bool `json:"code"` + Color string `json:"color"` + } `json:"annotations"` +} + +// PaginatedResponse is Notion's standard list envelope. NextCursor is +// the opaque string to pass back on the next call; HasMore signals when +// to stop. Results carries the typed array (we use json.RawMessage to +// avoid coupling list operations to a specific result type). +type PaginatedResponse struct { + Object string `json:"object"` // always "list" + Results []json.RawMessage `json:"results"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +// AccountStatus is the conversation-history snapshot for `notion ask`. +// "Accessible" is intentional: pages stay at zero until the user shares +// them with the integration, surfacing the #1 Notion UX papercut. +type AccountStatus struct { + Timestamp time.Time `json:"timestamp"` + WorkspaceName string `json:"workspace_name,omitempty"` + AccessiblePages int `json:"accessible_pages"` + DatabaseCount int `json:"database_count"` +} diff --git a/internal/notion/users.go b/internal/notion/users.go new file mode 100644 index 0000000..b71d340 --- /dev/null +++ b/internal/notion/users.go @@ -0,0 +1,67 @@ +package notion + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// Me returns the bot user behind the integration token. The bot envelope +// carries `workspace_name` which is the only way to label the workspace +// — Notion has no `GET /workspace` endpoint. +func (c *Client) Me(ctx context.Context) (*User, *Workspace, error) { + var u User + if err := c.Do(ctx, "GET", "/users/me", nil, &u); err != nil { + return nil, nil, err + } + ws := &Workspace{BotID: u.ID} + if u.Bot != nil { + ws.WorkspaceName = u.Bot.WorkspaceName + } + return &u, ws, nil +} + +// ListUsers paginates through every user the integration can see. +// pageSize must be 1..100; 0 → 100. The integration scope determines +// the result set — workspaces with SSO often hide non-bot users from +// internal integrations. +func (c *Client) ListUsers(ctx context.Context, pageSize int) ([]User, error) { + if pageSize <= 0 { + pageSize = 100 + } + pageSize = min(pageSize, 100) + var out []User + cursor := "" + for { + path := fmt.Sprintf("/users?page_size=%d", pageSize) + if cursor != "" { + path += "&start_cursor=" + url.QueryEscape(cursor) + } + var resp PaginatedResponse + if err := c.Do(ctx, "GET", path, nil, &resp); err != nil { + return nil, err + } + for _, raw := range resp.Results { + var u User + if err := json.Unmarshal(raw, &u); err != nil { + return nil, fmt.Errorf("decode user: %w", err) + } + out = append(out, u) + } + if !resp.HasMore || resp.NextCursor == "" { + break + } + cursor = resp.NextCursor + } + return out, nil +} + +// GetUser fetches a single user by ID. +func (c *Client) GetUser(ctx context.Context, id string) (*User, error) { + var u User + if err := c.Do(ctx, "GET", "/users/"+url.PathEscape(id), nil, &u); err != nil { + return nil, err + } + return &u, nil +}