PushNotes/CommitNotes Updated Field Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add url and warnings to PushedNote, and an updated: [PushedNote!]! field to both PushNotesPayload and CommitNotesPayload containing only the notes changed in that push/commit.

Architecture: Three tasks: (1) schema change + gqlgen regeneration; (2) pushnotes resolver + env + tests; (3) commitnotes resolver + env + tests. The url is computed via NoteViews.ResolveFullURL (already exists). Warnings come from NoteViews.Warnings() map. Both Env interfaces get PublicURL() string. The updated slice is built by looking up each pathID in the rebuilt NoteViews after save.

Tech Stack: Go, gqlgen, moq


File Map

File Change
internal/graph/schema.graphqls Add url, warnings to PushedNote; add updated to both payloads
internal/graph/model/models_gen.go Regenerated by gqlgen — do not edit manually
internal/graph/generated.go Regenerated by gqlgen — do not edit manually
internal/case/pushnotes/resolve.go Add PublicURL to Env; update buildPushedNotes; add buildUpdatedNotes
internal/case/pushnotes/mocks_test.go Regenerated by moq
internal/case/pushnotes/resolve_test.go Add tests for updated, url, warnings
internal/case/commitnotes/resolve.go Add PublicURL to Env; use nvs from PrepareLatestNotes; build updated
internal/case/commitnotes/resolve_test.go Add tests for updated

Task 1: Schema changes + gqlgen

Files:

  • Modify: internal/graph/schema.graphqls

  • Regenerated: internal/graph/model/models_gen.go, internal/graph/generated.go

  • Step 1: Add url and warnings to PushedNote in the schema

In internal/graph/schema.graphqls, find the PushedNote type (around line 1594):

type PushedNote {
  id: Int64!
  path: String!
  assets: [PushedNoteAsset!]!
  url: String
  warnings: [NoteWarning!]!
}

url is nullable (String not String!) because layout files don't have a note URL.

  • Step 2: Add updated to PushNotesPayload

Find PushNotesPayload (around line 1600):

type PushNotesPayload {
  notes: [PushedNote!]!
  updated: [PushedNote!]!
}
  • Step 3: Add updated to CommitNotesPayload

Find CommitNotesPayload (around line 1685):

type CommitNotesPayload {
  success: Boolean!
  updated: [PushedNote!]!
}
  • Step 4: Run gqlgen
cd /home/alexes/projects2/trip2g && make gqlgen 2>&1

Expected: regenerates models_gen.go and generated.go. The PushedNote struct will now have:

type PushedNote struct {
    ID       int64                  `json:"id"`
    Path     string                 `json:"path"`
    Assets   []PushedNoteAsset      `json:"assets"`
    URL      *string                `json:"url"`
    Warnings []appmodel.NoteWarning `json:"warnings"`
}

And PushNotesPayload will have Updated []PushedNote and CommitNotesPayload will have Updated []PushedNote.

  • Step 5: Build to confirm schema change compiles (expect errors in use-case files)
cd /home/alexes/projects2/trip2g && go build ./... 2>&1 | head -20

Expected: compile errors in pushnotes and commitnotes because PushNotesPayload and CommitNotesPayload structs now require Updated field. That's fine — next tasks fix them.

  • Step 6: Commit schema changes
git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated.go
git commit -m "feat(graphql): add url, warnings, updated to PushedNote and push/commit payloads"

Task 2: Update pushnotes resolver

Files:

  • Modify: internal/case/pushnotes/resolve.go

  • Regenerate: internal/case/pushnotes/mocks_test.go

  • Modify: internal/case/pushnotes/resolve_test.go

  • Step 1: Add PublicURL() string to the pushnotes Env interface

In internal/case/pushnotes/resolve.go, add PublicURL to the Env interface:

type Env interface {
	Logger() logger.Logger
	InsertNote(ctx context.Context, update appmodel.RawNote) (int64, error)
	InsertUncommittedPath(ctx context.Context, notePathID int64) error
	PrepareLatestNotes(ctx context.Context, partial bool) (*appmodel.NoteViews, error)
	HandleLatestNotesAfterSave(ctx context.Context, changedPathIDs []int64) error
	Layouts() *appmodel.Layouts
	LatestNoteViews() *appmodel.NoteViews
	CheckStorageLimits(ctx context.Context, additionalAssetBytes int64) (string, error)
	PublicURL() string
}
  • Step 2: Update buildPushedNotes to populate url and warnings

Replace the existing buildPushedNotes function:

func buildPushedNotes(nvs *appmodel.NoteViews, layouts *appmodel.Layouts, publicURL string) []model.PushedNote {
	warnings := nvs.Warnings()
	pushedNotes := []model.PushedNote{}

	for _, note := range nvs.List {
		assets := buildNoteAssets(note)
		urlVal := nvs.ResolveFullURL(note, publicURL)
		noteWarnings := warnings[note.Path]
		if noteWarnings == nil {
			noteWarnings = []appmodel.NoteWarning{}
		}
		pushedNotes = append(pushedNotes, model.PushedNote{
			ID:       note.VersionID,
			Path:     note.Path,
			Assets:   assets,
			URL:      &urlVal,
			Warnings: noteWarnings,
		})
	}

	for _, layout := range layouts.Map {
		assets := buildLayoutAssets(layout)
		pushedNotes = append(pushedNotes, model.PushedNote{
			ID:       layout.VersionID,
			Path:     layout.Path,
			Assets:   assets,
			URL:      nil,
			Warnings: []appmodel.NoteWarning{},
		})
	}

	return pushedNotes
}
  • Step 3: Add buildUpdatedNotes function

Add after buildPushedNotes:

// buildUpdatedNotes returns PushedNote entries for only the notes with the given pathIDs.
// Notes not found in nvs (e.g. deleted) are silently skipped.
func buildUpdatedNotes(nvs *appmodel.NoteViews, pathIDs []int64, publicURL string) []model.PushedNote {
	warnings := nvs.Warnings()
	result := make([]model.PushedNote, 0, len(pathIDs))
	for _, id := range pathIDs {
		note := nvs.GetByPathID(id)
		if note == nil {
			continue
		}
		urlVal := nvs.ResolveFullURL(note, publicURL)
		noteWarnings := warnings[note.Path]
		if noteWarnings == nil {
			noteWarnings = []appmodel.NoteWarning{}
		}
		result = append(result, model.PushedNote{
			ID:       note.VersionID,
			Path:     note.Path,
			Assets:   buildNoteAssets(note),
			URL:      &urlVal,
			Warnings: noteWarnings,
		})
	}
	return result
}
  • Step 4: Update Resolve to use new functions and populate Updated

In the Resolve function, update both return sites:

Early return (no updates):

if len(input.Updates) == 0 {
    nvs := env.LatestNoteViews()
    pushedNotes := buildPushedNotes(nvs, env.Layouts(), env.PublicURL())
    return &model.PushNotesPayload{Notes: pushedNotes, Updated: []model.PushedNote{}}, nil
}

Normal return (after save):

pushedNotes := buildPushedNotes(nvs, env.Layouts(), env.PublicURL())
updatedNotes := buildUpdatedNotes(nvs, pathIDs, env.PublicURL())

return &model.PushNotesPayload{Notes: pushedNotes, Updated: updatedNotes}, nil
  • Step 5: Regenerate pushnotes mock
cd /home/alexes/projects2/trip2g/internal/case/pushnotes && go generate .

This regenerates mocks_test.go adding PublicURLFunc func() string.

  • Step 6: Write failing unit tests

Add to internal/case/pushnotes/resolve_test.go, inside TestResolve table or as a separate test function:

func TestResolve_UpdatedNotes(t *testing.T) {
	ctx := context.Background()
	mockLogger := &logger.TestLogger{}

	makeNVS := func() *appmodel.NoteViews {
		nvs := appmodel.NewNoteViews()
		note := &appmodel.NoteView{
			PathID:    42,
			VersionID: 1,
			Path:      "my-note.md",
			Permalink: "/my-note",
		}
		note.AddWarning(appmodel.NoteWarningWarning, "broken link to [[missing]]")
		nvs.RegisterNote(note)
		return nvs
	}

	t.Run("updated contains pushed notes with url and warnings", func(t *testing.T) {
		env := newEnvMock(mockLogger)
		env.InsertNoteFunc = func(_ context.Context, _ appmodel.RawNote) (int64, error) {
			return 42, nil
		}
		env.PrepareLatestNotesFunc = func(_ context.Context, _ bool) (*appmodel.NoteViews, error) {
			return makeNVS(), nil
		}
		env.HandleLatestNotesAfterSaveFunc = func(_ context.Context, _ []int64) error {
			return nil
		}
		env.LayoutsFunc = func() *appmodel.Layouts {
			return &appmodel.Layouts{Map: map[string]appmodel.Layout{}}
		}
		env.PublicURLFunc = func() string { return "https://example.com" }

		input := model.PushNotesInput{
			Updates: []model.PushNoteInput{
				{Path: "my-note.md", Content: "# Hello"},
			},
		}

		result, err := pushnotes.Resolve(ctx, env, input)
		require.NoError(t, err)

		payload, ok := result.(*model.PushNotesPayload)
		require.True(t, ok)

		// updated contains only the pushed note
		require.Len(t, payload.Updated, 1)
		require.Equal(t, "my-note.md", payload.Updated[0].Path)
		require.NotNil(t, payload.Updated[0].URL)
		require.Equal(t, "https://example.com/my-note", *payload.Updated[0].URL)
		require.Len(t, payload.Updated[0].Warnings, 1)
		require.Equal(t, "broken link to [[missing]]", payload.Updated[0].Warnings[0].Message)

		// notes (all) also has url populated
		require.Len(t, payload.Notes, 1)
		require.NotNil(t, payload.Notes[0].URL)
		require.Equal(t, "https://example.com/my-note", *payload.Notes[0].URL)
	})

	t.Run("updated is empty when no updates provided", func(t *testing.T) {
		env := newEnvMock(mockLogger)
		env.LatestNoteViewsFunc = func() *appmodel.NoteViews {
			return makeNVS()
		}
		env.LayoutsFunc = func() *appmodel.Layouts {
			return &appmodel.Layouts{Map: map[string]appmodel.Layout{}}
		}
		env.PublicURLFunc = func() string { return "https://example.com" }

		input := model.PushNotesInput{Updates: []model.PushNoteInput{}}

		result, err := pushnotes.Resolve(ctx, env, input)
		require.NoError(t, err)

		payload, ok := result.(*model.PushNotesPayload)
		require.True(t, ok)
		require.Empty(t, payload.Updated)
	})
}
  • Step 7: Run tests — expect failures first
cd /home/alexes/projects2/trip2g && go test ./internal/case/pushnotes/... -run TestResolve_UpdatedNotes -v 2>&1

Expected: FAIL (functions not yet updated or missing PublicURLFunc).

  • Step 8: Run tests — verify they pass after implementation
cd /home/alexes/projects2/trip2g && go test ./internal/case/pushnotes/... -v 2>&1 | tail -15

Expected: all PASS.

  • Step 9: Build
cd /home/alexes/projects2/trip2g && go build ./... 2>&1
  • Step 10: Commit
git add internal/case/pushnotes/resolve.go internal/case/pushnotes/mocks_test.go internal/case/pushnotes/resolve_test.go
git commit -m "feat(pushnotes): populate url, warnings, and updated in PushNotesPayload"

Task 3: Update commitnotes resolver

Files:

  • Modify: internal/case/commitnotes/resolve.go

  • Modify: internal/case/commitnotes/resolve_test.go (create if missing)

  • Step 1: Add PublicURL() string to commitnotes Env

In internal/case/commitnotes/resolve.go, update the Env interface:

type Env interface {
	PrepareLatestNotes(ctx context.Context, partial bool) (*appmodel.NoteViews, error)
	HandleLatestNotesAfterSave(ctx context.Context, changedPathIDs []int64) error
	ListUncommittedPaths(ctx context.Context) ([]int64, error)
	ClearUncommittedPaths(ctx context.Context) error
	PublicURL() string
}
  • Step 2: Update Resolve to use nvs and build updated

Replace the full Resolve function:

func Resolve(ctx context.Context, env Env) (Payload, error) {
	pathIDs, err := env.ListUncommittedPaths(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to list uncommitted paths: %w", err)
	}

	nvs, err := env.PrepareLatestNotes(ctx, false)
	if err != nil {
		return nil, fmt.Errorf("failed to prepare notes: %w", err)
	}

	err = env.HandleLatestNotesAfterSave(ctx, pathIDs)
	if err != nil {
		return nil, fmt.Errorf("failed to handle latest notes after save: %w", err)
	}

	err = env.ClearUncommittedPaths(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to clear uncommitted paths: %w", err)
	}

	updated := buildUpdatedNotes(nvs, pathIDs, env.PublicURL())
	return &model.CommitNotesPayload{Success: true, Updated: updated}, nil
}
  • Step 3: Add buildUpdatedNotes to commitnotes

Add this function in internal/case/commitnotes/resolve.go (same logic as pushnotes, but standalone):

func buildUpdatedNotes(nvs *appmodel.NoteViews, pathIDs []int64, publicURL string) []model.PushedNote {
	warnings := nvs.Warnings()
	result := make([]model.PushedNote, 0, len(pathIDs))
	for _, id := range pathIDs {
		note := nvs.GetByPathID(id)
		if note == nil {
			continue
		}
		urlVal := nvs.ResolveFullURL(note, publicURL)
		noteWarnings := warnings[note.Path]
		if noteWarnings == nil {
			noteWarnings = []appmodel.NoteWarning{}
		}
		result = append(result, model.PushedNote{
			ID:       note.VersionID,
			Path:     note.Path,
			Assets:   []model.PushedNoteAsset{},
			URL:      &urlVal,
			Warnings: noteWarnings,
		})
	}
	return result
}

Note: Assets is an empty slice (not nil) to satisfy [PushedNoteAsset!]!. Commitnotes doesn't process assets — use cases should not build asset info they don't have.

  • Step 4: Check if commitnotes has a test file
ls /home/alexes/projects2/trip2g/internal/case/commitnotes/

If resolve_test.go doesn't exist, create it. If it does, add to it.

  • Step 5: Write failing unit test for commitnotes

Create or add to internal/case/commitnotes/resolve_test.go:

package commitnotes_test

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"

	"trip2g/internal/case/commitnotes"
	"trip2g/internal/graph/model"
	appmodel "trip2g/internal/model"
)

//go:generate go run github.com/matryer/moq -out mocks_test.go -pkg commitnotes_test . Env

type Env interface {
	PrepareLatestNotes(ctx context.Context, partial bool) (*appmodel.NoteViews, error)
	HandleLatestNotesAfterSave(ctx context.Context, changedPathIDs []int64) error
	ListUncommittedPaths(ctx context.Context) ([]int64, error)
	ClearUncommittedPaths(ctx context.Context) error
	PublicURL() string
}

func TestResolve_Updated(t *testing.T) {
	ctx := context.Background()

	makeNVS := func() *appmodel.NoteViews {
		nvs := appmodel.NewNoteViews()
		note := &appmodel.NoteView{
			PathID:    7,
			VersionID: 3,
			Path:      "commit-note.md",
			Permalink: "/commit-note",
		}
		nvs.RegisterNote(note)
		return nvs
	}

	env := &EnvMock{
		ListUncommittedPathsFunc: func(_ context.Context) ([]int64, error) {
			return []int64{7}, nil
		},
		PrepareLatestNotesFunc: func(_ context.Context, _ bool) (*appmodel.NoteViews, error) {
			return makeNVS(), nil
		},
		HandleLatestNotesAfterSaveFunc: func(_ context.Context, _ []int64) error {
			return nil
		},
		ClearUncommittedPathsFunc: func(_ context.Context) error {
			return nil
		},
		PublicURLFunc: func() string { return "https://site.com" },
	}

	result, err := commitnotes.Resolve(ctx, env)
	require.NoError(t, err)

	payload, ok := result.(*model.CommitNotesPayload)
	require.True(t, ok)
	require.True(t, payload.Success)

	require.Len(t, payload.Updated, 1)
	require.Equal(t, "commit-note.md", payload.Updated[0].Path)
	require.NotNil(t, payload.Updated[0].URL)
	require.Equal(t, "https://site.com/commit-note", *payload.Updated[0].URL)
	require.Empty(t, payload.Updated[0].Warnings)
}
  • Step 6: Generate commitnotes mock
cd /home/alexes/projects2/trip2g/internal/case/commitnotes && go generate .

If no //go:generate directive exists yet (check first), run manually:

cd /home/alexes/projects2/trip2g/internal/case/commitnotes && go run github.com/matryer/moq -out mocks_test.go -pkg commitnotes_test . Env
  • Step 7: Run commitnotes tests
cd /home/alexes/projects2/trip2g && go test ./internal/case/commitnotes/... -v 2>&1

Expected: all PASS.

  • Step 8: Run full build and all related tests
cd /home/alexes/projects2/trip2g && go build ./... 2>&1
cd /home/alexes/projects2/trip2g && go test ./internal/case/pushnotes/... ./internal/case/commitnotes/... -v 2>&1 | tail -10

Expected: clean build, all tests PASS.

  • Step 9: Commit
git add internal/case/commitnotes/resolve.go internal/case/commitnotes/resolve_test.go internal/case/commitnotes/mocks_test.go
git commit -m "feat(commitnotes): populate url, warnings, and updated in CommitNotesPayload"