feat(items): add first-class code metadata for TASK-123 (#41)

This commit is contained in:
xarmian
2026-04-02 11:15:38 -04:00
committed by GitHub
parent 0596ed07f4
commit bd9f281c81
9 changed files with 348 additions and 55 deletions
+30 -50
View File
@@ -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 {
+73
View File
@@ -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"`
+31
View File
@@ -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")
}
}
+35
View File
@@ -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)
+12 -4
View File
@@ -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)
}
+56
View File
@@ -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")
+1 -1
View File
@@ -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)
+16
View File
@@ -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 {
@@ -77,6 +77,7 @@
let itemLinks = $state<ItemLink[]>([]);
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}
</div>
{#if codeContext}
<div class="code-context-section">
<h3 class="section-title">Code Context</h3>
<div class="code-context-card">
<div class="code-context-meta">
<span class="code-provider">{formatFieldDisplay(codeContext.provider)}</span>
{#if codeContext.repo}
<span class="code-chip">{codeContext.repo}</span>
{/if}
{#if codeContext.branch}
<span class="code-chip">{codeContext.branch}</span>
{/if}
</div>
{#if codeContext.pull_request}
<div class="code-pr-row">
<a href={codeContext.pull_request.url} class="code-pr-link" target="_blank" rel="noreferrer">
PR #{codeContext.pull_request.number}: {codeContext.pull_request.title}
</a>
<span class="code-pr-state">{formatFieldDisplay(codeContext.pull_request.state)}</span>
</div>
{#if codeContext.pull_request.updated_at}
<div class="code-pr-updated">
Updated {relativeTime(codeContext.pull_request.updated_at)}
</div>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Layout wrapper -->
<div class="item-body layout-{layout}">
<!-- Fields -->
@@ -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);