diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 40117a1a..5b1d629a 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -2036,31 +2036,22 @@ func showCmd() *cobra.Command { fmt.Println(item.Content) } - // Show GitHub PR if linked - if item.Fields != "" { - var fieldsMap map[string]interface{} - if err := json.Unmarshal([]byte(item.Fields), &fieldsMap); err == nil { - if prRaw, ok := fieldsMap["github_pr"]; ok { - if prMap, ok := prRaw.(map[string]interface{}); ok { - fmt.Println("\n--- GitHub PR ---") - prNum := "" - if n, ok := prMap["number"].(float64); ok { - prNum = fmt.Sprintf("#%d", int(n)) - } - prState := fmt.Sprintf("%v", prMap["state"]) - prURL := fmt.Sprintf("%v", prMap["url"]) - prTitle := fmt.Sprintf("%v", prMap["title"]) - - stateColor := color.New(color.FgGreen) - switch prState { - case "MERGED": - stateColor = color.New(color.FgMagenta) - case "CLOSED": - stateColor = color.New(color.FgRed) - } - fmt.Printf("PR %-6s %s %s\n", prNum, stateColor.Sprint(prState), color.New(color.Faint).Sprint(prURL)) - fmt.Printf(" %q\n", prTitle) - } + // Show linked code context if present + if pr := extractPRFromItem(item); pr != nil { + fmt.Println("\n--- GitHub PR ---") + prNum := "" + if pr.Number > 0 { + prNum = fmt.Sprintf("#%d", pr.Number) + } + stateColor := prStateColor(pr.State) + fmt.Printf("PR %-6s %s %s\n", prNum, stateColor.Sprint(pr.State), color.New(color.Faint).Sprint(pr.URL)) + fmt.Printf(" %q\n", pr.Title) + if item.CodeContext != nil { + if item.CodeContext.Branch != "" { + fmt.Printf("Branch: %s\n", color.New(color.Faint).Sprint(item.CodeContext.Branch)) + } + if item.CodeContext.Repo != "" { + fmt.Printf("Repo: %s\n", color.New(color.Faint).Sprint(item.CodeContext.Repo)) } } } @@ -4740,7 +4731,7 @@ Examples: } var results []prStatus for _, item := range items { - pr := extractPRFromFields(item.Fields) + pr := extractPRFromItem(&item) if pr != nil { results = append(results, prStatus{ Ref: cli.ItemRef(item), @@ -4760,7 +4751,7 @@ Examples: count := 0 for _, item := range items { - pr := extractPRFromFields(item.Fields) + pr := extractPRFromItem(&item) if pr == nil { continue } @@ -4838,7 +4829,7 @@ func githubUnlinkCmd() *cobra.Command { // Helper functions for GitHub integration func showItemPRStatus(item *models.Item, bold, dim *color.Color) error { - pr := extractPRFromFields(item.Fields) + pr := extractPRFromItem(item) if pr == nil { return fmt.Errorf("item %q has no linked PR", item.Slug) } @@ -4862,31 +4853,20 @@ func showItemPRStatus(item *models.Item, bold, dim *color.Color) error { return nil } -func extractPRFromFields(fieldsJSON string) *GitHubPR { - if fieldsJSON == "" || fieldsJSON == "{}" { +func extractPRFromItem(item *models.Item) *GitHubPR { + if item == nil || item.CodeContext == nil || item.CodeContext.PullRequest == nil { return nil } - var fieldsMap map[string]interface{} - if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil { - return nil + pr := item.CodeContext.PullRequest + return &GitHubPR{ + Number: pr.Number, + URL: pr.URL, + Title: pr.Title, + State: pr.State, + Branch: item.CodeContext.Branch, + Repo: item.CodeContext.Repo, + UpdatedAt: pr.UpdatedAt, } - prRaw, ok := fieldsMap["github_pr"] - if !ok { - return nil - } - // Re-marshal and unmarshal to properly extract the struct - prJSON, err := json.Marshal(prRaw) - if err != nil { - return nil - } - var pr GitHubPR - if err := json.Unmarshal(prJSON, &pr); err != nil { - return nil - } - if pr.Number == 0 { - return nil - } - return &pr } func prStateColor(state string) *color.Color { diff --git a/internal/models/item.go b/internal/models/item.go index 35f69eb7..41e7b7f7 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "fmt" "time" ) @@ -34,6 +35,7 @@ type Item struct { CollectionIcon string `json:"collection_icon,omitempty"` CollectionPrefix string `json:"collection_prefix,omitempty"` DerivedClosure *ItemDerivedClosure `json:"derived_closure,omitempty"` + CodeContext *ItemCodeContext `json:"code_context,omitempty"` } // ComputeRef sets the Ref field from CollectionPrefix and ItemNumber. @@ -60,6 +62,77 @@ type ItemDerivedClosure struct { RelatedItems []ItemRelationRef `json:"related_items,omitempty"` } +type ItemCodeContext struct { + Provider string `json:"provider"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` + PullRequest *ItemPullRequestMetadata `json:"pull_request,omitempty"` +} + +type ItemPullRequestMetadata struct { + Number int `json:"number"` + URL string `json:"url"` + Title string `json:"title"` + State string `json:"state"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type githubPRFields struct { + Number int `json:"number"` + URL string `json:"url"` + Title string `json:"title"` + State string `json:"state"` + Branch string `json:"branch"` + Repo string `json:"repo"` + UpdatedAt string `json:"updated_at"` +} + +func ExtractItemCodeContext(fieldsJSON string) *ItemCodeContext { + if fieldsJSON == "" || fieldsJSON == "{}" { + return nil + } + + var fieldsMap map[string]any + if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil { + return nil + } + + raw, ok := fieldsMap["github_pr"] + if !ok { + return nil + } + + payload, err := json.Marshal(raw) + if err != nil { + return nil + } + + var githubPR githubPRFields + if err := json.Unmarshal(payload, &githubPR); err != nil { + return nil + } + if githubPR.Number == 0 && githubPR.URL == "" && githubPR.Branch == "" && githubPR.Repo == "" { + return nil + } + + context := &ItemCodeContext{ + Provider: "github", + Repo: githubPR.Repo, + Branch: githubPR.Branch, + } + if githubPR.Number != 0 || githubPR.URL != "" || githubPR.Title != "" || githubPR.State != "" { + context.PullRequest = &ItemPullRequestMetadata{ + Number: githubPR.Number, + URL: githubPR.URL, + Title: githubPR.Title, + State: githubPR.State, + UpdatedAt: githubPR.UpdatedAt, + } + } + + return context +} + type ItemCreate struct { Title string `json:"title"` Content string `json:"content,omitempty"` diff --git a/internal/models/item_test.go b/internal/models/item_test.go new file mode 100644 index 00000000..44f47f48 --- /dev/null +++ b/internal/models/item_test.go @@ -0,0 +1,31 @@ +package models + +import "testing" + +func TestExtractItemCodeContextFromGitHubPRFields(t *testing.T) { + context := ExtractItemCodeContext(`{"github_pr":{"number":42,"url":"https://github.com/xarmian/pad/pull/42","title":"Add branch metadata","state":"OPEN","branch":"feat/task-123-branch-pr-metadata","repo":"xarmian/pad","updated_at":"2026-04-02T14:00:00Z"}}`) + if context == nil { + t.Fatal("expected code context") + } + if context.Provider != "github" { + t.Fatalf("expected provider github, got %q", context.Provider) + } + if context.Branch != "feat/task-123-branch-pr-metadata" { + t.Fatalf("expected branch metadata, got %q", context.Branch) + } + if context.Repo != "xarmian/pad" { + t.Fatalf("expected repo xarmian/pad, got %q", context.Repo) + } + if context.PullRequest == nil { + t.Fatal("expected pull request metadata") + } + if context.PullRequest.Number != 42 { + t.Fatalf("expected PR #42, got #%d", context.PullRequest.Number) + } +} + +func TestExtractItemCodeContextReturnsNilForUnrelatedFields(t *testing.T) { + if got := ExtractItemCodeContext(`{"status":"open"}`); got != nil { + t.Fatal("expected nil code context for unrelated fields") + } +} diff --git a/internal/server/handlers_items_test.go b/internal/server/handlers_items_test.go index 4b002fce..78f4ab28 100644 --- a/internal/server/handlers_items_test.go +++ b/internal/server/handlers_items_test.go @@ -624,6 +624,41 @@ func TestGetItemIncludesDerivedClosureForImplementedItems(t *testing.T) { } } +func TestGetItemIncludesCodeContext(t *testing.T) { + srv := testServer(t) + slug := createWSWithCollections(t, srv) + + rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{ + "title": "Linked Task", + "fields": `{"status":"open"}`, + }) + var item models.Item + parseJSON(t, rr, &item) + + fields := `{"status":"open","github_pr":{"number":40,"url":"https://github.com/xarmian/pad/pull/40","title":"Surface lineage relationships and derived closure for TASK-122","state":"MERGED","branch":"feat/task-122-lineage-display","repo":"xarmian/pad","updated_at":"2026-04-02T14:46:09Z"}}` + updated, err := srv.store.UpdateItem(item.ID, models.ItemUpdate{Fields: &fields}) + if err != nil { + t.Fatalf("update item fields: %v", err) + } + + rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+updated.Slug, nil) + if rr.Code != http.StatusOK { + t.Fatalf("get item: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var fetched models.Item + parseJSON(t, rr, &fetched) + if fetched.CodeContext == nil { + t.Fatal("expected code context in item response") + } + if fetched.CodeContext.Branch != "feat/task-122-lineage-display" { + t.Fatalf("expected branch metadata, got %q", fetched.CodeContext.Branch) + } + if fetched.CodeContext.PullRequest == nil || fetched.CodeContext.PullRequest.Number != 40 { + t.Fatalf("expected PR metadata, got %#v", fetched.CodeContext.PullRequest) + } +} + func TestItemVersions(t *testing.T) { srv := testServer(t) slug := createWSWithCollections(t, srv) diff --git a/internal/store/items.go b/internal/store/items.go index de2a8adb..9129d774 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -122,7 +122,7 @@ func (s *Store) GetItem(id string) (*models.Item, error) { item.CreatedAt = parseTime(createdAt) item.UpdatedAt = parseTime(updatedAt) item.DeletedAt = parseTimePtr(deletedAt) - item.ComputeRef() + hydrateItemComputedMetadata(&item) return &item, nil } @@ -230,7 +230,7 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model item.CreatedAt = parseTime(createdAt) item.UpdatedAt = parseTime(updatedAt) item.DeletedAt = parseTimePtr(deletedAt) - item.ComputeRef() + hydrateItemComputedMetadata(&item) return &item, nil } if err != sql.ErrNoRows { @@ -302,7 +302,7 @@ func (s *Store) GetItemBySlugIncludeDeleted(workspaceID, slug string) (*models.I item.CreatedAt = parseTime(createdAt) item.UpdatedAt = parseTime(updatedAt) item.DeletedAt = parseTimePtr(deletedAt) - item.ComputeRef() + hydrateItemComputedMetadata(&item) return &item, nil } @@ -1036,8 +1036,16 @@ func scanItems(rows *sql.Rows) ([]models.Item, error) { item.Pinned = pinned == 1 item.CreatedAt = parseTime(createdAt) item.UpdatedAt = parseTime(updatedAt) - item.ComputeRef() + hydrateItemComputedMetadata(&item) items = append(items, item) } return items, rows.Err() } + +func hydrateItemComputedMetadata(item *models.Item) { + if item == nil { + return + } + item.ComputeRef() + item.CodeContext = models.ExtractItemCodeContext(item.Fields) +} diff --git a/internal/store/items_test.go b/internal/store/items_test.go index fe2d0eae..9dea6f5f 100644 --- a/internal/store/items_test.go +++ b/internal/store/items_test.go @@ -312,6 +312,62 @@ func TestItemCRUD(t *testing.T) { } } +func TestItemCodeContextIsHydratedOnRead(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Test") + col := createTestCollection(t, s, ws.ID, "Tasks") + + item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{ + Title: "Link PR", + Fields: `{"status":"open","github_pr":{"number":7,"url":"https://github.com/xarmian/pad/pull/7","title":"Link PR","state":"OPEN","branch":"feat/link-pr","repo":"xarmian/pad","updated_at":"2026-04-02T14:00:00Z"}}`, + }) + if err != nil { + t.Fatalf("CreateItem error: %v", err) + } + + got, err := s.GetItem(item.ID) + if err != nil { + t.Fatalf("GetItem error: %v", err) + } + if got == nil || got.CodeContext == nil { + t.Fatal("expected code context on item read") + } + if got.CodeContext.Branch != "feat/link-pr" { + t.Fatalf("expected branch feat/link-pr, got %q", got.CodeContext.Branch) + } + if got.CodeContext.PullRequest == nil || got.CodeContext.PullRequest.Number != 7 { + t.Fatalf("expected PR #7, got %#v", got.CodeContext.PullRequest) + } +} + +func TestListItemsIncludesCodeContext(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Test") + col := createTestCollection(t, s, ws.ID, "Tasks") + + _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{ + Title: "Linked Item", + Fields: `{"status":"open","github_pr":{"number":9,"url":"https://github.com/xarmian/pad/pull/9","title":"Linked Item","state":"MERGED","branch":"feat/linked-item","repo":"xarmian/pad","updated_at":"2026-04-02T14:10:00Z"}}`, + }) + if err != nil { + t.Fatalf("CreateItem error: %v", err) + } + + items, err := s.ListItems(ws.ID, models.ItemListParams{}) + if err != nil { + t.Fatalf("ListItems error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].CodeContext == nil { + t.Fatal("expected list items to include code context") + } + if items[0].CodeContext.PullRequest == nil || items[0].CodeContext.PullRequest.State != "MERGED" { + t.Fatalf("expected merged PR metadata, got %#v", items[0].CodeContext.PullRequest) + } +} + func TestItemListByCollection(t *testing.T) { s := testStore(t) ws := createTestWorkspace(t, s, "Test") diff --git a/internal/store/search.go b/internal/store/search.go index fcb0d605..8a93bb24 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -69,7 +69,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { r.Item.Pinned = pinned == 1 r.Item.CreatedAt = parseTime(createdAt) r.Item.UpdatedAt = parseTime(updatedAt) - r.Item.ComputeRef() + hydrateItemComputedMetadata(&r.Item) // Don't include full content in search results r.Item.Content = "" results = append(results, r) diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 606dd89b..4cafd22b 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -151,6 +151,21 @@ export interface ItemDerivedClosure { related_items?: ItemRelationRef[]; } +export interface ItemPullRequestMetadata { + number: number; + url: string; + title: string; + state: string; + updated_at?: string; +} + +export interface ItemCodeContext { + provider: string; + repo?: string; + branch?: string; + pull_request?: ItemPullRequestMetadata; +} + export interface Item { id: string; workspace_id: string; @@ -174,6 +189,7 @@ export interface Item { collection_prefix?: string; item_number?: number; derived_closure?: ItemDerivedClosure; + code_context?: ItemCodeContext; } export interface ItemCreate { diff --git a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte index b15ee5ae..2aa71c6b 100644 --- a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte @@ -77,6 +77,7 @@ let itemLinks = $state([]); let relationshipGroups = $derived(item ? buildRelationshipGroups(item, itemLinks) : []); let closureEntries = $derived(item?.derived_closure?.related_items?.map((related) => relationRefEntry(related)) ?? []); + let codeContext = $derived(item?.code_context ?? null); $effect(() => { if (wsSlug && collSlug && itemSlug) { @@ -494,6 +495,36 @@ {/if} + {#if codeContext} +
+

Code Context

+
+
+ {formatFieldDisplay(codeContext.provider)} + {#if codeContext.repo} + {codeContext.repo} + {/if} + {#if codeContext.branch} + {codeContext.branch} + {/if} +
+ {#if codeContext.pull_request} +
+ + PR #{codeContext.pull_request.number}: {codeContext.pull_request.title} + + {formatFieldDisplay(codeContext.pull_request.state)} +
+ {#if codeContext.pull_request.updated_at} +
+ Updated {relativeTime(codeContext.pull_request.updated_at)} +
+ {/if} + {/if} +
+
+ {/if} +
@@ -904,6 +935,69 @@ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } + /* Code context */ + .code-context-section { + margin-bottom: var(--space-6); + } + .code-context-card { + padding: var(--space-4); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: var(--space-3); + } + .code-context-meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; + } + .code-provider { + font-size: 0.8em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent-blue); + } + .code-chip { + font-family: var(--font-mono); + font-size: 0.8em; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 999px; + } + .code-pr-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + } + .code-pr-link { + font-weight: 600; + color: var(--text-primary); + text-decoration: none; + } + .code-pr-link:hover { + color: var(--accent-blue); + text-decoration: underline; + } + .code-pr-state { + font-size: 0.75em; + font-weight: 600; + color: var(--text-muted); + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 999px; + } + .code-pr-updated { + font-size: 0.8em; + color: var(--text-muted); + } + /* Derived closure */ .closure-notice { margin-top: var(--space-6);