From d03ba976ea7f585091370e18607aecb63846b06f Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 09:46:21 +0100 Subject: [PATCH 01/31] fix: align content status sync and rollback cleanup --- cmd/dry_run_remote.go | 25 +++-- cmd/dry_run_remote_test.go | 6 +- cmd/pull_testhelpers_test.go | 2 +- cmd/push_test.go | 8 +- internal/confluence/client_pages_test.go | 21 +++- internal/confluence/client_pages_write.go | 7 +- internal/confluence/metadata.go | 52 +++++++-- internal/confluence/metadata_test.go | 25 ++++- internal/confluence/types.go | 8 +- internal/sync/pull.go | 4 +- internal/sync/pull_testhelpers_test.go | 2 +- internal/sync/push.go | 9 +- internal/sync/push_adf.go | 7 +- internal/sync/push_adf_test.go | 50 +++++++++ internal/sync/push_hierarchy.go | 4 +- internal/sync/push_page.go | 44 +++++--- internal/sync/push_rollback.go | 25 ++++- internal/sync/push_rollback_test.go | 65 ++++++++++- internal/sync/push_test.go | 127 ++++++++++++++++++++++ internal/sync/push_testhelpers_test.go | 36 ++++-- internal/sync/push_types.go | 10 +- 21 files changed, 455 insertions(+), 82 deletions(-) diff --git a/cmd/dry_run_remote.go b/cmd/dry_run_remote.go index e58bc31..2d77dee 100644 --- a/cmd/dry_run_remote.go +++ b/cmd/dry_run_remote.go @@ -32,21 +32,21 @@ func (d *dryRunPushRemote) GetPage(ctx context.Context, pageID string) (confluen return d.inner.GetPage(ctx, pageID) } -func (d *dryRunPushRemote) GetContentStatus(ctx context.Context, pageID string) (string, error) { +func (d *dryRunPushRemote) GetContentStatus(ctx context.Context, pageID string, pageStatus string) (string, error) { if strings.HasPrefix(pageID, "dry-run-") { return "", nil } - return d.inner.GetContentStatus(ctx, pageID) + return d.inner.GetContentStatus(ctx, pageID, pageStatus) } -func (d *dryRunPushRemote) SetContentStatus(ctx context.Context, pageID string, statusName string) error { - fmt.Fprintf(d.out, "[DRY-RUN] SET CONTENT STATUS (PUT %s/wiki/rest/api/content/%s/state)\n", d.domain, pageID) +func (d *dryRunPushRemote) SetContentStatus(ctx context.Context, pageID string, pageStatus string, statusName string) error { + fmt.Fprintf(d.out, "[DRY-RUN] SET CONTENT STATUS (PUT %s/wiki/rest/api/content/%s/state?status=%s)\n", d.domain, pageID, pageStatus) fmt.Fprintf(d.out, " Name: %s\n\n", statusName) return nil } -func (d *dryRunPushRemote) DeleteContentStatus(ctx context.Context, pageID string) error { - fmt.Fprintf(d.out, "[DRY-RUN] DELETE CONTENT STATUS (DELETE %s/wiki/rest/api/content/%s/state)\n\n", d.domain, pageID) +func (d *dryRunPushRemote) DeleteContentStatus(ctx context.Context, pageID string, pageStatus string) error { + fmt.Fprintf(d.out, "[DRY-RUN] DELETE CONTENT STATUS (DELETE %s/wiki/rest/api/content/%s/state?status=%s)\n\n", d.domain, pageID, pageStatus) return nil } @@ -158,12 +158,15 @@ func (d *dryRunPushRemote) WaitForArchiveTask(ctx context.Context, taskID string return confluence.ArchiveTaskStatus{TaskID: taskID, State: confluence.ArchiveTaskStateSucceeded, RawStatus: "DRY_RUN"}, nil } -func (d *dryRunPushRemote) DeletePage(ctx context.Context, pageID string, hardDelete bool) error { - purge := "" - if hardDelete { - purge = "?purge=true" +func (d *dryRunPushRemote) DeletePage(ctx context.Context, pageID string, opts confluence.PageDeleteOptions) error { + query := "" + switch { + case opts.Draft: + query = "?draft=true" + case opts.Purge: + query = "?purge=true" } - fmt.Fprintf(d.out, "[DRY-RUN] DELETE PAGE (DELETE %s/wiki/api/v2/pages/%s%s)\n\n", d.domain, pageID, purge) + fmt.Fprintf(d.out, "[DRY-RUN] DELETE PAGE (DELETE %s/wiki/api/v2/pages/%s%s)\n\n", d.domain, pageID, query) return nil } diff --git a/cmd/dry_run_remote_test.go b/cmd/dry_run_remote_test.go index 7baeca7..f01df29 100644 --- a/cmd/dry_run_remote_test.go +++ b/cmd/dry_run_remote_test.go @@ -27,11 +27,11 @@ func TestDryRunRemote(t *testing.T) { t.Error("GetPage failed") } - if err := remote.SetContentStatus(ctx, "123", "current"); err != nil { + if err := remote.SetContentStatus(ctx, "123", "current", "Ready"); err != nil { t.Error("SetContentStatus failed") } - if err := remote.DeleteContentStatus(ctx, "123"); err != nil { + if err := remote.DeleteContentStatus(ctx, "123", "current"); err != nil { t.Error("DeleteContentStatus failed") } @@ -51,7 +51,7 @@ func TestDryRunRemote(t *testing.T) { t.Error("WaitForArchiveTask failed") } - if err := remote.DeletePage(ctx, "123", true); err != nil { + if err := remote.DeletePage(ctx, "123", confluence.PageDeleteOptions{Purge: true}); err != nil { t.Error("DeletePage failed") } diff --git a/cmd/pull_testhelpers_test.go b/cmd/pull_testhelpers_test.go index 3f9d0c2..f5b7080 100644 --- a/cmd/pull_testhelpers_test.go +++ b/cmd/pull_testhelpers_test.go @@ -98,7 +98,7 @@ func (f *cmdFakePullRemote) GetPage(_ context.Context, pageID string) (confluenc return page, nil } -func (f *cmdFakePullRemote) GetContentStatus(_ context.Context, pageID string) (string, error) { +func (f *cmdFakePullRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { return "", nil } diff --git a/cmd/push_test.go b/cmd/push_test.go index d44ef70..92a7c27 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -406,15 +406,15 @@ func (f *cmdFakePushRemote) GetPage(_ context.Context, pageID string) (confluenc return page, nil } -func (f *cmdFakePushRemote) GetContentStatus(_ context.Context, pageID string) (string, error) { +func (f *cmdFakePushRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { return "", nil } -func (f *cmdFakePushRemote) SetContentStatus(_ context.Context, pageID string, statusName string) error { +func (f *cmdFakePushRemote) SetContentStatus(_ context.Context, pageID string, _ string, statusName string) error { return nil } -func (f *cmdFakePushRemote) DeleteContentStatus(_ context.Context, pageID string) error { +func (f *cmdFakePushRemote) DeleteContentStatus(_ context.Context, pageID string, _ string) error { return nil } @@ -476,7 +476,7 @@ func (f *cmdFakePushRemote) WaitForArchiveTask(_ context.Context, taskID string, return confluence.ArchiveTaskStatus{TaskID: taskID, State: confluence.ArchiveTaskStateSucceeded}, nil } -func (f *cmdFakePushRemote) DeletePage(_ context.Context, pageID string, _ bool) error { +func (f *cmdFakePushRemote) DeletePage(_ context.Context, pageID string, _ confluence.PageDeleteOptions) error { f.deletePageCalls = append(f.deletePageCalls, pageID) return nil } diff --git a/internal/confluence/client_pages_test.go b/internal/confluence/client_pages_test.go index 8f5c65e..dffa9d0 100644 --- a/internal/confluence/client_pages_test.go +++ b/internal/confluence/client_pages_test.go @@ -155,6 +155,18 @@ func TestArchiveAndDeleteEndpoints(t *testing.T) { if got := r.URL.Query().Get("purge"); got != "true" { t.Fatalf("purge query = %q, want true", got) } + if got := r.URL.Query().Get("draft"); got != "" { + t.Fatalf("draft query = %q, want empty", got) + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodDelete && r.URL.Path == "/wiki/api/v2/pages/84": + deleteCalls++ + if got := r.URL.Query().Get("draft"); got != "true" { + t.Fatalf("draft query = %q, want true", got) + } + if got := r.URL.Query().Get("purge"); got != "" { + t.Fatalf("purge query = %q, want empty", got) + } w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) @@ -179,15 +191,18 @@ func TestArchiveAndDeleteEndpoints(t *testing.T) { t.Fatalf("task ID = %q, want task-9001", archiveResult.TaskID) } - if err := client.DeletePage(context.Background(), "42", true); err != nil { + if err := client.DeletePage(context.Background(), "42", PageDeleteOptions{Purge: true}); err != nil { t.Fatalf("DeletePage() unexpected error: %v", err) } + if err := client.DeletePage(context.Background(), "84", PageDeleteOptions{Draft: true}); err != nil { + t.Fatalf("DeletePage() draft unexpected error: %v", err) + } if archiveCalls != 1 { t.Fatalf("archive calls = %d, want 1", archiveCalls) } - if deleteCalls != 1 { - t.Fatalf("delete calls = %d, want 1", deleteCalls) + if deleteCalls != 2 { + t.Fatalf("delete calls = %d, want 2", deleteCalls) } } diff --git a/internal/confluence/client_pages_write.go b/internal/confluence/client_pages_write.go index 80d81e7..0a02b33 100644 --- a/internal/confluence/client_pages_write.go +++ b/internal/confluence/client_pages_write.go @@ -96,16 +96,19 @@ func (c *Client) UpdatePage(ctx context.Context, pageID string, input PageUpsert return payload.toModel(c.baseURL), nil } -func (c *Client) DeletePage(ctx context.Context, pageID string, hardDelete bool) error { +func (c *Client) DeletePage(ctx context.Context, pageID string, opts PageDeleteOptions) error { id := strings.TrimSpace(pageID) if id == "" { return errors.New("page ID is required") } query := url.Values{} - if hardDelete { + if opts.Purge { query.Set("purge", "true") } + if opts.Draft { + query.Set("draft", "true") + } req, err := c.newRequest( ctx, diff --git a/internal/confluence/metadata.go b/internal/confluence/metadata.go index a5d30e0..45c24dd 100644 --- a/internal/confluence/metadata.go +++ b/internal/confluence/metadata.go @@ -10,17 +10,20 @@ import ( ) // GetContentStatus fetches the visual UI content status (lozenge) for a page via v1 API. -func (c *Client) GetContentStatus(ctx context.Context, pageID string) (string, error) { +func (c *Client) GetContentStatus(ctx context.Context, pageID string, pageStatus string) (string, error) { id := strings.TrimSpace(pageID) if id == "" { return "", errors.New("page ID is required") } + query := url.Values{} + query.Set("status", normalizeContentStatePageStatus(pageStatus)) + req, err := c.newRequest( ctx, http.MethodGet, "/wiki/rest/api/content/"+url.PathEscape(id)+"/state", - nil, + query, nil, ) if err != nil { @@ -28,7 +31,10 @@ func (c *Client) GetContentStatus(ctx context.Context, pageID string) (string, e } var result struct { - Name string `json:"name"` + Name string `json:"name"` + ContentState struct { + Name string `json:"name"` + } `json:"contentState"` } if err := c.do(req, &result); err != nil { // If 404, it might just mean no state is set @@ -38,27 +44,39 @@ func (c *Client) GetContentStatus(ctx context.Context, pageID string) (string, e return "", fmt.Errorf("execute content status request: %w", err) } - return result.Name, nil + if name := strings.TrimSpace(result.ContentState.Name); name != "" { + return name, nil + } + return strings.TrimSpace(result.Name), nil } // SetContentStatus sets the visual UI content status (lozenge) for a page via v1 API. -func (c *Client) SetContentStatus(ctx context.Context, pageID string, statusName string) error { +func (c *Client) SetContentStatus(ctx context.Context, pageID string, pageStatus string, statusName string) error { id := strings.TrimSpace(pageID) if id == "" { return errors.New("page ID is required") } + query := url.Values{} + query.Set("status", normalizeContentStatePageStatus(pageStatus)) + payload := struct { - Name string `json:"name"` + ContentState struct { + Name string `json:"name"` + } `json:"contentState"` }{ - Name: statusName, + ContentState: struct { + Name string `json:"name"` + }{ + Name: statusName, + }, } req, err := c.newRequest( ctx, http.MethodPut, "/wiki/rest/api/content/"+url.PathEscape(id)+"/state", - nil, + query, payload, ) if err != nil { @@ -74,17 +92,20 @@ func (c *Client) SetContentStatus(ctx context.Context, pageID string, statusName } // DeleteContentStatus removes the visual UI content status (lozenge) from a page via v1 API. -func (c *Client) DeleteContentStatus(ctx context.Context, pageID string) error { +func (c *Client) DeleteContentStatus(ctx context.Context, pageID string, pageStatus string) error { id := strings.TrimSpace(pageID) if id == "" { return errors.New("page ID is required") } + query := url.Values{} + query.Set("status", normalizeContentStatePageStatus(pageStatus)) + req, err := c.newRequest( ctx, http.MethodDelete, "/wiki/rest/api/content/"+url.PathEscape(id)+"/state", - nil, + query, nil, ) if err != nil { @@ -102,6 +123,17 @@ func (c *Client) DeleteContentStatus(ctx context.Context, pageID string) error { return nil } +func normalizeContentStatePageStatus(pageStatus string) string { + switch strings.ToLower(strings.TrimSpace(pageStatus)) { + case "draft": + return "draft" + case "archived": + return "archived" + default: + return "current" + } +} + // GetLabels fetches all labels for a given page via v1 API. func (c *Client) GetLabels(ctx context.Context, pageID string) ([]string, error) { id := strings.TrimSpace(pageID) diff --git a/internal/confluence/metadata_test.go b/internal/confluence/metadata_test.go index 6db62c4..abbf46c 100644 --- a/internal/confluence/metadata_test.go +++ b/internal/confluence/metadata_test.go @@ -2,6 +2,7 @@ package confluence import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -11,15 +12,29 @@ import ( func TestClient_ContentStatus(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/wiki/rest/api/content/123/state", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("status"); got != "current" { + t.Fatalf("status query = %q, want current", got) + } switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") - if _, err := io.WriteString(w, `{"name": "Ready to review", "color": "yellow", "id": 80}`); err != nil { + if _, err := io.WriteString(w, `{"contentState":{"name":"Ready to review","color":"yellow","id":80}}`); err != nil { t.Fatalf("write response: %v", err) } case http.MethodPut: + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + contentState, ok := body["contentState"].(map[string]any) + if !ok { + t.Fatalf("contentState payload = %#v, want object", body["contentState"]) + } + if got := contentState["name"]; got != "Ready to review" { + t.Fatalf("contentState.name = %v, want Ready to review", got) + } w.Header().Set("Content-Type", "application/json") - if _, err := io.WriteString(w, `{"name": "Ready to review"}`); err != nil { + if _, err := io.WriteString(w, `{"contentState":{"name":"Ready to review"}}`); err != nil { t.Fatalf("write response: %v", err) } case http.MethodDelete: @@ -44,7 +59,7 @@ func TestClient_ContentStatus(t *testing.T) { ctx := context.Background() // Test Get - status, err := client.GetContentStatus(ctx, "123") + status, err := client.GetContentStatus(ctx, "123", "current") if err != nil { t.Fatalf("GetContentStatus() failed: %v", err) } @@ -53,13 +68,13 @@ func TestClient_ContentStatus(t *testing.T) { } // Test Set - err = client.SetContentStatus(ctx, "123", "Ready to review") + err = client.SetContentStatus(ctx, "123", "current", "Ready to review") if err != nil { t.Fatalf("SetContentStatus() failed: %v", err) } // Test Delete - err = client.DeleteContentStatus(ctx, "123") + err = client.DeleteContentStatus(ctx, "123", "current") if err != nil { t.Fatalf("DeleteContentStatus() failed: %v", err) } diff --git a/internal/confluence/types.go b/internal/confluence/types.go index 3050ffa..c7dad03 100644 --- a/internal/confluence/types.go +++ b/internal/confluence/types.go @@ -35,7 +35,7 @@ type Service interface { ListChanges(ctx context.Context, opts ChangeListOptions) (ChangeListResult, error) ArchivePages(ctx context.Context, pageIDs []string) (ArchiveResult, error) WaitForArchiveTask(ctx context.Context, taskID string, opts ArchiveTaskWaitOptions) (ArchiveTaskStatus, error) - DeletePage(ctx context.Context, pageID string, hardDelete bool) error + DeletePage(ctx context.Context, pageID string, opts PageDeleteOptions) error CreateFolder(ctx context.Context, input FolderCreateInput) (Folder, error) ListFolders(ctx context.Context, opts FolderListOptions) (FolderListResult, error) DeleteFolder(ctx context.Context, folderID string) error @@ -131,6 +131,12 @@ type PageUpsertInput struct { BodyADF json.RawMessage } +// PageDeleteOptions controls page deletion semantics for current vs draft content. +type PageDeleteOptions struct { + Purge bool + Draft bool +} + // Change captures a page change useful for incremental sync planning. type Change struct { PageID string diff --git a/internal/sync/pull.go b/internal/sync/pull.go index 0fb4125..a6b1b2a 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -35,7 +35,7 @@ type PullRemote interface { GetFolder(ctx context.Context, folderID string) (confluence.Folder, error) ListChanges(ctx context.Context, opts confluence.ChangeListOptions) (confluence.ChangeListResult, error) GetPage(ctx context.Context, pageID string) (confluence.Page, error) - GetContentStatus(ctx context.Context, pageID string) (string, error) + GetContentStatus(ctx context.Context, pageID string, pageStatus string) (string, error) GetLabels(ctx context.Context, pageID string) ([]string, error) ListAttachments(ctx context.Context, pageID string) ([]confluence.Attachment, error) DownloadAttachment(ctx context.Context, attachmentID string, pageID string, out io.Writer) error @@ -262,7 +262,7 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, return fmt.Errorf("fetch page %s: %w", pageID, err) } - status, err := remote.GetContentStatus(gCtx, pageID) + status, err := remote.GetContentStatus(gCtx, pageID, page.Status) if err != nil { existingFM, ok := readExistingFrontmatter(pageID) if ok && existingFM.Status != "" { diff --git a/internal/sync/pull_testhelpers_test.go b/internal/sync/pull_testhelpers_test.go index 072c121..7ff06c8 100644 --- a/internal/sync/pull_testhelpers_test.go +++ b/internal/sync/pull_testhelpers_test.go @@ -76,7 +76,7 @@ func (f *fakePullRemote) GetPage(_ context.Context, pageID string) (confluence.P return page, nil } -func (f *fakePullRemote) GetContentStatus(_ context.Context, pageID string) (string, error) { +func (f *fakePullRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { if f.contentStatuses == nil { return "", nil } diff --git a/internal/sync/push.go b/internal/sync/push.go index d2e7bc3..7c7de9c 100644 --- a/internal/sync/push.go +++ b/internal/sync/push.go @@ -210,7 +210,8 @@ func pushDeletePage( page := remotePageByID[pageID] if opts.HardDelete { - if err := remote.DeletePage(ctx, pageID, true); err != nil && !errors.Is(err, confluence.ErrNotFound) { + deleteOpts := deleteOptionsForPageLifecycle(page.Status, true) + if err := remote.DeletePage(ctx, pageID, deleteOpts); err != nil && !errors.Is(err, confluence.ErrNotFound) { return PushCommitPlan{}, fmt.Errorf("hard-delete page %s: %w", pageID, err) } } else { @@ -515,7 +516,7 @@ func pushUpsertPage( return failWithRollback(fmt.Errorf("pre-created placeholder page for %s returned empty page ID", relPath)) } - rollback.trackCreatedPage(pageID) + rollback.trackCreatedPage(pageID, targetState) localVersion = precreatedPage.Version remotePage = precreatedPage remotePageByID[pageID] = precreatedPage @@ -548,7 +549,7 @@ func pushUpsertPage( return failWithRollback(fmt.Errorf("create placeholder page for %s returned empty page ID", relPath)) } - rollback.trackCreatedPage(pageID) + rollback.trackCreatedPage(pageID, targetState) localVersion = created.Version remotePage = created remotePageByID[pageID] = created @@ -746,7 +747,7 @@ func pushUpsertPage( rollback.markContentRestoreRequired() if isExistingPage { - snapshot, snapshotErr := capturePageMetadataSnapshot(ctx, remote, pageID) + snapshot, snapshotErr := capturePageMetadataSnapshot(ctx, remote, pageID, remotePage.Status) if snapshotErr != nil { return failWithRollback(fmt.Errorf("capture metadata snapshot for %s: %w", relPath, snapshotErr)) } diff --git a/internal/sync/push_adf.go b/internal/sync/push_adf.go index f113af9..b30e201 100644 --- a/internal/sync/push_adf.go +++ b/internal/sync/push_adf.go @@ -77,17 +77,18 @@ func walkAndFixMediaNodes(node any, pageID string) bool { func syncPageMetadata(ctx context.Context, remote PushRemote, pageID string, doc fs.MarkdownDocument) error { // 1. Sync Content Status targetStatus := strings.TrimSpace(doc.Frontmatter.Status) - currentStatus, err := remote.GetContentStatus(ctx, pageID) + pageStatus := normalizePageLifecycleState(doc.Frontmatter.State) + currentStatus, err := remote.GetContentStatus(ctx, pageID, pageStatus) if err != nil { return fmt.Errorf("get content status: %w", err) } if targetStatus != currentStatus { if targetStatus == "" { - if err := remote.DeleteContentStatus(ctx, pageID); err != nil { + if err := remote.DeleteContentStatus(ctx, pageID, pageStatus); err != nil { return fmt.Errorf("delete content status: %w", err) } } else { - if err := remote.SetContentStatus(ctx, pageID, targetStatus); err != nil { + if err := remote.SetContentStatus(ctx, pageID, pageStatus, targetStatus); err != nil { return fmt.Errorf("set content status: %w", err) } } diff --git a/internal/sync/push_adf_test.go b/internal/sync/push_adf_test.go index a5ca0ae..ec0e6fe 100644 --- a/internal/sync/push_adf_test.go +++ b/internal/sync/push_adf_test.go @@ -87,3 +87,53 @@ func TestSyncPageMetadata_EquivalentLabelSetsDoNotChurn(t *testing.T) { t.Fatalf("remove label calls = %d, want 0", len(remote.removeLabelCalls)) } } + +func TestSyncPageMetadata_SetsContentStatusOnlyWhenPresent(t *testing.T) { + remote := newRollbackPushRemote() + + doc := fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + State: "draft", + Status: "Ready to review", + }, + } + + if err := syncPageMetadata(context.Background(), remote, "1", doc); err != nil { + t.Fatalf("syncPageMetadata() error: %v", err) + } + + if len(remote.setContentStatusArgs) != 1 { + t.Fatalf("set content status args = %d, want 1", len(remote.setContentStatusArgs)) + } + if got := remote.setContentStatusArgs[0]; got.PageStatus != "draft" || got.StatusName != "Ready to review" { + t.Fatalf("unexpected content status call: %+v", got) + } + if len(remote.deleteContentStatusArgs) != 0 { + t.Fatalf("delete content status args = %d, want 0", len(remote.deleteContentStatusArgs)) + } +} + +func TestSyncPageMetadata_RemovesContentStatusWhenCleared(t *testing.T) { + remote := newRollbackPushRemote() + remote.contentStatuses["1"] = "Ready" + + doc := fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + State: "current", + }, + } + + if err := syncPageMetadata(context.Background(), remote, "1", doc); err != nil { + t.Fatalf("syncPageMetadata() error: %v", err) + } + + if len(remote.deleteContentStatusArgs) != 1 { + t.Fatalf("delete content status args = %d, want 1", len(remote.deleteContentStatusArgs)) + } + if got := remote.deleteContentStatusArgs[0]; got.PageStatus != "current" { + t.Fatalf("unexpected delete content status call: %+v", got) + } + if len(remote.setContentStatusArgs) != 0 { + t.Fatalf("set content status args = %d, want 0", len(remote.setContentStatusArgs)) + } +} diff --git a/internal/sync/push_hierarchy.go b/internal/sync/push_hierarchy.go index 8d7042b..01851e8 100644 --- a/internal/sync/push_hierarchy.go +++ b/internal/sync/push_hierarchy.go @@ -328,7 +328,6 @@ func normalizePushState(state fs.SpaceState) fs.SpaceState { if state.AttachmentIndex == nil { state.AttachmentIndex = map[string]string{} } - normalizedPageIndex := make(map[string]string, len(state.PagePathIndex)) for path, id := range state.PagePathIndex { normalizedPageIndex[normalizeRelPath(path)] = id @@ -585,7 +584,8 @@ func cleanupPendingPrecreatedPages( continue } - if err := remote.DeletePage(ctx, pageID, true); err != nil && !errors.Is(err, confluence.ErrNotFound) { + deleteOpts := deleteOptionsForPageLifecycle(precreatedPages[relPath].Status, false) + if err := remote.DeletePage(ctx, pageID, deleteOpts); err != nil && !errors.Is(err, confluence.ErrNotFound) { appendPushDiagnostic( diagnostics, relPath, diff --git a/internal/sync/push_page.go b/internal/sync/push_page.go index d6e63ca..ae803b8 100644 --- a/internal/sync/push_page.go +++ b/internal/sync/push_page.go @@ -78,8 +78,8 @@ func restorePageContentSnapshot(ctx context.Context, remote PushRemote, pageID s return nil } -func capturePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string) (pushMetadataSnapshot, error) { - status, err := remote.GetContentStatus(ctx, pageID) +func capturePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string, pageStatus string) (pushMetadataSnapshot, error) { + status, err := remote.GetContentStatus(ctx, pageID, pageStatus) if err != nil { return pushMetadataSnapshot{}, fmt.Errorf("get content status: %w", err) } @@ -91,33 +91,41 @@ func capturePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID return pushMetadataSnapshot{ ContentStatus: strings.TrimSpace(status), + PageStatus: normalizePageLifecycleState(pageStatus), Labels: fs.NormalizeLabels(labels), }, nil } -func restorePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string, snapshot pushMetadataSnapshot) error { +type metadataRestoreResult struct { + ContentStatusRestored bool +} + +func restorePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string, snapshot pushMetadataSnapshot) (metadataRestoreResult, error) { targetStatus := strings.TrimSpace(snapshot.ContentStatus) - currentStatus, err := remote.GetContentStatus(ctx, pageID) + pageStatus := normalizePageLifecycleState(snapshot.PageStatus) + currentStatus, err := remote.GetContentStatus(ctx, pageID, pageStatus) if err != nil { - return fmt.Errorf("get content status: %w", err) + return metadataRestoreResult{}, fmt.Errorf("get content status: %w", err) } currentStatus = strings.TrimSpace(currentStatus) + result := metadataRestoreResult{} if currentStatus != targetStatus { if targetStatus == "" { - if err := remote.DeleteContentStatus(ctx, pageID); err != nil { - return fmt.Errorf("delete content status: %w", err) + if err := remote.DeleteContentStatus(ctx, pageID, pageStatus); err != nil { + return metadataRestoreResult{}, fmt.Errorf("delete content status: %w", err) } } else { - if err := remote.SetContentStatus(ctx, pageID, targetStatus); err != nil { - return fmt.Errorf("set content status: %w", err) + if err := remote.SetContentStatus(ctx, pageID, pageStatus, targetStatus); err != nil { + return metadataRestoreResult{}, fmt.Errorf("set content status: %w", err) } } + result.ContentStatusRestored = true } remoteLabels, err := remote.GetLabels(ctx, pageID) if err != nil { - return fmt.Errorf("get labels: %w", err) + return metadataRestoreResult{}, fmt.Errorf("get labels: %w", err) } targetLabelSet := map[string]struct{}{} @@ -135,7 +143,7 @@ func restorePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID continue } if err := remote.RemoveLabel(ctx, pageID, label); err != nil { - return fmt.Errorf("remove label %q: %w", label, err) + return metadataRestoreResult{}, fmt.Errorf("remove label %q: %w", label, err) } } @@ -150,11 +158,21 @@ func restorePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID if len(toAdd) > 0 { if err := remote.AddLabels(ctx, pageID, toAdd); err != nil { - return fmt.Errorf("add labels: %w", err) + return metadataRestoreResult{}, fmt.Errorf("add labels: %w", err) } } - return nil + return result, nil +} + +func deleteOptionsForPageLifecycle(pageStatus string, preferPermanent bool) confluence.PageDeleteOptions { + if normalizePageLifecycleState(pageStatus) == "draft" { + return confluence.PageDeleteOptions{Draft: true} + } + if preferPermanent { + return confluence.PageDeleteOptions{Purge: true} + } + return confluence.PageDeleteOptions{} } func resolveLocalTitle(doc fs.MarkdownDocument, relPath string) string { diff --git a/internal/sync/push_rollback.go b/internal/sync/push_rollback.go index f2bbba4..9e395c4 100644 --- a/internal/sync/push_rollback.go +++ b/internal/sync/push_rollback.go @@ -28,12 +28,13 @@ func appendPushDiagnostic(diagnostics *[]PushDiagnostic, path, code, message str }) } -func (r *pushRollbackTracker) trackCreatedPage(pageID string) { +func (r *pushRollbackTracker) trackCreatedPage(pageID string, pageStatus string) { pageID = strings.TrimSpace(pageID) if pageID == "" { return } r.createdPageID = pageID + r.createdPageStatus = normalizePageLifecycleState(pageStatus) } func (r *pushRollbackTracker) trackUploadedAttachment(pageID, attachmentID, path string) { @@ -106,8 +107,17 @@ func (r *pushRollbackTracker) rollback(ctx context.Context, remote PushRemote) e if r.metadataRestoreReq && r.metadataSnapshot != nil && strings.TrimSpace(r.metadataPageID) != "" { slog.Info("push_rollback_step", "path", r.relPath, "step", "metadata", "page_id", r.metadataPageID) - if err := restorePageMetadataSnapshot(ctx, remote, r.metadataPageID, *r.metadataSnapshot); err != nil { + restoreResult, err := restorePageMetadataSnapshot(ctx, remote, r.metadataPageID, *r.metadataSnapshot) + if err != nil { slog.Warn("push_rollback_step_failed", "path", r.relPath, "step", "metadata", "page_id", r.metadataPageID, "error", err.Error()) + if strings.Contains(err.Error(), "content status") { + appendPushDiagnostic( + r.diagnostics, + r.relPath, + "ROLLBACK_CONTENT_STATUS_FAILED", + fmt.Sprintf("failed to restore content status for page %s: %v", r.metadataPageID, err), + ) + } appendPushDiagnostic( r.diagnostics, r.relPath, @@ -117,6 +127,14 @@ func (r *pushRollbackTracker) rollback(ctx context.Context, remote PushRemote) e rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore metadata for page %s: %w", r.metadataPageID, err)) } else { slog.Info("push_rollback_step_succeeded", "path", r.relPath, "step", "metadata", "page_id", r.metadataPageID) + if restoreResult.ContentStatusRestored { + appendPushDiagnostic( + r.diagnostics, + r.relPath, + "ROLLBACK_CONTENT_STATUS_RESTORED", + fmt.Sprintf("restored content status for page %s", r.metadataPageID), + ) + } appendPushDiagnostic( r.diagnostics, r.relPath, @@ -163,7 +181,8 @@ func (r *pushRollbackTracker) rollback(ctx context.Context, remote PushRemote) e if strings.TrimSpace(r.createdPageID) != "" { slog.Info("push_rollback_step", "path", r.relPath, "step", "created_page", "page_id", r.createdPageID) - if err := remote.DeletePage(ctx, r.createdPageID, true); err != nil && !errors.Is(err, confluence.ErrNotFound) { + deleteOpts := deleteOptionsForPageLifecycle(r.createdPageStatus, false) + if err := remote.DeletePage(ctx, r.createdPageID, deleteOpts); err != nil && !errors.Is(err, confluence.ErrNotFound) { slog.Warn("push_rollback_step_failed", "path", r.relPath, "step", "created_page", "page_id", r.createdPageID, "error", err.Error()) appendPushDiagnostic( r.diagnostics, diff --git a/internal/sync/push_rollback_test.go b/internal/sync/push_rollback_test.go index 9f7fad9..c0da42c 100644 --- a/internal/sync/push_rollback_test.go +++ b/internal/sync/push_rollback_test.go @@ -65,6 +65,15 @@ func TestPush_RollbackDeletesCreatedPageAndAttachmentsOnUpdateFailure(t *testing if len(remote.deletePageCalls) != 1 { t.Fatalf("delete page calls = %d, want 1", len(remote.deletePageCalls)) } + if len(remote.deletePageOpts) != 1 { + t.Fatalf("delete page opts = %d, want 1", len(remote.deletePageOpts)) + } + if remote.deletePageOpts[0].Purge { + t.Fatalf("rollback should not purge current pages: %+v", remote.deletePageOpts[0]) + } + if remote.deletePageOpts[0].Draft { + t.Fatalf("rollback should not use draft delete for current pages: %+v", remote.deletePageOpts[0]) + } hasAttachmentRollback := false hasPageRollback := false @@ -139,17 +148,69 @@ func TestPush_RollbackRestoresMetadataOnSyncFailure(t *testing.T) { if len(remote.deleteContentStatusCalls) == 0 { t.Fatalf("expected rollback to delete content status") } + if len(remote.deleteContentStatusArgs) == 0 || remote.deleteContentStatusArgs[0].PageStatus != "current" { + t.Fatalf("expected rollback delete content status call with current state, got %+v", remote.deleteContentStatusArgs) + } hasMetadataRollback := false + hasContentStatusRollback := false for _, diag := range result.Diagnostics { - if diag.Code == "ROLLBACK_METADATA_RESTORED" { + switch diag.Code { + case "ROLLBACK_METADATA_RESTORED": hasMetadataRollback = true - break + case "ROLLBACK_CONTENT_STATUS_RESTORED": + hasContentStatusRollback = true } } if !hasMetadataRollback { t.Fatalf("expected ROLLBACK_METADATA_RESTORED diagnostic, got %+v", result.Diagnostics) } + if !hasContentStatusRollback { + t.Fatalf("expected ROLLBACK_CONTENT_STATUS_RESTORED diagnostic, got %+v", result.Diagnostics) + } +} + +func TestPush_RollbackDeletesCreatedDraftPageWithDraftDeleteOption(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "draft.md") + + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Draft", + State: "draft", + }, + Body: "draft body\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.failUpdate = true + + _, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG"}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{ + Type: PushChangeAdd, + Path: "draft.md", + }}, + }) + if err == nil { + t.Fatal("expected update failure") + } + + if len(remote.deletePageOpts) != 1 { + t.Fatalf("delete page opts = %d, want 1", len(remote.deletePageOpts)) + } + if !remote.deletePageOpts[0].Draft { + t.Fatalf("rollback should delete draft pages with draft=true, got %+v", remote.deletePageOpts[0]) + } + if remote.deletePageOpts[0].Purge { + t.Fatalf("rollback should not purge draft pages, got %+v", remote.deletePageOpts[0]) + } } func TestPush_RollbackRestoresPageContentOnPostUpdateFailure(t *testing.T) { diff --git a/internal/sync/push_test.go b/internal/sync/push_test.go index 3936961..a6a2965 100644 --- a/internal/sync/push_test.go +++ b/internal/sync/push_test.go @@ -246,3 +246,130 @@ func TestPush_RetriesUpdateWhenHierarchyParentIsStale(t *testing.T) { t.Fatalf("expected UPDATE_RETRIED_AFTER_NOT_FOUND diagnostic, got %+v", result.Diagnostics) } } + +func TestPush_NewPageWithContentStatusSyncsLozenge(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "new.md") + + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "New", + Status: "Ready to review", + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG"}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{ + Type: PushChangeAdd, + Path: "new.md", + }}, + }) + if err != nil { + t.Fatalf("Push() unexpected error: %v", err) + } + + pageID := strings.TrimSpace(result.State.PagePathIndex["new.md"]) + if pageID == "" { + t.Fatalf("expected new page id in state, got %+v", result.State.PagePathIndex) + } + if got := strings.TrimSpace(remote.contentStatuses[pageID]); got != "Ready to review" { + t.Fatalf("content status = %q, want Ready to review", got) + } + if len(remote.setContentStatusArgs) != 1 { + t.Fatalf("set content status args = %d, want 1", len(remote.setContentStatusArgs)) + } + if got := remote.setContentStatusArgs[0]; got.PageStatus != "current" || got.StatusName != "Ready to review" { + t.Fatalf("unexpected content status call: %+v", got) + } +} + +func TestPush_ExistingPageCanSetAndClearContentStatus(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + + writeDoc := func(status string) { + t.Helper() + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + Status: status, + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + + writeDoc("In progress") + if _, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }); err != nil { + t.Fatalf("Push() set status unexpected error: %v", err) + } + + if len(remote.setContentStatusArgs) != 1 { + t.Fatalf("set content status args = %d, want 1", len(remote.setContentStatusArgs)) + } + if got := remote.setContentStatusArgs[0]; got.PageStatus != "current" || got.StatusName != "In progress" { + t.Fatalf("unexpected set content status call: %+v", got) + } + + writeDoc("") + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.contentStatuses["1"] = "In progress" + + if _, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }); err != nil { + t.Fatalf("Push() clear status unexpected error: %v", err) + } + + if len(remote.deleteContentStatusArgs) != 1 { + t.Fatalf("delete content status args = %d, want 1", len(remote.deleteContentStatusArgs)) + } + if got := remote.deleteContentStatusArgs[0]; got.PageStatus != "current" { + t.Fatalf("unexpected delete content status call: %+v", got) + } + if got := strings.TrimSpace(remote.contentStatuses["1"]); got != "" { + t.Fatalf("content status after clear = %q, want empty", got) + } +} diff --git a/internal/sync/push_testhelpers_test.go b/internal/sync/push_testhelpers_test.go index 0b2f2a5..84ca837 100644 --- a/internal/sync/push_testhelpers_test.go +++ b/internal/sync/push_testhelpers_test.go @@ -23,6 +23,12 @@ type fakePageMove struct { targetID string } +type contentStatusCall struct { + PageID string + PageStatus string + StatusName string +} + func (f *fakeFolderPushRemote) GetSpace(_ context.Context, spaceKey string) (confluence.Space, error) { return confluence.Space{ID: "space-1", Key: spaceKey}, nil } @@ -38,15 +44,15 @@ func (f *fakeFolderPushRemote) GetPage(_ context.Context, pageID string) (conflu return confluence.Page{}, confluence.ErrNotFound } -func (f *fakeFolderPushRemote) GetContentStatus(_ context.Context, pageID string) (string, error) { +func (f *fakeFolderPushRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { return "", nil } -func (f *fakeFolderPushRemote) SetContentStatus(_ context.Context, pageID string, statusName string) error { +func (f *fakeFolderPushRemote) SetContentStatus(_ context.Context, pageID string, _ string, statusName string) error { return nil } -func (f *fakeFolderPushRemote) DeleteContentStatus(_ context.Context, pageID string) error { +func (f *fakeFolderPushRemote) DeleteContentStatus(_ context.Context, pageID string, _ string) error { return nil } @@ -78,7 +84,7 @@ func (f *fakeFolderPushRemote) WaitForArchiveTask(_ context.Context, _ string, _ return confluence.ArchiveTaskStatus{State: confluence.ArchiveTaskStateSucceeded}, nil } -func (f *fakeFolderPushRemote) DeletePage(_ context.Context, pageID string, hardDelete bool) error { +func (f *fakeFolderPushRemote) DeletePage(_ context.Context, pageID string, opts confluence.PageDeleteOptions) error { return nil } @@ -134,9 +140,12 @@ type rollbackPushRemote struct { uploadAttachmentCalls int archiveTaskCalls []string deletePageCalls []string + deletePageOpts []confluence.PageDeleteOptions deleteAttachmentCalls []string setContentStatusCalls []string + setContentStatusArgs []contentStatusCall deleteContentStatusCalls []string + deleteContentStatusArgs []contentStatusCall addLabelsCalls []string removeLabelCalls []string archiveTaskStatus confluence.ArchiveTaskStatus @@ -181,18 +190,27 @@ func (f *rollbackPushRemote) GetPage(_ context.Context, pageID string) (confluen return page, nil } -func (f *rollbackPushRemote) GetContentStatus(_ context.Context, pageID string) (string, error) { +func (f *rollbackPushRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { return f.contentStatuses[pageID], nil } -func (f *rollbackPushRemote) SetContentStatus(_ context.Context, pageID string, statusName string) error { +func (f *rollbackPushRemote) SetContentStatus(_ context.Context, pageID string, pageStatus string, statusName string) error { f.setContentStatusCalls = append(f.setContentStatusCalls, pageID) + f.setContentStatusArgs = append(f.setContentStatusArgs, contentStatusCall{ + PageID: pageID, + PageStatus: strings.TrimSpace(pageStatus), + StatusName: strings.TrimSpace(statusName), + }) f.contentStatuses[pageID] = strings.TrimSpace(statusName) return nil } -func (f *rollbackPushRemote) DeleteContentStatus(_ context.Context, pageID string) error { +func (f *rollbackPushRemote) DeleteContentStatus(_ context.Context, pageID string, pageStatus string) error { f.deleteContentStatusCalls = append(f.deleteContentStatusCalls, pageID) + f.deleteContentStatusArgs = append(f.deleteContentStatusArgs, contentStatusCall{ + PageID: pageID, + PageStatus: strings.TrimSpace(pageStatus), + }) f.contentStatuses[pageID] = "" return nil } @@ -233,6 +251,7 @@ func (f *rollbackPushRemote) CreatePage(_ context.Context, input confluence.Page SpaceID: input.SpaceID, ParentPageID: input.ParentPageID, Title: input.Title, + Status: input.Status, Version: 1, WebURL: "https://example.atlassian.net/wiki/pages/" + id, BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), @@ -301,8 +320,9 @@ func (f *rollbackPushRemote) WaitForArchiveTask(_ context.Context, taskID string return status, nil } -func (f *rollbackPushRemote) DeletePage(_ context.Context, pageID string, _ bool) error { +func (f *rollbackPushRemote) DeletePage(_ context.Context, pageID string, opts confluence.PageDeleteOptions) error { f.deletePageCalls = append(f.deletePageCalls, pageID) + f.deletePageOpts = append(f.deletePageOpts, opts) delete(f.pagesByID, pageID) filtered := make([]confluence.Page, 0, len(f.pages)) for _, page := range f.pages { diff --git a/internal/sync/push_types.go b/internal/sync/push_types.go index c5efdd1..9678283 100644 --- a/internal/sync/push_types.go +++ b/internal/sync/push_types.go @@ -17,9 +17,9 @@ type PushRemote interface { GetSpace(ctx context.Context, spaceKey string) (confluence.Space, error) ListPages(ctx context.Context, opts confluence.PageListOptions) (confluence.PageListResult, error) GetPage(ctx context.Context, pageID string) (confluence.Page, error) - GetContentStatus(ctx context.Context, pageID string) (string, error) - SetContentStatus(ctx context.Context, pageID string, statusName string) error - DeleteContentStatus(ctx context.Context, pageID string) error + GetContentStatus(ctx context.Context, pageID string, pageStatus string) (string, error) + SetContentStatus(ctx context.Context, pageID string, pageStatus string, statusName string) error + DeleteContentStatus(ctx context.Context, pageID string, pageStatus string) error GetLabels(ctx context.Context, pageID string) ([]string, error) AddLabels(ctx context.Context, pageID string, labels []string) error RemoveLabel(ctx context.Context, pageID string, labelName string) error @@ -27,7 +27,7 @@ type PushRemote interface { UpdatePage(ctx context.Context, pageID string, input confluence.PageUpsertInput) (confluence.Page, error) ArchivePages(ctx context.Context, pageIDs []string) (confluence.ArchiveResult, error) WaitForArchiveTask(ctx context.Context, taskID string, opts confluence.ArchiveTaskWaitOptions) (confluence.ArchiveTaskStatus, error) - DeletePage(ctx context.Context, pageID string, hardDelete bool) error + DeletePage(ctx context.Context, pageID string, opts confluence.PageDeleteOptions) error UploadAttachment(ctx context.Context, input confluence.AttachmentUploadInput) (confluence.Attachment, error) DeleteAttachment(ctx context.Context, attachmentID string, pageID string) error CreateFolder(ctx context.Context, input confluence.FolderCreateInput) (confluence.Folder, error) @@ -106,6 +106,7 @@ type PushResult struct { type pushMetadataSnapshot struct { ContentStatus string + PageStatus string Labels []string } @@ -126,6 +127,7 @@ type rollbackAttachment struct { type pushRollbackTracker struct { relPath string createdPageID string + createdPageStatus string uploadedAssets []rollbackAttachment contentPageID string contentSnapshot *pushContentSnapshot From f15e6b9e806e4fa550d849c23f5ad9d384eb57bf Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 10:32:57 +0100 Subject: [PATCH 02/31] Fix content status API payload and add rollback coverage --- internal/confluence/metadata.go | 14 ++- internal/confluence/metadata_test.go | 14 +-- internal/sync/push_rollback_test.go | 140 +++++++++++++++++++++++++ internal/sync/push_testhelpers_test.go | 8 ++ 4 files changed, 161 insertions(+), 15 deletions(-) diff --git a/internal/confluence/metadata.go b/internal/confluence/metadata.go index 45c24dd..548a75e 100644 --- a/internal/confluence/metadata.go +++ b/internal/confluence/metadata.go @@ -56,20 +56,18 @@ func (c *Client) SetContentStatus(ctx context.Context, pageID string, pageStatus if id == "" { return errors.New("page ID is required") } + statusName = strings.TrimSpace(statusName) + if statusName == "" { + return errors.New("status name is required") + } query := url.Values{} query.Set("status", normalizeContentStatePageStatus(pageStatus)) payload := struct { - ContentState struct { - Name string `json:"name"` - } `json:"contentState"` + Name string `json:"name"` }{ - ContentState: struct { - Name string `json:"name"` - }{ - Name: statusName, - }, + Name: statusName, } req, err := c.newRequest( diff --git a/internal/confluence/metadata_test.go b/internal/confluence/metadata_test.go index abbf46c..4b0eb1e 100644 --- a/internal/confluence/metadata_test.go +++ b/internal/confluence/metadata_test.go @@ -18,7 +18,7 @@ func TestClient_ContentStatus(t *testing.T) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") - if _, err := io.WriteString(w, `{"contentState":{"name":"Ready to review","color":"yellow","id":80}}`); err != nil { + if _, err := io.WriteString(w, `{"name":"Ready to review","color":"yellow","id":80}`); err != nil { t.Fatalf("write response: %v", err) } case http.MethodPut: @@ -26,15 +26,15 @@ func TestClient_ContentStatus(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode request body: %v", err) } - contentState, ok := body["contentState"].(map[string]any) - if !ok { - t.Fatalf("contentState payload = %#v, want object", body["contentState"]) + contentState, hasContentState := body["contentState"] + if hasContentState { + t.Fatalf("contentState payload = %#v; expected top-level name payload", contentState) } - if got := contentState["name"]; got != "Ready to review" { - t.Fatalf("contentState.name = %v, want Ready to review", got) + if got := body["name"]; got != "Ready to review" { + t.Fatalf("name payload = %v, want Ready to review", got) } w.Header().Set("Content-Type", "application/json") - if _, err := io.WriteString(w, `{"contentState":{"name":"Ready to review"}}`); err != nil { + if _, err := io.WriteString(w, `{"name":"Ready to review","color":"yellow","id":80}`); err != nil { t.Fatalf("write response: %v", err) } case http.MethodDelete: diff --git a/internal/sync/push_rollback_test.go b/internal/sync/push_rollback_test.go index c0da42c..dda8b04 100644 --- a/internal/sync/push_rollback_test.go +++ b/internal/sync/push_rollback_test.go @@ -170,6 +170,146 @@ func TestPush_RollbackRestoresMetadataOnSyncFailure(t *testing.T) { } } +func TestPush_RollbackDeletesCreatedPageWhenMetadataSyncStatusFails(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "new.md") + + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "New", + Status: "In progress", + Labels: []string{"team"}, + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.failSetContentStatus = true + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG"}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{ + Type: PushChangeAdd, + Path: "new.md", + }}, + }) + if err == nil { + t.Fatal("expected metadata sync failure") + } + if !strings.Contains(err.Error(), "sync metadata for new.md: set content status") { + t.Fatalf("unexpected error: %v", err) + } + + if remote.createPageCalls != 1 { + t.Fatalf("create page calls = %d, want 1", remote.createPageCalls) + } + if len(remote.setContentStatusCalls) != 1 { + t.Fatalf("set content status calls = %d, want 1", len(remote.setContentStatusCalls)) + } + if got := remote.setContentStatusArgs[0]; got.PageID == "" || got.PageStatus != "current" || got.StatusName != "In progress" { + t.Fatalf("unexpected set content status call: %+v", remote.setContentStatusArgs[0]) + } + if len(remote.deletePageCalls) != 1 { + t.Fatalf("delete page calls = %d, want 1", len(remote.deletePageCalls)) + } + if remote.deletePageOpts[0].Purge || remote.deletePageOpts[0].Draft { + t.Fatalf("rollback should not purge or draft-delete current page: %+v", remote.deletePageOpts[0]) + } + + hasPageRollback := false + for _, diag := range result.Diagnostics { + if diag.Code == "ROLLBACK_PAGE_DELETED" { + hasPageRollback = true + break + } + } + if !hasPageRollback { + t.Fatalf("expected ROLLBACK_PAGE_DELETED diagnostic, got %+v", result.Diagnostics) + } +} + +func TestPush_RollbackReportsContentStatusRestoreFailure(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + Labels: []string{"team"}, + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + ParentPageID: "", + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + remote.contentStatuses["1"] = "In review" + remote.labelsByPage["1"] = []string{} + remote.failAddLabels = true + remote.failSetContentStatus = true + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{ + Type: PushChangeModify, + Path: "root.md", + }}, + }) + if err == nil { + t.Fatal("expected metadata sync failure") + } + if !strings.Contains(err.Error(), "sync metadata for root.md: add labels") { + t.Fatalf("unexpected error: %v", err) + } + + if len(remote.deleteContentStatusArgs) != 1 { + t.Fatalf("delete content status calls = %d, want 1", len(remote.deleteContentStatusArgs)) + } + if len(remote.setContentStatusArgs) != 1 { + t.Fatalf("set content status calls = %d, want 1", len(remote.setContentStatusArgs)) + } + if remote.setContentStatusArgs[0].StatusName != "In review" { + t.Fatalf("unexpected rollback set content status call: %+v", remote.setContentStatusArgs[0]) + } + + if got := strings.TrimSpace(remote.contentStatuses["1"]); got != "" { + t.Fatalf("content status after failed rollback = %q, want empty", got) + } + + hasFailureDiag := false + for _, diag := range result.Diagnostics { + if diag.Code == "ROLLBACK_CONTENT_STATUS_FAILED" { + hasFailureDiag = true + break + } + } + if !hasFailureDiag { + t.Fatalf("expected ROLLBACK_CONTENT_STATUS_FAILED diagnostic, got %+v", result.Diagnostics) + } +} + func TestPush_RollbackDeletesCreatedDraftPageWithDraftDeleteOption(t *testing.T) { spaceDir := t.TempDir() mdPath := filepath.Join(spaceDir, "draft.md") diff --git a/internal/sync/push_testhelpers_test.go b/internal/sync/push_testhelpers_test.go index 84ca837..b7f7806 100644 --- a/internal/sync/push_testhelpers_test.go +++ b/internal/sync/push_testhelpers_test.go @@ -153,6 +153,8 @@ type rollbackPushRemote struct { archiveTaskWaitErr error failUpdate bool failAddLabels bool + failSetContentStatus bool + failDeleteContentStatus bool rejectParentID string rejectParentErr error updateInputsByPageID map[string]confluence.PageUpsertInput @@ -201,6 +203,9 @@ func (f *rollbackPushRemote) SetContentStatus(_ context.Context, pageID string, PageStatus: strings.TrimSpace(pageStatus), StatusName: strings.TrimSpace(statusName), }) + if f.failSetContentStatus { + return errors.New("simulated set content status failure") + } f.contentStatuses[pageID] = strings.TrimSpace(statusName) return nil } @@ -211,6 +216,9 @@ func (f *rollbackPushRemote) DeleteContentStatus(_ context.Context, pageID strin PageID: pageID, PageStatus: strings.TrimSpace(pageStatus), }) + if f.failDeleteContentStatus { + return errors.New("simulated delete content status failure") + } f.contentStatuses[pageID] = "" return nil } From cf4b841168d020a75ccf6a47c90df286ced411de Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 10:47:41 +0100 Subject: [PATCH 03/31] Fix cross-space link resolution parity --- cmd/pull.go | 6 ++ cmd/push_test.go | 121 ++++++++++++++++++++++++++++++++++++ cmd/push_worktree.go | 2 +- cmd/validate.go | 22 +++---- internal/sync/hooks.go | 111 +++++++++++++++++++++++++++++++-- internal/sync/hooks_test.go | 74 ++++++++++++++++++++++ internal/sync/index.go | 97 +++++++++++++++++++++++++++++ internal/sync/index_test.go | 69 ++++++++++++++++++++ internal/sync/pull.go | 20 +++++- internal/sync/pull_test.go | 101 ++++++++++++++++++++++++++++++ 10 files changed, 604 insertions(+), 19 deletions(-) create mode 100644 internal/sync/index_test.go diff --git a/cmd/pull.go b/cmd/pull.go index 9ec751b..4a27d04 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -249,10 +249,16 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { }() } + globalPageIndex, err := syncflow.BuildGlobalPageIndex(repoRoot) + if err != nil { + return fmt.Errorf("build global page index: %w", err) + } + result, err = syncflow.Pull(ctx, remote, syncflow.PullOptions{ SpaceKey: pullCtx.spaceKey, SpaceDir: pullCtx.spaceDir, State: state, + GlobalPageIndex: globalPageIndex, PullStartedAt: pullStartedAt, OverlapWindow: syncflow.DefaultPullOverlapWindow, TargetPageID: pullCtx.targetPageID, diff --git a/cmd/push_test.go b/cmd/push_test.go index 92a7c27..d7b2568 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -299,6 +299,127 @@ func TestRunPush_WorksWithoutGitRemoteConfigured(t *testing.T) { } } +func TestRunPush_CrossSpaceRelativeLinkParityWithValidate(t *testing.T) { + runParallelCommandTest(t) + + testCases := []struct { + name string + sourceDirName string + sourceKey string + targetDirName string + targetKey string + targetPageID string + targetFileName string + }{ + { + name: "ENG_to_TD", + sourceDirName: "Engineering (ENG)", + sourceKey: "ENG", + targetDirName: "Technical Docs (TD)", + targetKey: "TD", + targetPageID: "200", + targetFileName: "target.md", + }, + { + name: "TD_to_ENG", + sourceDirName: "Technical Docs (TD)", + sourceKey: "TD", + targetDirName: "Engineering (ENG)", + targetKey: "ENG", + targetPageID: "300", + targetFileName: "target.md", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + repo := t.TempDir() + setupGitRepo(t, repo) + setupEnv(t) + + sourceSpaceDir := filepath.Join(repo, tc.sourceDirName) + targetSpaceDir := filepath.Join(repo, tc.targetDirName) + if err := os.MkdirAll(sourceSpaceDir, 0o750); err != nil { + t.Fatalf("mkdir source dir: %v", err) + } + if err := os.MkdirAll(targetSpaceDir, 0o750); err != nil { + t.Fatalf("mkdir target dir: %v", err) + } + + targetPath := filepath.Join(targetSpaceDir, tc.targetFileName) + writeMarkdown(t, targetPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Target", + ID: tc.targetPageID, + Version: 1, + }, + Body: "target\n", + }) + + if err := fs.SaveState(sourceSpaceDir, fs.SpaceState{SpaceKey: tc.sourceKey}); err != nil { + t.Fatalf("save source state: %v", err) + } + if err := fs.SaveState(targetSpaceDir, fs.SpaceState{ + SpaceKey: tc.targetKey, + PagePathIndex: map[string]string{tc.targetFileName: tc.targetPageID}, + }); err != nil { + t.Fatalf("save target state: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(".env\n.confluence-state.json\n"), 0o600); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "baseline") + runGitForTest(t, repo, "tag", "-a", fmt.Sprintf("confluence-sync/pull/%s/20260305T120000Z", tc.sourceKey), "-m", "baseline pull") + + linkTargetDir := strings.ReplaceAll(tc.targetDirName, " ", "%20") + writeMarkdown(t, filepath.Join(sourceSpaceDir, "new.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "New Page", + }, + Body: fmt.Sprintf("[Cross Space](../%s/%s#section-a)\n", linkTargetDir, tc.targetFileName), + }) + + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local change") + + fake := newCmdFakePushRemote(1) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + chdirRepo(t, sourceSpaceDir) + + validateOut := &bytes.Buffer{} + if err := runValidateTargetWithContext(context.Background(), validateOut, config.Target{Mode: config.TargetModeSpace, Value: sourceSpaceDir}); err != nil { + t.Fatalf("validate failed before push: %v\nOutput:\n%s", err, validateOut.String()) + } + + cmd := &cobra.Command{} + cmd.SetOut(&bytes.Buffer{}) + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, false); err != nil { + t.Fatalf("runPush() unexpected error: %v", err) + } + + if len(fake.updateCalls) == 0 { + t.Fatal("expected at least one update call") + } + body := string(fake.updateCalls[len(fake.updateCalls)-1].Input.BodyADF) + expectedFragment := "pageId=" + tc.targetPageID + "#section-a" + if !strings.Contains(body, expectedFragment) { + t.Fatalf("expected pushed ADF to contain %q, body=%s", expectedFragment, body) + } + }) + } +} + type failingPushRemote struct { *cmdFakePushRemote } diff --git a/cmd/push_worktree.go b/cmd/push_worktree.go index 0cf4571..cb969e4 100644 --- a/cmd/push_worktree.go +++ b/cmd/push_worktree.go @@ -114,7 +114,7 @@ func runPushInWorktree( return fmt.Errorf("load state: %w", err) } - globalPageIndex, err := syncflow.BuildGlobalPageIndex(worktreeDir) + globalPageIndex, err := buildWorkspaceGlobalPageIndex(wtSpaceDir) if err != nil { return fmt.Errorf("build global page index: %w", err) } diff --git a/cmd/validate.go b/cmd/validate.go index f1206d8..b1ac897 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -117,12 +117,7 @@ func runValidateTargetWithContext(ctx context.Context, out io.Writer, target con return fmt.Errorf("validation failed: duplicate page IDs detected - rename each file to have a unique id or remove the duplicate id") } - var globalIndex syncflow.GlobalPageIndex - globalIndexRoot := filepath.Dir(targetCtx.spaceDir) - if repoRoot, rootErr := gitRepoRoot(); rootErr == nil { - globalIndexRoot = repoRoot - } - globalIndex, err = syncflow.BuildGlobalPageIndex(globalIndexRoot) + globalIndex, err := buildWorkspaceGlobalPageIndex(targetCtx.spaceDir) if err != nil { return fmt.Errorf("failed to build global page index: %w", err) } @@ -417,12 +412,7 @@ func runValidateChangedPushFiles(ctx context.Context, out io.Writer, spaceDir st return fmt.Errorf("validation failed: duplicate page IDs detected - rename each file to have a unique id or remove the duplicate id") } - var globalIndex syncflow.GlobalPageIndex - globalIndexRoot := filepath.Dir(targetCtx.spaceDir) - if repoRoot, rootErr := gitRepoRoot(); rootErr == nil { - globalIndexRoot = repoRoot - } - globalIndex, err = syncflow.BuildGlobalPageIndex(globalIndexRoot) + globalIndex, err := buildWorkspaceGlobalPageIndex(targetCtx.spaceDir) if err != nil { return fmt.Errorf("failed to build global page index: %w", err) } @@ -479,6 +469,14 @@ func pushChangedAbsPaths(spaceDir string, changes []syncflow.PushFileChange) []s return out } +func buildWorkspaceGlobalPageIndex(spaceDir string) (syncflow.GlobalPageIndex, error) { + globalIndexRoot, err := syncflow.ResolveGlobalIndexRoot(spaceDir) + if err != nil { + return nil, err + } + return syncflow.BuildGlobalPageIndex(globalIndexRoot) +} + // detectDuplicatePageIDs returns an error message for each Confluence page ID // that appears in more than one file within the index. // A duplicate ID typically means a file was copy-pasted (rename trap) and diff --git a/internal/sync/hooks.go b/internal/sync/hooks.go index 784085c..4fe5c5b 100644 --- a/internal/sync/hooks.go +++ b/internal/sync/hooks.go @@ -14,11 +14,30 @@ import ( mdconv "github.com/rgonek/jira-adf-converter/mdconverter" ) +type ForwardLinkNotice struct { + Code string + Message string +} + // NewForwardLinkHook creates a link hook for ADF -> Markdown conversion. // It resolves Confluence page IDs to relative Markdown paths. // sourcePath is the absolute path of the file being generated. // pagePathByID maps page IDs to absolute or relative-to-root paths of target files. func NewForwardLinkHook(sourcePath string, pagePathByID map[string]string, currentSpaceKey string) adfconv.LinkRenderHook { + return NewForwardLinkHookWithGlobalIndex(sourcePath, "", pagePathByID, nil, currentSpaceKey, nil) +} + +// NewForwardLinkHookWithGlobalIndex resolves same-space links to relative markdown +// and intentionally preserves known absolute cross-space links without emitting +// unresolved-reference warnings. +func NewForwardLinkHookWithGlobalIndex( + sourcePath string, + currentSpaceDir string, + pagePathByID map[string]string, + globalIndex GlobalPageIndex, + currentSpaceKey string, + onNotice func(ForwardLinkNotice), +) adfconv.LinkRenderHook { return func(ctx context.Context, in adfconv.LinkRenderInput) (adfconv.LinkRenderOutput, error) { pageID := in.Meta.PageID if pageID == "" { @@ -52,6 +71,23 @@ func NewForwardLinkHook(sourcePath string, pagePathByID map[string]string, curre } } + if shouldPreserveAbsoluteCrossSpaceLink(pageID, in, currentSpaceDir, currentSpaceKey, globalIndex) { + href := preservedCrossSpaceHref(in) + if href != "" { + if onNotice != nil { + onNotice(ForwardLinkNotice{ + Code: "CROSS_SPACE_LINK_PRESERVED", + Message: fmt.Sprintf("preserved absolute cross-space link to page %s: %s", pageID, href), + }) + } + return adfconv.LinkRenderOutput{ + Href: href, + Title: in.Title, + Handled: true, + }, nil + } + } + if in.Meta.SpaceKey == "" || strings.EqualFold(in.Meta.SpaceKey, currentSpaceKey) { return adfconv.LinkRenderOutput{}, adfconv.ErrUnresolved } @@ -61,6 +97,59 @@ func NewForwardLinkHook(sourcePath string, pagePathByID map[string]string, curre } } +func shouldPreserveAbsoluteCrossSpaceLink( + pageID string, + in adfconv.LinkRenderInput, + currentSpaceDir string, + currentSpaceKey string, + globalIndex GlobalPageIndex, +) bool { + if strings.TrimSpace(pageID) == "" { + return false + } + + if targetSpaceKey := strings.TrimSpace(in.Meta.SpaceKey); targetSpaceKey != "" && !strings.EqualFold(targetSpaceKey, currentSpaceKey) { + return true + } + + candidatePath := strings.TrimSpace(globalIndex[pageID]) + if candidatePath == "" { + return false + } + if _, err := os.Stat(candidatePath); err != nil { + return false + } + if strings.TrimSpace(currentSpaceDir) == "" { + return true + } + return !isSubpathOrSame(currentSpaceDir, candidatePath) +} + +func preservedCrossSpaceHref(in adfconv.LinkRenderInput) string { + href := strings.TrimSpace(in.Href) + if href == "" { + return "" + } + + anchor := strings.TrimSpace(in.Meta.Anchor) + if anchor == "" { + return href + } + + parsed, err := url.Parse(href) + if err == nil { + if strings.TrimSpace(parsed.Fragment) == "" { + parsed.Fragment = anchor + } + return parsed.String() + } + + if !strings.Contains(href, "#") { + href += "#" + anchor + } + return href +} + // ExtractPageID parses a Confluence URL to extract the page ID. func ExtractPageID(href string) string { if href == "" { @@ -371,12 +460,9 @@ func NewReverseLinkHookWithGlobalIndex(spaceDir string, index PageIndex, globalI // Look up in local space index first. pageID, ok := index[targetPath] if !ok { - pageID, ok = globalPathIndex[normalizeAbsolutePathKey(destPath)] + pageID, ok = resolveGlobalPageID(destPath, globalPathIndex, globalIndex) if !ok { - pageID, ok = resolveGlobalPageIDBySameFile(destPath, globalIndex) - if !ok { - return mdconv.LinkParseOutput{}, mdconv.ErrUnresolved - } + return mdconv.LinkParseOutput{}, mdconv.ErrUnresolved } } @@ -402,6 +488,21 @@ func decodeMarkdownPath(path string) string { return decoded } +func resolveGlobalPageID(destPath string, globalPathIndex map[string]string, globalIndex GlobalPageIndex) (string, bool) { + destPath = strings.TrimSpace(destPath) + if destPath == "" { + return "", false + } + + if _, err := os.Stat(destPath); err == nil { + if pageID, ok := globalPathIndex[normalizeAbsolutePathKey(destPath)]; ok { + return pageID, true + } + } + + return resolveGlobalPageIDBySameFile(destPath, globalIndex) +} + func resolveGlobalPageIDBySameFile(destPath string, globalIndex GlobalPageIndex) (string, bool) { destPath = strings.TrimSpace(destPath) if destPath == "" || len(globalIndex) == 0 { diff --git a/internal/sync/hooks_test.go b/internal/sync/hooks_test.go index 6a3e75b..8454dec 100644 --- a/internal/sync/hooks_test.go +++ b/internal/sync/hooks_test.go @@ -56,6 +56,57 @@ func TestForwardLinkHook(t *testing.T) { } } +func TestForwardLinkHookWithGlobalIndex_PreservesAbsoluteCrossSpaceLink(t *testing.T) { + tmpDir := t.TempDir() + engDir := filepath.Join(tmpDir, "Engineering (ENG)") + tdDir := filepath.Join(tmpDir, "Technical Docs (TD)") + if err := os.MkdirAll(engDir, 0o750); err != nil { + t.Fatalf("mkdir eng dir: %v", err) + } + if err := os.MkdirAll(tdDir, 0o750); err != nil { + t.Fatalf("mkdir td dir: %v", err) + } + + sourcePath := filepath.Join(engDir, "index.md") + targetPath := filepath.Join(tdDir, "Target Page.md") + if err := os.WriteFile(targetPath, []byte("target"), 0o600); err != nil { + t.Fatalf("write target file: %v", err) + } + + notices := make([]ForwardLinkNotice, 0, 1) + hook := NewForwardLinkHookWithGlobalIndex( + sourcePath, + engDir, + PageIndex{"index.md": "1"}, + GlobalPageIndex{"77": targetPath}, + "ENG", + func(notice ForwardLinkNotice) { + notices = append(notices, notice) + }, + ) + + out, err := hook(context.Background(), adfconv.LinkRenderInput{ + Href: "https://example.atlassian.net/wiki/pages/viewpage.action?pageId=77", + Title: "Cross Space", + Meta: adfconv.LinkMetadata{ + PageID: "77", + Anchor: "section-a", + }, + }) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !out.Handled { + t.Fatal("Expected Handled=true") + } + if got, want := out.Href, "https://example.atlassian.net/wiki/pages/viewpage.action?pageId=77#section-a"; got != want { + t.Fatalf("Href = %q, want %q", got, want) + } + if len(notices) != 1 || notices[0].Code != "CROSS_SPACE_LINK_PRESERVED" { + t.Fatalf("expected preserved-link notice, got %+v", notices) + } +} + func TestForwardMediaHook(t *testing.T) { sourcePath, _ := filepath.Abs("myspace/index.md") targetPath, _ := filepath.Abs("myspace/assets/image.png") @@ -317,6 +368,29 @@ func TestReverseLinkHookWithGlobalIndex_ResolvesViaSameFileFallback(t *testing.T } } +func TestReverseLinkHookWithGlobalIndex_DoesNotResolveMissingGlobalPath(t *testing.T) { + tmpDir := t.TempDir() + engDir := filepath.Join(tmpDir, "Engineering (ENG)") + if err := os.MkdirAll(engDir, 0o750); err != nil { + t.Fatalf("mkdir eng dir: %v", err) + } + + hook := NewReverseLinkHookWithGlobalIndex( + engDir, + PageIndex{"index.md": "1"}, + GlobalPageIndex{"77": filepath.Join(tmpDir, "Technical Docs (TD)", "Target Page.md")}, + "https://example.atlassian.net", + ) + + _, err := hook(context.Background(), mdconv.LinkParseInput{ + SourcePath: filepath.Join(engDir, "index.md"), + Destination: "../Technical%20Docs%20(TD)/Target%20Page.md", + }) + if err != mdconv.ErrUnresolved { + t.Fatalf("Expected ErrUnresolved for missing global target path, got %v", err) + } +} + func TestReverseMediaHook(t *testing.T) { // Need to create real files for Stat check tmpDir := t.TempDir() diff --git a/internal/sync/index.go b/internal/sync/index.go index 1816840..971ddcb 100644 --- a/internal/sync/index.go +++ b/internal/sync/index.go @@ -1,6 +1,7 @@ package sync import ( + "fmt" "os" "path/filepath" "runtime" @@ -15,6 +16,37 @@ type PageIndex map[string]string // GlobalPageIndex maps page IDs to absolute local file paths. type GlobalPageIndex map[string]string +// ResolveGlobalIndexRoot returns the repository/worktree root to use for +// cross-space page indexing. If no git root can be discovered, it falls back to +// the parent of the provided space directory so sibling spaces remain visible. +func ResolveGlobalIndexRoot(spaceDir string) (string, error) { + spaceDir = strings.TrimSpace(spaceDir) + if spaceDir == "" { + return "", fmt.Errorf("space directory is required") + } + + absPath, err := filepath.Abs(spaceDir) + if err != nil { + return "", err + } + if info, statErr := os.Stat(absPath); statErr == nil && !info.IsDir() { + absPath = filepath.Dir(absPath) + } + + fallbackRoot := filepath.Dir(absPath) + for current := absPath; ; current = filepath.Dir(current) { + if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { + return current, nil + } + parent := filepath.Dir(current) + if parent == current { + break + } + } + + return fallbackRoot, nil +} + // BuildPageIndex scans a space directory and returns a map of relative path -> page ID. func BuildPageIndex(spaceDir string) (PageIndex, error) { @@ -114,7 +146,52 @@ func SeedPendingPageIDsForFiles(spaceDir string, index PageIndex, files []string // BuildGlobalPageIndex aggregates paths from all discovered spaces in root. func BuildGlobalPageIndex(root string) (GlobalPageIndex, error) { + root = strings.TrimSpace(root) + if root == "" { + return GlobalPageIndex{}, nil + } + + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + global := make(GlobalPageIndex) + err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "assets" || strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { + return nil + } + + fm, err := fs.ReadFrontmatter(path) + if err != nil { + return nil + } + + pageID := strings.TrimSpace(fm.ID) + if pageID == "" { + return nil + } + + absPath, err := filepath.Abs(path) + if err != nil { + return nil + } + global[pageID] = absPath + return nil + }) + if err != nil { + return nil, err + } + states, err := fs.FindAllStateFiles(root) if err != nil { return nil, err @@ -122,9 +199,13 @@ func BuildGlobalPageIndex(root string) (GlobalPageIndex, error) { for dir, state := range states { for relPath, pageID := range state.PagePathIndex { + pageID = strings.TrimSpace(pageID) if pageID == "" { continue } + if _, exists := global[pageID]; exists { + continue + } absPath := filepath.Join(dir, filepath.FromSlash(relPath)) global[pageID] = absPath } @@ -164,6 +245,7 @@ func normalizeAbsolutePathKey(path string) string { return "" } + path = trimWindowsDevicePrefix(path) path = filepath.Clean(path) path = filepath.ToSlash(path) if runtime.GOOS == "windows" { @@ -171,3 +253,18 @@ func normalizeAbsolutePathKey(path string) string { } return path } + +func trimWindowsDevicePrefix(path string) string { + if runtime.GOOS != "windows" { + return path + } + + switch { + case strings.HasPrefix(path, `\\?\UNC\`): + return `\\` + strings.TrimPrefix(path, `\\?\UNC\`) + case strings.HasPrefix(path, `\\?\`): + return strings.TrimPrefix(path, `\\?\`) + default: + return path + } +} diff --git a/internal/sync/index_test.go b/internal/sync/index_test.go new file mode 100644 index 0000000..8fdefcc --- /dev/null +++ b/internal/sync/index_test.go @@ -0,0 +1,69 @@ +package sync + +import ( + "os" + "path/filepath" + "testing" + + "github.com/rgonek/confluence-markdown-sync/internal/fs" +) + +func TestResolveGlobalIndexRoot_FindsGitWorktreeRoot(t *testing.T) { + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: /tmp/fake\n"), 0o600); err != nil { + t.Fatalf("write .git file: %v", err) + } + + spaceDir := filepath.Join(repo, "Engineering (ENG)") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space dir: %v", err) + } + + root, err := ResolveGlobalIndexRoot(spaceDir) + if err != nil { + t.Fatalf("ResolveGlobalIndexRoot() error: %v", err) + } + if root != repo { + t.Fatalf("root = %q, want %q", root, repo) + } +} + +func TestBuildGlobalPageIndex_ScansMarkdownWithoutStateFiles(t *testing.T) { + repo := t.TempDir() + engDir := filepath.Join(repo, "Engineering (ENG)") + tdDir := filepath.Join(repo, "Technical Docs (TD)") + if err := os.MkdirAll(engDir, 0o750); err != nil { + t.Fatalf("mkdir eng dir: %v", err) + } + if err := os.MkdirAll(tdDir, 0o750); err != nil { + t.Fatalf("mkdir td dir: %v", err) + } + + engPath := filepath.Join(engDir, "root.md") + if err := fs.WriteMarkdownDocument(engPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Root", ID: "100", Version: 1}, + Body: "root\n", + }); err != nil { + t.Fatalf("write eng markdown: %v", err) + } + + tdPath := filepath.Join(tdDir, "target.md") + if err := fs.WriteMarkdownDocument(tdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Target", ID: "200", Version: 1}, + Body: "target\n", + }); err != nil { + t.Fatalf("write td markdown: %v", err) + } + + index, err := BuildGlobalPageIndex(repo) + if err != nil { + t.Fatalf("BuildGlobalPageIndex() error: %v", err) + } + + if got := normalizeAbsolutePathKey(index["100"]); got != normalizeAbsolutePathKey(engPath) { + t.Fatalf("index[100] = %q, want %q", index["100"], engPath) + } + if got := normalizeAbsolutePathKey(index["200"]); got != normalizeAbsolutePathKey(tdPath) { + t.Fatalf("index[200] = %q, want %q", index["200"], tdPath) + } +} diff --git a/internal/sync/pull.go b/internal/sync/pull.go index a6b1b2a..7f24575 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -55,6 +55,7 @@ type PullOptions struct { SpaceKey string SpaceDir string State fs.SpaceState + GlobalPageIndex GlobalPageIndex PullStartedAt time.Time OverlapWindow time.Duration TargetPageID string @@ -539,8 +540,18 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, opts.Progress.SetCurrentItem(filepath.Base(outputPath)) } + linkNotices := make([]ForwardLinkNotice, 0) forward, err := converter.Forward(ctx, page.BodyADF, converter.ForwardConfig{ - LinkHook: NewForwardLinkHook(outputPath, pagePathByIDAbs, opts.SpaceKey), + LinkHook: NewForwardLinkHookWithGlobalIndex( + outputPath, + spaceDir, + pagePathByIDAbs, + opts.GlobalPageIndex, + opts.SpaceKey, + func(notice ForwardLinkNotice) { + linkNotices = append(linkNotices, notice) + }, + ), MediaHook: NewForwardMediaHook(outputPath, attachmentPathByID), }, outputPath) if err != nil { @@ -582,6 +593,13 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, relPath = filepath.ToSlash(relPath) updatedMarkdown = append(updatedMarkdown, relPath) + for _, notice := range linkNotices { + diagnostics = append(diagnostics, PullDiagnostic{ + Path: relPath, + Code: notice.Code, + Message: notice.Message, + }) + } for _, warning := range forward.Warnings { diagnostics = append(diagnostics, PullDiagnostic{ Path: relPath, diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index 5b6db58..becd248 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -203,6 +203,107 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { } } +func TestPull_PreservesAbsoluteCrossSpaceLinksWithoutUnresolvedWarnings(t *testing.T) { + repo := t.TempDir() + engDir := filepath.Join(repo, "Engineering (ENG)") + tdDir := filepath.Join(repo, "Technical Docs (TD)") + if err := os.MkdirAll(engDir, 0o750); err != nil { + t.Fatalf("mkdir eng dir: %v", err) + } + if err := os.MkdirAll(tdDir, 0o750); err != nil { + t.Fatalf("mkdir td dir: %v", err) + } + + targetPath := filepath.Join(tdDir, "target.md") + if err := fs.WriteMarkdownDocument(targetPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Target", ID: "200", Version: 1}, + Body: "target\n", + }); err != nil { + t.Fatalf("write cross-space target: %v", err) + } + + globalIndex, err := BuildGlobalPageIndex(repo) + if err != nil { + t.Fatalf("BuildGlobalPageIndex() error: %v", err) + } + + fake := &fakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + }}, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, map[string]any{ + "version": 1, + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "Cross Space", + "marks": []any{ + map[string]any{ + "type": "link", + "attrs": map[string]any{ + "href": "https://example.atlassian.net/wiki/pages/viewpage.action?pageId=200", + "pageId": "200", + "anchor": "section-a", + }, + }, + }, + }, + }, + }, + }, + }), + }, + }, + attachments: map[string][]byte{}, + } + + result, err := Pull(context.Background(), fake, PullOptions{ + SpaceKey: "ENG", + SpaceDir: engDir, + GlobalPageIndex: globalIndex, + }) + if err != nil { + t.Fatalf("Pull() error: %v", err) + } + + rootDoc, err := fs.ReadMarkdownDocument(filepath.Join(engDir, "Root.md")) + if err != nil { + t.Fatalf("read Root.md: %v", err) + } + if !strings.Contains(rootDoc.Body, "[Cross Space](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=200#section-a)") { + t.Fatalf("expected preserved absolute cross-space link, got:\n%s", rootDoc.Body) + } + + foundPreserved := false + for _, d := range result.Diagnostics { + if d.Code == "unresolved_reference" { + t.Fatalf("did not expect unresolved_reference diagnostic, got %+v", result.Diagnostics) + } + if d.Code == "CROSS_SPACE_LINK_PRESERVED" { + foundPreserved = true + } + } + if !foundPreserved { + t.Fatalf("expected CROSS_SPACE_LINK_PRESERVED diagnostic, got %+v", result.Diagnostics) + } +} + func TestPull_FolderListFailureFallsBackToPageHierarchy(t *testing.T) { tmpDir := t.TempDir() spaceDir := filepath.Join(tmpDir, "ENG") From f583a10aaf19638e6c0133cfacabff4faf22abbe Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 10:58:51 +0100 Subject: [PATCH 04/31] Fix cross-space hook test fixture --- internal/sync/hooks_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/sync/hooks_test.go b/internal/sync/hooks_test.go index 8454dec..5c9dc97 100644 --- a/internal/sync/hooks_test.go +++ b/internal/sync/hooks_test.go @@ -303,6 +303,9 @@ func TestReverseLinkHookWithGlobalIndex_ResolvesCrossSpaceLink(t *testing.T) { } targetPath := filepath.Join(tdDir, "Target Page.md") + if err := os.WriteFile(targetPath, []byte("target"), 0o600); err != nil { + t.Fatalf("write target file: %v", err) + } hook := NewReverseLinkHookWithGlobalIndex( engDir, PageIndex{"index.md": "1"}, From 574ff68768f88d0811350f139b8fa5784e5a7537 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 12:22:30 +0100 Subject: [PATCH 05/31] feat: enhance attachment handling in push sync and add related tests --- cmd/push_changes.go | 7 +- internal/sync/pull_test.go | 71 ++++++++ internal/sync/push.go | 6 + internal/sync/push_assets.go | 21 ++- internal/sync/push_assets_test.go | 275 ++++++++++++++++++++++++++++++ 5 files changed, 372 insertions(+), 8 deletions(-) diff --git a/cmd/push_changes.go b/cmd/push_changes.go index 4aedc21..5698d35 100644 --- a/cmd/push_changes.go +++ b/cmd/push_changes.go @@ -417,9 +417,12 @@ func printPushSyncSummary(out io.Writer, commits []syncflow.PushCommitPlan, diag } attachmentDeleted := 0 + attachmentCreated := 0 attachmentPreserved := 0 for _, diag := range diagnostics { switch diag.Code { + case "ATTACHMENT_CREATED": + attachmentCreated++ case "ATTACHMENT_DELETED": attachmentDeleted++ case "ATTACHMENT_PRESERVED": @@ -429,8 +432,8 @@ func printPushSyncSummary(out io.Writer, commits []syncflow.PushCommitPlan, diag _, _ = fmt.Fprintln(out, "\nSync Summary:") _, _ = fmt.Fprintf(out, " pages changed: %d (deleted: %d)\n", len(commits), deletedPages) - if attachmentDeleted > 0 || attachmentPreserved > 0 { - _, _ = fmt.Fprintf(out, " attachments: deleted %d, preserved %d\n", attachmentDeleted, attachmentPreserved) + if attachmentCreated > 0 || attachmentDeleted > 0 || attachmentPreserved > 0 { + _, _ = fmt.Fprintf(out, " attachments: created %d, preserved %d, deleted %d\n", attachmentCreated, attachmentPreserved, attachmentDeleted) } if len(diagnostics) > 0 { _, _ = fmt.Fprintf(out, " diagnostics: %d\n", len(diagnostics)) diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index becd248..9dd1ae8 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -677,3 +677,74 @@ func TestPull_TrashedRecoveryDeletesLocalPage(t *testing.T) { t.Fatalf("expected trashed.md in deleted markdown list, got %v", result.DeletedMarkdown) } } + +func TestPull_RemovesLocalAttachmentWhenRemoteNoLongerReferencesIt(t *testing.T) { + tmpDir := t.TempDir() + spaceDir := filepath.Join(tmpDir, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + staleAssetPath := filepath.Join(spaceDir, "assets", "1", "att-legacy-binary.bin") + if err := os.MkdirAll(filepath.Dir(staleAssetPath), 0o750); err != nil { + t.Fatalf("mkdir assets dir: %v", err) + } + if err := os.WriteFile(staleAssetPath, []byte("legacy"), 0o600); err != nil { + t.Fatalf("write stale attachment: %v", err) + } + + state := fs.SpaceState{ + SpaceKey: "ENG", + PagePathIndex: map[string]string{ + "root.md": "1", + }, + AttachmentIndex: map[string]string{ + filepath.ToSlash("assets/1/att-legacy-binary.bin"): "att-legacy", + }, + } + + remote := &fakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG"}, + pages: []confluence.Page{ + { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.February, 2, 11, 0, 0, 0, time.UTC), + }, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.February, 2, 11, 0, 0, 0, time.UTC), + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + }, + }, + attachments: map[string][]byte{}, + } + + result, err := Pull(context.Background(), remote, PullOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + State: state, + }) + if err != nil { + t.Fatalf("Pull() unexpected error: %v", err) + } + + if _, err := os.Stat(staleAssetPath); !os.IsNotExist(err) { + t.Fatalf("expected stale attachment to be deleted from local workspace, stat=%v", err) + } + + if _, exists := result.State.AttachmentIndex[filepath.ToSlash("assets/1/att-legacy-binary.bin")]; exists { + t.Fatalf("expected stale attachment index to be removed") + } + + if len(result.DeletedAssets) != 1 || result.DeletedAssets[0] != filepath.ToSlash("assets/1/att-legacy-binary.bin") { + t.Fatalf("expected deleted assets to include stale attachment, got %+v", result.DeletedAssets) + } +} diff --git a/internal/sync/push.go b/internal/sync/push.go index 7c7de9c..60a5bd7 100644 --- a/internal/sync/push.go +++ b/internal/sync/push.go @@ -592,6 +592,12 @@ func pushUpsertPage( attachmentIDByPath[assetRelPath] = uploadedID state.AttachmentIndex[assetRelPath] = uploadedID rollback.trackUploadedAttachment(pageID, uploadedID, assetRelPath) + appendPushDiagnostic( + diagnostics, + assetRelPath, + "ATTACHMENT_CREATED", + fmt.Sprintf("uploaded attachment %s from %s", uploadedID, assetRelPath), + ) referencedIDs[uploadedID] = struct{}{} touchedAssets = append(touchedAssets, assetRelPath) } diff --git a/internal/sync/push_assets.go b/internal/sync/push_assets.go index fa8808c..77d4ff0 100644 --- a/internal/sync/push_assets.go +++ b/internal/sync/push_assets.go @@ -106,8 +106,8 @@ func CollectReferencedAssetPaths(spaceDir, sourcePath, body string) ([]string, e return sortedStringKeys(paths), nil } -// PrepareMarkdownForAttachmentConversion rewrites local file links ([]()) into -// inline media spans so strict reverse conversion can preserve attachment +// PrepareMarkdownForAttachmentConversion rewrites local file links and image references +// into inline media spans so strict reverse conversion can preserve attachment // references without dropping inline context. func PrepareMarkdownForAttachmentConversion(spaceDir, sourcePath, body string, attachmentIndex map[string]string) (string, error) { references, err := collectLocalAssetReferences(spaceDir, sourcePath, body) @@ -117,7 +117,7 @@ func PrepareMarkdownForAttachmentConversion(spaceDir, sourcePath, body string, a rewrites := make([]markdownDestinationRewrite, 0) for _, reference := range references { - if reference.Occurrence.kind != markdownReferenceKindLink { + if reference.Occurrence.kind != markdownReferenceKindLink && reference.Occurrence.kind != markdownReferenceKindImage { continue } @@ -128,10 +128,11 @@ func PrepareMarkdownForAttachmentConversion(spaceDir, sourcePath, body string, a displayName := attachmentDisplayNameForPath(reference.RelPath, attachmentID) mediaType := mediaTypeForDestination(reference.RelPath) - rewrites = append(rewrites, markdownDestinationRewrite{ + rewrite := markdownDestinationRewrite{ Occurrence: reference.Occurrence, ReplacementToken: formatPandocInlineMediaToken(displayName, attachmentID, mediaType), - }) + } + rewrites = append(rewrites, rewrite) } if len(rewrites) == 0 { @@ -373,7 +374,15 @@ func applyMarkdownDestinationRewrites(body string, rewrites []markdownDestinatio continue } - builder.Write(content[last:tokenStart]) + replacementStart := tokenStart + if strings.TrimSpace(rewrite.ReplacementToken) != "" && + rewrite.Occurrence.kind == markdownReferenceKindImage && + tokenStart > 0 && + content[tokenStart-1] == '!' { + replacementStart = tokenStart - 1 + } + + builder.Write(content[last:replacementStart]) if strings.TrimSpace(rewrite.ReplacementToken) != "" { builder.WriteString(rewrite.ReplacementToken) last = tokenEnd diff --git a/internal/sync/push_assets_test.go b/internal/sync/push_assets_test.go index ec2fe8f..4a623d8 100644 --- a/internal/sync/push_assets_test.go +++ b/internal/sync/push_assets_test.go @@ -2,8 +2,10 @@ package sync import ( "context" + "encoding/json" "os" "path/filepath" + "sort" "strings" "testing" @@ -11,6 +13,64 @@ import ( "github.com/rgonek/confluence-markdown-sync/internal/fs" ) +func collectADFMediaNodes(node any, out *[]map[string]any) { + switch typed := node.(type) { + case map[string]any: + nodeType, _ := typed["type"].(string) + if nodeType == "media" || nodeType == "mediaInline" { + if attrs, ok := typed["attrs"].(map[string]any); ok { + copy := make(map[string]any, len(attrs)) + for key, value := range attrs { + copy[key] = value + } + *out = append(*out, copy) + } + } + for _, value := range typed { + collectADFMediaNodes(value, out) + } + case []any: + for _, value := range typed { + collectADFMediaNodes(value, out) + } + } +} + +func mustCollectADFMediaNodes(t *testing.T, raw []byte) []map[string]any { + t.Helper() + + var rawJSON any + if err := json.Unmarshal(raw, &rawJSON); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + + out := make([]map[string]any, 0) + collectADFMediaNodes(rawJSON, &out) + return out +} + +func idsFromStateAttachmentIndex(state fs.SpaceState, prefix string) []string { + ids := make([]string, 0) + for path, id := range state.AttachmentIndex { + if strings.HasPrefix(normalizeRelPath(path), normalizeRelPath(prefix)) && strings.TrimSpace(id) != "" { + ids = append(ids, id) + } + } + + sort.Strings(ids) + return ids +} + +func mediaNodeID(attrs map[string]any) string { + if id, ok := attrs["id"].(string); ok && strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + if id, ok := attrs["attachmentId"].(string); ok && strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + return "" +} + func TestPush_KeepOrphanAssetsPreservesUnreferencedAttachment(t *testing.T) { spaceDir := t.TempDir() mdPath := filepath.Join(spaceDir, "root.md") @@ -274,6 +334,221 @@ func TestPush_UploadsInlineLocalFileLinksWithoutEmbeddedPlaceholder(t *testing.T } } +func TestPush_UploadsImageAndFileAttachmentsWithResolvedIDs(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + imagePath := filepath.Join(spaceDir, "diagram.png") + filePath := filepath.Join(spaceDir, "manual.pdf") + relImagePath := filepath.ToSlash("diagram.png") + relFilePath := filepath.ToSlash("manual.pdf") + + if err := os.WriteFile(imagePath, []byte("png"), 0o600); err != nil { + t.Fatalf("write image: %v", err) + } + if err := os.WriteFile(filePath, []byte("pdf"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + + Version: 1, + }, + Body: "![diagram](" + relImagePath + ")\n[Manual](" + relFilePath + ")\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + WebURL: "https://example.atlassian.net/wiki/pages/1", + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + ConflictPolicy: PushConflictPolicyCancel, + State: fs.SpaceState{ + SpaceKey: "ENG", + PagePathIndex: map[string]string{ + "root.md": "1", + }, + }, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }) + if err != nil { + t.Fatalf("Push() unexpected error: %v", err) + } + + if remote.uploadAttachmentCalls != 2 { + t.Fatalf("upload attachment calls = %d, want 2", remote.uploadAttachmentCalls) + } + if len(result.State.AttachmentIndex) != 2 { + t.Fatalf("attachment index size = %d, want 2", len(result.State.AttachmentIndex)) + } + + uploadedIDs := idsFromStateAttachmentIndex(result.State, "assets/1") + if len(uploadedIDs) != 2 { + t.Fatalf("resolved attachment IDs = %v, want 2", uploadedIDs) + } + if _, err := fs.ReadMarkdownDocument(mdPath); err != nil { + t.Fatalf("read markdown: %v", err) + } + + updatedDoc, err := fs.ReadMarkdownDocument(mdPath) + if err != nil { + t.Fatalf("read markdown: %v", err) + } + if !strings.Contains(updatedDoc.Body, "assets/1/") { + t.Fatalf("expected page asset paths, got %q", updatedDoc.Body) + } + + payload, ok := remote.updateInputsByPageID["1"] + if !ok { + t.Fatalf("expected update payload for page 1") + } + body := string(payload.BodyADF) + if strings.Contains(body, "UNKNOWN_MEDIA_ID") { + t.Fatalf("did not expect UNKNOWN_MEDIA_ID in pushed ADF body: %s", body) + } + if strings.Contains(body, "[Embedded content]") { + t.Fatalf("did not expect embedded content placeholder in pushed ADF body: %s", body) + } + + mediaNodes := mustCollectADFMediaNodes(t, payload.BodyADF) + seenIDs := map[string]struct{}{} + seenPng := false + seenPdf := false + for _, attrs := range mediaNodes { + id := mediaNodeID(attrs) + if strings.TrimSpace(id) != "" { + seenIDs[strings.TrimSpace(id)] = struct{}{} + } + if mediaType := strings.TrimSpace(mediaNodeType(attrs)); mediaType != "" { + switch mediaType { + case "image": + seenPng = true + case "file": + seenPdf = true + } + } + } + + for _, expectedID := range uploadedIDs { + if _, ok := seenIDs[expectedID]; !ok { + t.Fatalf("pushed ADF missing media id %q, media nodes: %#v, body=%s", expectedID, mediaNodes, string(payload.BodyADF)) + } + } + if !seenPng { + t.Fatalf("expected pushed media payload to include image-like attachment, media nodes: %#v, body=%s", mediaNodes, body) + } + if !seenPdf { + t.Fatalf("expected pushed media payload to include file-like attachment, media nodes: %#v, body=%s", mediaNodes, body) + } +} + +func mediaNodeType(attrs map[string]any) string { + if raw, ok := attrs["type"].(string); ok { + return strings.TrimSpace(strings.ToLower(raw)) + } + return "" +} + +func TestPush_RemovesDetachedRemoteAttachmentsDuringUpdate(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + keepPath := filepath.Join(spaceDir, "assets", "1", "keep.png") + stalePath := filepath.Join(spaceDir, "assets", "1", "stale.pdf") + if err := os.MkdirAll(filepath.Dir(keepPath), 0o750); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + if err := os.WriteFile(keepPath, []byte("png"), 0o600); err != nil { + t.Fatalf("write keep asset: %v", err) + } + if err := os.WriteFile(stalePath, []byte("pdf"), 0o600); err != nil { + t.Fatalf("write stale asset: %v", err) + } + + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + + Version: 1, + }, + Body: "![Keep](assets/1/keep.png)\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + ConflictPolicy: PushConflictPolicyCancel, + State: fs.SpaceState{ + SpaceKey: "ENG", + PagePathIndex: map[string]string{ + "root.md": "1", + }, + AttachmentIndex: map[string]string{ + filepath.ToSlash("assets/1/keep.png"): "att-keep", + filepath.ToSlash("assets/1/stale.pdf"): "att-stale", + }, + }, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }) + if err != nil { + t.Fatalf("Push() unexpected error: %v", err) + } + + if len(remote.deleteAttachmentCalls) != 1 { + t.Fatalf("delete attachment calls = %d, want 1", len(remote.deleteAttachmentCalls)) + } + if got := strings.TrimSpace(remote.deleteAttachmentCalls[0]); got != "att-stale" { + t.Fatalf("deleted attachment id = %q, want att-stale", got) + } + + if got := strings.TrimSpace(result.State.AttachmentIndex[filepath.ToSlash("assets/1/stale.pdf")]); got != "" { + t.Fatalf("expected stale attachment to be removed from state, got %q", got) + } + if got := strings.TrimSpace(result.State.AttachmentIndex[filepath.ToSlash("assets/1/keep.png")]); got != "att-keep" { + t.Fatalf("expected kept attachment ID = att-keep, got %q", got) + } + + hasAttachmentDeletedDiag := false + for _, diag := range result.Diagnostics { + if diag.Code == "ATTACHMENT_DELETED" && diag.Path == filepath.ToSlash("assets/1/stale.pdf") { + hasAttachmentDeletedDiag = true + break + } + } + if !hasAttachmentDeletedDiag { + t.Fatalf("expected ATTACHMENT_DELETED diagnostic for stale.pdf, got %+v", result.Diagnostics) + } +} + func TestOutsideSpaceAssetError_ContainsSuggestedPath(t *testing.T) { spaceDir := t.TempDir() sourcePath := filepath.Join(spaceDir, "docs", "page.md") From 5a88a6ff83eaf4bef53348cd30d6ff900a66ddbd Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 12:31:55 +0100 Subject: [PATCH 06/31] Fix hierarchy pull path invariants --- internal/fs/state.go | 35 ++++- internal/fs/state_path_normalization_test.go | 49 ++++++ internal/sync/pull.go | 8 +- internal/sync/pull_paths.go | 19 ++- internal/sync/push_hierarchy.go | 1 + internal/sync/workstream_d_hierarchy_test.go | 156 +++++++++++++++++++ 6 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 internal/fs/state_path_normalization_test.go create mode 100644 internal/sync/workstream_d_hierarchy_test.go diff --git a/internal/fs/state.go b/internal/fs/state.go index 1288161..e9ea141 100644 --- a/internal/fs/state.go +++ b/internal/fs/state.go @@ -116,16 +116,39 @@ func FindAllStateFiles(root string) (map[string]SpaceState, error) { func (s *SpaceState) normalize() { s.SpaceKey = strings.TrimSpace(s.SpaceKey) + s.PagePathIndex = normalizeStatePathMap(s.PagePathIndex) + s.AttachmentIndex = normalizeStatePathMap(s.AttachmentIndex) + s.FolderPathIndex = normalizeStatePathMap(s.FolderPathIndex) +} - if s.PagePathIndex == nil { - s.PagePathIndex = map[string]string{} +func normalizeStatePathMap(in map[string]string) map[string]string { + if in == nil { + return map[string]string{} } - if s.AttachmentIndex == nil { - s.AttachmentIndex = map[string]string{} + + out := make(map[string]string, len(in)) + for key, value := range in { + normalizedKey := normalizeStatePath(key) + if normalizedKey == "" { + continue + } + out[normalizedKey] = strings.TrimSpace(value) } - if s.FolderPathIndex == nil { - s.FolderPathIndex = map[string]string{} + return out +} + +func normalizeStatePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + + path = filepath.ToSlash(filepath.Clean(path)) + path = strings.TrimPrefix(path, "./") + if path == "." { + return "" } + return path } func validateWatermark(v string) error { diff --git a/internal/fs/state_path_normalization_test.go b/internal/fs/state_path_normalization_test.go new file mode 100644 index 0000000..7990fad --- /dev/null +++ b/internal/fs/state_path_normalization_test.go @@ -0,0 +1,49 @@ +package fs + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSaveAndLoadState_NormalizesPathSeparators(t *testing.T) { + spaceDir := filepath.Join(t.TempDir(), "ENG") + + if err := SaveState(spaceDir, SpaceState{ + PagePathIndex: map[string]string{ + `Root\Root.md`: "1", + }, + AttachmentIndex: map[string]string{ + `assets\1\diagram.png`: "att-1", + }, + FolderPathIndex: map[string]string{ + `Root\Section`: "folder-1", + }, + }); err != nil { + t.Fatalf("SaveState() unexpected error: %v", err) + } + + raw, err := os.ReadFile(StatePath(spaceDir)) + if err != nil { + t.Fatalf("ReadFile() unexpected error: %v", err) + } + if strings.Contains(string(raw), `\\`) { + t.Fatalf("state file should persist slash-normalized paths, got:\n%s", string(raw)) + } + + state, err := LoadState(spaceDir) + if err != nil { + t.Fatalf("LoadState() unexpected error: %v", err) + } + + if got := state.PagePathIndex["Root/Root.md"]; got != "1" { + t.Fatalf("PagePathIndex[Root/Root.md] = %q, want 1", got) + } + if got := state.AttachmentIndex["assets/1/diagram.png"]; got != "att-1" { + t.Fatalf("AttachmentIndex[assets/1/diagram.png] = %q, want att-1", got) + } + if got := state.FolderPathIndex["Root/Section"]; got != "folder-1" { + t.Fatalf("FolderPathIndex[Root/Section] = %q, want folder-1", got) + } +} diff --git a/internal/sync/pull.go b/internal/sync/pull.go index 7f24575..51523a6 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -98,13 +98,7 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, return PullResult{}, fmt.Errorf("resolve space directory: %w", err) } - state := opts.State - if state.PagePathIndex == nil { - state.PagePathIndex = map[string]string{} - } - if state.AttachmentIndex == nil { - state.AttachmentIndex = map[string]string{} - } + state := normalizePullState(opts.State) pullStartedAt := opts.PullStartedAt if pullStartedAt.IsZero() { diff --git a/internal/sync/pull_paths.go b/internal/sync/pull_paths.go index ca8d6cb..6ab8b7a 100644 --- a/internal/sync/pull_paths.go +++ b/internal/sync/pull_paths.go @@ -25,8 +25,10 @@ func PlanPagePaths( pageByID := map[string]confluence.Page{} hasChildren := map[string]bool{} for _, page := range pages { - pageByID[page.ID] = page - if page.ParentType == "page" || page.ParentType == "" { + pageID := strings.TrimSpace(page.ID) + pageByID[pageID] = page + parentType := strings.ToLower(strings.TrimSpace(page.ParentType)) + if parentType == "" || parentType == "page" { parentID := strings.TrimSpace(page.ParentPageID) if parentID != "" { hasChildren[parentID] = true @@ -37,7 +39,7 @@ func PlanPagePaths( folderByID = map[string]confluence.Folder{} } for _, folder := range folderByID { - if folder.ParentType == "page" { + if strings.EqualFold(strings.TrimSpace(folder.ParentType), "page") { parentID := strings.TrimSpace(folder.ParentID) if parentID != "" { hasChildren[parentID] = true @@ -262,6 +264,13 @@ func cloneStringMap(in map[string]string) map[string]string { return out } +func normalizePullState(state fs.SpaceState) fs.SpaceState { + state.PagePathIndex = cloneStringMap(state.PagePathIndex) + state.AttachmentIndex = cloneStringMap(state.AttachmentIndex) + state.FolderPathIndex = cloneStringMap(state.FolderPathIndex) + return state +} + type recoveryRemote interface { GetPage(ctx context.Context, pageID string) (confluence.Page, error) } @@ -327,7 +336,7 @@ func buildFolderPathIndex(folderByID map[string]confluence.Folder, pageByID map[ for folderID := range folderByID { localPath := buildFolderLocalPath(folderID, folderByID, pageByID) if localPath != "" { - folderPathIndex[localPath] = folderID + folderPathIndex[normalizeRelPath(localPath)] = folderID } } @@ -392,5 +401,5 @@ func buildFolderLocalPath(folderID string, folderByID map[string]confluence.Fold segments[i], segments[j] = segments[j], segments[i] } - return filepath.Join(segments...) + return normalizeRelPath(filepath.Join(segments...)) } diff --git a/internal/sync/push_hierarchy.go b/internal/sync/push_hierarchy.go index 01851e8..6a07da9 100644 --- a/internal/sync/push_hierarchy.go +++ b/internal/sync/push_hierarchy.go @@ -334,6 +334,7 @@ func normalizePushState(state fs.SpaceState) fs.SpaceState { } state.PagePathIndex = normalizedPageIndex state.AttachmentIndex = cloneStringMap(state.AttachmentIndex) + state.FolderPathIndex = cloneStringMap(state.FolderPathIndex) return state } diff --git a/internal/sync/workstream_d_hierarchy_test.go b/internal/sync/workstream_d_hierarchy_test.go new file mode 100644 index 0000000..5fc404f --- /dev/null +++ b/internal/sync/workstream_d_hierarchy_test.go @@ -0,0 +1,156 @@ +package sync + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rgonek/confluence-markdown-sync/internal/confluence" + "github.com/rgonek/confluence-markdown-sync/internal/fs" +) + +func TestPlanPagePaths_TreatsParentTypesCaseInsensitively(t *testing.T) { + spaceDir := t.TempDir() + + pages := []confluence.Page{ + {ID: "1", Title: "Root"}, + {ID: "2", Title: "Direct Child", ParentPageID: "1", ParentType: "PAGE"}, + {ID: "3", Title: "Folder Child", ParentPageID: "folder-1", ParentType: "folder"}, + } + folderByID := map[string]confluence.Folder{ + "folder-1": {ID: "folder-1", Title: "Section", ParentID: "1", ParentType: "PAGE"}, + } + + _, relByID := PlanPagePaths(spaceDir, nil, pages, folderByID) + + if got := relByID["1"]; got != "Root/Root.md" { + t.Fatalf("root path = %q, want Root/Root.md", got) + } + if got := relByID["2"]; got != "Root/Direct-Child.md" { + t.Fatalf("direct child path = %q, want Root/Direct-Child.md", got) + } + if got := relByID["3"]; got != "Root/Section/Folder-Child.md" { + t.Fatalf("folder child path = %q, want Root/Section/Folder-Child.md", got) + } +} + +func TestPull_RoundTripsMixedHierarchyToIndexPaths(t *testing.T) { + tmpDir := t.TempDir() + spaceDir := filepath.Join(tmpDir, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + modifiedAt := time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC) + emptyADF := map[string]any{ + "version": 1, + "type": "doc", + "content": []any{}, + } + + fake := &fakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Version: 1, LastModified: modifiedAt}, + {ID: "2", SpaceID: "space-1", Title: "Direct Child", ParentPageID: "1", ParentType: "PAGE", Version: 1, LastModified: modifiedAt}, + {ID: "3", SpaceID: "space-1", Title: "Folder Child", ParentPageID: "folder-1", ParentType: "folder", Version: 1, LastModified: modifiedAt}, + {ID: "4", SpaceID: "space-1", Title: "Nested Folder Child", ParentPageID: "folder-2", ParentType: "folder", Version: 1, LastModified: modifiedAt}, + }, + folderByID: map[string]confluence.Folder{ + "folder-1": {ID: "folder-1", Title: "Section", ParentID: "1", ParentType: "PAGE"}, + "folder-2": {ID: "folder-2", Title: "Subsection", ParentID: "folder-1", ParentType: "folder"}, + }, + pagesByID: map[string]confluence.Page{ + "1": {ID: "1", SpaceID: "space-1", Title: "Root", Version: 1, LastModified: modifiedAt, BodyADF: rawJSON(t, emptyADF)}, + "2": {ID: "2", SpaceID: "space-1", Title: "Direct Child", ParentPageID: "1", ParentType: "PAGE", Version: 1, LastModified: modifiedAt, BodyADF: rawJSON(t, emptyADF)}, + "3": {ID: "3", SpaceID: "space-1", Title: "Folder Child", ParentPageID: "folder-1", ParentType: "folder", Version: 1, LastModified: modifiedAt, BodyADF: rawJSON(t, emptyADF)}, + "4": {ID: "4", SpaceID: "space-1", Title: "Nested Folder Child", ParentPageID: "folder-2", ParentType: "folder", Version: 1, LastModified: modifiedAt, BodyADF: rawJSON(t, emptyADF)}, + }, + } + + result, err := Pull(context.Background(), fake, PullOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + State: fs.SpaceState{ + PagePathIndex: map[string]string{ + `Legacy\Root.md`: "1", + }, + FolderPathIndex: map[string]string{ + `Legacy\Section`: "folder-1", + }, + }, + }) + if err != nil { + t.Fatalf("Pull() error: %v", err) + } + + expectedPaths := map[string]string{ + "Root/Root.md": "1", + "Root/Direct-Child.md": "2", + "Root/Section/Folder-Child.md": "3", + "Root/Section/Subsection/Nested-Folder-Child.md": "4", + } + for relPath, pageID := range expectedPaths { + if _, err := os.Stat(filepath.Join(spaceDir, filepath.FromSlash(relPath))); err != nil { + t.Fatalf("expected markdown at %s: %v", relPath, err) + } + if got := result.State.PagePathIndex[relPath]; got != pageID { + t.Fatalf("state page_path_index[%s] = %q, want %s", relPath, got, pageID) + } + } + + if _, err := os.Stat(filepath.Join(spaceDir, "Root.md")); !os.IsNotExist(err) { + t.Fatalf("Root.md should not exist at top level, stat error=%v", err) + } + + if got := result.State.FolderPathIndex["Root/Section"]; got != "folder-1" { + t.Fatalf("state folder_path_index[Root/Section] = %q, want folder-1", got) + } + if got := result.State.FolderPathIndex["Root/Section/Subsection"]; got != "folder-2" { + t.Fatalf("state folder_path_index[Root/Section/Subsection] = %q, want folder-2", got) + } + for path := range result.State.FolderPathIndex { + if strings.Contains(path, `\`) { + t.Fatalf("folder_path_index should use slash-normalized paths, got %q", path) + } + } +} + +func TestNormalizePullAndPushState_NormalizeAllPathIndexes(t *testing.T) { + state := fs.SpaceState{ + PagePathIndex: map[string]string{ + `Root\Root.md`: "1", + }, + AttachmentIndex: map[string]string{ + `assets\1\diagram.png`: "att-1", + }, + FolderPathIndex: map[string]string{ + `Root\Section`: "folder-1", + }, + } + + pullState := normalizePullState(state) + if got := pullState.PagePathIndex["Root/Root.md"]; got != "1" { + t.Fatalf("normalizePullState page path = %q, want 1", got) + } + if got := pullState.AttachmentIndex["assets/1/diagram.png"]; got != "att-1" { + t.Fatalf("normalizePullState attachment path = %q, want att-1", got) + } + if got := pullState.FolderPathIndex["Root/Section"]; got != "folder-1" { + t.Fatalf("normalizePullState folder path = %q, want folder-1", got) + } + + pushState := normalizePushState(state) + if got := pushState.PagePathIndex["Root/Root.md"]; got != "1" { + t.Fatalf("normalizePushState page path = %q, want 1", got) + } + if got := pushState.AttachmentIndex["assets/1/diagram.png"]; got != "att-1" { + t.Fatalf("normalizePushState attachment path = %q, want att-1", got) + } + if got := pushState.FolderPathIndex["Root/Section"]; got != "folder-1" { + t.Fatalf("normalizePushState folder path = %q, want folder-1", got) + } +} From 6cb36c361175f73822ac2181439bcc90971bcb5e Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 12:41:26 +0100 Subject: [PATCH 07/31] Run gofmt after hierarchy remediation --- internal/sync/workstream_d_hierarchy_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/sync/workstream_d_hierarchy_test.go b/internal/sync/workstream_d_hierarchy_test.go index 5fc404f..a3e5279 100644 --- a/internal/sync/workstream_d_hierarchy_test.go +++ b/internal/sync/workstream_d_hierarchy_test.go @@ -88,9 +88,9 @@ func TestPull_RoundTripsMixedHierarchyToIndexPaths(t *testing.T) { } expectedPaths := map[string]string{ - "Root/Root.md": "1", - "Root/Direct-Child.md": "2", - "Root/Section/Folder-Child.md": "3", + "Root/Root.md": "1", + "Root/Direct-Child.md": "2", + "Root/Section/Folder-Child.md": "3", "Root/Section/Subsection/Nested-Folder-Child.md": "4", } for relPath, pageID := range expectedPaths { From e646b30271d72c612eec60aa5cf75c3e499e00be Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 12:49:29 +0100 Subject: [PATCH 08/31] Document Mermaid support contract --- ...3-05-live-workflow-findings-remediation.md | 23 +++++++++---------- agents/plans/confluence_sync_cli.md | 10 ++++++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/agents/plans/2026-03-05-live-workflow-findings-remediation.md b/agents/plans/2026-03-05-live-workflow-findings-remediation.md index e0fc8e4..f66befb 100644 --- a/agents/plans/2026-03-05-live-workflow-findings-remediation.md +++ b/agents/plans/2026-03-05-live-workflow-findings-remediation.md @@ -177,21 +177,20 @@ The following items are not blockers for limited beta usage, but should be resol ### Plan -- Decide explicitly whether Mermaid is: - - supported as a first-class rendered Confluence extension, or - - preserved only as fenced code -- If Mermaid support is intended: - - implement forward and reverse extension handlers similar to `plantumlcloud` - - ensure push emits the correct Confluence extension/macro payload - - ensure pull round-trips back to authored Markdown form -- If Mermaid support is not intended: - - document it clearly in `README.md`, `AGENTS.md`, and usage docs - - add validation or diagnostics so users understand the downgrade before push +- Adopt the portable support contract: + - PlantUML remains first-class via the existing `plantumlcloud` extension handler. + - Mermaid is preserved only as fenced code and is not treated as a rendered Confluence diagram macro. +- Document Mermaid behavior clearly in `README.md`, `AGENTS.md`, and usage docs so authored expectations match live Confluence behavior. +- Add validation warnings that fire before push when Mermaid fenced code blocks are present, making the downgrade explicit while preserving successful syncs. +- Keep reverse conversion behavior unchanged for Mermaid content: + - push emits Confluence ADF `codeBlock` nodes with language `mermaid` + - pull round-trips those nodes back to authored Mermaid fenced code ### Validation -- Add round-trip tests for Mermaid matching the chosen support model. -- Add one live integration test that checks actual rendered ADF node type for Mermaid content. +- Add round-trip tests proving Mermaid fenced code stays Mermaid fenced code after reverse + forward conversion. +- Add command tests proving `validate` warns, but does not fail, when Mermaid code fences are present. +- Add one sandbox E2E test that checks pushed Mermaid content is stored remotely as a `codeBlock` node with language `mermaid`. ## Workstream F: Cleanup and Recovery Artifact Lifecycle diff --git a/agents/plans/confluence_sync_cli.md b/agents/plans/confluence_sync_cli.md index bfa6c73..8aecc70 100644 --- a/agents/plans/confluence_sync_cli.md +++ b/agents/plans/confluence_sync_cli.md @@ -188,6 +188,16 @@ Compatibility and precedence: - **Side-Effect Boundary**: - Hooks return mapping decisions only; sync orchestration owns network/filesystem side effects (downloads/uploads/file writes/deletes). +#### 2.3.4.1 Diagram Extension Contract +- **PlantUML**: + - Supported as a first-class Confluence extension through the existing `plantumlcloud` extension handler. + - Pull and push must preserve authored Markdown round-trip semantics for supported PlantUML blocks. +- **Mermaid**: + - Not treated as a first-class rendered Confluence extension. + - Markdown Mermaid fences push as ADF `codeBlock` nodes with language `mermaid`. + - Pull must preserve those `codeBlock` nodes as authored Mermaid fenced code. + - `validate` must emit a non-fatal warning before push so users know Mermaid will be preserved as code, not rendered as a Confluence diagram macro. + #### 2.3.5 Git Integration Enhancements - **Smart .gitignore**: `init` adds `.DS_Store`, `*.tmp`, `.confluence-state.json`, `.env`, `conf.exe`, etc. - **Diff Command**: `conf diff [TARGET]` fetches remote, converts to MD, and runs `git diff --no-index` (`.md` suffix => file mode, otherwise space mode). From c14aae8d313e1a732753cfcb4f82cce268568f69 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 12:52:37 +0100 Subject: [PATCH 09/31] Warn on Mermaid code fence downgrade --- cmd/push_test.go | 55 +++++++++++++++++++++++++++++++++ cmd/validate.go | 72 +++++++++++++++++++++++++++++++++++--------- cmd/validate_test.go | 40 ++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 14 deletions(-) diff --git a/cmd/push_test.go b/cmd/push_test.go index d7b2568..0ead58b 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -299,6 +299,61 @@ func TestRunPush_WorksWithoutGitRemoteConfigured(t *testing.T) { } } +func TestRunPush_MermaidWarningAppearsBeforePushAndPushesCodeBlockADF(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "```mermaid\ngraph TD\n A --> B\n```\n", + }) + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local mermaid change") + + fake := newCmdFakePushRemote(1) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + setupEnv(t) + chdirRepo(t, spaceDir) + + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, false); err != nil { + t.Fatalf("runPush() unexpected error: %v\nOutput:\n%s", err, out.String()) + } + + if !strings.Contains(out.String(), "MERMAID_PRESERVED_AS_CODEBLOCK") { + t.Fatalf("expected Mermaid warning in push output, got:\n%s", out.String()) + } + if len(fake.updateCalls) == 0 { + t.Fatal("expected push to update at least one page") + } + + adf := string(fake.updateCalls[len(fake.updateCalls)-1].Input.BodyADF) + if !strings.Contains(adf, "\"type\":\"codeBlock\"") { + t.Fatalf("expected Mermaid push ADF to contain codeBlock node, got: %s", adf) + } + if !strings.Contains(adf, "\"language\":\"mermaid\"") { + t.Fatalf("expected Mermaid push ADF to preserve mermaid language, got: %s", adf) + } +} + func TestRunPush_CrossSpaceRelativeLinkParityWithValidate(t *testing.T) { runParallelCommandTest(t) diff --git a/cmd/validate.go b/cmd/validate.go index b1ac897..ea43da3 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -15,6 +15,7 @@ import ( "github.com/rgonek/confluence-markdown-sync/internal/converter" "github.com/rgonek/confluence-markdown-sync/internal/fs" "github.com/rgonek/confluence-markdown-sync/internal/git" + "github.com/rgonek/confluence-markdown-sync/internal/search" syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" "github.com/rgonek/jira-adf-converter/mdconverter" "github.com/spf13/cobra" @@ -43,6 +44,16 @@ type baselineFrontmatterCacheEntry struct { fm fs.Frontmatter } +type validateWarning struct { + Code string + Message string +} + +type validateFileResult struct { + Issues []fs.ValidationIssue + Warnings []validateWarning +} + func newValidateCmd() *cobra.Command { return &cobra.Command{ Use: "validate [TARGET]", @@ -142,8 +153,9 @@ func runValidateTargetWithContext(ctx context.Context, out io.Writer, target con rel, _ := filepath.Rel(targetCtx.spaceDir, file) - issues := validateFile(ctx, file, targetCtx.spaceDir, linkHook, state.AttachmentIndex) - issues = append(issues, immutableResolver.validate(file)...) + fileResult := validateFile(ctx, file, targetCtx.spaceDir, linkHook, state.AttachmentIndex) + issues := append(fileResult.Issues, immutableResolver.validate(file)...) + printValidateWarnings(out, rel, fileResult.Warnings) if len(issues) == 0 { continue } @@ -326,37 +338,39 @@ func (r *validateImmutableFrontmatterResolver) readBaselineFrontmatter(absPath s return doc.Frontmatter, true } -func validateFile(ctx context.Context, path, spaceDir string, linkHook mdconverter.LinkParseHook, attachmentIndex map[string]string) []fs.ValidationIssue { - var issues []fs.ValidationIssue +func validateFile(ctx context.Context, path, spaceDir string, linkHook mdconverter.LinkParseHook, attachmentIndex map[string]string) validateFileResult { + result := validateFileResult{} // Read full document doc, err := fs.ReadMarkdownDocument(path) if err != nil { - return append(issues, fs.ValidationIssue{ + result.Issues = append(result.Issues, fs.ValidationIssue{ Code: "read_error", Message: err.Error(), }) + return result } + result.Warnings = append(result.Warnings, mermaidValidationWarnings(doc.Body)...) // 1. Validate Schema res := fs.ValidateFrontmatterSchema(doc.Frontmatter) - issues = append(issues, res.Issues...) + result.Issues = append(result.Issues, res.Issues...) strictAttachmentIndex, _, err := syncflow.BuildStrictAttachmentIndex(spaceDir, path, doc.Body, attachmentIndex) if err != nil { - issues = append(issues, fs.ValidationIssue{ + result.Issues = append(result.Issues, fs.ValidationIssue{ Code: "conversion_error", Message: err.Error(), }) - return issues + return result } preparedBody, err := syncflow.PrepareMarkdownForAttachmentConversion(spaceDir, path, doc.Body, strictAttachmentIndex) if err != nil { - issues = append(issues, fs.ValidationIssue{ + result.Issues = append(result.Issues, fs.ValidationIssue{ Code: "conversion_error", Message: err.Error(), }) - return issues + return result } mediaHook := syncflow.NewReverseMediaHook(spaceDir, strictAttachmentIndex) @@ -368,13 +382,13 @@ func validateFile(ctx context.Context, path, spaceDir string, linkHook mdconvert }, path) if err != nil { - issues = append(issues, fs.ValidationIssue{ + result.Issues = append(result.Issues, fs.ValidationIssue{ Code: "conversion_error", Message: err.Error(), }) } - return issues + return result } // runValidateChangedPushFiles validates only the files in changedAbsPaths but builds the full @@ -436,8 +450,9 @@ func runValidateChangedPushFiles(ctx context.Context, out io.Writer, spaceDir st rel, _ := filepath.Rel(targetCtx.spaceDir, file) - issues := validateFile(ctx, file, targetCtx.spaceDir, linkHook, state.AttachmentIndex) - issues = append(issues, immutableResolver.validate(file)...) + fileResult := validateFile(ctx, file, targetCtx.spaceDir, linkHook, state.AttachmentIndex) + issues := append(fileResult.Issues, immutableResolver.validate(file)...) + printValidateWarnings(out, rel, fileResult.Warnings) if len(issues) == 0 { continue } @@ -512,3 +527,32 @@ func detectDuplicatePageIDs(index syncflow.PageIndex) []string { } return errs } + +func printValidateWarnings(out io.Writer, relPath string, warnings []validateWarning) { + if len(warnings) == 0 { + return + } + + _, _ = fmt.Fprintf(out, "Validation warning for %s:\n", filepath.ToSlash(relPath)) + for _, warning := range warnings { + _, _ = fmt.Fprintf(out, " - [%s] %s\n", warning.Code, warning.Message) + } +} + +func mermaidValidationWarnings(body string) []validateWarning { + structure := search.ParseMarkdownStructure([]byte(body)) + warnings := make([]validateWarning, 0) + for _, block := range structure.CodeBlocks { + if !strings.EqualFold(strings.TrimSpace(block.Language), "mermaid") { + continue + } + warnings = append(warnings, validateWarning{ + Code: "MERMAID_PRESERVED_AS_CODEBLOCK", + Message: fmt.Sprintf( + "Mermaid fenced code at line %d will be pushed as a Confluence code block with language mermaid; it will not render as a Mermaid diagram macro", + block.Line, + ), + }) + } + return warnings +} diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 2fdd6e3..00cfe63 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -265,6 +265,46 @@ func TestRunValidateTarget_AllowsLocalFileLinkAttachment(t *testing.T) { } } +func TestRunValidateTarget_WarnsForMermaidFenceButSucceeds(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + setupGitRepo(t, repo) + setupEnv(t) + + spaceDir := filepath.Join(repo, "Engineering (ENG)") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space dir: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Root"}, + Body: "```mermaid\ngraph TD\n A --> B\n```\n", + }) + if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG"}); err != nil { + t.Fatalf("save state: %v", err) + } + + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "baseline") + + chdirRepo(t, repo) + out := &bytes.Buffer{} + if err := runValidateTargetWithContext(context.Background(), out, config.Target{Mode: config.TargetModeSpace, Value: "Engineering (ENG)"}); err != nil { + t.Fatalf("expected validate success, got: %v\nOutput:\n%s", err, out.String()) + } + + got := out.String() + if !strings.Contains(got, "MERMAID_PRESERVED_AS_CODEBLOCK") { + t.Fatalf("expected Mermaid warning code, got:\n%s", got) + } + if !strings.Contains(got, "line ") { + t.Fatalf("expected Mermaid warning line detail, got:\n%s", got) + } + if !strings.Contains(got, "Validation successful") { + t.Fatalf("expected validate success footer, got:\n%s", got) + } +} + func TestRunValidateTarget_FailsForMissingAssetFile(t *testing.T) { runParallelCommandTest(t) repo := t.TempDir() From 4b878b2b22a76d939b6dc442cd9269cc6c03f8d0 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 12:57:02 +0100 Subject: [PATCH 10/31] Add Mermaid compatibility coverage --- AGENTS.md | 4 + README.md | 1 + cmd/agents.go | 2 +- cmd/e2e_test.go | 118 ++++++++++++++++++ docs/usage.md | 7 ++ internal/converter/reverse_test.go | 18 +++ .../testdata/roundtrip/mermaid.golden.md | 4 + .../converter/testdata/roundtrip/mermaid.md | 4 + 8 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 internal/converter/testdata/roundtrip/mermaid.golden.md create mode 100644 internal/converter/testdata/roundtrip/mermaid.md diff --git a/AGENTS.md b/AGENTS.md index 57d5958..c415842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,10 @@ The agent manages the full sync cycle. - `validate`/`push` run with strict resolution (`ErrUnresolved` => conversion failure). - `validate` must use the same strict reverse-conversion profile and hook adapters as `push`. - Hooks return mapping decisions only; sync orchestration owns downloads/uploads and file writes/deletes. +- Diagram contract: + - PlantUML is supported as a first-class `plantumlcloud` Confluence extension. + - Mermaid is preserved as fenced code / ADF `codeBlock` content, not a rendered Confluence diagram macro. + - `validate` should warn before push when Mermaid fences are present so the downgrade is explicit. ## Git Workflow Requirements - `push` uses an ephemeral sync branch: `sync//`. diff --git a/README.md b/README.md index 0bf41f7..7ccca2a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ conf push ENG --on-conflict=cancel - Version: `conf version` or `conf --version` - Target rule: `.md` suffix means file mode; otherwise space mode (`SPACE_KEY`) - Required auth: `ATLASSIAN_DOMAIN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_API_TOKEN` +- Diagram support: PlantUML is preserved as a Confluence extension; Mermaid is preserved as fenced code / ADF `codeBlock` and `validate` warns before push - Label rules: labels are trimmed, lowercased, deduplicated, and sorted; empty labels and labels containing whitespace are rejected - Git remote is optional (local Git is enough) diff --git a/cmd/agents.go b/cmd/agents.go index 592aac4..3c3fc85 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -102,7 +102,7 @@ This space directory contains technical documentation for [%s]. You are a technical writer and software engineer. Your goal is to maintain high-quality, accurate, and developer-friendly documentation. ## Space-Specific Rules -- **Diagrams**: Use Mermaid or PlantUML for all architecture diagrams. +- **Diagrams**: Use PlantUML when the page needs a rendered Confluence diagram. Mermaid fences are preserved as code blocks and will not render as Mermaid macros. - **Code Snippets**: Always specify the language for syntax highlighting. - **API Docs**: Ensure all endpoints include request/response examples. - **Links**: Use relative Markdown links for cross-references between pages. diff --git a/cmd/e2e_test.go b/cmd/e2e_test.go index 81d353c..f0bf961 100644 --- a/cmd/e2e_test.go +++ b/cmd/e2e_test.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "os/exec" @@ -277,6 +278,90 @@ func TestWorkflow_AgenticFullCycle(t *testing.T) { fmt.Printf("Agentic full cycle succeeded\n") } +func TestWorkflow_MermaidPushPreservesCodeBlock(t *testing.T) { + spaceKey := requireE2ESandboxSpaceKey(t) + + ctx := context.Background() + cfg, err := config.Load("") + if err != nil { + t.Fatalf("Load config: %v", err) + } + + client, err := confluence.NewClient(confluence.ClientConfig{ + BaseURL: cfg.Domain, + Email: cfg.Email, + APIToken: cfg.APIToken, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + rootDir := projectRootFromWD(t) + confBin := confBinaryForOS(rootDir) + + tmpDir, err := os.MkdirTemp("", "conf-e2e-mermaid-*") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + defer os.RemoveAll(tmpDir) + + runCMS := func(args ...string) string { + cmd := exec.Command(confBin, args...) + cmd.Dir = tmpDir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Command conf %v failed: %v\n%s", args, err, string(out)) + } + return string(out) + } + + runCMS("init") + runCMS("pull", spaceKey, "--yes", "--non-interactive") + + spaceDir := findPulledSpaceDir(t, tmpDir) + stamp := time.Now().UTC().Format("20060102T150405") + title := "Mermaid E2E " + stamp + filePath := filepath.Join(spaceDir, "Mermaid-E2E-"+stamp+".md") + if err := fs.WriteMarkdownDocument(filePath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: title}, + Body: "```mermaid\ngraph TD\n A --> B\n```\n", + }); err != nil { + t.Fatalf("WriteMarkdown: %v", err) + } + + runCMS("validate", filePath) + runCMS("push", filePath, "--on-conflict=cancel", "--yes", "--non-interactive") + + doc, err := fs.ReadMarkdownDocument(filePath) + if err != nil { + t.Fatalf("ReadMarkdown: %v", err) + } + pageID := strings.TrimSpace(doc.Frontmatter.ID) + if pageID == "" { + t.Fatal("expected pushed Mermaid page to receive a page id") + } + t.Cleanup(func() { + if err := client.DeletePage(context.Background(), pageID, confluence.PageDeleteOptions{}); err != nil && err != confluence.ErrNotFound { + t.Logf("cleanup delete page %s: %v", pageID, err) + } + }) + + deadline := time.Now().Add(30 * time.Second) + for { + page, err := client.GetPage(ctx, pageID) + if err == nil && adfContainsCodeBlockLanguage(page.BodyADF, "mermaid") { + return + } + if time.Now().After(deadline) { + if err != nil { + t.Fatalf("GetPage(%s) did not expose Mermaid codeBlock before timeout: %v", pageID, err) + } + t.Fatalf("remote ADF for page %s did not contain Mermaid codeBlock before timeout: %s", pageID, string(page.BodyADF)) + } + time.Sleep(2 * time.Second) + } +} + func TestWorkflow_PushDryRunNonMutating(t *testing.T) { spaceKey := requireE2ESandboxSpaceKey(t) @@ -536,3 +621,36 @@ func findFirstMarkdownFile(t *testing.T, spaceDir string) string { } return matched } + +func adfContainsCodeBlockLanguage(adf []byte, language string) bool { + var root any + if err := json.Unmarshal(adf, &root); err != nil { + return false + } + return walkADFForCodeBlockLanguage(root, language) +} + +func walkADFForCodeBlockLanguage(node any, language string) bool { + switch typed := node.(type) { + case map[string]any: + if nodeType, ok := typed["type"].(string); ok && nodeType == "codeBlock" { + if attrs, ok := typed["attrs"].(map[string]any); ok { + if lang, ok := attrs["language"].(string); ok && strings.EqualFold(strings.TrimSpace(lang), language) { + return true + } + } + } + for _, value := range typed { + if walkADFForCodeBlockLanguage(value, language) { + return true + } + } + case []any: + for _, value := range typed { + if walkADFForCodeBlockLanguage(value, language) { + return true + } + } + } + return false +} diff --git a/docs/usage.md b/docs/usage.md index 237a6e5..a0ee1b6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -109,6 +109,8 @@ Checks include: - link/asset resolution, - strict Markdown -> ADF conversion compatibility. +Validation also emits non-fatal compatibility warnings for content that will sync successfully but will not render as a first-class Confluence feature. Today that includes Mermaid fenced code blocks, which are preserved as ADF `codeBlock` nodes instead of diagram macros. + Use this before major pushes or in CI. ### `conf diff [TARGET]` @@ -225,6 +227,11 @@ Local state file: - `.confluence-state.json` (per space, gitignored) +## Diagram Support + +- PlantUML: supported as a first-class Confluence extension through `plantumlcloud`, with round-trip preservation in pull and push. +- Mermaid: preserved as fenced code in Markdown and as ADF `codeBlock` content with language `mermaid` in Confluence. It does not render as a Mermaid macro, and `conf validate` warns before push so the downgrade is explicit. + ## Typical Team Workflow ```powershell diff --git a/internal/converter/reverse_test.go b/internal/converter/reverse_test.go index c84e20d..d684e6c 100644 --- a/internal/converter/reverse_test.go +++ b/internal/converter/reverse_test.go @@ -84,3 +84,21 @@ func TestReverseStrict(t *testing.T) { t.Error("Expected error in strict mode for unresolved link, got nil") } } + +func TestReverse_MermaidCodeFenceProducesCodeBlockADF(t *testing.T) { + ctx := context.Background() + markdown := []byte("```mermaid\ngraph TD\n A --> B\n```\n") + + res, err := Reverse(ctx, markdown, ReverseConfig{Strict: true}, "test.md") + if err != nil { + t.Fatalf("Reverse failed: %v", err) + } + + adfStr := string(res.ADF) + if !strings.Contains(adfStr, "\"type\":\"codeBlock\"") { + t.Fatalf("expected Mermaid ADF to contain codeBlock node, got %s", adfStr) + } + if !strings.Contains(adfStr, "\"language\":\"mermaid\"") { + t.Fatalf("expected Mermaid ADF to preserve mermaid language, got %s", adfStr) + } +} diff --git a/internal/converter/testdata/roundtrip/mermaid.golden.md b/internal/converter/testdata/roundtrip/mermaid.golden.md new file mode 100644 index 0000000..dd14820 --- /dev/null +++ b/internal/converter/testdata/roundtrip/mermaid.golden.md @@ -0,0 +1,4 @@ +```mermaid +graph TD + A --> B +``` diff --git a/internal/converter/testdata/roundtrip/mermaid.md b/internal/converter/testdata/roundtrip/mermaid.md new file mode 100644 index 0000000..dd14820 --- /dev/null +++ b/internal/converter/testdata/roundtrip/mermaid.md @@ -0,0 +1,4 @@ +```mermaid +graph TD + A --> B +``` From ebe546aa9888d1788097cbc3fa06acc9a753738e Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 13:15:52 +0100 Subject: [PATCH 11/31] Clean up stale sync recovery branches safely --- cmd/clean.go | 331 +++++++++++++++++++++++++++++++-------- cmd/clean_branch_test.go | 137 ++++++++-------- cmd/clean_test.go | 295 +++++++++++++++++----------------- 3 files changed, 482 insertions(+), 281 deletions(-) diff --git a/cmd/clean.go b/cmd/clean.go index f15ed11..b6cdd35 100644 --- a/cmd/clean.go +++ b/cmd/clean.go @@ -22,9 +22,9 @@ func newCleanCmd() *cobra.Command { Long: `clean inspects and cleans leftover sync artifacts when pull/push was interrupted. It can: -- switch away from sync/* branches, - remove stale .confluence-worktrees directories, -- prune refs/confluence-sync/snapshots/* refs, +- prune stale refs/confluence-sync/snapshots/* refs, +- delete stale sync/* recovery branches when safe, - and normalize readable state files.`, Args: cobra.NoArgs, RunE: runClean, @@ -49,11 +49,6 @@ func runClean(cmd *cobra.Command, _ []string) error { } currentBranch = strings.TrimSpace(currentBranch) - targetBranch := "" - if strings.HasPrefix(currentBranch, "sync/") { - targetBranch, _ = resolveCleanTargetBranch(client) - } - worktreesRoot := filepath.Join(client.RootDir, ".confluence-worktrees") worktreeDirs, err := listCleanWorktreeDirs(worktreesRoot) if err != nil { @@ -65,40 +60,48 @@ func runClean(cmd *cobra.Command, _ []string) error { return err } - hasActions := (targetBranch != "") || len(worktreeDirs) > 0 || len(snapshotRefs) > 0 + worktreeBranches, err := listCleanWorktreeBranches(client) + if err != nil { + return err + } - _, _ = fmt.Fprintf(out, "Repository: %s\n", client.RootDir) - if strings.HasPrefix(currentBranch, "sync/") { - if targetBranch != "" { - _, _ = fmt.Fprintf(out, "Branch: %s (will switch to %s)\n", currentBranch, targetBranch) - } else { - _, _ = fmt.Fprintf(out, "Branch: %s (no fallback branch detected)\n", currentBranch) - } - } else { - _, _ = fmt.Fprintf(out, "Branch: %s\n", currentBranch) + syncBranches, err := listCleanSyncBranches(client) + if err != nil { + return err } + + syncPlan := buildCleanSyncPlan(currentBranch, worktreeDirs, snapshotRefs, syncBranches, worktreeBranches) + hasActions := len(worktreeDirs) > 0 || len(syncPlan.DeleteSnapshotRefs) > 0 || len(syncPlan.DeleteBranches) > 0 + + _, _ = fmt.Fprintf(out, "Repository: %s\n", client.RootDir) + _, _ = fmt.Fprintf(out, "Branch: %s\n", currentBranch) _, _ = fmt.Fprintf(out, "Worktrees in .confluence-worktrees: %d\n", len(worktreeDirs)) - _, _ = fmt.Fprintf(out, "Snapshot refs: %d\n", len(snapshotRefs)) + _, _ = fmt.Fprintf(out, "Snapshot refs eligible for cleanup: %d\n", len(syncPlan.DeleteSnapshotRefs)) + _, _ = fmt.Fprintf(out, "Sync branches eligible for cleanup: %d\n", len(syncPlan.DeleteBranches)) + if len(syncPlan.RetainedSnapshotRefs) > 0 { + _, _ = fmt.Fprintf(out, "Snapshot refs retained for active recovery: %d\n", len(syncPlan.RetainedSnapshotRefs)) + } + if len(syncPlan.SkippedBranches) > 0 { + _, _ = fmt.Fprintf(out, "Sync branches retained: %d\n", len(syncPlan.SkippedBranches)) + } if !hasActions { if err := normalizeCleanStates(out, client.RootDir); err != nil { return err } - _, _ = fmt.Fprintln(out, "clean completed: workspace is already clean") + reportSkippedCleanSyncBranches(out, syncPlan.SkippedBranches) + if len(syncPlan.SkippedBranches) == 0 { + _, _ = fmt.Fprintln(out, "clean completed: workspace is already clean") + } else { + _, _ = fmt.Fprintf(out, "clean completed: removed 0 worktree(s), deleted 0 snapshot ref(s), deleted 0 sync branch(es), skipped %d sync branch(es)\n", len(syncPlan.SkippedBranches)) + } return nil } - if err := confirmCleanActions(cmd.InOrStdin(), out, currentBranch, len(worktreeDirs), len(snapshotRefs)); err != nil { + if err := confirmCleanActions(cmd.InOrStdin(), out, currentBranch, len(worktreeDirs), len(syncPlan.DeleteSnapshotRefs), len(syncPlan.DeleteBranches)); err != nil { return err } - if targetBranch != "" { - if _, err := client.Run("checkout", targetBranch); err != nil { - return fmt.Errorf("checkout %s: %w", targetBranch, err) - } - _, _ = fmt.Fprintf(out, "Switched branch to %s\n", targetBranch) - } - removedWorktrees := 0 for _, wtDir := range worktreeDirs { if err := client.RemoveWorktree(wtDir); err != nil { @@ -107,18 +110,46 @@ func runClean(cmd *cobra.Command, _ []string) error { } } removedWorktrees++ + _, _ = fmt.Fprintf(out, "Removed worktree: %s\n", wtDir) } if err := client.PruneWorktrees(); err != nil { _, _ = fmt.Fprintf(out, "warning: failed to prune worktrees: %v\n", err) } deletedRefs := 0 - for _, ref := range snapshotRefs { + deletedSnapshotRefs := make(map[string]struct{}, len(syncPlan.DeleteSnapshotRefs)) + for _, ref := range syncPlan.DeleteSnapshotRefs { if delErr := client.DeleteRef(ref); delErr != nil { _, _ = fmt.Fprintf(out, "warning: failed to delete snapshot ref %s: %v\n", ref, delErr) continue } deletedRefs++ + deletedSnapshotRefs[ref] = struct{}{} + _, _ = fmt.Fprintf(out, "Deleted snapshot ref: %s\n", ref) + } + + deletedBranches := 0 + skippedBranches := append([]cleanSkippedSyncBranch(nil), syncPlan.SkippedBranches...) + for _, plan := range syncPlan.DeleteBranches { + if plan.RequiresSnapshotDeletion { + if _, ok := deletedSnapshotRefs[plan.SnapshotRef]; !ok { + skippedBranches = append(skippedBranches, cleanSkippedSyncBranch{ + Name: plan.Name, + Reason: fmt.Sprintf("retained because snapshot ref %s could not be removed", plan.SnapshotRef), + }) + continue + } + } + if err := client.DeleteBranch(plan.Name); err != nil { + _, _ = fmt.Fprintf(out, "warning: failed to delete sync branch %s: %v\n", plan.Name, err) + skippedBranches = append(skippedBranches, cleanSkippedSyncBranch{ + Name: plan.Name, + Reason: err.Error(), + }) + continue + } + deletedBranches++ + _, _ = fmt.Fprintf(out, "Deleted sync branch: %s\n", plan.Name) } if err := normalizeCleanStates(out, client.RootDir); err != nil { @@ -135,11 +166,12 @@ func runClean(cmd *cobra.Command, _ []string) error { } } - _, _ = fmt.Fprintf(out, "clean completed: removed %d worktree(s), deleted %d snapshot ref(s)\n", removedWorktrees, deletedRefs) + reportSkippedCleanSyncBranches(out, skippedBranches) + _, _ = fmt.Fprintf(out, "clean completed: removed %d worktree(s), deleted %d snapshot ref(s), deleted %d sync branch(es), skipped %d sync branch(es)\n", removedWorktrees, deletedRefs, deletedBranches, len(skippedBranches)) return nil } -func confirmCleanActions(in io.Reader, out io.Writer, branch string, worktreeCount, refCount int) error { +func confirmCleanActions(in io.Reader, out io.Writer, branch string, worktreeCount, refCount, syncBranchCount int) error { if flagYes { return nil } @@ -147,14 +179,14 @@ func confirmCleanActions(in io.Reader, out io.Writer, branch string, worktreeCou return fmt.Errorf("clean requires confirmation; rerun with --yes") } - title := fmt.Sprintf("Apply clean actions for branch=%s, worktrees=%d, snapshot refs=%d?", branch, worktreeCount, refCount) + title := fmt.Sprintf("Apply clean actions for branch=%s, worktrees=%d, snapshot refs=%d, sync branches=%d?", branch, worktreeCount, refCount, syncBranchCount) if outputSupportsProgress(out) { var confirm bool form := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title(title). - Description("This may switch branches and delete stale sync metadata."). + Description("This removes stale sync metadata while retaining active recovery branches."). Value(&confirm), ), ).WithOutput(out) @@ -181,41 +213,6 @@ func confirmCleanActions(in io.Reader, out io.Writer, branch string, worktreeCou return nil } -func resolveCleanTargetBranch(client *git.Client) (string, error) { - if branchExists(client, "main") { - return "main", nil - } - if branchExists(client, "master") { - return "master", nil - } - - defaultRef, err := client.Run("symbolic-ref", "refs/remotes/origin/HEAD") - if err != nil { - return "", nil - } - defaultRef = strings.TrimSpace(defaultRef) - parts := strings.Split(defaultRef, "/") - if len(parts) == 0 { - return "", nil - } - name := strings.TrimSpace(parts[len(parts)-1]) - if name == "" { - return "", nil - } - if branchExists(client, name) { - return name, nil - } - return "", nil -} - -func branchExists(client *git.Client, name string) bool { - if strings.TrimSpace(name) == "" { - return false - } - _, err := client.Run("show-ref", "--verify", "--quiet", "refs/heads/"+name) - return err == nil -} - func listCleanWorktreeDirs(worktreesRoot string) ([]string, error) { entries, err := os.ReadDir(worktreesRoot) if err != nil { @@ -236,6 +233,49 @@ func listCleanWorktreeDirs(worktreesRoot string) ([]string, error) { return dirs, nil } +func listCleanWorktreeBranches(client *git.Client) (map[string][]string, error) { + raw, err := client.Run("worktree", "list", "--porcelain") + if err != nil { + return nil, fmt.Errorf("list worktrees: %w", err) + } + + result := make(map[string][]string) + lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") + var worktreePath string + var branchRef string + flush := func() { + if worktreePath == "" || !strings.HasPrefix(branchRef, "refs/heads/") { + worktreePath = "" + branchRef = "" + return + } + branch := strings.TrimPrefix(branchRef, "refs/heads/") + result[branch] = append(result[branch], cleanPathForComparison(worktreePath)) + worktreePath = "" + branchRef = "" + } + + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" { + flush() + continue + } + switch { + case strings.HasPrefix(line, "worktree "): + worktreePath = strings.TrimSpace(strings.TrimPrefix(line, "worktree ")) + case strings.HasPrefix(line, "branch "): + branchRef = strings.TrimSpace(strings.TrimPrefix(line, "branch ")) + } + } + flush() + + for branch := range result { + sort.Strings(result[branch]) + } + return result, nil +} + func listCleanSnapshotRefs(client *git.Client) ([]string, error) { raw, err := client.Run("for-each-ref", "--format=%(refname)", "refs/confluence-sync/snapshots/") if err != nil { @@ -253,6 +293,163 @@ func listCleanSnapshotRefs(client *git.Client) ([]string, error) { return refs, nil } +func listCleanSyncBranches(client *git.Client) ([]string, error) { + raw, err := client.Run("for-each-ref", "--format=%(refname:short)", "refs/heads/sync/") + if err != nil { + return nil, fmt.Errorf("list sync branches: %w", err) + } + + branches := make([]string, 0) + for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + branches = append(branches, line) + } + sort.Strings(branches) + return branches, nil +} + +type cleanSyncBranchPlan struct { + Name string + SnapshotRef string + RequiresSnapshotDeletion bool +} + +type cleanSkippedSyncBranch struct { + Name string + Reason string +} + +type cleanSyncPlan struct { + DeleteSnapshotRefs []string + RetainedSnapshotRefs []string + DeleteBranches []cleanSyncBranchPlan + SkippedBranches []cleanSkippedSyncBranch +} + +func buildCleanSyncPlan(currentBranch string, removableWorktrees, snapshotRefs, syncBranches []string, worktreeBranches map[string][]string) cleanSyncPlan { + removableSet := make(map[string]struct{}, len(removableWorktrees)) + for _, wtDir := range removableWorktrees { + removableSet[cleanPathForComparison(wtDir)] = struct{}{} + } + + snapshotSet := make(map[string]struct{}, len(snapshotRefs)) + for _, ref := range snapshotRefs { + snapshotSet[ref] = struct{}{} + } + + protectedSnapshotRefs := make(map[string]struct{}) + deleteBranches := make([]cleanSyncBranchPlan, 0, len(syncBranches)) + skippedBranches := make([]cleanSkippedSyncBranch, 0) + + for _, branch := range syncBranches { + snapshotRef, ok := managedSnapshotRefForSyncBranch(branch) + if branch == currentBranch { + skippedBranches = append(skippedBranches, cleanSkippedSyncBranch{ + Name: branch, + Reason: "current HEAD is on this sync branch", + }) + if ok { + if _, exists := snapshotSet[snapshotRef]; exists { + protectedSnapshotRefs[snapshotRef] = struct{}{} + } + } + continue + } + if !ok { + skippedBranches = append(skippedBranches, cleanSkippedSyncBranch{ + Name: branch, + Reason: "branch does not match managed sync// format", + }) + continue + } + if reason := cleanSyncBranchWorktreeBlockReason(worktreeBranches[branch], removableSet); reason != "" { + skippedBranches = append(skippedBranches, cleanSkippedSyncBranch{ + Name: branch, + Reason: reason, + }) + if _, exists := snapshotSet[snapshotRef]; exists { + protectedSnapshotRefs[snapshotRef] = struct{}{} + } + continue + } + _, requiresSnapshotDeletion := snapshotSet[snapshotRef] + deleteBranches = append(deleteBranches, cleanSyncBranchPlan{ + Name: branch, + SnapshotRef: snapshotRef, + RequiresSnapshotDeletion: requiresSnapshotDeletion, + }) + } + + deleteSnapshotRefs := make([]string, 0, len(snapshotRefs)) + retainedSnapshotRefs := make([]string, 0) + for _, ref := range snapshotRefs { + if _, keep := protectedSnapshotRefs[ref]; keep { + retainedSnapshotRefs = append(retainedSnapshotRefs, ref) + continue + } + deleteSnapshotRefs = append(deleteSnapshotRefs, ref) + } + + return cleanSyncPlan{ + DeleteSnapshotRefs: deleteSnapshotRefs, + RetainedSnapshotRefs: retainedSnapshotRefs, + DeleteBranches: deleteBranches, + SkippedBranches: skippedBranches, + } +} + +func managedSnapshotRefForSyncBranch(branch string) (string, bool) { + parts := strings.Split(strings.TrimSpace(branch), "/") + if len(parts) != 3 || parts[0] != "sync" || parts[1] == "" || parts[2] == "" { + return "", false + } + return fmt.Sprintf("refs/confluence-sync/snapshots/%s/%s", parts[1], parts[2]), true +} + +func cleanSyncBranchWorktreeBlockReason(paths []string, removableSet map[string]struct{}) string { + for _, path := range paths { + normalized := cleanPathForComparison(path) + if normalized == "" { + continue + } + if _, removable := removableSet[normalized]; removable { + continue + } + if _, err := os.Stat(normalized); err == nil { + return fmt.Sprintf("linked worktree remains at %s", path) + } else if !os.IsNotExist(err) { + return fmt.Sprintf("linked worktree status unknown at %s: %v", path, err) + } + } + return "" +} + +func cleanPathForComparison(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if eval, err := filepath.EvalSymlinks(abs); err == nil { + abs = eval + } + return filepath.Clean(abs) +} + +func reportSkippedCleanSyncBranches(out io.Writer, skipped []cleanSkippedSyncBranch) { + if len(skipped) == 0 { + return + } + for _, branch := range skipped { + _, _ = fmt.Fprintf(out, "Retained sync branch %s: %s\n", branch.Name, branch.Reason) + } +} + func normalizeCleanStates(out io.Writer, repoRoot string) error { states, err := fs.FindAllStateFiles(repoRoot) if err != nil { diff --git a/cmd/clean_branch_test.go b/cmd/clean_branch_test.go index 856c418..f6d1311 100644 --- a/cmd/clean_branch_test.go +++ b/cmd/clean_branch_test.go @@ -1,92 +1,101 @@ package cmd import ( + "os" + "path/filepath" + "reflect" + "strings" "testing" - - "github.com/rgonek/confluence-markdown-sync/internal/git" ) -func TestResolveCleanTargetBranchHelper(t *testing.T) { - runParallelCommandTest(t) - tempDir := t.TempDir() +func TestManagedSnapshotRefForSyncBranch(t *testing.T) { + t.Run("managed branch", func(t *testing.T) { + ref, ok := managedSnapshotRefForSyncBranch("sync/ENG/20260305T211238Z") + if !ok { + t.Fatal("expected managed sync branch") + } + if ref != "refs/confluence-sync/snapshots/ENG/20260305T211238Z" { + t.Fatalf("unexpected snapshot ref %q", ref) + } + }) + + t.Run("unmanaged branch", func(t *testing.T) { + if _, ok := managedSnapshotRefForSyncBranch("sync/test"); ok { + t.Fatal("expected unmanaged sync branch to be ignored") + } + }) +} - setupGitRepo(t, tempDir) - chdirRepo(t, tempDir) +func TestBuildCleanSyncPlan(t *testing.T) { + runParallelCommandTest(t) - client, err := git.NewClient() - if err != nil { - t.Fatalf("failed to create client: %v", err) + repo := t.TempDir() + removableWorktree := filepath.Join(repo, ".confluence-worktrees", "ENG-20260305T211238Z") + if err := ensureDir(removableWorktree); err != nil { + t.Fatalf("create removable worktree: %v", err) } - // Init empty commit so branch exists - if _, err := client.Run("commit", "--allow-empty", "-m", "init"); err != nil { - t.Fatalf("failed to commit: %v", err) + activeWorktree := filepath.Join(repo, ".active-recovery") + if err := ensureDir(activeWorktree); err != nil { + t.Fatalf("create active worktree: %v", err) } - // Force rename to main first to test that logic. - if _, err := client.Run("branch", "-M", "main"); err != nil { - t.Fatalf("failed to rename branch: %v", err) + snapshotRefs := []string{ + "refs/confluence-sync/snapshots/ENG/20260305T211238Z", + "refs/confluence-sync/snapshots/OPS/20260305T211239Z", + "refs/confluence-sync/snapshots/QA/20260305T211240Z", } - - branch, err := resolveCleanTargetBranch(client) - if err != nil { - t.Fatalf("expected no error, got %v", err) + syncBranches := []string{ + "sync/ENG/20260305T211238Z", + "sync/OPS/20260305T211239Z", + "sync/QA/20260305T211240Z", + "sync/manual", } - if branch != "main" { - t.Fatalf("expected main, got %q", branch) + worktreeBranches := map[string][]string{ + "sync/ENG/20260305T211238Z": {removableWorktree}, + "sync/OPS/20260305T211239Z": {activeWorktree}, } - // Try testing branchExists specifically on a valid and invalid branch - if !branchExists(client, "main") { - t.Fatalf("expected main to exist") + plan := buildCleanSyncPlan("sync/QA/20260305T211240Z", []string{removableWorktree}, snapshotRefs, syncBranches, worktreeBranches) + + gotDeleteRefs := append([]string(nil), plan.DeleteSnapshotRefs...) + gotRetainedRefs := append([]string(nil), plan.RetainedSnapshotRefs...) + gotDeleteBranches := make([]string, 0, len(plan.DeleteBranches)) + for _, branch := range plan.DeleteBranches { + gotDeleteBranches = append(gotDeleteBranches, branch.Name) } - if branchExists(client, "missing-branch-12345") { - t.Fatalf("expected missing-branch-12345 to not exist") + gotSkipped := make(map[string]string, len(plan.SkippedBranches)) + for _, branch := range plan.SkippedBranches { + gotSkipped[branch.Name] = branch.Reason } -} - -func TestResolveCleanTargetBranch_Fallback(t *testing.T) { - runParallelCommandTest(t) - tempDir := t.TempDir() - - setupGitRepo(t, tempDir) - chdirRepo(t, tempDir) - client, err := git.NewClient() - if err != nil { - t.Fatalf("failed to create client: %v", err) + if !reflect.DeepEqual(gotDeleteRefs, []string{ + "refs/confluence-sync/snapshots/ENG/20260305T211238Z", + }) { + t.Fatalf("DeleteSnapshotRefs = %#v", gotDeleteRefs) } - - if _, err := client.Run("commit", "--allow-empty", "-m", "init"); err != nil { - t.Fatalf("failed to commit: %v", err) + if !reflect.DeepEqual(gotRetainedRefs, []string{ + "refs/confluence-sync/snapshots/OPS/20260305T211239Z", + "refs/confluence-sync/snapshots/QA/20260305T211240Z", + }) { + t.Fatalf("RetainedSnapshotRefs = %#v", gotRetainedRefs) } - if _, err := client.Run("branch", "-M", "other-branch"); err != nil { // no main or master - t.Fatalf("failed to rename branch: %v", err) + if !reflect.DeepEqual(gotDeleteBranches, []string{ + "sync/ENG/20260305T211238Z", + }) { + t.Fatalf("DeleteBranches = %#v", gotDeleteBranches) } - - // Create fake symbolic-ref response - if _, err := client.Run("remote", "add", "origin", "https://github.com/fake/fake.git"); err != nil { - t.Fatalf("failed to add remote: %v", err) + if !strings.HasPrefix(gotSkipped["sync/OPS/20260305T211239Z"], "linked worktree remains at ") { + t.Fatalf("unexpected OPS skip reason %q", gotSkipped["sync/OPS/20260305T211239Z"]) } - - // It should fallback properly if origin exists or fallback to nothing - branch, err := resolveCleanTargetBranch(client) - if err != nil { - t.Fatalf("expected no error, got %v", err) + if gotSkipped["sync/QA/20260305T211240Z"] != "current HEAD is on this sync branch" { + t.Fatalf("unexpected QA skip reason %q", gotSkipped["sync/QA/20260305T211240Z"]) } - if branch != "" && branch != "other-branch" { - t.Logf("resolved branch: %q", branch) + if gotSkipped["sync/manual"] != "branch does not match managed sync// format" { + t.Fatalf("unexpected manual skip reason %q", gotSkipped["sync/manual"]) } } -func TestConfirmCleanActions_UI(t *testing.T) { - runParallelCommandTest(t) - // Let's test non-interactive yes by manually replacing the global - flagYes = true - defer func() { flagYes = false }() - - err := confirmCleanActions(nil, nil, "main", 1, 1) - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } +func ensureDir(path string) error { + return os.MkdirAll(path, 0o750) } diff --git a/cmd/clean_test.go b/cmd/clean_test.go index 5b8c109..9eeb3b7 100644 --- a/cmd/clean_test.go +++ b/cmd/clean_test.go @@ -10,32 +10,24 @@ import ( "testing" "github.com/rgonek/confluence-markdown-sync/internal/fs" - "github.com/rgonek/confluence-markdown-sync/internal/git" ) func TestCleanCmd(t *testing.T) { runParallelCommandTest(t) tempDir := t.TempDir() - err := os.MkdirAll(filepath.Join(tempDir, ".git"), 0700) - if err != nil { - t.Fatalf("failed to create fake git dir: %v", err) - } - worktreesDir := filepath.Join(tempDir, ".confluence-worktrees") - err = os.MkdirAll(filepath.Join(worktreesDir, "wt1"), 0700) - if err != nil { - t.Fatalf("failed to create worktree: %v", err) + if err := os.MkdirAll(filepath.Join(worktreesDir, "wt1"), 0o700); err != nil { + t.Fatalf("create worktree: %v", err) } - err = os.MkdirAll(filepath.Join(worktreesDir, "wt2"), 0700) - if err != nil { - t.Fatalf("failed to create worktree: %v", err) + if err := os.MkdirAll(filepath.Join(worktreesDir, "wt2"), 0o700); err != nil { + t.Fatalf("create worktree: %v", err) } t.Run("listCleanWorktreeDirs", func(t *testing.T) { dirs, err := listCleanWorktreeDirs(worktreesDir) if err != nil { - t.Fatalf("expected no error, got %v", err) + t.Fatalf("listCleanWorktreeDirs() error: %v", err) } if len(dirs) != 2 { t.Fatalf("expected 2 worktree dirs, got %d", len(dirs)) @@ -53,26 +45,20 @@ func TestCleanCmd(t *testing.T) { }) t.Run("normalizeCleanStates", func(t *testing.T) { - stateFile := filepath.Join(tempDir, "SPACE", ".confluence-state.json") - err = os.MkdirAll(filepath.Dir(stateFile), 0700) - if err != nil { - t.Fatalf("failed to create state dir: %v", err) + stateDir := filepath.Join(tempDir, "SPACE") + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatalf("create state dir: %v", err) } - - state := fs.SpaceState{SpaceKey: "SPACE"} - err = fs.SaveState(filepath.Dir(stateFile), state) - if err != nil { - t.Fatalf("failed to save state: %v", err) + if err := fs.SaveState(stateDir, fs.SpaceState{SpaceKey: "SPACE"}); err != nil { + t.Fatalf("save state: %v", err) } out := new(bytes.Buffer) - err = normalizeCleanStates(out, tempDir) - if err != nil { - t.Fatalf("expected no error, got %v", err) + if err := normalizeCleanStates(out, tempDir); err != nil { + t.Fatalf("normalizeCleanStates() error: %v", err) } - - if !bytes.Contains(out.Bytes(), []byte("Normalized")) { - t.Fatalf("expected 'Normalized' in output, got %q", out.String()) + if !strings.Contains(out.String(), "Normalized 1 state file(s)") { + t.Fatalf("unexpected normalize output: %q", out.String()) } }) @@ -84,12 +70,8 @@ func TestCleanCmd(t *testing.T) { flagNonInteractive = false }() - out := new(bytes.Buffer) - in := bytes.NewBufferString("\n") - - err := confirmCleanActions(in, out, "main", 1, 1) - if err != nil { - t.Fatalf("expected no error, got %v", err) + if err := confirmCleanActions(bytes.NewBufferString("\n"), new(bytes.Buffer), "main", 1, 1, 1); err != nil { + t.Fatalf("confirmCleanActions() error: %v", err) } }) @@ -98,12 +80,8 @@ func TestCleanCmd(t *testing.T) { flagNonInteractive = true defer func() { flagNonInteractive = false }() - out := new(bytes.Buffer) - in := bytes.NewBufferString("\n") - - err := confirmCleanActions(in, out, "main", 1, 1) - if err == nil { - t.Fatalf("expected error, got nil") + if err := confirmCleanActions(bytes.NewBufferString("\n"), new(bytes.Buffer), "main", 1, 1, 1); err == nil { + t.Fatal("expected error") } }) @@ -111,130 +89,138 @@ func TestCleanCmd(t *testing.T) { flagYes = false flagNonInteractive = false - out := new(bytes.Buffer) - in := bytes.NewBufferString("y\n") - - err := confirmCleanActions(in, out, "main", 1, 1) - if err != nil { - t.Fatalf("expected no error, got %v", err) + if err := confirmCleanActions(bytes.NewBufferString("yes\n"), new(bytes.Buffer), "main", 1, 1, 1); err != nil { + t.Fatalf("confirmCleanActions() error: %v", err) } }) - t.Run("confirmCleanActions interactive yes alternative", func(t *testing.T) { + t.Run("confirmCleanActions interactive no", func(t *testing.T) { flagYes = false flagNonInteractive = false - out := new(bytes.Buffer) - in := bytes.NewBufferString("yes\n") - - err := confirmCleanActions(in, out, "main", 1, 1) - if err != nil { - t.Fatalf("expected no error, got %v", err) + if err := confirmCleanActions(bytes.NewBufferString("n\n"), new(bytes.Buffer), "main", 1, 1, 1); err == nil { + t.Fatal("expected error") } }) +} - t.Run("confirmCleanActions interactive no", func(t *testing.T) { - flagYes = false - flagNonInteractive = false +func TestRunClean_RemovesSafeSyncBranchAndSnapshot(t *testing.T) { + runParallelCommandTest(t) - out := new(bytes.Buffer) - in := bytes.NewBufferString("n\n") + repo := setupGitRepoForClean(t) + chdirRepo(t, repo) + setCleanAutomationFlags(t) - err := confirmCleanActions(in, out, "main", 1, 1) - if err == nil { - t.Fatalf("expected error, got nil") - } - }) + syncBranch := "sync/ENG/20260305T211238Z" + snapshotRef := "refs/confluence-sync/snapshots/ENG/20260305T211238Z" + worktreeDir := filepath.Join(repo, ".confluence-worktrees", "ENG-20260305T211238Z") - t.Run("branchExists helper", func(t *testing.T) { - // Mock git client just enough for branchExists - client := &git.Client{} // This is risky but branchExists is small - if branchExists(client, "") { - t.Fatalf("expected false for empty branch name") - } - }) -} + runGitForClean(t, repo, "branch", syncBranch, "main") + runGitForClean(t, repo, "worktree", "add", worktreeDir, syncBranch) + head := strings.TrimSpace(runGitForClean(t, repo, "rev-parse", syncBranch)) + runGitForClean(t, repo, "update-ref", snapshotRef, head) -func TestRunClean_Integration(t *testing.T) { - // DO NOT runParallelCommandTest here because we modify global flags - repo := setupGitRepoForClean(t) + out := runCleanForTest(t) - oldWD, _ := os.Getwd() - defer func() { - _ = os.Chdir(oldWD) - }() - if err := os.Chdir(repo); err != nil { - t.Fatalf("chdir repo: %v", err) + if _, err := os.Stat(worktreeDir); !os.IsNotExist(err) { + t.Fatalf("expected worktree dir to be removed, stat err=%v", err) + } + if branchList := strings.TrimSpace(runGitForClean(t, repo, "branch", "--list", syncBranch)); branchList != "" { + t.Fatalf("expected sync branch to be deleted, got %q", branchList) + } + if refs := strings.TrimSpace(runGitForClean(t, repo, "for-each-ref", "--format=%(refname)", snapshotRef)); refs != "" { + t.Fatalf("expected snapshot ref to be deleted, got %q", refs) + } + if currentBranch := strings.TrimSpace(runGitForClean(t, repo, "rev-parse", "--abbrev-ref", "HEAD")); currentBranch != "main" { + t.Fatalf("expected to remain on main, got %q", currentBranch) } - // Create a sync branch and switch to it - runGitForClean(t, repo, "checkout", "-b", "sync/test") + if !strings.Contains(out, "Deleted snapshot ref: "+snapshotRef) { + t.Fatalf("expected snapshot deletion in output, got:\n%s", out) + } + if !strings.Contains(out, "Deleted sync branch: "+syncBranch) { + t.Fatalf("expected sync branch deletion in output, got:\n%s", out) + } + if !strings.Contains(out, "clean completed: removed 1 worktree(s), deleted 1 snapshot ref(s), deleted 1 sync branch(es), skipped 0 sync branch(es)") { + t.Fatalf("unexpected summary output:\n%s", out) + } +} + +func TestRunClean_PreservesCurrentSyncBranchRecoveryArtifacts(t *testing.T) { + runParallelCommandTest(t) - // Create a snapshot ref - head, _ := git.RunGit(repo, "rev-parse", "HEAD") - head = strings.TrimSpace(head) - snapshotRef := "refs/confluence-sync/snapshots/TEST/123" + repo := setupGitRepoForClean(t) + chdirRepo(t, repo) + setCleanAutomationFlags(t) + + syncBranch := "sync/ENG/20260305T211239Z" + snapshotRef := "refs/confluence-sync/snapshots/ENG/20260305T211239Z" + + runGitForClean(t, repo, "checkout", "-b", syncBranch) + head := strings.TrimSpace(runGitForClean(t, repo, "rev-parse", "HEAD")) runGitForClean(t, repo, "update-ref", snapshotRef, head) - // Create a dummy worktree dir - wtDir := filepath.Join(repo, ".confluence-worktrees", "test-wt") - if err := os.MkdirAll(wtDir, 0750); err != nil { - t.Fatalf("failed to create worktree dir: %v", err) + out := runCleanForTest(t) + + if currentBranch := strings.TrimSpace(runGitForClean(t, repo, "rev-parse", "--abbrev-ref", "HEAD")); currentBranch != syncBranch { + t.Fatalf("expected to stay on %s, got %q", syncBranch, currentBranch) + } + if branchList := strings.TrimSpace(runGitForClean(t, repo, "branch", "--list", syncBranch)); branchList == "" { + t.Fatalf("expected sync branch to be retained") + } + if refs := strings.TrimSpace(runGitForClean(t, repo, "for-each-ref", "--format=%(refname)", snapshotRef)); refs != snapshotRef { + t.Fatalf("expected snapshot ref to be retained, got %q", refs) } + if !strings.Contains(out, "Retained sync branch "+syncBranch+": current HEAD is on this sync branch") { + t.Fatalf("expected retained branch reason in output, got:\n%s", out) + } + if !strings.Contains(out, "clean completed: removed 0 worktree(s), deleted 0 snapshot ref(s), deleted 0 sync branch(es), skipped 1 sync branch(es)") { + t.Fatalf("unexpected summary output:\n%s", out) + } +} - // Run clean - oldYes := flagYes - oldNonInt := flagNonInteractive - flagYes = true - flagNonInteractive = true +func TestRunClean_PreservesLinkedWorktreeRecoveryArtifacts(t *testing.T) { + runParallelCommandTest(t) - // Mock outputSupportsProgress to false to avoid interactive forms - oldSupportsProgress := outputSupportsProgress - outputSupportsProgress = func(out io.Writer) bool { return false } + repo := setupGitRepoForClean(t) + chdirRepo(t, repo) + setCleanAutomationFlags(t) - defer func() { - flagYes = oldYes - flagNonInteractive = oldNonInt - outputSupportsProgress = oldSupportsProgress - }() + syncBranch := "sync/ENG/20260305T211240Z" + snapshotRef := "refs/confluence-sync/snapshots/ENG/20260305T211240Z" + activeWorktree := filepath.Join(repo, "active-recovery") - cmd := newCleanCmd() - out := new(bytes.Buffer) - cmd.SetOut(out) - cmd.SetErr(out) - // Add a trailing newline to the input to satisfy any potential reads - cmd.SetIn(strings.NewReader("y\n")) + runGitForClean(t, repo, "branch", syncBranch, "main") + runGitForClean(t, repo, "worktree", "add", activeWorktree, syncBranch) + head := strings.TrimSpace(runGitForClean(t, repo, "rev-parse", syncBranch)) + runGitForClean(t, repo, "update-ref", snapshotRef, head) - if err := runClean(cmd, nil); err != nil { - t.Fatalf("runClean failed: %v\nOutput: %s", err, out.String()) - } + out := runCleanForTest(t) - // Verify actions - client, _ := git.NewClient() - branch, _ := client.CurrentBranch() - if branch != "main" { - t.Errorf("expected branch to be main, got %q", branch) + if _, err := os.Stat(activeWorktree); err != nil { + t.Fatalf("expected linked worktree to remain: %v", err) } - - if _, err := os.Stat(wtDir); !os.IsNotExist(err) { - t.Errorf("expected worktree dir to be removed") + if branchList := strings.TrimSpace(runGitForClean(t, repo, "branch", "--list", syncBranch)); branchList == "" { + t.Fatalf("expected sync branch to be retained") } - - refs, _ := client.Run("for-each-ref", "--format=%(refname)", "refs/confluence-sync/snapshots/") - if strings.Contains(refs, snapshotRef) { - t.Errorf("expected snapshot ref to be deleted") + if refs := strings.TrimSpace(runGitForClean(t, repo, "for-each-ref", "--format=%(refname)", snapshotRef)); refs != snapshotRef { + t.Fatalf("expected snapshot ref to be retained, got %q", refs) + } + if !strings.Contains(out, "Retained sync branch "+syncBranch+": linked worktree remains at ") { + t.Fatalf("expected linked-worktree retain reason in output, got:\n%s", out) } } func setupGitRepoForClean(t *testing.T) string { t.Helper() + repo := t.TempDir() runGitForClean(t, repo, "init", "-b", "main") runGitForClean(t, repo, "config", "user.email", "clean-test@example.com") runGitForClean(t, repo, "config", "user.name", "clean-test") - if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("content"), 0600); err != nil { - t.Fatalf("failed to write file: %v", err) + if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("content"), 0o600); err != nil { + t.Fatalf("write file: %v", err) } runGitForClean(t, repo, "add", "file.txt") runGitForClean(t, repo, "commit", "-m", "initial") @@ -242,38 +228,47 @@ func setupGitRepoForClean(t *testing.T) string { return repo } -func runGitForClean(t *testing.T, dir string, args ...string) { +func runGitForClean(t *testing.T, dir string, args ...string) string { t.Helper() - cmd := exec.Command("git", args...) //nolint:gosec // Intentionally running git in test + + cmd := exec.Command("git", args...) //nolint:gosec // test helper executes git only cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %s failed: %v\n%s", args, err, string(out)) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(out)) } + return string(out) } -func TestResolveCleanTargetBranch(t *testing.T) { - repo := t.TempDir() - setupGitRepo(t, repo) +func setCleanAutomationFlags(t *testing.T) { + t.Helper() - client := &git.Client{RootDir: repo} - runGitForTest(t, repo, "commit", "--allow-empty", "-m", "init") - runGitForTest(t, repo, "branch", "-m", "foo") + previousYes := flagYes + previousNonInteractive := flagNonInteractive + previousSupportsProgress := outputSupportsProgress - target, err := resolveCleanTargetBranch(client) - if err != nil || target != "" { - t.Errorf("expected empty target branch, got %q, %v", target, err) - } + flagYes = true + flagNonInteractive = true + outputSupportsProgress = func(io.Writer) bool { return false } - runGitForTest(t, repo, "branch", "main") - target, _ = resolveCleanTargetBranch(client) - if target != "main" { - t.Errorf("expected main, got %q", target) - } + t.Cleanup(func() { + flagYes = previousYes + flagNonInteractive = previousNonInteractive + outputSupportsProgress = previousSupportsProgress + }) +} + +func runCleanForTest(t *testing.T) string { + t.Helper() - runGitForTest(t, repo, "branch", "master") - runGitForTest(t, repo, "branch", "-d", "main") - target, _ = resolveCleanTargetBranch(client) - if target != "master" { - t.Errorf("expected master, got %q", target) + cmd := newCleanCmd() + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetIn(strings.NewReader("y\n")) + + if err := runClean(cmd, nil); err != nil { + t.Fatalf("runClean() error: %v\nOutput:\n%s", err, out.String()) } + return out.String() } From c37e636631f526176da5b86b6d66c10cdfeb218b Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 13:33:29 +0100 Subject: [PATCH 12/31] Improve init env scaffolding --- README.md | 1 + cmd/init.go | 36 ++++++++++++++++++++++++++++++++---- cmd/init_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ docs/usage.md | 1 + 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7ccca2a..51fa4e1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ conf init ``` `conf init` prepares Git metadata, `.gitignore`, and `.env` scaffolding, and creates an initial commit when it initializes a new Git repository. +If `ATLASSIAN_*` or legacy `CONFLUENCE_*` credentials are already set in the environment, `conf init` writes `.env` from them without prompting. `conf pull` mirrors Confluence hierarchy locally by placing folders and child pages in nested directories. Pages with children use `/.md` so they are distinct from pure folders. diff --git a/cmd/init.go b/cmd/init.go index b3aee15..a01503d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,6 +2,7 @@ package cmd import ( "bufio" + "errors" "fmt" "os" "os/exec" @@ -9,6 +10,7 @@ import ( "strings" "github.com/charmbracelet/huh" + "github.com/rgonek/confluence-markdown-sync/internal/config" "github.com/spf13/cobra" ) @@ -293,6 +295,13 @@ func ensureDotEnv(cmd *cobra.Command) (bool, error) { } out := cmd.OutOrStdout() + if cfg, ok, err := loadInitEnvScaffoldConfig(); err != nil { + return false, err + } else if ok { + _, _ = fmt.Fprintln(out, "\nNo .env file found. Scaffolding it from existing Atlassian environment variables.") + return true, writeDotEnvFile(*cfg) + } + _, _ = fmt.Fprintln(out, "\nNo .env file found. Please enter your Atlassian credentials.") var domain, email, token string @@ -325,14 +334,33 @@ func ensureDotEnv(cmd *cobra.Command) (bool, error) { token = promptField(scanner, out, "ATLASSIAN_API_TOKEN") } + return true, writeDotEnvFile(config.Config{ + Domain: domain, + Email: email, + APIToken: token, + }) +} + +func loadInitEnvScaffoldConfig() (*config.Config, bool, error) { + cfg, err := config.Load("") + if err == nil { + return cfg, true, nil + } + if errors.Is(err, config.ErrMissingConfig) { + return nil, false, nil + } + return nil, false, fmt.Errorf("resolve environment-backed auth for .env scaffolding: %w", err) +} + +func writeDotEnvFile(cfg config.Config) error { lines := []string{ "# Atlassian / Confluence credentials", - fmt.Sprintf("ATLASSIAN_DOMAIN=%s", strings.TrimRight(domain, "/")), - fmt.Sprintf("ATLASSIAN_EMAIL=%s", email), - fmt.Sprintf("ATLASSIAN_API_TOKEN=%s", token), + fmt.Sprintf("ATLASSIAN_DOMAIN=%s", strings.TrimRight(cfg.Domain, "/")), + fmt.Sprintf("ATLASSIAN_EMAIL=%s", strings.TrimSpace(cfg.Email)), + fmt.Sprintf("ATLASSIAN_API_TOKEN=%s", strings.TrimSpace(cfg.APIToken)), } - return true, os.WriteFile(".env", []byte(strings.Join(lines, "\n")+"\n"), 0o600) //nolint:gosec // Writing static filename + return os.WriteFile(".env", []byte(strings.Join(lines, "\n")+"\n"), 0o600) //nolint:gosec // Writing static filename } func promptField(scanner *bufio.Scanner, out interface{ Write([]byte) (int, error) }, label string) string { diff --git a/cmd/init_test.go b/cmd/init_test.go index b9a3d8d..9c30942 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -76,3 +76,50 @@ func TestRunInit_DoesNotCreateCommitInsideExistingRepo(t *testing.T) { t.Fatalf("HEAD changed unexpectedly for existing repo: before=%s after=%s", headBefore, headAfter) } } + +func TestRunInit_ScaffoldsDotEnvFromExistingEnvironmentWithoutPrompt(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + chdirRepo(t, repo) + + t.Setenv("ATLASSIAN_DOMAIN", "https://env-example.atlassian.net/") + t.Setenv("ATLASSIAN_EMAIL", "env-user@example.com") + t.Setenv("ATLASSIAN_API_TOKEN", "env-token-123") + t.Setenv("GIT_AUTHOR_NAME", "conf-test") + t.Setenv("GIT_AUTHOR_EMAIL", "conf-test@example.com") + t.Setenv("GIT_COMMITTER_NAME", "conf-test") + t.Setenv("GIT_COMMITTER_EMAIL", "conf-test@example.com") + + cmd := newInitCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetIn(strings.NewReader("")) + + if err := runInit(cmd, nil); err != nil { + t.Fatalf("runInit() error: %v", err) + } + + dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) + if err != nil { + t.Fatalf("read .env: %v", err) + } + + dotEnv := string(dotEnvRaw) + if !strings.Contains(dotEnv, "ATLASSIAN_DOMAIN=https://env-example.atlassian.net\n") { + t.Fatalf(".env missing normalized domain:\n%s", dotEnv) + } + if !strings.Contains(dotEnv, "ATLASSIAN_EMAIL=env-user@example.com\n") { + t.Fatalf(".env missing email:\n%s", dotEnv) + } + if !strings.Contains(dotEnv, "ATLASSIAN_API_TOKEN=env-token-123\n") { + t.Fatalf(".env missing API token:\n%s", dotEnv) + } + + output := out.String() + if !strings.Contains(output, "Scaffolding it from existing Atlassian environment variables.") { + t.Fatalf("expected env-backed scaffolding message, got:\n%s", output) + } + if strings.Contains(output, "Please enter your Atlassian credentials") { + t.Fatalf("did not expect interactive credential prompt when env is complete:\n%s", output) + } +} diff --git a/docs/usage.md b/docs/usage.md index a0ee1b6..755cc18 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -55,6 +55,7 @@ conf init - initialize Git when missing, - ensure `.gitignore` entries, - create `.env` when needed, +- scaffold `.env` directly from already-set `ATLASSIAN_*` / `CONFLUENCE_*` variables without prompting, - scaffold helper files, - create an initial commit when a new Git repository is initialized. From fe507e44bab2382e54fa4e14976146a61fcfc974 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 13:37:03 +0100 Subject: [PATCH 13/31] Clarify status scope and diff metadata --- AGENTS.md | 1 + README.md | 3 +- cmd/diff.go | 4 ++ cmd/diff_render.go | 36 ++++++++++++++++ cmd/diff_test.go | 66 +++++++++++++++++++++++++++++ cmd/pull_testhelpers_test.go | 12 +++++- cmd/status.go | 3 ++ cmd/status_run_test.go | 82 ++++++++++++++++++++++++++++++++++++ docs/usage.md | 15 +++++++ 9 files changed, 219 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c415842..f4d34a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ Validation failures must stop `push` immediately. ## Command Model - Commands: `init`, `pull`, `push`, `validate`, `diff`, `search`. +- `status` reports Markdown page drift only; attachment-only changes should be checked with `git status` or `conf diff`. - `[TARGET]` parsing rule: - Ends with `.md` => file mode. - Otherwise => space mode (`SPACE_KEY`). diff --git a/README.md b/README.md index 51fa4e1..c1c4b16 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,12 @@ conf push ENG --on-conflict=cancel ``` ## At a glance 👀 -- Commands: `init`, `init agents [TARGET]`, `pull [TARGET]`, `push [TARGET]`, `validate [TARGET]`, `diff [TARGET]`, `relink [TARGET]` +- Commands: `init`, `init agents [TARGET]`, `pull [TARGET]`, `push [TARGET]`, `status [TARGET]`, `validate [TARGET]`, `diff [TARGET]`, `relink [TARGET]` - Version: `conf version` or `conf --version` - Target rule: `.md` suffix means file mode; otherwise space mode (`SPACE_KEY`) - Required auth: `ATLASSIAN_DOMAIN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_API_TOKEN` - Diagram support: PlantUML is preserved as a Confluence extension; Mermaid is preserved as fenced code / ADF `codeBlock` and `validate` warns before push +- Status scope: `conf status` reports Markdown page drift only; use `git status` or `conf diff` for attachment-only changes - Label rules: labels are trimmed, lowercased, deduplicated, and sorted; empty labels and labels containing whitespace are rejected - Git remote is optional (local Git is enough) diff --git a/cmd/diff.go b/cmd/diff.go index 3314bea..b119427 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -226,6 +226,7 @@ func runDiffFileMode( return fmt.Errorf("fetch page %s: %w", diffCtx.targetPageID, err) } + page, metadataDiags := hydrateDiffPageMetadata(ctx, remote, page, relPath) rendered, diagnostics, err := renderDiffMarkdown( ctx, page, @@ -238,6 +239,7 @@ func runDiffFileMode( if err != nil { return err } + diagnostics = append(metadataDiags, diagnostics...) for _, diag := range diagnostics { if _, err := fmt.Fprintf(out, "warning: %s [%s] %s\n", diag.Path, diag.Code, diag.Message); err != nil { @@ -302,6 +304,7 @@ func runDiffSpaceMode( return fmt.Errorf("planned relative path missing for page %s", page.ID) } + page, metadataDiags := hydrateDiffPageMetadata(ctx, remote, page, relPath) rendered, pageDiags, err := renderDiffMarkdown( ctx, page, @@ -314,6 +317,7 @@ func runDiffSpaceMode( if err != nil { return err } + pageDiags = append(metadataDiags, pageDiags...) diagnostics = append(diagnostics, pageDiags...) dstPath := filepath.Join(remoteSnapshot, filepath.FromSlash(relPath)) diff --git a/cmd/diff_render.go b/cmd/diff_render.go index 7d257ea..f726cdd 100644 --- a/cmd/diff_render.go +++ b/cmd/diff_render.go @@ -14,6 +14,39 @@ import ( "github.com/rgonek/confluence-markdown-sync/internal/fs" ) +func hydrateDiffPageMetadata( + ctx context.Context, + remote syncflow.PullRemote, + page confluence.Page, + relPath string, +) (confluence.Page, []syncflow.PullDiagnostic) { + diagnostics := make([]syncflow.PullDiagnostic, 0, 2) + + status, err := remote.GetContentStatus(ctx, page.ID, page.Status) + if err != nil { + diagnostics = append(diagnostics, syncflow.PullDiagnostic{ + Path: filepath.ToSlash(relPath), + Code: "CONTENT_STATUS_FETCH_FAILED", + Message: fmt.Sprintf("fetch content status for page %s: %v", page.ID, err), + }) + } else { + page.ContentStatus = status + } + + labels, err := remote.GetLabels(ctx, page.ID) + if err != nil { + diagnostics = append(diagnostics, syncflow.PullDiagnostic{ + Path: filepath.ToSlash(relPath), + Code: "LABELS_FETCH_FAILED", + Message: fmt.Sprintf("fetch labels for page %s: %v", page.ID, err), + }) + } else { + page.Labels = labels + } + + return page, diagnostics +} + func renderDiffMarkdown( ctx context.Context, page confluence.Page, @@ -36,6 +69,9 @@ func renderDiffMarkdown( Title: page.Title, ID: page.ID, Version: page.Version, + State: page.Status, + Status: page.ContentStatus, + Labels: page.Labels, }, Body: forward.Markdown, } diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 8c38f52..76841cc 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -442,6 +442,72 @@ func TestRunDiff_FileModeIgnoresMetadataOnlyChanges(t *testing.T) { } } +func TestRunDiff_FileModeShowsSyncedMetadataParity(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + localFile := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 2, + }, + Body: "same body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Status: "draft", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "draft", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("same body")), + }, + }, + attachments: map[string][]byte{}, + contentStatusByID: map[string]string{"1": "Ready to review"}, + labelsByPage: map[string][]string{"1": []string{"beta", "alpha"}}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "+state: draft") { + t.Fatalf("expected state metadata diff, got:\n%s", got) + } + if !strings.Contains(got, "+status: Ready to review") { + t.Fatalf("expected content-status diff, got:\n%s", got) + } + if !strings.Contains(got, "+labels:") || !strings.Contains(got, "+ - alpha") || !strings.Contains(got, "+ - beta") { + t.Fatalf("expected normalized labels diff, got:\n%s", got) + } +} + func diffUnresolvedADF() map[string]any { return map[string]any{ "version": 1, diff --git a/cmd/pull_testhelpers_test.go b/cmd/pull_testhelpers_test.go index f5b7080..646ce29 100644 --- a/cmd/pull_testhelpers_test.go +++ b/cmd/pull_testhelpers_test.go @@ -55,6 +55,8 @@ type cmdFakePullRemote struct { pagesByID map[string]confluence.Page attachments map[string][]byte attachmentsByPage map[string][]confluence.Attachment + contentStatusByID map[string]string + labelsByPage map[string][]string } func (f *cmdFakePullRemote) GetUser(_ context.Context, accountID string) (confluence.User, error) { @@ -99,11 +101,17 @@ func (f *cmdFakePullRemote) GetPage(_ context.Context, pageID string) (confluenc } func (f *cmdFakePullRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { - return "", nil + if f.contentStatusByID == nil { + return "", nil + } + return f.contentStatusByID[pageID], nil } func (f *cmdFakePullRemote) GetLabels(_ context.Context, pageID string) ([]string, error) { - return nil, nil + if f.labelsByPage == nil { + return nil, nil + } + return append([]string(nil), f.labelsByPage[pageID]...), nil } func (f *cmdFakePullRemote) ListAttachments(_ context.Context, pageID string) ([]confluence.Attachment, error) { diff --git a/cmd/status.go b/cmd/status.go index 71d063f..d7bae52 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -36,6 +36,8 @@ type StatusReport struct { MaxVersionDrift int } +const statusScopeNote = "Scope: markdown pages only; attachment-only drift is excluded from `conf status` output. Use `git status` or `conf diff` to inspect assets." + var newStatusRemote = func(cfg *config.Config) (StatusRemote, error) { return newConfluenceClientFromConfig(cfg) } @@ -127,6 +129,7 @@ func runStatus(cmd *cobra.Command, target config.Target) error { _, _ = fmt.Fprintf(out, "Space: %s\n", spaceKey) _, _ = fmt.Fprintf(out, "Directory: %s\n", initialCtx.spaceDir) + _, _ = fmt.Fprintf(out, "Note: %s\n", statusScopeNote) printStatusSection(out, "Local not pushed", report.LocalAdded, report.LocalModified, report.LocalDeleted) printStatusSection(out, "Remote not pulled", report.RemoteAdded, report.RemoteModified, report.RemoteDeleted) diff --git a/cmd/status_run_test.go b/cmd/status_run_test.go index 0ae69dd..6109b0d 100644 --- a/cmd/status_run_test.go +++ b/cmd/status_run_test.go @@ -6,7 +6,9 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" + "time" "github.com/rgonek/confluence-markdown-sync/internal/config" "github.com/rgonek/confluence-markdown-sync/internal/confluence" @@ -195,3 +197,83 @@ func TestBuildStatusReport_Drift(t *testing.T) { ctx := context.Background() _, _ = buildStatusReport(ctx, mock, target, initialPullContext{}, fs.SpaceState{}, "TEST", "filterPath") } + +func TestRunStatus_ExplainsMarkdownOnlyScopeForAssetDrift(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + setupGitRepo(t, repo) + chdirRepo(t, repo) + setupEnv(t) + + spaceDir := filepath.Join(repo, "TEST") + if err := os.MkdirAll(filepath.Join(spaceDir, "assets", "1"), 0o750); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "page.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Page", + ID: "1", + Version: 1, + }, + Body: "body\n", + }) + if err := os.WriteFile(filepath.Join(spaceDir, "assets", "1", "file.png"), []byte("png"), 0o600); err != nil { + t.Fatalf("write asset: %v", err) + } + if err := fs.SaveState(spaceDir, fs.SpaceState{ + SpaceKey: "TEST", + PagePathIndex: map[string]string{ + "page.md": "1", + }, + AttachmentIndex: map[string]string{ + "assets/1/file.png": "att-1", + }, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + runGitForStatus(t, repo, "add", ".") + runGitForStatus(t, repo, "commit", "-m", "baseline") + tagTime := time.Now().UTC().Format("20060102T150405Z") + runGitForStatus(t, repo, "tag", "-a", "confluence-sync/pull/TEST/"+tagTime, "-m", "pull") + + if err := os.Remove(filepath.Join(spaceDir, "assets", "1", "file.png")); err != nil { + t.Fatalf("remove asset: %v", err) + } + + mock := &mockStatusRemote{ + space: confluence.Space{ID: "space-1", Key: "TEST"}, + pages: confluence.PageListResult{ + Pages: []confluence.Page{ + {ID: "1", Title: "Page", Version: 1}, + }, + }, + page: confluence.Page{ID: "1", SpaceID: "space-1", Status: "current"}, + } + + oldNewStatusRemote := newStatusRemote + newStatusRemote = func(cfg *config.Config) (StatusRemote, error) { + return mock, nil + } + defer func() { newStatusRemote = oldNewStatusRemote }() + + cmd := newStatusCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runStatus(cmd, config.Target{Value: "TEST", Mode: config.TargetModeSpace}); err != nil { + t.Fatalf("runStatus() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, statusScopeNote) { + t.Fatalf("expected markdown-only scope note, got:\n%s", got) + } + if strings.Contains(got, "assets/1/file.png") { + t.Fatalf("status output should not list attachment-only drift, got:\n%s", got) + } + if !strings.Contains(got, "added (0)") || !strings.Contains(got, "modified (0)") || !strings.Contains(got, "deleted (0)") { + t.Fatalf("expected clean markdown status for asset-only drift, got:\n%s", got) + } +} diff --git a/docs/usage.md b/docs/usage.md index 755cc18..f83332a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -8,6 +8,7 @@ This guide covers day-to-day usage of `conf`. - `pull` converts Confluence ADF to Markdown. - `push` converts Markdown back to ADF and updates Confluence. +- `status` inspects Markdown page drift against the last sync baseline and current remote state. - `validate` checks a workspace before remote writes. - `diff` previews local vs remote content. - `init agents` scaffolds an `AGENTS.md` file for AI-assisted authoring. @@ -114,6 +115,18 @@ Validation also emits non-fatal compatibility warnings for content that will syn Use this before major pushes or in CI. +### `conf status [TARGET]` + +Shows a high-level sync summary for Markdown pages. + +Highlights: + +- compares local Markdown drift against the last sync baseline, +- checks whether tracked remote pages are ahead, missing, or newly added, +- focuses on Markdown page files only. + +Attachment-only changes are intentionally excluded from `conf status`. Use `git status` or `conf diff` when you need asset visibility. + ### `conf diff [TARGET]` Shows a local-vs-remote diff. @@ -122,6 +135,8 @@ Highlights: - fetches remote content, - converts using best-effort forward conversion, +- includes synced frontmatter parity such as `state`, `status`, and `labels`, +- strips read-only author/timestamp metadata so the diff stays focused on actionable drift, - compares using `git diff --no-index`, - supports both file and space targets. From 8ce4deba3a219ce28c2b938f144ba9c6f31d2345 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 13:40:48 +0100 Subject: [PATCH 14/31] Polish workflow diagnostics --- cmd/diagnostics.go | 42 +++++++++ cmd/diagnostics_test.go | 53 ++++++++++++ cmd/diff.go | 6 +- cmd/pull.go | 5 +- cmd/pull_state.go | 4 +- docs/usage.md | 1 + internal/sync/push.go | 6 +- internal/sync/push_folder_logging.go | 100 ++++++++++++++++++++++ internal/sync/push_folder_logging_test.go | 45 ++++++++++ internal/sync/push_hierarchy.go | 4 +- internal/sync/push_types.go | 1 + 11 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 cmd/diagnostics.go create mode 100644 cmd/diagnostics_test.go create mode 100644 internal/sync/push_folder_logging.go create mode 100644 internal/sync/push_folder_logging_test.go diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go new file mode 100644 index 0000000..1cde8fb --- /dev/null +++ b/cmd/diagnostics.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + "io" + "strings" + + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" +) + +func writeSyncDiagnostic(out io.Writer, diag syncflow.PullDiagnostic) error { + _, err := io.WriteString(out, formatSyncDiagnostic(diag)) + return err +} + +func formatSyncDiagnostic(diag syncflow.PullDiagnostic) string { + level, qualifier := classifySyncDiagnostic(diag.Code) + message := strings.TrimSpace(diag.Message) + if qualifier != "" { + message = qualifier + ": " + message + } + return fmt.Sprintf("%s: %s [%s] %s\n", level, diag.Path, diag.Code, message) +} + +func classifySyncDiagnostic(code string) (level string, qualifier string) { + switch strings.TrimSpace(code) { + case "CROSS_SPACE_LINK_PRESERVED": + return "note", "no action required" + case "unresolved_reference": + return "warning", "broken reference preserved as fallback output" + case "FOLDER_LOOKUP_UNAVAILABLE", + "CONTENT_STATUS_FETCH_FAILED", + "LABELS_FETCH_FAILED", + "UNKNOWN_MEDIA_ID_LOOKUP_FAILED", + "UNKNOWN_MEDIA_ID_RESOLVED", + "UNKNOWN_MEDIA_ID_UNRESOLVED", + "ATTACHMENT_DOWNLOAD_SKIPPED": + return "warning", "degraded but pullable content" + default: + return "warning", "" + } +} diff --git a/cmd/diagnostics_test.go b/cmd/diagnostics_test.go new file mode 100644 index 0000000..d1c7d40 --- /dev/null +++ b/cmd/diagnostics_test.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "strings" + "testing" + + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" +) + +func TestFormatSyncDiagnostic_Classification(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + diag syncflow.PullDiagnostic + wantStart string + wantText string + }{ + { + name: "preserved cross-space link is a note", + diag: syncflow.PullDiagnostic{Path: "page.md", Code: "CROSS_SPACE_LINK_PRESERVED", Message: "preserved absolute cross-space link"}, + wantStart: "note: page.md [CROSS_SPACE_LINK_PRESERVED]", + wantText: "no action required", + }, + { + name: "unresolved reference is called broken fallback", + diag: syncflow.PullDiagnostic{Path: "page.md", Code: "unresolved_reference", Message: "page id 404 could not be resolved"}, + wantStart: "warning: page.md [unresolved_reference]", + wantText: "broken reference preserved as fallback output", + }, + { + name: "folder fallback is marked degraded but pullable", + diag: syncflow.PullDiagnostic{Path: "folder-1", Code: "FOLDER_LOOKUP_UNAVAILABLE", Message: "falling back to page-only hierarchy"}, + wantStart: "warning: folder-1 [FOLDER_LOOKUP_UNAVAILABLE]", + wantText: "degraded but pullable content", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := formatSyncDiagnostic(tc.diag) + if !strings.HasPrefix(got, tc.wantStart) { + t.Fatalf("unexpected prefix:\n%s", got) + } + if !strings.Contains(got, tc.wantText) { + t.Fatalf("expected %q in:\n%s", tc.wantText, got) + } + }) + } +} diff --git a/cmd/diff.go b/cmd/diff.go index b119427..5ed823c 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -147,7 +147,7 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { return err } for _, diag := range folderDiags { - if _, err := fmt.Fprintf(out, "warning: %s [%s] %s\n", diag.Path, diag.Code, diag.Message); err != nil { + if err := writeSyncDiagnostic(out, diag); err != nil { return fmt.Errorf("write diagnostic output: %w", err) } } @@ -242,7 +242,7 @@ func runDiffFileMode( diagnostics = append(metadataDiags, diagnostics...) for _, diag := range diagnostics { - if _, err := fmt.Fprintf(out, "warning: %s [%s] %s\n", diag.Path, diag.Code, diag.Message); err != nil { + if err := writeSyncDiagnostic(out, diag); err != nil { return fmt.Errorf("write diagnostic output: %w", err) } } @@ -330,7 +330,7 @@ func runDiffSpaceMode( } for _, diag := range diagnostics { - if _, err := fmt.Fprintf(out, "warning: %s [%s] %s\n", diag.Path, diag.Code, diag.Message); err != nil { + if err := writeSyncDiagnostic(out, diag); err != nil { return fmt.Errorf("write diagnostic output: %w", err) } } diff --git a/cmd/pull.go b/cmd/pull.go index 4a27d04..a077778 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -288,8 +288,9 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { } for _, diag := range result.Diagnostics { - - _, _ = fmt.Fprintf(out, "warning: %s [%s] %s\n", diag.Path, diag.Code, diag.Message) + if err := writeSyncDiagnostic(out, diag); err != nil { + return fmt.Errorf("write diagnostic output: %w", err) + } } hasChanges := false diff --git a/cmd/pull_state.go b/cmd/pull_state.go index db18339..4cfa4ee 100644 --- a/cmd/pull_state.go +++ b/cmd/pull_state.go @@ -40,7 +40,9 @@ func loadPullStateWithHealing( } for _, diag := range diagnostics { - _, _ = fmt.Fprintf(out, "warning: %s [%s] %s\n", diag.Path, diag.Code, diag.Message) + if err := writeSyncDiagnostic(out, diag); err != nil { + return fs.SpaceState{}, fmt.Errorf("write diagnostic output: %w", err) + } } _, _ = fmt.Fprintln(out, "State file healed successfully.") diff --git a/docs/usage.md b/docs/usage.md index f83332a..2285af0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -89,6 +89,7 @@ Pulls remote Confluence content into local Markdown. Highlights: - best-effort conversion (unresolved references become diagnostics), +- diagnostics distinguish preserved cross-space links (`note`), degraded-but-pullable fallbacks, and broken references left as fallback output, - page files follow Confluence hierarchy (folders and parent/child pages become nested directories), - pages that have children are written as `/.md` so they are distinguishable from folders, - same-space links rewritten to relative Markdown links, diff --git a/internal/sync/push.go b/internal/sync/push.go index 60a5bd7..02c050a 100644 --- a/internal/sync/push.go +++ b/internal/sync/push.go @@ -36,6 +36,7 @@ func Push(ctx context.Context, remote PushRemote, opts PushOptions) (PushResult, state := normalizePushState(opts.State) policy := normalizeConflictPolicy(opts.ConflictPolicy) + opts.folderListTracker = newFolderListFallbackTracker() space, err := remote.GetSpace(ctx, opts.SpaceKey) if err != nil { @@ -53,11 +54,10 @@ func Push(ctx context.Context, remote PushRemote, opts PushOptions) (PushResult, } // Try to list folders, but don't fail the whole push if it's broken (Confluence bug) - remoteFolders, err := listAllPushFolders(ctx, remote, confluence.FolderListOptions{ + remoteFolders, err := listAllPushFoldersWithTracking(ctx, remote, confluence.FolderListOptions{ SpaceID: space.ID, - }) + }, opts.folderListTracker, "space-scan") if err != nil { - slog.Warn("list_folders_failed_falling_back_to_pages", "error", err.Error()) remoteFolders = nil } diff --git a/internal/sync/push_folder_logging.go b/internal/sync/push_folder_logging.go new file mode 100644 index 0000000..9ccdf10 --- /dev/null +++ b/internal/sync/push_folder_logging.go @@ -0,0 +1,100 @@ +package sync + +import ( + "context" + "errors" + "log/slog" + "strconv" + "strings" + "sync" + + "github.com/rgonek/confluence-markdown-sync/internal/confluence" +) + +type folderListFallbackTracker struct { + mu sync.Mutex + seen map[string]folderListFallbackState +} + +type folderListFallbackState struct { + count int + suppressionAnnounced bool +} + +func newFolderListFallbackTracker() *folderListFallbackTracker { + return &folderListFallbackTracker{ + seen: map[string]folderListFallbackState{}, + } +} + +func (t *folderListFallbackTracker) Report(scope string, err error) { + if t == nil || err == nil { + return + } + + scope = strings.TrimSpace(scope) + if scope == "" { + scope = "space-scan" + } + + key := folderListFallbackFingerprint(err) + + t.mu.Lock() + state := t.seen[key] + state.count++ + + firstOccurrence := state.count == 1 + announceSuppression := false + if !firstOccurrence && !state.suppressionAnnounced { + state.suppressionAnnounced = true + announceSuppression = true + } + t.seen[key] = state + t.mu.Unlock() + + if firstOccurrence { + slog.Warn( + "folder_list_unavailable_falling_back_to_pages", + "scope", scope, + "error", err.Error(), + "note", "continuing with page-based hierarchy fallback; repeated folder-list failures in this push will be suppressed", + ) + return + } + + if announceSuppression { + slog.Info( + "folder_list_unavailable_repeats_suppressed", + "scope", scope, + "error", err.Error(), + "repeat_count", state.count-1, + ) + } +} + +func folderListFallbackFingerprint(err error) string { + var apiErr *confluence.APIError + if errors.As(err, &apiErr) { + return strings.Join([]string{ + strings.TrimSpace(apiErr.Method), + strings.TrimSpace(apiErr.URL), + strconv.Itoa(apiErr.StatusCode), + strings.TrimSpace(apiErr.Message), + }, "|") + } + return strings.TrimSpace(err.Error()) +} + +func listAllPushFoldersWithTracking( + ctx context.Context, + remote PushRemote, + opts confluence.FolderListOptions, + tracker *folderListFallbackTracker, + scope string, +) ([]confluence.Folder, error) { + folders, err := listAllPushFolders(ctx, remote, opts) + if err != nil { + tracker.Report(scope, err) + } + return folders, err +} diff --git a/internal/sync/push_folder_logging_test.go b/internal/sync/push_folder_logging_test.go new file mode 100644 index 0000000..8f8c344 --- /dev/null +++ b/internal/sync/push_folder_logging_test.go @@ -0,0 +1,45 @@ +package sync + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/rgonek/confluence-markdown-sync/internal/confluence" +) + +func TestFolderListFallbackTracker_SuppressesRepeatedWarnings(t *testing.T) { + t.Parallel() + + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + tracker := newFolderListFallbackTracker() + err := &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders", + Message: "Internal Server Error", + } + + tracker.Report("space-scan", err) + tracker.Report("Parent/Child", err) + tracker.Report("Parent/Grandchild", err) + + got := logs.String() + if strings.Count(got, "folder_list_unavailable_falling_back_to_pages") != 1 { + t.Fatalf("expected one warning log, got:\n%s", got) + } + if strings.Count(got, "folder_list_unavailable_repeats_suppressed") != 1 { + t.Fatalf("expected one suppression log, got:\n%s", got) + } + if strings.Contains(got, "repeat_count=2") { + t.Fatalf("suppression log should only be emitted once, got:\n%s", got) + } + if !strings.Contains(got, "page-based hierarchy fallback") { + t.Fatalf("expected clearer fallback note, got:\n%s", got) + } +} diff --git a/internal/sync/push_hierarchy.go b/internal/sync/push_hierarchy.go index 6a07da9..8458a7e 100644 --- a/internal/sync/push_hierarchy.go +++ b/internal/sync/push_hierarchy.go @@ -133,10 +133,10 @@ func ensureFolderHierarchy( // 2. If not found and it's a conflict, try robust listing if !foundExisting && strings.Contains(err.Error(), "400") && (strings.Contains(strings.ToLower(err.Error()), "folder exists with the same title") || strings.Contains(strings.ToLower(err.Error()), "already exists with the same title")) { - folders, listErr := listAllPushFolders(ctx, remote, confluence.FolderListOptions{ + folders, listErr := listAllPushFoldersWithTracking(ctx, remote, confluence.FolderListOptions{ SpaceID: spaceID, Title: seg, - }) + }, opts.folderListTracker, currentPath) if listErr == nil { for _, f := range folders { if strings.EqualFold(strings.TrimSpace(f.Title), strings.TrimSpace(seg)) { diff --git a/internal/sync/push_types.go b/internal/sync/push_types.go index 9678283..7bdd33f 100644 --- a/internal/sync/push_types.go +++ b/internal/sync/push_types.go @@ -76,6 +76,7 @@ type PushOptions struct { ArchiveTimeout time.Duration ArchivePollInterval time.Duration Progress Progress + folderListTracker *folderListFallbackTracker } // PushCommitPlan describes local paths and metadata for one push commit. From 6c5f90a1b4e5055458c478384450886d26233b87 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 14:13:16 +0100 Subject: [PATCH 15/31] Align AGENTS command model --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f4d34a3..31a16c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ The agent manages the full sync cycle. Validation failures must stop `push` immediately. ## Command Model -- Commands: `init`, `pull`, `push`, `validate`, `diff`, `search`. +- Commands: `init`, `pull`, `push`, `status`, `clean`, `prune`, `validate`, `diff`, `relink`, `version`, `doctor`, `search`. - `status` reports Markdown page drift only; attachment-only changes should be checked with `git status` or `conf diff`. - `[TARGET]` parsing rule: - Ends with `.md` => file mode. From 2128910620da41a8d8c3082364fb768756e7ee52 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 14:40:42 +0100 Subject: [PATCH 16/31] Polish init status and clean flows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...26-03-05-live-workflow-polish-followups.md | 6 + cmd/clean.go | 2 +- cmd/clean_test.go | 20 +++ cmd/init_test.go | 96 ++++++++++ cmd/push_changes.go | 14 +- cmd/push_changes_test.go | 81 +++++++++ cmd/status.go | 4 +- cmd/status_run_test.go | 168 ++++++++++++++++++ 8 files changed, 385 insertions(+), 6 deletions(-) create mode 100644 cmd/push_changes_test.go diff --git a/agents/plans/2026-03-05-live-workflow-polish-followups.md b/agents/plans/2026-03-05-live-workflow-polish-followups.md index 8b2edf6..fabc5bd 100644 --- a/agents/plans/2026-03-05-live-workflow-polish-followups.md +++ b/agents/plans/2026-03-05-live-workflow-polish-followups.md @@ -10,6 +10,12 @@ Capture the non-blocking but high-value workflow, diagnostics, and operator-expe - The remediation plan covers production blockers. - This plan covers workflow smoothness, clarity, and maintainability improvements that should follow once the blocking correctness issues are under control. +## Implementation Progress + +- [x] Batch 1 completed: items 1, 2, 5, and 7 are closed with regression coverage on this branch. +- [x] Item 8 was re-verified as already complete on this branch. +- [ ] Remaining items: 3, 4, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17, and 18. + ## Improvements ### 1. Non-Interactive `init` Should Respect Existing Environment Auth diff --git a/cmd/clean.go b/cmd/clean.go index b6cdd35..f0e7299 100644 --- a/cmd/clean.go +++ b/cmd/clean.go @@ -91,7 +91,7 @@ func runClean(cmd *cobra.Command, _ []string) error { } reportSkippedCleanSyncBranches(out, syncPlan.SkippedBranches) if len(syncPlan.SkippedBranches) == 0 { - _, _ = fmt.Fprintln(out, "clean completed: workspace is already clean") + _, _ = fmt.Fprintln(out, "clean completed: workspace is already clean (removed 0 worktree(s), deleted 0 snapshot ref(s), deleted 0 sync branch(es), skipped 0 sync branch(es))") } else { _, _ = fmt.Fprintf(out, "clean completed: removed 0 worktree(s), deleted 0 snapshot ref(s), deleted 0 sync branch(es), skipped %d sync branch(es)\n", len(syncPlan.SkippedBranches)) } diff --git a/cmd/clean_test.go b/cmd/clean_test.go index 9eeb3b7..4f6c946 100644 --- a/cmd/clean_test.go +++ b/cmd/clean_test.go @@ -211,6 +211,26 @@ func TestRunClean_PreservesLinkedWorktreeRecoveryArtifacts(t *testing.T) { } } +func TestRunClean_AlreadyCleanReportsExplicitNoopSummary(t *testing.T) { + runParallelCommandTest(t) + + repo := setupGitRepoForClean(t) + chdirRepo(t, repo) + setCleanAutomationFlags(t) + + out := runCleanForTest(t) + + if strings.Contains(out, "Deleted snapshot ref:") { + t.Fatalf("expected no snapshot refs to be deleted, got:\n%s", out) + } + if strings.Contains(out, "Deleted sync branch:") { + t.Fatalf("expected no sync branches to be deleted, got:\n%s", out) + } + if !strings.Contains(out, "clean completed: workspace is already clean (removed 0 worktree(s), deleted 0 snapshot ref(s), deleted 0 sync branch(es), skipped 0 sync branch(es))") { + t.Fatalf("unexpected summary output:\n%s", out) + } +} + func setupGitRepoForClean(t *testing.T) string { t.Helper() diff --git a/cmd/init_test.go b/cmd/init_test.go index 9c30942..9f2b9d9 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -123,3 +123,99 @@ func TestRunInit_ScaffoldsDotEnvFromExistingEnvironmentWithoutPrompt(t *testing. t.Fatalf("did not expect interactive credential prompt when env is complete:\n%s", output) } } + +func TestRunInit_PartialEnvironmentStillPromptsForCredentials(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + chdirRepo(t, repo) + + t.Setenv("CONFLUENCE_URL", "") + t.Setenv("CONFLUENCE_EMAIL", "") + t.Setenv("CONFLUENCE_API_TOKEN", "") + t.Setenv("ATLASSIAN_DOMAIN", "https://env-example.atlassian.net") + t.Setenv("ATLASSIAN_EMAIL", "") + t.Setenv("ATLASSIAN_API_TOKEN", "") + t.Setenv("GIT_AUTHOR_NAME", "conf-test") + t.Setenv("GIT_AUTHOR_EMAIL", "conf-test@example.com") + t.Setenv("GIT_COMMITTER_NAME", "conf-test") + t.Setenv("GIT_COMMITTER_EMAIL", "conf-test@example.com") + + cmd := newInitCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetIn(strings.NewReader("https://prompt-example.atlassian.net\nprompt-user@example.com\nprompt-token-123\n")) + + if err := runInit(cmd, nil); err != nil { + t.Fatalf("runInit() error: %v", err) + } + + dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) + if err != nil { + t.Fatalf("read .env: %v", err) + } + + dotEnv := string(dotEnvRaw) + if !strings.Contains(dotEnv, "ATLASSIAN_DOMAIN=https://prompt-example.atlassian.net\n") { + t.Fatalf(".env missing prompted domain:\n%s", dotEnv) + } + if !strings.Contains(dotEnv, "ATLASSIAN_EMAIL=prompt-user@example.com\n") { + t.Fatalf(".env missing prompted email:\n%s", dotEnv) + } + if !strings.Contains(dotEnv, "ATLASSIAN_API_TOKEN=prompt-token-123\n") { + t.Fatalf(".env missing prompted API token:\n%s", dotEnv) + } + + output := out.String() + if !strings.Contains(output, "Please enter your Atlassian credentials") { + t.Fatalf("expected interactive credential prompt when env is partial, got:\n%s", output) + } + if strings.Contains(output, "Scaffolding it from existing Atlassian environment variables.") { + t.Fatalf("did not expect env-backed scaffolding message when env is partial:\n%s", output) + } +} + +func TestRunInit_ExistingDotEnvRemainsUnchanged(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + chdirRepo(t, repo) + + originalDotEnv := "# existing credentials\nATLASSIAN_DOMAIN=https://existing.atlassian.net\nATLASSIAN_EMAIL=existing-user@example.com\nATLASSIAN_API_TOKEN=existing-token\n" + if err := os.WriteFile(filepath.Join(repo, ".env"), []byte(originalDotEnv), 0o600); err != nil { + t.Fatalf("write .env: %v", err) + } + + t.Setenv("CONFLUENCE_URL", "") + t.Setenv("CONFLUENCE_EMAIL", "") + t.Setenv("CONFLUENCE_API_TOKEN", "") + t.Setenv("ATLASSIAN_DOMAIN", "https://env-example.atlassian.net") + t.Setenv("ATLASSIAN_EMAIL", "env-user@example.com") + t.Setenv("ATLASSIAN_API_TOKEN", "env-token-123") + + cmd := newInitCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetIn(strings.NewReader("")) + + if err := runInit(cmd, nil); err != nil { + t.Fatalf("runInit() error: %v", err) + } + + dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) + if err != nil { + t.Fatalf("read .env: %v", err) + } + if string(dotEnvRaw) != originalDotEnv { + t.Fatalf(".env changed unexpectedly:\n%s", string(dotEnvRaw)) + } + + output := out.String() + if !strings.Contains(output, ".env already exists") { + t.Fatalf("expected existing .env message, got:\n%s", output) + } + if strings.Contains(output, "Scaffolding it from existing Atlassian environment variables.") { + t.Fatalf("did not expect scaffolding message when .env already exists:\n%s", output) + } + if strings.Contains(output, "Please enter your Atlassian credentials") { + t.Fatalf("did not expect credential prompt when .env already exists:\n%s", output) + } +} diff --git a/cmd/push_changes.go b/cmd/push_changes.go index 5698d35..11387ba 100644 --- a/cmd/push_changes.go +++ b/cmd/push_changes.go @@ -417,23 +417,29 @@ func printPushSyncSummary(out io.Writer, commits []syncflow.PushCommitPlan, diag } attachmentDeleted := 0 - attachmentCreated := 0 + attachmentUploaded := 0 attachmentPreserved := 0 + attachmentSkipped := 0 for _, diag := range diagnostics { switch diag.Code { case "ATTACHMENT_CREATED": - attachmentCreated++ + attachmentUploaded++ case "ATTACHMENT_DELETED": attachmentDeleted++ case "ATTACHMENT_PRESERVED": attachmentPreserved++ + attachmentSkipped++ + default: + if strings.HasPrefix(diag.Code, "ATTACHMENT_") && strings.Contains(diag.Code, "SKIPPED") { + attachmentSkipped++ + } } } _, _ = fmt.Fprintln(out, "\nSync Summary:") _, _ = fmt.Fprintf(out, " pages changed: %d (deleted: %d)\n", len(commits), deletedPages) - if attachmentCreated > 0 || attachmentDeleted > 0 || attachmentPreserved > 0 { - _, _ = fmt.Fprintf(out, " attachments: created %d, preserved %d, deleted %d\n", attachmentCreated, attachmentPreserved, attachmentDeleted) + if attachmentUploaded > 0 || attachmentDeleted > 0 || attachmentPreserved > 0 || attachmentSkipped > 0 { + _, _ = fmt.Fprintf(out, " attachments: uploaded %d, deleted %d, preserved %d, skipped %d\n", attachmentUploaded, attachmentDeleted, attachmentPreserved, attachmentSkipped) } if len(diagnostics) > 0 { _, _ = fmt.Fprintf(out, " diagnostics: %d\n", len(diagnostics)) diff --git a/cmd/push_changes_test.go b/cmd/push_changes_test.go new file mode 100644 index 0000000..02f646d --- /dev/null +++ b/cmd/push_changes_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" +) + +func TestPrintPushSyncSummary_UploadOnlyPush(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + printPushSyncSummary(out, []syncflow.PushCommitPlan{{Path: "root.md"}}, []syncflow.PushDiagnostic{ + {Path: "assets/1/new.png", Code: "ATTACHMENT_CREATED", Message: "uploaded attachment att-1 from assets/1/new.png"}, + }) + + got := out.String() + if !strings.Contains(got, "pages changed: 1 (deleted: 0)") { + t.Fatalf("expected page count summary, got:\n%s", got) + } + if !strings.Contains(got, "attachments: uploaded 1, deleted 0, preserved 0, skipped 0") { + t.Fatalf("expected upload-focused attachment summary, got:\n%s", got) + } +} + +func TestPrintPushSyncSummary_DeleteOnlyPush(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + printPushSyncSummary(out, []syncflow.PushCommitPlan{{Path: "root.md", Deleted: true}}, []syncflow.PushDiagnostic{ + {Path: "assets/1/old.png", Code: "ATTACHMENT_DELETED", Message: "deleted attachment att-1 during page removal"}, + }) + + got := out.String() + if !strings.Contains(got, "pages changed: 1 (deleted: 1)") { + t.Fatalf("expected deleted page count summary, got:\n%s", got) + } + if !strings.Contains(got, "attachments: uploaded 0, deleted 1, preserved 0, skipped 0") { + t.Fatalf("expected delete-focused attachment summary, got:\n%s", got) + } +} + +func TestPrintPushSyncSummary_MixedPageAndAttachmentPush(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + printPushSyncSummary(out, []syncflow.PushCommitPlan{ + {Path: "root.md"}, + {Path: "old.md", Deleted: true}, + }, []syncflow.PushDiagnostic{ + {Path: "assets/1/new.png", Code: "ATTACHMENT_CREATED", Message: "uploaded attachment att-1 from assets/1/new.png"}, + {Path: "assets/2/old.png", Code: "ATTACHMENT_DELETED", Message: "deleted attachment att-2 during page removal"}, + }) + + got := out.String() + if !strings.Contains(got, "pages changed: 2 (deleted: 1)") { + t.Fatalf("expected mixed page summary, got:\n%s", got) + } + if !strings.Contains(got, "attachments: uploaded 1, deleted 1, preserved 0, skipped 0") { + t.Fatalf("expected mixed attachment summary, got:\n%s", got) + } + if !strings.Contains(got, "diagnostics: 2") { + t.Fatalf("expected diagnostics count, got:\n%s", got) + } +} + +func TestPrintPushSyncSummary_KeepOrphanAssetsCountsPreservedAndSkipped(t *testing.T) { + t.Parallel() + + out := &bytes.Buffer{} + printPushSyncSummary(out, []syncflow.PushCommitPlan{{Path: "root.md"}}, []syncflow.PushDiagnostic{ + {Path: "assets/1/orphan.png", Code: "ATTACHMENT_PRESERVED", Message: "kept unreferenced attachment att-1 because --keep-orphan-assets is enabled"}, + }) + + got := out.String() + if !strings.Contains(got, "attachments: uploaded 0, deleted 0, preserved 1, skipped 1") { + t.Fatalf("expected keep-orphan-assets summary to show preserved and skipped counts, got:\n%s", got) + } +} diff --git a/cmd/status.go b/cmd/status.go index d7bae52..cc47648 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -36,7 +36,7 @@ type StatusReport struct { MaxVersionDrift int } -const statusScopeNote = "Scope: markdown pages only; attachment-only drift is excluded from `conf status` output. Use `git status` or `conf diff` to inspect assets." +const statusScopeNote = "Scope: markdown/page drift only; attachment-only drift is excluded from `conf status` output. Use `git status` or `conf diff` to inspect assets." var newStatusRemote = func(cfg *config.Config) (StatusRemote, error) { return newConfluenceClientFromConfig(cfg) @@ -49,6 +49,8 @@ func newStatusCmd() *cobra.Command { Short: "Inspect local and remote sync drift", Long: `status prints a high-level sync summary without mutating local files or remote content. +Status scope: markdown/page drift only; attachment-only drift is excluded from ` + "`conf status`" + ` output. Use ` + "`git status`" + ` or ` + "`conf diff`" + ` to inspect assets. + TARGET follows the standard rule: - .md suffix => file mode (space inferred from file) - otherwise => space mode (SPACE_KEY or space directory).`, diff --git a/cmd/status_run_test.go b/cmd/status_run_test.go index 6109b0d..187f5bc 100644 --- a/cmd/status_run_test.go +++ b/cmd/status_run_test.go @@ -277,3 +277,171 @@ func TestRunStatus_ExplainsMarkdownOnlyScopeForAssetDrift(t *testing.T) { t.Fatalf("expected clean markdown status for asset-only drift, got:\n%s", got) } } + +func TestStatusCmdHelp_ExplainsMarkdownPageScope(t *testing.T) { + runParallelCommandTest(t) + + cmd := newStatusCmd() + if !strings.Contains(cmd.Long, "markdown/page drift only") { + t.Fatalf("expected long help to explain markdown/page-only scope, got:\n%s", cmd.Long) + } + if !strings.Contains(cmd.Long, "attachment-only drift is excluded") { + t.Fatalf("expected long help to explain excluded attachment-only drift, got:\n%s", cmd.Long) + } +} + +func TestRunStatus_PageAndAssetScopeCases(t *testing.T) { + runParallelCommandTest(t) + + testCases := []struct { + name string + mutate func(t *testing.T, spaceDir string) + wantPresent []string + wantAbsent []string + wantMarkdownOnly bool + }{ + { + name: "page only changes are listed", + mutate: func(t *testing.T, spaceDir string) { + writeMarkdown(t, filepath.Join(spaceDir, "page.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Page", + ID: "1", + Version: 1, + }, + Body: "updated body\n", + }) + }, + wantPresent: []string{"modified (1):", "- page.md"}, + wantAbsent: []string{"assets/1/file.png"}, + }, + { + name: "asset only changes stay excluded", + mutate: func(t *testing.T, spaceDir string) { + if err := os.Remove(filepath.Join(spaceDir, "assets", "1", "file.png")); err != nil { + t.Fatalf("remove asset: %v", err) + } + }, + wantPresent: []string{"added (0)", "modified (0)", "deleted (0)"}, + wantAbsent: []string{"assets/1/file.png"}, + wantMarkdownOnly: true, + }, + { + name: "mixed page and asset changes show only page drift", + mutate: func(t *testing.T, spaceDir string) { + writeMarkdown(t, filepath.Join(spaceDir, "page.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Page", + ID: "1", + Version: 1, + }, + Body: "updated body\n", + }) + if err := os.Remove(filepath.Join(spaceDir, "assets", "1", "file.png")); err != nil { + t.Fatalf("remove asset: %v", err) + } + }, + wantPresent: []string{"modified (1):", "- page.md"}, + wantAbsent: []string{"assets/1/file.png"}, + wantMarkdownOnly: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + repo, spaceDir := setupStatusScopeRepo(t) + + tc.mutate(t, spaceDir) + got := runStatusWithMockRemote(t, repo) + + if tc.wantMarkdownOnly && !strings.Contains(got, statusScopeNote) { + t.Fatalf("expected markdown-only scope note, got:\n%s", got) + } + for _, want := range tc.wantPresent { + if !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, got) + } + } + for _, unwanted := range tc.wantAbsent { + if strings.Contains(got, unwanted) { + t.Fatalf("expected output to exclude %q, got:\n%s", unwanted, got) + } + } + }) + } +} + +func setupStatusScopeRepo(t *testing.T) (string, string) { + t.Helper() + + repo := t.TempDir() + setupGitRepo(t, repo) + chdirRepo(t, repo) + setupEnv(t) + + spaceDir := filepath.Join(repo, "TEST") + if err := os.MkdirAll(filepath.Join(spaceDir, "assets", "1"), 0o750); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "page.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Page", + ID: "1", + Version: 1, + }, + Body: "body\n", + }) + if err := os.WriteFile(filepath.Join(spaceDir, "assets", "1", "file.png"), []byte("png"), 0o600); err != nil { + t.Fatalf("write asset: %v", err) + } + if err := fs.SaveState(spaceDir, fs.SpaceState{ + SpaceKey: "TEST", + PagePathIndex: map[string]string{ + "page.md": "1", + }, + AttachmentIndex: map[string]string{ + "assets/1/file.png": "att-1", + }, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + runGitForStatus(t, repo, "add", ".") + runGitForStatus(t, repo, "commit", "-m", "baseline") + tagTime := time.Now().UTC().Format("20060102T150405Z") + runGitForStatus(t, repo, "tag", "-a", "confluence-sync/pull/TEST/"+tagTime, "-m", "pull") + + return repo, spaceDir +} + +func runStatusWithMockRemote(t *testing.T, repo string) string { + t.Helper() + + mock := &mockStatusRemote{ + space: confluence.Space{ID: "space-1", Key: "TEST"}, + pages: confluence.PageListResult{ + Pages: []confluence.Page{ + {ID: "1", Title: "Page", Version: 1}, + }, + }, + page: confluence.Page{ID: "1", SpaceID: "space-1", Status: "current"}, + } + + oldNewStatusRemote := newStatusRemote + newStatusRemote = func(cfg *config.Config) (StatusRemote, error) { + return mock, nil + } + t.Cleanup(func() { newStatusRemote = oldNewStatusRemote }) + + chdirRepo(t, repo) + cmd := newStatusCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runStatus(cmd, config.Target{Value: "TEST", Mode: config.TargetModeSpace}); err != nil { + t.Fatalf("runStatus() error: %v", err) + } + + return out.String() +} From ed37f34715adb6fe7fdfa1e77eadd452b45f53a9 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 15:12:35 +0100 Subject: [PATCH 17/31] Add recover flow and metadata diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- ...26-03-05-live-workflow-polish-followups.md | 3 +- cmd/diff.go | 15 + cmd/diff_pages.go | 9 +- cmd/diff_render.go | 98 +++- cmd/diff_test.go | 368 +++++++++++++++ cmd/doctor.go | 275 +++++++++-- cmd/doctor_test.go | 184 ++++++-- cmd/push.go | 19 + cmd/recover.go | 429 ++++++++++++++++++ cmd/recover_test.go | 157 +++++++ cmd/root.go | 1 + internal/sync/folder_fallback.go | 103 +++++ internal/sync/pull_pages.go | 9 +- internal/sync/pull_test.go | 59 +++ internal/sync/push_folder_logging.go | 17 +- internal/sync/push_folder_logging_test.go | 2 - 17 files changed, 1639 insertions(+), 111 deletions(-) create mode 100644 cmd/recover.go create mode 100644 cmd/recover_test.go create mode 100644 internal/sync/folder_fallback.go diff --git a/README.md b/README.md index c1c4b16..3103f91 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ conf push ENG --on-conflict=cancel ``` ## At a glance 👀 -- Commands: `init`, `init agents [TARGET]`, `pull [TARGET]`, `push [TARGET]`, `status [TARGET]`, `validate [TARGET]`, `diff [TARGET]`, `relink [TARGET]` +- Commands: `init`, `init agents [TARGET]`, `pull [TARGET]`, `push [TARGET]`, `recover`, `status [TARGET]`, `clean`, `validate [TARGET]`, `diff [TARGET]`, `relink [TARGET]` - Version: `conf version` or `conf --version` - Target rule: `.md` suffix means file mode; otherwise space mode (`SPACE_KEY`) - Required auth: `ATLASSIAN_DOMAIN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_API_TOKEN` diff --git a/agents/plans/2026-03-05-live-workflow-polish-followups.md b/agents/plans/2026-03-05-live-workflow-polish-followups.md index fabc5bd..2c267b7 100644 --- a/agents/plans/2026-03-05-live-workflow-polish-followups.md +++ b/agents/plans/2026-03-05-live-workflow-polish-followups.md @@ -13,8 +13,9 @@ Capture the non-blocking but high-value workflow, diagnostics, and operator-expe ## Implementation Progress - [x] Batch 1 completed: items 1, 2, 5, and 7 are closed with regression coverage on this branch. +- [x] Batch 2 completed: items 3, 6, 12, and 13 are closed with regression coverage on this branch. - [x] Item 8 was re-verified as already complete on this branch. -- [ ] Remaining items: 3, 4, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17, and 18. +- [ ] Remaining items: 4, 9, 10, 11, 14, 15, 16, 17, and 18. ## Improvements diff --git a/cmd/diff.go b/cmd/diff.go index 5ed823c..d7d0073 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -250,6 +250,9 @@ func runDiffFileMode( if err := os.WriteFile(remoteFile, rendered, 0o600); err != nil { return fmt.Errorf("write diff file: %w", err) } + if err := writeDiffMetadataSummary(out, []diffMetadataSummary{summarizeMetadataDrift(relPath, localRaw, rendered)}); err != nil { + return err + } return printNoIndexDiff(out, localFile, remoteFile) } @@ -285,6 +288,7 @@ func runDiffSpaceMode( sort.Strings(pageIDs) diagnostics := make([]syncflow.PullDiagnostic, 0) + metadataSummaries := make([]diffMetadataSummary, 0, len(pageIDs)) for _, pageID := range pageIDs { page, err := remote.GetPage(ctx, pageID) if err != nil { @@ -327,6 +331,14 @@ func runDiffSpaceMode( if err := os.WriteFile(dstPath, rendered, 0o600); err != nil { return fmt.Errorf("write remote snapshot file: %w", err) } + + localRaw, err := os.ReadFile(sourcePath) //nolint:gosec // planned path is scoped under the current workspace + if err == nil { + localRaw, err = normalizeDiffMarkdown(localRaw) + } + if err == nil { + metadataSummaries = append(metadataSummaries, summarizeMetadataDrift(relPath, localRaw, rendered)) + } } for _, diag := range diagnostics { @@ -334,6 +346,9 @@ func runDiffSpaceMode( return fmt.Errorf("write diagnostic output: %w", err) } } + if err := writeDiffMetadataSummary(out, metadataSummaries); err != nil { + return err + } return printNoIndexDiff(out, localSnapshot, remoteSnapshot) } diff --git a/cmd/diff_pages.go b/cmd/diff_pages.go index 6b9c9a3..88feab7 100644 --- a/cmd/diff_pages.go +++ b/cmd/diff_pages.go @@ -72,6 +72,7 @@ func recoverMissingPagesForDiff(ctx context.Context, remote syncflow.PullRemote, func resolveDiffFolderHierarchyFromPages(ctx context.Context, remote syncflow.PullRemote, pages []confluence.Page) (map[string]confluence.Folder, []syncflow.PullDiagnostic, error) { folderByID := map[string]confluence.Folder{} diagnostics := []syncflow.PullDiagnostic{} + fallbackTracker := syncflow.NewFolderLookupFallbackTracker() queue := []string{} enqueued := map[string]struct{}{} @@ -105,11 +106,9 @@ func resolveDiffFolderHierarchyFromPages(ctx context.Context, remote syncflow.Pu if !shouldIgnoreFolderHierarchyError(err) { return nil, nil, fmt.Errorf("get folder %s: %w", folderID, err) } - diagnostics = append(diagnostics, syncflow.PullDiagnostic{ - Path: folderID, - Code: "FOLDER_LOOKUP_UNAVAILABLE", - Message: fmt.Sprintf("folder %s unavailable, falling back to page-only hierarchy: %v", folderID, err), - }) + if diag, ok := fallbackTracker.Report("diff-folder-hierarchy", folderID, err); ok { + diagnostics = append(diagnostics, diag) + } continue } diff --git a/cmd/diff_render.go b/cmd/diff_render.go index f726cdd..3c502d2 100644 --- a/cmd/diff_render.go +++ b/cmd/diff_render.go @@ -3,15 +3,17 @@ package cmd import ( "context" "fmt" - syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" + "io" "os" "path/filepath" + "slices" "sort" "strings" "github.com/rgonek/confluence-markdown-sync/internal/confluence" "github.com/rgonek/confluence-markdown-sync/internal/converter" "github.com/rgonek/confluence-markdown-sync/internal/fs" + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" ) func hydrateDiffPageMetadata( @@ -181,3 +183,97 @@ func buildDiffAttachmentPathByID(spaceDir string, attachmentIndex map[string]str return out } + +type diffMetadataSummary struct { + Path string + Changes []string +} + +func summarizeMetadataDrift(relPath string, localRaw, remoteRaw []byte) diffMetadataSummary { + localDoc, err := fs.ParseMarkdownDocument(localRaw) + if err != nil { + return diffMetadataSummary{} + } + remoteDoc, err := fs.ParseMarkdownDocument(remoteRaw) + if err != nil { + return diffMetadataSummary{} + } + + changes := make([]string, 0, 3) + localState := displayDiffState(localDoc.Frontmatter.State) + remoteState := displayDiffState(remoteDoc.Frontmatter.State) + if localState != remoteState { + changes = append(changes, fmt.Sprintf("state: %s -> %s", localState, remoteState)) + } + + localStatus := strings.TrimSpace(localDoc.Frontmatter.Status) + remoteStatus := strings.TrimSpace(remoteDoc.Frontmatter.Status) + if localStatus != remoteStatus { + changes = append(changes, fmt.Sprintf("status: %q -> %q", localStatus, remoteStatus)) + } + + localLabels := fs.NormalizeLabels(localDoc.Frontmatter.Labels) + remoteLabels := fs.NormalizeLabels(remoteDoc.Frontmatter.Labels) + if !slices.Equal(localLabels, remoteLabels) { + changes = append(changes, fmt.Sprintf("labels: %s -> %s", formatDiffLabels(localLabels), formatDiffLabels(remoteLabels))) + } + + if len(changes) == 0 { + return diffMetadataSummary{} + } + + return diffMetadataSummary{ + Path: filepath.ToSlash(relPath), + Changes: changes, + } +} + +func writeDiffMetadataSummary(out io.Writer, summaries []diffMetadataSummary) error { + filtered := make([]diffMetadataSummary, 0, len(summaries)) + for _, summary := range summaries { + if summary.Path == "" || len(summary.Changes) == 0 { + continue + } + filtered = append(filtered, summary) + } + if len(filtered) == 0 { + return nil + } + + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Path < filtered[j].Path + }) + + if _, err := fmt.Fprintln(out, "metadata drift summary:"); err != nil { + return fmt.Errorf("write metadata drift summary: %w", err) + } + for _, summary := range filtered { + if _, err := fmt.Fprintf(out, " %s\n", summary.Path); err != nil { + return fmt.Errorf("write metadata drift summary: %w", err) + } + for _, change := range summary.Changes { + if _, err := fmt.Fprintf(out, " - %s\n", change); err != nil { + return fmt.Errorf("write metadata drift summary: %w", err) + } + } + } + if _, err := fmt.Fprintln(out); err != nil { + return fmt.Errorf("write metadata drift summary: %w", err) + } + return nil +} + +func displayDiffState(state string) string { + normalized := strings.TrimSpace(strings.ToLower(state)) + if normalized == "" || normalized == "current" { + return "current" + } + return normalized +} + +func formatDiffLabels(labels []string) string { + if len(labels) == 0 { + return "[]" + } + return "[" + strings.Join(labels, ", ") + "]" +} diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 76841cc..98701dd 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -281,6 +281,93 @@ func TestRunDiff_FolderListFailureFallsBackToPageHierarchy(t *testing.T) { } } +func TestRunDiff_DeduplicatesFolderFallbackWarnings(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + localFile := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "old body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", ParentPageID: "folder-1", ParentType: "folder", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 5, 0, 0, time.UTC)}, + }, + folderErr: &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders", + Message: "Internal Server Error", + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + ParentPageID: "folder-1", + ParentType: "folder", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("new body")), + }, + "2": { + ID: "2", + SpaceID: "space-1", + Title: "Child", + ParentPageID: "folder-2", + ParentType: "folder", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 5, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("child body")), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if count := strings.Count(got, "[FOLDER_LOOKUP_UNAVAILABLE]"); count != 1 { + t.Fatalf("expected one deduplicated folder fallback warning, got %d:\n%s", count, got) + } + if strings.Contains(got, "Internal Server Error") { + t.Fatalf("expected concise operator warning without raw API error, got:\n%s", got) + } + if strings.Contains(got, "/wiki/api/v2/folders") { + t.Fatalf("expected concise operator warning without raw API URL, got:\n%s", got) + } + if !strings.Contains(got, "falling back to page-only hierarchy for affected pages") { + t.Fatalf("expected concise folder fallback warning, got:\n%s", got) + } +} + func TestRunDiff_RespectsCanceledContext(t *testing.T) { runParallelCommandTest(t) ctx, cancel := context.WithCancel(context.Background()) @@ -508,6 +595,287 @@ func TestRunDiff_FileModeShowsSyncedMetadataParity(t *testing.T) { } } +func TestRunDiff_FileModeShowsLabelOnlyMetadataSummary(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + localFile := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 2, + Labels: []string{"beta"}, + }, + Body: "same body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("same body")), + }, + }, + attachments: map[string][]byte{}, + labelsByPage: map[string][]string{"1": {"gamma", "alpha", "gamma"}}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "metadata drift summary") { + t.Fatalf("expected metadata summary, got:\n%s", got) + } + if !strings.Contains(got, "labels: [beta] -> [alpha, gamma]") { + t.Fatalf("expected normalized label summary, got:\n%s", got) + } + if strings.Index(got, "metadata drift summary") > strings.Index(got, "diff --git") { + t.Fatalf("expected metadata summary before textual diff, got:\n%s", got) + } +} + +func TestRunDiff_SpaceModeShowsMetadataSummaryForRemoteMetadataOnlyChanges(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 2, + }, + Body: "same body\n", + }) + + if err := fs.SaveState(spaceDir, fs.SpaceState{ + PagePathIndex: map[string]string{ + "root.md": "1", + }, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Status: "draft", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "draft", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("same body")), + }, + }, + attachments: map[string][]byte{}, + contentStatusByID: map[string]string{"1": "Ready to review"}, + labelsByPage: map[string][]string{"1": {"beta", "alpha"}}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeSpace, Value: "ENG"}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "metadata drift summary") { + t.Fatalf("expected metadata summary, got:\n%s", got) + } + if !strings.Contains(got, "root.md") { + t.Fatalf("expected metadata summary to include path, got:\n%s", got) + } + if !strings.Contains(got, "state: current -> draft") { + t.Fatalf("expected state summary, got:\n%s", got) + } + if !strings.Contains(got, `status: "" -> "Ready to review"`) { + t.Fatalf("expected status summary, got:\n%s", got) + } + if !strings.Contains(got, "labels: [] -> [alpha, beta]") { + t.Fatalf("expected labels summary, got:\n%s", got) + } +} + +func TestRunDiff_FileModeOmitsMetadataSummaryForContentOnlyChanges(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + localFile := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 2, + State: "draft", + Status: "Ready to review", + Labels: []string{"alpha", "beta"}, + }, + Body: "old body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Status: "draft", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "draft", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("new body")), + }, + }, + attachments: map[string][]byte{}, + contentStatusByID: map[string]string{"1": "Ready to review"}, + labelsByPage: map[string][]string{"1": {"beta", "alpha"}}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if strings.Contains(got, "metadata drift summary") { + t.Fatalf("did not expect metadata summary for content-only changes, got:\n%s", got) + } + if !strings.Contains(got, "-old body") || !strings.Contains(got, "+new body") { + t.Fatalf("expected content diff, got:\n%s", got) + } +} + +func TestRunDiff_FileModeShowsMetadataSummaryBeforeCombinedMetadataAndContentChanges(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + localFile := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 2, + Labels: []string{"beta"}, + }, + Body: "old body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Status: "draft", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "draft", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("new body")), + }, + }, + attachments: map[string][]byte{}, + contentStatusByID: map[string]string{"1": "Ready to review"}, + labelsByPage: map[string][]string{"1": {"beta", "alpha"}}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "metadata drift summary") { + t.Fatalf("expected metadata summary, got:\n%s", got) + } + if !strings.Contains(got, "state: current -> draft") { + t.Fatalf("expected state summary, got:\n%s", got) + } + if !strings.Contains(got, "-old body") || !strings.Contains(got, "+new body") { + t.Fatalf("expected content diff, got:\n%s", got) + } + if strings.Index(got, "metadata drift summary") > strings.Index(got, "diff --git") { + t.Fatalf("expected metadata summary before textual diff, got:\n%s", got) + } +} + func diffUnresolvedADF() map[string]any { return map[string]any{ "version": 1, diff --git a/cmd/doctor.go b/cmd/doctor.go index fbf2822..d6d2c22 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -6,19 +6,23 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/rgonek/confluence-markdown-sync/internal/config" "github.com/rgonek/confluence-markdown-sync/internal/fs" + "github.com/rgonek/confluence-markdown-sync/internal/git" "github.com/spf13/cobra" ) // DoctorIssue describes a single consistency problem found by the doctor command. type DoctorIssue struct { // Kind identifies the category of issue. - Kind string - Path string - Message string + Kind string + Path string + Message string + Severity string + Repairable bool } // DoctorReport is the full set of issues found for a space. @@ -84,6 +88,7 @@ func runDoctor(cmd *cobra.Command, target config.Target, repair bool) error { if err != nil { return err } + appendDoctorGitIssues(&report) if len(report.Issues) == 0 { _, _ = fmt.Fprintln(out, "No issues found.") @@ -92,7 +97,11 @@ func runDoctor(cmd *cobra.Command, target config.Target, repair bool) error { _, _ = fmt.Fprintf(out, "\nFound %d issue(s):\n", len(report.Issues)) for _, issue := range report.Issues { - _, _ = fmt.Fprintf(out, " [%s] %s: %s\n", issue.Kind, issue.Path, issue.Message) + repairability := "manual" + if issue.Repairable { + repairability = "repairable" + } + _, _ = fmt.Fprintf(out, " [%s][%s] %s: %s: %s\n", issue.Severity, repairability, issue.Kind, issue.Path, issue.Message) } if !repair { @@ -127,49 +136,77 @@ func buildDoctorReport(_ context.Context, spaceDir, _ string, state fs.SpaceStat relPath = normalizeRepoRelPath(relPath) pageID = strings.TrimSpace(pageID) if relPath == "" || pageID == "" { - report.Issues = append(report.Issues, DoctorIssue{ - Kind: "empty-index-entry", - Path: relPath, - Message: "state index contains an empty path or ID; entry can be removed", - }) + report.Issues = append(report.Issues, newDoctorIssue( + "empty-index-entry", + relPath, + "state index contains an empty path or ID; entry can be removed", + "error", + true, + )) continue } absPath := filepath.Join(spaceDir, filepath.FromSlash(relPath)) doc, readErr := fs.ReadMarkdownDocument(absPath) if os.IsNotExist(readErr) || (readErr != nil && strings.Contains(readErr.Error(), "no such file")) { - report.Issues = append(report.Issues, DoctorIssue{ - Kind: "missing-file", - Path: relPath, - Message: fmt.Sprintf("state tracks page %s but file does not exist on disk", pageID), - }) + report.Issues = append(report.Issues, newDoctorIssue( + "missing-file", + relPath, + fmt.Sprintf("state tracks page %s but file does not exist on disk", pageID), + "error", + true, + )) continue } if readErr != nil { - report.Issues = append(report.Issues, DoctorIssue{ - Kind: "unreadable-file", - Path: relPath, - Message: fmt.Sprintf("cannot read file: %v", readErr), - }) + report.Issues = append(report.Issues, newDoctorIssue( + "unreadable-file", + relPath, + fmt.Sprintf("cannot read file: %v", readErr), + "error", + false, + )) continue } frontmatterID := strings.TrimSpace(doc.Frontmatter.ID) if frontmatterID != pageID { - report.Issues = append(report.Issues, DoctorIssue{ - Kind: "id-mismatch", - Path: relPath, - Message: fmt.Sprintf("state has id=%s but file frontmatter has id=%s", pageID, frontmatterID), - }) + report.Issues = append(report.Issues, newDoctorIssue( + "id-mismatch", + relPath, + fmt.Sprintf("state has id=%s but file frontmatter has id=%s", pageID, frontmatterID), + "error", + false, + )) } // Check for git conflict markers in the file. if containsConflictMarkers(doc.Body) { - report.Issues = append(report.Issues, DoctorIssue{ - Kind: "conflict-markers", - Path: relPath, - Message: "file contains unresolved git conflict markers", - }) + report.Issues = append(report.Issues, newDoctorIssue( + "conflict-markers", + relPath, + "file contains unresolved git conflict markers", + "error", + false, + )) + } + + if containsUnknownMediaPlaceholder(doc.Body) { + report.Issues = append(report.Issues, newDoctorIssue( + "unknown-media-placeholder", + relPath, + "file contains unresolved UNKNOWN_MEDIA_ID placeholder content from best-effort sync fallback", + "warning", + false, + )) + } else if containsEmbeddedContentPlaceholder(doc.Body) { + report.Issues = append(report.Issues, newDoctorIssue( + "embedded-content-placeholder", + relPath, + "file contains unresolved embedded-content placeholder text from degraded round-trip output", + "warning", + false, + )) } } @@ -189,14 +226,18 @@ func buildDoctorReport(_ context.Context, spaceDir, _ string, state fs.SpaceStat for pageID, relPath := range localIDs { if _, tracked := stateIDSet[pageID]; !tracked { - report.Issues = append(report.Issues, DoctorIssue{ - Kind: "untracked-id", - Path: relPath, - Message: fmt.Sprintf("file has id=%s in frontmatter but is not tracked in state index", pageID), - }) + report.Issues = append(report.Issues, newDoctorIssue( + "untracked-id", + relPath, + fmt.Sprintf("file has id=%s in frontmatter but is not tracked in state index", pageID), + "warning", + true, + )) } } + report.Issues = append(report.Issues, detectHierarchyLayoutIssues(spaceDir)...) + sortDoctorIssues(report.Issues) return report, nil } @@ -246,6 +287,19 @@ func repairDoctorIssues(out io.Writer, spaceDir string, state fs.SpaceState, iss _, _ = fmt.Fprintf(out, " repaired [untracked-id]: added %s -> %s to state index\n", issue.Path, pageID) repaired++ + case "stale-sync-branch": + client, err := git.NewClient() + if err != nil { + errs = append(errs, fmt.Sprintf("[stale-sync-branch] %s: initialize git client: %v", issue.Path, err)) + continue + } + if err := client.DeleteBranch(issue.Path); err != nil { + errs = append(errs, fmt.Sprintf("[stale-sync-branch] %s: delete branch: %v", issue.Path, err)) + continue + } + _, _ = fmt.Fprintf(out, " repaired [stale-sync-branch]: deleted stale recovery branch %s\n", issue.Path) + repaired++ + default: errs = append(errs, fmt.Sprintf("[%s] %s: %s — manual resolution required", issue.Kind, issue.Path, issue.Message)) } @@ -266,3 +320,156 @@ func containsConflictMarkers(text string) bool { } return false } + +func newDoctorIssue(kind, path, message, severity string, repairable bool) DoctorIssue { + return DoctorIssue{ + Kind: kind, + Path: normalizeRepoRelPath(path), + Message: message, + Severity: strings.TrimSpace(strings.ToLower(severity)), + Repairable: repairable, + } +} + +func sortDoctorIssues(issues []DoctorIssue) { + sort.SliceStable(issues, func(i, j int) bool { + if issues[i].Severity != issues[j].Severity { + return issues[i].Severity < issues[j].Severity + } + if issues[i].Path != issues[j].Path { + return issues[i].Path < issues[j].Path + } + return issues[i].Kind < issues[j].Kind + }) +} + +func containsUnknownMediaPlaceholder(text string) bool { + return strings.Contains(text, "UNKNOWN_MEDIA_ID") +} + +func containsEmbeddedContentPlaceholder(text string) bool { + return strings.Contains(text, "[Embedded content]") +} + +func detectHierarchyLayoutIssues(spaceDir string) []DoctorIssue { + paths, err := listDoctorMarkdownPaths(spaceDir) + if err != nil { + return []DoctorIssue{newDoctorIssue( + "hierarchy-layout-scan", + spaceDir, + fmt.Sprintf("cannot scan markdown hierarchy: %v", err), + "error", + false, + )} + } + + pathSet := make(map[string]struct{}, len(paths)) + for _, relPath := range paths { + pathSet[normalizeRepoRelPath(relPath)] = struct{}{} + } + + issues := make([]DoctorIssue, 0) + for _, relPath := range paths { + normalized := normalizeRepoRelPath(relPath) + if normalized == "" { + continue + } + stem := strings.TrimSuffix(filepath.Base(normalized), filepath.Ext(normalized)) + if stem == "" { + continue + } + dir := normalizeRepoRelPath(filepath.Dir(normalized)) + expectedIndex := normalizeRepoRelPath(filepath.Join(dir, stem, stem+".md")) + if expectedIndex == normalized { + continue + } + hasChildMarkdown := false + childPrefix := normalizeRepoRelPath(filepath.Join(dir, stem)) + "/" + for candidate := range pathSet { + if strings.HasPrefix(candidate, childPrefix) && candidate != expectedIndex { + hasChildMarkdown = true + break + } + } + if !hasChildMarkdown { + continue + } + issues = append(issues, newDoctorIssue( + "hierarchy-layout", + normalized, + fmt.Sprintf("page has nested child markdown under %s but parent pages with children must live at %s", childPrefix[:len(childPrefix)-1], expectedIndex), + "warning", + false, + )) + } + return issues +} + +func listDoctorMarkdownPaths(spaceDir string) ([]string, error) { + paths := make([]string, 0) + err := filepath.WalkDir(spaceDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { + return nil + } + rel, relErr := filepath.Rel(spaceDir, path) + if relErr != nil { + return relErr + } + paths = append(paths, normalizeRepoRelPath(rel)) + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(paths) + return paths, nil +} + +func appendDoctorGitIssues(report *DoctorReport) { + client, err := git.NewClient() + if err != nil { + return + } + + currentBranch, err := client.CurrentBranch() + if err != nil { + return + } + syncBranches, err := listCleanSyncBranches(client) + if err != nil { + return + } + worktreeBranches, err := listCleanWorktreeBranches(client) + if err != nil { + return + } + + for _, branch := range syncBranches { + if branch == currentBranch { + continue + } + if _, ok := managedSnapshotRefForSyncBranch(branch); !ok { + continue + } + if len(worktreeBranches[branch]) > 0 { + continue + } + report.Issues = append(report.Issues, newDoctorIssue( + "stale-sync-branch", + branch, + "managed recovery branch has no linked worktree and appears to be abandoned", + "warning", + true, + )) + } + sortDoctorIssues(report.Issues) +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 867c102..c1edd50 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -4,99 +4,191 @@ import ( "bytes" "os" "path/filepath" + "strings" "testing" "github.com/rgonek/confluence-markdown-sync/internal/config" "github.com/rgonek/confluence-markdown-sync/internal/fs" ) -func TestRunDoctor(t *testing.T) { +func TestRunDoctor_ReportsStructuralAndSemanticIssues(t *testing.T) { runParallelCommandTest(t) - cmd := newDoctorCmd() - if cmd == nil { - t.Fatal("expected command not to be nil") + repo := t.TempDir() + setupGitRepo(t, repo) + chdirRepo(t, repo) + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("baseline\n"), 0o600); err != nil { + t.Fatalf("write baseline: %v", err) } + runGitForTest(t, repo, "add", "README.md") + runGitForTest(t, repo, "commit", "-m", "baseline") - repo := t.TempDir() spaceDir := filepath.Join(repo, "TEST") - if err := os.MkdirAll(spaceDir, 0o750); err != nil { + if err := os.MkdirAll(filepath.Join(spaceDir, "Parent"), 0o750); err != nil { t.Fatalf("mkdir space dir: %v", err) } state := fs.NewSpaceState() state.SpaceKey = "TEST" state.PagePathIndex = map[string]string{ - "page.md": "1", - "missing.md": "2", - "empty.md": "", + "page.md": "1", + "missing.md": "2", + "empty.md": "", + "conflict.md": "4", + "mismatch.md": "6", + "unknown-media.md": "8", + "embedded-only.md": "9", + "Parent.md": "10", + "Parent/Child.md": "11", + "unreadable.md": "12", } if err := fs.SaveState(spaceDir, state); err != nil { t.Fatalf("write state: %v", err) } - pageContent := "---\nid: 1\nversion: 1\n---\npage" - if err := os.WriteFile(filepath.Join(spaceDir, "page.md"), []byte(pageContent), 0o600); err != nil { - t.Fatalf("write page: %v", err) - } - - orphanContent := "---\nid: 3\nversion: 1\n---\norphan" - if err := os.WriteFile(filepath.Join(spaceDir, "orphan.md"), []byte(orphanContent), 0o600); err != nil { - t.Fatalf("write orphan: %v", err) - } + writeDoctorMarkdown(t, filepath.Join(spaceDir, "page.md"), "1", "page") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "orphan.md"), "3", "orphan") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "mismatch.md"), "5", "mismatch") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "unknown-media.md"), "8", "[Embedded content] [Media: UNKNOWN_MEDIA_ID]") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "embedded-only.md"), "9", "before\n[Embedded content]\nafter") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "Parent.md"), "10", "parent in wrong place") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "Parent", "Child.md"), "11", "child") conflictContent := "---\nid: 4\nversion: 1\n---\n<<<<<<<\nlocal\n=======\nremote\n>>>>>>>\n" if err := os.WriteFile(filepath.Join(spaceDir, "conflict.md"), []byte(conflictContent), 0o600); err != nil { t.Fatalf("write conflict: %v", err) } - state.PagePathIndex["conflict.md"] = "4" - if err := fs.SaveState(spaceDir, state); err != nil { - t.Fatalf("write state: %v", err) + + unreadableFile := filepath.Join(spaceDir, "unreadable.md") + if err := os.WriteFile(unreadableFile, []byte("---\nid: 12\nversion: 1\n---\n"), 0o200); err != nil { + t.Fatalf("write unreadable: %v", err) } - mismatchContent := "---\nid: 5\nversion: 1\n---\nmismatch" - if err := os.WriteFile(filepath.Join(spaceDir, "mismatch.md"), []byte(mismatchContent), 0o600); err != nil { - t.Fatalf("write mismatch: %v", err) + out := new(bytes.Buffer) + cmd := newDoctorCmd() + cmd.SetOut(out) + cmd.SetErr(new(bytes.Buffer)) + + target := config.Target{Value: spaceDir, Mode: config.TargetModeSpace} + if err := runDoctor(cmd, target, false); err != nil { + t.Fatalf("runDoctor failed: %v", err) } - state.PagePathIndex["mismatch.md"] = "6" - if err := fs.SaveState(spaceDir, state); err != nil { - t.Fatalf("write state: %v", err) + + got := out.String() + for _, want := range []string{ + "[error][repairable] missing-file: missing.md", + "[error][repairable] empty-index-entry: empty.md", + "[error][manual] id-mismatch: mismatch.md", + "[error][manual] conflict-markers: conflict.md", + "[warning][manual] unknown-media-placeholder: unknown-media.md", + "[warning][manual] embedded-content-placeholder: embedded-only.md", + "[warning][manual] hierarchy-layout: Parent.md", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, got) + } + } + if !strings.Contains(got, "Run with --repair to automatically fix repairable issues.") { + t.Fatalf("expected repair hint, got:\n%s", got) } +} - unreadableFile := filepath.Join(spaceDir, "unreadable.md") - if err := os.WriteFile(unreadableFile, []byte(""), 0o200); err != nil { // write-only - t.Fatalf("write unreadable: %v", err) +func TestRunDoctor_RepairRemovesOnlySafeIssues(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupGitRepo(t, repo) + chdirRepo(t, repo) + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("baseline\n"), 0o600); err != nil { + t.Fatalf("write baseline: %v", err) + } + runGitForTest(t, repo, "add", "README.md") + runGitForTest(t, repo, "commit", "-m", "baseline") + + spaceDir := filepath.Join(repo, "TEST") + if err := os.MkdirAll(filepath.Join(spaceDir, "Parent"), 0o750); err != nil { + t.Fatalf("mkdir space dir: %v", err) + } + + state := fs.NewSpaceState() + state.SpaceKey = "TEST" + state.PagePathIndex = map[string]string{ + "page.md": "1", + "missing.md": "2", + "unknown-media.md": "8", + "Parent.md": "10", + "Parent/Child.md": "11", } - state.PagePathIndex["unreadable.md"] = "7" if err := fs.SaveState(spaceDir, state); err != nil { t.Fatalf("write state: %v", err) } - cmd.SetOut(new(bytes.Buffer)) - cmd.SetErr(new(bytes.Buffer)) - target := config.Target{Value: spaceDir, Mode: config.TargetModeSpace} + writeDoctorMarkdown(t, filepath.Join(spaceDir, "page.md"), "1", "page") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "orphan.md"), "3", "orphan") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "unknown-media.md"), "8", "[Embedded content] [Media: UNKNOWN_MEDIA_ID]") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "Parent.md"), "10", "parent in wrong place") + writeDoctorMarkdown(t, filepath.Join(spaceDir, "Parent", "Child.md"), "11", "child") - // Test without repair - if err := runDoctor(cmd, target, false); err != nil { - t.Fatalf("runDoctor failed: %v", err) - } + syncBranch := "sync/TEST/20260305T211238Z" + runGitForTest(t, repo, "branch", syncBranch, "main") + + out := new(bytes.Buffer) + cmd := newDoctorCmd() + cmd.SetOut(out) + cmd.SetErr(new(bytes.Buffer)) - // Test with repair + target := config.Target{Value: spaceDir, Mode: config.TargetModeSpace} if err := runDoctor(cmd, target, true); err != nil { t.Fatalf("runDoctor repair failed: %v", err) } - newState, _ := fs.LoadState(spaceDir) - if newState.PagePathIndex["page.md"] != "1" { - t.Errorf("expected page.md to stay") + newState, err := fs.LoadState(spaceDir) + if err != nil { + t.Fatalf("load state after repair: %v", err) } if _, ok := newState.PagePathIndex["missing.md"]; ok { - t.Errorf("expected missing.md to be removed") + t.Fatalf("expected missing.md to be removed from state") } if newState.PagePathIndex["orphan.md"] != "3" { - t.Errorf("expected orphan.md to be added") + t.Fatalf("expected orphan.md to be added to state, got %q", newState.PagePathIndex["orphan.md"]) + } + if branchList := strings.TrimSpace(runGitForTest(t, repo, "branch", "--list", syncBranch)); branchList != "" { + t.Fatalf("expected sync branch to be deleted, got %q", branchList) } - if _, ok := newState.PagePathIndex["empty.md"]; ok { - t.Errorf("expected empty.md to be removed") + if _, err := os.Stat(filepath.Join(spaceDir, "unknown-media.md")); err != nil { + t.Fatalf("expected unknown-media.md to remain unchanged: %v", err) + } + if _, err := os.Stat(filepath.Join(spaceDir, "Parent.md")); err != nil { + t.Fatalf("expected Parent.md to remain unchanged: %v", err) + } + + got := out.String() + for _, want := range []string{ + "repaired [missing-file]: removed stale state entry for missing.md", + "repaired [untracked-id]: added orphan.md -> 3 to state index", + "repaired [stale-sync-branch]: deleted stale recovery branch sync/TEST/20260305T211238Z", + "[unknown-media-placeholder] unknown-media.md", + "[hierarchy-layout] Parent.md", + "manual resolution required", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected repair output to contain %q, got:\n%s", want, got) + } + } +} + +func writeDoctorMarkdown(t *testing.T, path, id, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir markdown dir: %v", err) + } + if err := fs.WriteMarkdownDocument(path, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + ID: id, + Version: 1, + }, + Body: body, + }); err != nil { + t.Fatalf("write markdown %s: %v", path, err) } } diff --git a/cmd/push.go b/cmd/push.go index 62b9a60..97eb767 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -160,6 +160,10 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun if err != nil { return err } + currentBranch, err := gitClient.CurrentBranch() + if err != nil { + return err + } spaceScopePath, err := gitScopePathFromPath(spaceDir) if err != nil { @@ -270,6 +274,21 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun _ = gitClient.RemoveWorktree(worktreeDir) }() + defer func() { + if runErr == nil { + _ = deleteRecoveryMetadata(gitClient.RootDir, refKey, tsStr) + return + } + _ = writeRecoveryMetadata(gitClient.RootDir, recoveryMetadata{ + SpaceKey: refKey, + Timestamp: tsStr, + SyncBranch: syncBranchName, + SnapshotRef: snapshotName, + OriginalBranch: strings.TrimSpace(currentBranch), + FailureReason: runErr.Error(), + }) + }() + return runPushInWorktree(ctx, cmd, out, target, spaceKey, spaceDir, onConflict, tsStr, gitClient, spaceScopePath, changeScopePath, worktreeDir, syncBranchName, snapshotName, &stashRef) } diff --git a/cmd/recover.go b/cmd/recover.go new file mode 100644 index 0000000..0901df8 --- /dev/null +++ b/cmd/recover.go @@ -0,0 +1,429 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/huh" + "github.com/rgonek/confluence-markdown-sync/internal/git" + "github.com/spf13/cobra" +) + +var ( + flagRecoverDiscard string + flagRecoverDiscardAll bool +) + +func newRecoverCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "recover", + Short: "inspect or discard retained failed push recovery artifacts", + Long: `recover inspects retained failed push recovery artifacts without changing them by default. + +It can list retained sync branches, snapshot refs, linked worktrees, and any recorded +failure reason. With --discard or --discard-all it safely removes abandoned recovery +artifacts while preserving the current recovery branch and active linked worktrees.`, + Args: cobra.NoArgs, + RunE: runRecover, + } + + cmd.Flags().StringVar(&flagRecoverDiscard, "discard", "", "Discard a specific recovery run (sync branch, snapshot ref, SPACE_KEY/TIMESTAMP, or TIMESTAMP)") + cmd.Flags().BoolVar(&flagRecoverDiscardAll, "discard-all", false, "Discard all safe retained recovery artifacts") + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Auto-approve discard actions") + cmd.Flags().BoolVar(&flagNonInteractive, "non-interactive", false, "Disable prompts; fail fast when confirmation is required") + return cmd +} + +func runRecover(cmd *cobra.Command, _ []string) error { + out := ensureSynchronizedCmdOutput(cmd) + + if strings.TrimSpace(flagRecoverDiscard) != "" && flagRecoverDiscardAll { + return fmt.Errorf("recover accepts either --discard or --discard-all, not both") + } + + client, err := git.NewClient() + if err != nil { + return fmt.Errorf("init git client: %w", err) + } + + currentBranch, err := client.CurrentBranch() + if err != nil { + return err + } + currentBranch = strings.TrimSpace(currentBranch) + + runs, err := listRecoveryRuns(client, currentBranch) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(out, "Repository: %s\n", client.RootDir) + _, _ = fmt.Fprintf(out, "Branch: %s\n", currentBranch) + + if len(runs) == 0 { + _, _ = fmt.Fprintln(out, "recover: no retained failed push artifacts found") + return nil + } + + selectedRuns := runs + if selector := strings.TrimSpace(flagRecoverDiscard); selector != "" { + selectedRuns = selectRecoveryRuns(runs, selector) + if len(selectedRuns) == 0 { + return fmt.Errorf("recover: no retained recovery run matches %q", selector) + } + } + + if !flagRecoverDiscardAll && strings.TrimSpace(flagRecoverDiscard) == "" { + renderRecoveryRuns(out, runs) + _, _ = fmt.Fprintf(out, "recover: %d retained recovery run(s) found; rerun with --discard or --discard-all to remove abandoned artifacts\n", len(runs)) + return nil + } + + if err := confirmRecoverDiscard(cmd.InOrStdin(), out, len(selectedRuns)); err != nil { + return err + } + + discarded := 0 + skipped := 0 + for _, run := range selectedRuns { + if run.CurrentBranch { + skipped++ + _, _ = fmt.Fprintf(out, "Retained recovery run %s: current HEAD is on this sync branch\n", run.SyncBranch) + continue + } + if run.WorktreeBlockReason != "" { + skipped++ + _, _ = fmt.Fprintf(out, "Retained recovery run %s: %s\n", run.SyncBranch, run.WorktreeBlockReason) + continue + } + + if run.SnapshotRef != "" { + if err := client.DeleteRef(run.SnapshotRef); err != nil { + return err + } + } + if run.SyncBranch != "" { + if err := client.DeleteBranch(run.SyncBranch); err != nil { + return err + } + } + if err := deleteRecoveryMetadata(client.RootDir, run.SpaceKey, run.Timestamp); err != nil { + return err + } + + discarded++ + _, _ = fmt.Fprintf(out, "Discarded recovery run: %s\n", run.SyncBranch) + } + + _, _ = fmt.Fprintf(out, "recover completed: discarded %d recovery run(s), retained %d recovery run(s)\n", discarded, skipped) + return nil +} + +type recoveryRun struct { + SpaceKey string + Timestamp string + SyncBranch string + SnapshotRef string + FailureReason string + OriginalBranch string + WorktreePaths []string + WorktreeBlockReason string + CurrentBranch bool +} + +func listRecoveryRuns(client *git.Client, currentBranch string) ([]recoveryRun, error) { + snapshotRefs, err := listCleanSnapshotRefs(client) + if err != nil { + return nil, err + } + syncBranches, err := listCleanSyncBranches(client) + if err != nil { + return nil, err + } + worktreeBranches, err := listCleanWorktreeBranches(client) + if err != nil { + return nil, err + } + metadataByKey, err := listRecoveryMetadata(client.RootDir) + if err != nil { + return nil, err + } + + snapshotSet := make(map[string]struct{}, len(snapshotRefs)) + for _, ref := range snapshotRefs { + snapshotSet[ref] = struct{}{} + } + + runMap := make(map[string]recoveryRun) + for _, branch := range syncBranches { + spaceKey, timestamp, ok := parseManagedSyncBranch(branch) + if !ok { + continue + } + key := recoveryRunKey(spaceKey, timestamp) + run := runMap[key] + run.SpaceKey = spaceKey + run.Timestamp = timestamp + run.SyncBranch = branch + run.SnapshotRef = fmt.Sprintf("refs/confluence-sync/snapshots/%s/%s", spaceKey, timestamp) + run.WorktreePaths = append([]string(nil), worktreeBranches[branch]...) + run.WorktreeBlockReason = cleanSyncBranchWorktreeBlockReason(worktreeBranches[branch], map[string]struct{}{}) + run.CurrentBranch = branch == currentBranch + if metadata, ok := metadataByKey[key]; ok { + run.FailureReason = strings.TrimSpace(metadata.FailureReason) + run.OriginalBranch = strings.TrimSpace(metadata.OriginalBranch) + } + if _, ok := snapshotSet[run.SnapshotRef]; !ok { + run.SnapshotRef = "" + } + runMap[key] = run + } + + for _, ref := range snapshotRefs { + spaceKey, timestamp, ok := parseSnapshotRef(ref) + if !ok { + continue + } + key := recoveryRunKey(spaceKey, timestamp) + run := runMap[key] + run.SpaceKey = spaceKey + run.Timestamp = timestamp + run.SnapshotRef = ref + if run.SyncBranch == "" { + run.SyncBranch = fmt.Sprintf("sync/%s/%s", spaceKey, timestamp) + } + if metadata, ok := metadataByKey[key]; ok { + run.FailureReason = strings.TrimSpace(metadata.FailureReason) + run.OriginalBranch = strings.TrimSpace(metadata.OriginalBranch) + } + runMap[key] = run + } + + for key, metadata := range metadataByKey { + run := runMap[key] + if run.SpaceKey == "" { + run.SpaceKey = metadata.SpaceKey + } + if run.Timestamp == "" { + run.Timestamp = metadata.Timestamp + } + if run.SyncBranch == "" { + run.SyncBranch = strings.TrimSpace(metadata.SyncBranch) + } + if run.SnapshotRef == "" { + run.SnapshotRef = strings.TrimSpace(metadata.SnapshotRef) + } + if run.FailureReason == "" { + run.FailureReason = strings.TrimSpace(metadata.FailureReason) + } + if run.OriginalBranch == "" { + run.OriginalBranch = strings.TrimSpace(metadata.OriginalBranch) + } + runMap[key] = run + } + + runs := make([]recoveryRun, 0, len(runMap)) + for _, run := range runMap { + runs = append(runs, run) + } + sort.Slice(runs, func(i, j int) bool { + if runs[i].Timestamp == runs[j].Timestamp { + return runs[i].SpaceKey < runs[j].SpaceKey + } + return runs[i].Timestamp < runs[j].Timestamp + }) + return runs, nil +} + +func selectRecoveryRuns(runs []recoveryRun, selector string) []recoveryRun { + selector = strings.TrimSpace(selector) + selected := make([]recoveryRun, 0) + for _, run := range runs { + if selector == run.SyncBranch || + selector == run.SnapshotRef || + selector == run.Timestamp || + selector == fmt.Sprintf("%s/%s", run.SpaceKey, run.Timestamp) { + selected = append(selected, run) + } + } + return selected +} + +func renderRecoveryRuns(out io.Writer, runs []recoveryRun) { + _, _ = fmt.Fprintf(out, "Retained recovery runs: %d\n", len(runs)) + for _, run := range runs { + _, _ = fmt.Fprintf(out, "- %s\n", run.SyncBranch) + if run.SnapshotRef != "" { + _, _ = fmt.Fprintf(out, " snapshot: %s\n", run.SnapshotRef) + } + if run.OriginalBranch != "" { + _, _ = fmt.Fprintf(out, " original branch: %s\n", run.OriginalBranch) + } + if strings.TrimSpace(run.FailureReason) == "" { + _, _ = fmt.Fprintln(out, " failure: unavailable") + } else { + _, _ = fmt.Fprintf(out, " failure: %s\n", run.FailureReason) + } + if run.CurrentBranch { + _, _ = fmt.Fprintln(out, " status: current HEAD is on this recovery branch") + } else if run.WorktreeBlockReason != "" { + _, _ = fmt.Fprintf(out, " status: %s\n", run.WorktreeBlockReason) + } else { + _, _ = fmt.Fprintln(out, " status: safe to discard") + } + } +} + +func confirmRecoverDiscard(in io.Reader, out io.Writer, runCount int) error { + if flagYes { + return nil + } + if flagNonInteractive { + return fmt.Errorf("recover discard requires confirmation; rerun with --yes") + } + + title := fmt.Sprintf("Discard %d retained recovery run(s)?", runCount) + if outputSupportsProgress(out) { + var confirm bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(title). + Description("This removes retained sync branches, snapshot refs, and stored recovery metadata when safe."). + Value(&confirm), + ), + ).WithOutput(out) + if err := form.Run(); err != nil { + return err + } + if !confirm { + return fmt.Errorf("recover discard cancelled") + } + return nil + } + + if _, err := fmt.Fprintf(out, "%s [y/N]: ", title); err != nil { + return fmt.Errorf("write prompt: %w", err) + } + choice, err := readPromptLine(in) + if err != nil { + return err + } + choice = strings.ToLower(strings.TrimSpace(choice)) + if choice != "y" && choice != "yes" { + return fmt.Errorf("recover discard cancelled") + } + return nil +} + +type recoveryMetadata struct { + SpaceKey string `json:"space_key"` + Timestamp string `json:"timestamp"` + SyncBranch string `json:"sync_branch"` + SnapshotRef string `json:"snapshot_ref"` + OriginalBranch string `json:"original_branch,omitempty"` + FailureReason string `json:"failure_reason,omitempty"` +} + +func listRecoveryMetadata(repoRoot string) (map[string]recoveryMetadata, error) { + root := filepath.Join(repoRoot, ".git", "confluence-recovery") + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return map[string]recoveryMetadata{}, nil + } + return nil, fmt.Errorf("read recovery metadata root: %w", err) + } + + result := make(map[string]recoveryMetadata) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + spaceKey := entry.Name() + spaceDir := filepath.Join(root, spaceKey) + files, err := os.ReadDir(spaceDir) + if err != nil { + return nil, fmt.Errorf("read recovery metadata %s: %w", spaceDir, err) + } + for _, file := range files { + if file.IsDir() || filepath.Ext(file.Name()) != ".json" { + continue + } + raw, err := os.ReadFile(filepath.Join(spaceDir, file.Name())) + if err != nil { + return nil, fmt.Errorf("read recovery metadata %s: %w", file.Name(), err) + } + var metadata recoveryMetadata + if err := json.Unmarshal(raw, &metadata); err != nil { + return nil, fmt.Errorf("decode recovery metadata %s: %w", file.Name(), err) + } + if metadata.SpaceKey == "" { + metadata.SpaceKey = spaceKey + } + if metadata.Timestamp == "" { + metadata.Timestamp = strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())) + } + result[recoveryRunKey(metadata.SpaceKey, metadata.Timestamp)] = metadata + } + } + return result, nil +} + +func writeRecoveryMetadata(repoRoot string, metadata recoveryMetadata) error { + path := recoveryMetadataPath(repoRoot, metadata.SpaceKey, metadata.Timestamp) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create recovery metadata dir: %w", err) + } + raw, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("encode recovery metadata: %w", err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + return fmt.Errorf("write recovery metadata: %w", err) + } + return nil +} + +func deleteRecoveryMetadata(repoRoot, spaceKey, timestamp string) error { + path := recoveryMetadataPath(repoRoot, spaceKey, timestamp) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete recovery metadata: %w", err) + } + _ = os.Remove(filepath.Dir(path)) + return nil +} + +func recoveryMetadataPath(repoRoot, spaceKey, timestamp string) string { + return filepath.Join(repoRoot, ".git", "confluence-recovery", spaceKey, timestamp+".json") +} + +func parseManagedSyncBranch(branch string) (spaceKey, timestamp string, ok bool) { + parts := strings.Split(strings.TrimSpace(branch), "/") + if len(parts) != 3 || parts[0] != "sync" || parts[1] == "" || parts[2] == "" { + return "", "", false + } + return parts[1], parts[2], true +} + +func parseSnapshotRef(ref string) (spaceKey, timestamp string, ok bool) { + ref = strings.TrimSpace(ref) + prefix := "refs/confluence-sync/snapshots/" + if !strings.HasPrefix(ref, prefix) { + return "", "", false + } + parts := strings.Split(strings.TrimPrefix(ref, prefix), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +func recoveryRunKey(spaceKey, timestamp string) string { + return strings.TrimSpace(spaceKey) + "@" + strings.TrimSpace(timestamp) +} diff --git a/cmd/recover_test.go b/cmd/recover_test.go new file mode 100644 index 0000000..74b186d --- /dev/null +++ b/cmd/recover_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/rgonek/confluence-markdown-sync/internal/config" + "github.com/rgonek/confluence-markdown-sync/internal/fs" + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" + "github.com/spf13/cobra" +) + +func TestRunRecover_ListsRetainedFailedPushArtifacts(t *testing.T) { + runParallelCommandTest(t) + + _, spaceDir, syncBranch, snapshotRef := createFailedPushRecoveryRun(t) + chdirRepo(t, spaceDir) + + out, err := runRecoverForTest(t) + if err != nil { + t.Fatalf("recover inspection failed: %v\nOutput:\n%s", err, out) + } + + if !strings.Contains(out, syncBranch) { + t.Fatalf("expected recover output to include sync branch %q, got:\n%s", syncBranch, out) + } + if !strings.Contains(out, snapshotRef) { + t.Fatalf("expected recover output to include snapshot ref %q, got:\n%s", snapshotRef, out) + } + if !strings.Contains(out, "simulated update failure") { + t.Fatalf("expected recover output to include failure reason, got:\n%s", out) + } +} + +func TestRunRecover_DiscardAllRemovesAbandonedRecoveryArtifacts(t *testing.T) { + runParallelCommandTest(t) + + repo, spaceDir, syncBranch, snapshotRef := createFailedPushRecoveryRun(t) + chdirRepo(t, spaceDir) + + out, err := runRecoverForTest(t, "--discard-all", "--yes", "--non-interactive") + if err != nil { + t.Fatalf("recover discard failed: %v\nOutput:\n%s", err, out) + } + + if branchList := strings.TrimSpace(runGitForTest(t, repo, "branch", "--list", syncBranch)); branchList != "" { + t.Fatalf("expected sync branch to be deleted, got %q", branchList) + } + if refs := strings.TrimSpace(runGitForTest(t, repo, "for-each-ref", "--format=%(refname)", snapshotRef)); refs != "" { + t.Fatalf("expected snapshot ref to be deleted, got %q", refs) + } + if !strings.Contains(out, "Discarded recovery run: "+syncBranch) { + t.Fatalf("expected discarded recovery output, got:\n%s", out) + } +} + +func TestRunRecover_DiscardAllPreservesCurrentRecoveryBranch(t *testing.T) { + runParallelCommandTest(t) + + repo, spaceDir, syncBranch, snapshotRef := createFailedPushRecoveryRun(t) + chdirRepo(t, repo) + runGitForTest(t, repo, "checkout", syncBranch) + + out, err := runRecoverForTest(t, "--discard-all", "--yes", "--non-interactive") + if err != nil { + t.Fatalf("recover discard on current branch failed: %v\nOutput:\n%s", err, out) + } + + if branchList := strings.TrimSpace(runGitForTest(t, repo, "branch", "--list", syncBranch)); branchList == "" { + t.Fatalf("expected current sync branch to be retained") + } + if refs := strings.TrimSpace(runGitForTest(t, repo, "for-each-ref", "--format=%(refname)", snapshotRef)); refs != snapshotRef { + t.Fatalf("expected snapshot ref to be retained, got %q", refs) + } + if !strings.Contains(out, "Retained recovery run "+syncBranch+": current HEAD is on this sync branch") { + t.Fatalf("expected retained current-branch reason, got:\n%s", out) + } + + _ = spaceDir +} + +func createFailedPushRecoveryRun(t *testing.T) (repo string, spaceDir string, syncBranch string, snapshotRef string) { + t.Helper() + + repo = t.TempDir() + spaceDir = preparePushRepoWithBaseline(t, repo) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "Updated local content that will fail\n", + }) + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local change") + + fake := newCmdFakePushRemote(1) + failingFake := &failingPushRemote{cmdFakePushRemote: fake} + + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return failingFake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return failingFake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + setupEnv(t) + chdirRepo(t, spaceDir) + + cmd := &cobra.Command{} + cmd.SetOut(&bytes.Buffer{}) + + err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, false) + if err == nil { + t.Fatal("runPush() expected error") + } + + snapshotRef = strings.TrimSpace(runGitForTest(t, repo, "for-each-ref", "--format=%(refname)", "refs/confluence-sync/snapshots/ENG/")) + if snapshotRef == "" { + t.Fatal("expected snapshot ref to be retained") + } + syncBranch = strings.TrimSpace(runGitForTest(t, repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/sync/ENG/")) + if syncBranch == "" { + t.Fatal("expected sync branch to be retained") + } + + return repo, spaceDir, syncBranch, snapshotRef +} + +func runRecoverForTest(t *testing.T, args ...string) (string, error) { + t.Helper() + + previousYes := flagYes + previousNonInteractive := flagNonInteractive + defer func() { + flagYes = previousYes + flagNonInteractive = previousNonInteractive + }() + + cmd := newRecoverCmd() + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(out) + cmd.SetIn(strings.NewReader("y\n")) + cmd.SetArgs(args) + + err := cmd.Execute() + return out.String(), err +} diff --git a/cmd/root.go b/cmd/root.go index fd94fca..98bc6ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -99,6 +99,7 @@ func init() { newInitCmd(), newPullCmd(), newPushCmd(), + newRecoverCmd(), newStatusCmd(), newCleanCmd(), newPruneCmd(), diff --git a/internal/sync/folder_fallback.go b/internal/sync/folder_fallback.go new file mode 100644 index 0000000..b743df3 --- /dev/null +++ b/internal/sync/folder_fallback.go @@ -0,0 +1,103 @@ +package sync + +import ( + "errors" + "log/slog" + "strconv" + "strings" + "sync" + + "github.com/rgonek/confluence-markdown-sync/internal/confluence" +) + +const ( + folderLookupUnavailablePath = "folder hierarchy" + folderLookupUnavailableMessage = "folder lookup unavailable, falling back to page-only hierarchy for affected pages" +) + +type FolderLookupFallbackTracker struct { + mu sync.Mutex + seen map[string]folderLookupFallbackState +} + +type folderLookupFallbackState struct { + count int + suppressionAnnounced bool +} + +func NewFolderLookupFallbackTracker() *FolderLookupFallbackTracker { + return &FolderLookupFallbackTracker{ + seen: map[string]folderLookupFallbackState{}, + } +} + +func (t *FolderLookupFallbackTracker) Report(scope string, path string, err error) (PullDiagnostic, bool) { + if t == nil || err == nil { + return PullDiagnostic{}, false + } + + scope = strings.TrimSpace(scope) + if scope == "" { + scope = folderLookupUnavailablePath + } + + path = strings.TrimSpace(path) + if path == "" { + path = folderLookupUnavailablePath + } + + key := folderFallbackFingerprint(err) + + t.mu.Lock() + state := t.seen[key] + state.count++ + + firstOccurrence := state.count == 1 + announceSuppression := false + if !firstOccurrence && !state.suppressionAnnounced { + state.suppressionAnnounced = true + announceSuppression = true + } + t.seen[key] = state + t.mu.Unlock() + + if firstOccurrence { + slog.Warn( + "folder_lookup_unavailable_falling_back_to_pages", + "scope", scope, + "path", path, + "error", err.Error(), + "note", "continuing with page-based hierarchy fallback; repeated folder lookup failures in this run will be suppressed", + ) + return PullDiagnostic{ + Path: folderLookupUnavailablePath, + Code: "FOLDER_LOOKUP_UNAVAILABLE", + Message: folderLookupUnavailableMessage, + }, true + } + + if announceSuppression { + slog.Info( + "folder_lookup_unavailable_repeats_suppressed", + "scope", scope, + "path", path, + "error", err.Error(), + "repeat_count", state.count-1, + ) + } + + return PullDiagnostic{}, false +} + +func folderFallbackFingerprint(err error) string { + var apiErr *confluence.APIError + if errors.As(err, &apiErr) { + return strings.Join([]string{ + strings.TrimSpace(apiErr.Method), + strings.TrimSpace(apiErr.URL), + strconv.Itoa(apiErr.StatusCode), + strings.TrimSpace(apiErr.Message), + }, "|") + } + return strings.TrimSpace(err.Error()) +} diff --git a/internal/sync/pull_pages.go b/internal/sync/pull_pages.go index e26ce05..1845351 100644 --- a/internal/sync/pull_pages.go +++ b/internal/sync/pull_pages.go @@ -111,6 +111,7 @@ func listAllPages(ctx context.Context, remote PullRemote, opts confluence.PageLi func resolveFolderHierarchyFromPages(ctx context.Context, remote PullRemote, pages []confluence.Page) (map[string]confluence.Folder, []PullDiagnostic, error) { folderByID := map[string]confluence.Folder{} diagnostics := []PullDiagnostic{} + fallbackTracker := NewFolderLookupFallbackTracker() queue := []string{} enqueued := map[string]struct{}{} @@ -144,11 +145,9 @@ func resolveFolderHierarchyFromPages(ctx context.Context, remote PullRemote, pag if !shouldIgnoreFolderHierarchyError(err) { return nil, nil, fmt.Errorf("get folder %s: %w", folderID, err) } - diagnostics = append(diagnostics, PullDiagnostic{ - Path: folderID, - Code: "FOLDER_LOOKUP_UNAVAILABLE", - Message: fmt.Sprintf("folder %s unavailable, falling back to page-only hierarchy: %v", folderID, err), - }) + if diag, ok := fallbackTracker.Report("pull-folder-hierarchy", folderID, err); ok { + diagnostics = append(diagnostics, diag) + } continue } diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index 9dd1ae8..61f07fe 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -1,8 +1,10 @@ package sync import ( + "bytes" "context" "fmt" + "log/slog" "os" "path/filepath" "strings" @@ -369,6 +371,63 @@ func TestPull_FolderListFailureFallsBackToPageHierarchy(t *testing.T) { } } +func TestResolveFolderHierarchyFromPages_DeduplicatesFallbackDiagnostics(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + pages := []confluence.Page{ + {ID: "1", Title: "Root", ParentPageID: "folder-1", ParentType: "folder"}, + {ID: "2", Title: "Child", ParentPageID: "folder-2", ParentType: "folder"}, + } + errBoom := &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders", + Message: "Internal Server Error", + } + remote := &fakePullRemote{ + folderErr: errBoom, + } + + _, diagnostics, err := resolveFolderHierarchyFromPages(context.Background(), remote, pages) + if err != nil { + t.Fatalf("resolveFolderHierarchyFromPages() error: %v", err) + } + + fallbackDiagnostics := 0 + for _, diag := range diagnostics { + if diag.Code != "FOLDER_LOOKUP_UNAVAILABLE" { + continue + } + fallbackDiagnostics++ + if strings.Contains(diag.Message, "Internal Server Error") { + t.Fatalf("expected concise diagnostic without raw API error, got %q", diag.Message) + } + if strings.Contains(diag.Message, "/wiki/api/v2/folders") { + t.Fatalf("expected concise diagnostic without raw API URL, got %q", diag.Message) + } + if !strings.Contains(diag.Message, "falling back to page-only hierarchy for affected pages") { + t.Fatalf("expected concise fallback explanation, got %q", diag.Message) + } + } + if fallbackDiagnostics != 1 { + t.Fatalf("expected one deduplicated fallback diagnostic, got %+v", diagnostics) + } + + gotLogs := logs.String() + if strings.Count(gotLogs, "folder_lookup_unavailable_falling_back_to_pages") != 1 { + t.Fatalf("expected one warning log with raw error details, got:\n%s", gotLogs) + } + if !strings.Contains(gotLogs, "Internal Server Error") { + t.Fatalf("expected raw error details in logs, got:\n%s", gotLogs) + } + if !strings.Contains(gotLogs, "/wiki/api/v2/folders") { + t.Fatalf("expected raw API URL in logs, got:\n%s", gotLogs) + } +} + func TestPull_ForceFullPullsAllPagesWithoutIncrementalChanges(t *testing.T) { tmpDir := t.TempDir() spaceDir := filepath.Join(tmpDir, "ENG") diff --git a/internal/sync/push_folder_logging.go b/internal/sync/push_folder_logging.go index 9ccdf10..8ee045e 100644 --- a/internal/sync/push_folder_logging.go +++ b/internal/sync/push_folder_logging.go @@ -2,9 +2,7 @@ package sync import ( "context" - "errors" "log/slog" - "strconv" "strings" "sync" @@ -37,7 +35,7 @@ func (t *folderListFallbackTracker) Report(scope string, err error) { scope = "space-scan" } - key := folderListFallbackFingerprint(err) + key := folderFallbackFingerprint(err) t.mu.Lock() state := t.seen[key] @@ -72,19 +70,6 @@ func (t *folderListFallbackTracker) Report(scope string, err error) { } } -func folderListFallbackFingerprint(err error) string { - var apiErr *confluence.APIError - if errors.As(err, &apiErr) { - return strings.Join([]string{ - strings.TrimSpace(apiErr.Method), - strings.TrimSpace(apiErr.URL), - strconv.Itoa(apiErr.StatusCode), - strings.TrimSpace(apiErr.Message), - }, "|") - } - return strings.TrimSpace(err.Error()) -} - func listAllPushFoldersWithTracking( ctx context.Context, remote PushRemote, diff --git a/internal/sync/push_folder_logging_test.go b/internal/sync/push_folder_logging_test.go index 8f8c344..341017a 100644 --- a/internal/sync/push_folder_logging_test.go +++ b/internal/sync/push_folder_logging_test.go @@ -10,8 +10,6 @@ import ( ) func TestFolderListFallbackTracker_SuppressesRepeatedWarnings(t *testing.T) { - t.Parallel() - var logs bytes.Buffer previous := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))) From b9a7c9a70b99d6b0194401dec9b5e8b55b3aab66 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 17:21:54 +0100 Subject: [PATCH 18/31] Harden search and sync compatibility flows Complete the search hardening, tenant capability fallback, sandbox workflow, and doctor/recover follow-up batch with regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 4 + README.md | 6 +- ...26-03-05-live-workflow-polish-followups.md | 3 +- cmd/doctor.go | 20 +- cmd/doctor_test.go | 54 ++ cmd/push.go | 10 +- cmd/push_recovery_metadata_test.go | 131 ++++ cmd/recover.go | 39 +- cmd/recover_test.go | 30 + cmd/search.go | 32 +- cmd/search_test.go | 153 +++++ docs/automation.md | 143 +++++ docs/usage.md | 13 + internal/search/blevestore/store.go | 40 ++ internal/search/indexer.go | 60 +- internal/search/indexer_test.go | 99 +++ internal/search/sqlitestore/store.go | 23 + internal/search/store.go | 3 + internal/sync/folder_fallback.go | 34 +- internal/sync/pull.go | 39 +- internal/sync/pull_pages.go | 7 + internal/sync/pull_test.go | 65 ++ internal/sync/pull_testhelpers_test.go | 8 + internal/sync/push.go | 30 +- internal/sync/push_adf.go | 45 +- internal/sync/push_adf_test.go | 89 ++- internal/sync/push_hierarchy.go | 29 +- internal/sync/push_page.go | 62 +- internal/sync/push_rollback.go | 9 +- internal/sync/push_rollback_test.go | 64 ++ internal/sync/push_testhelpers_test.go | 19 +- internal/sync/push_types.go | 10 +- internal/sync/tenant_capabilities.go | 279 +++++++++ internal/sync/tenant_capabilities_test.go | 592 ++++++++++++++++++ 34 files changed, 2129 insertions(+), 115 deletions(-) create mode 100644 cmd/push_recovery_metadata_test.go create mode 100644 internal/sync/tenant_capabilities.go create mode 100644 internal/sync/tenant_capabilities_test.go diff --git a/AGENTS.md b/AGENTS.md index 31a16c9..f408c74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,10 @@ Validation failures must stop `push` immediately. - `--label LABEL` — filter by label (repeatable). - `--heading TEXT` — restrict to sections under matching headings. - `--reindex` — force full rebuild. + - `--result-detail full|standard|minimal` — control payload size/detail. + - `--created-by USER` / `--updated-by USER` — filter by creator or last updater. + - `--created-after DATE` / `--created-before DATE` — bound created timestamps. + - `--updated-after DATE` / `--updated-before DATE` — bound updated timestamps. - `--list-labels` / `--list-spaces` — facet discovery. - `--format text|json|auto` — output format (auto: TTY→text, pipe→json). - `--limit N` (default 20) — max results. diff --git a/README.md b/README.md index 3103f91..b1143a6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Write docs like code. Publish to Confluence with confidence. ✍️ - 📝 Markdown-first authoring with Confluence as the destination. - 🛡️ Safe sync model with validation before remote writes. - 👀 Clear preview step via `conf diff` before push. +- 🔎 Local full-text search across synced Markdown with SQLite or Bleve backends. - 🤖 Works in local repos and automation pipelines. ## Install 🛠️ @@ -58,18 +59,19 @@ conf push ENG --on-conflict=cancel ``` ## At a glance 👀 -- Commands: `init`, `init agents [TARGET]`, `pull [TARGET]`, `push [TARGET]`, `recover`, `status [TARGET]`, `clean`, `validate [TARGET]`, `diff [TARGET]`, `relink [TARGET]` +- Commands: `init`, `init agents [TARGET]`, `pull [TARGET]`, `push [TARGET]`, `recover`, `status [TARGET]`, `clean`, `validate [TARGET]`, `diff [TARGET]`, `relink [TARGET]`, `search QUERY` - Version: `conf version` or `conf --version` - Target rule: `.md` suffix means file mode; otherwise space mode (`SPACE_KEY`) - Required auth: `ATLASSIAN_DOMAIN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_API_TOKEN` - Diagram support: PlantUML is preserved as a Confluence extension; Mermaid is preserved as fenced code / ADF `codeBlock` and `validate` warns before push - Status scope: `conf status` reports Markdown page drift only; use `git status` or `conf diff` for attachment-only changes - Label rules: labels are trimmed, lowercased, deduplicated, and sorted; empty labels and labels containing whitespace are rejected +- Search filters: `--space`, repeatable `--label`, `--heading`, `--created-by`, `--updated-by`, date bounds, and `--result-detail` - Git remote is optional (local Git is enough) ## Docs 📚 - Usage and command reference: `docs/usage.md` -- Automation and CI behavior: `docs/automation.md` +- Automation, CI behavior, and live sandbox smoke-test runbook: `docs/automation.md` - Security policy: `SECURITY.md` - Support policy: `SUPPORT.md` - License: `LICENSE` diff --git a/agents/plans/2026-03-05-live-workflow-polish-followups.md b/agents/plans/2026-03-05-live-workflow-polish-followups.md index 2c267b7..eccb9a7 100644 --- a/agents/plans/2026-03-05-live-workflow-polish-followups.md +++ b/agents/plans/2026-03-05-live-workflow-polish-followups.md @@ -14,8 +14,9 @@ Capture the non-blocking but high-value workflow, diagnostics, and operator-expe - [x] Batch 1 completed: items 1, 2, 5, and 7 are closed with regression coverage on this branch. - [x] Batch 2 completed: items 3, 6, 12, and 13 are closed with regression coverage on this branch. +- [x] Batch 3 completed: items 10, 11, and 18 are closed, and follow-up hardening landed for items 6, 12, and 13. - [x] Item 8 was re-verified as already complete on this branch. -- [ ] Remaining items: 4, 9, 10, 11, 14, 15, 16, 17, and 18. +- [ ] Remaining items: 4, 9, 14, 15, 16, and 17. ## Improvements diff --git a/cmd/doctor.go b/cmd/doctor.go index d6d2c22..e25a5ef 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -128,8 +128,11 @@ func runDoctor(cmd *cobra.Command, target config.Target, repair bool) error { } // buildDoctorReport scans the space directory and state for consistency issues. -func buildDoctorReport(_ context.Context, spaceDir, _ string, state fs.SpaceState) (DoctorReport, error) { - report := DoctorReport{SpaceDir: spaceDir} +func buildDoctorReport(_ context.Context, spaceDir, spaceKey string, state fs.SpaceState) (DoctorReport, error) { + report := DoctorReport{ + SpaceDir: spaceDir, + SpaceKey: strings.TrimSpace(spaceKey), + } // 1. Check every state entry: file must exist and its id frontmatter must match. for relPath, pageID := range state.PagePathIndex { @@ -435,6 +438,11 @@ func listDoctorMarkdownPaths(spaceDir string) ([]string, error) { } func appendDoctorGitIssues(report *DoctorReport) { + targetSpaceKey := fs.SanitizePathSegment(report.SpaceKey) + if targetSpaceKey == "" { + return + } + client, err := git.NewClient() if err != nil { return @@ -457,6 +465,9 @@ func appendDoctorGitIssues(report *DoctorReport) { if branch == currentBranch { continue } + if !doctorSyncBranchMatchesSpace(branch, targetSpaceKey) { + continue + } if _, ok := managedSnapshotRefForSyncBranch(branch); !ok { continue } @@ -473,3 +484,8 @@ func appendDoctorGitIssues(report *DoctorReport) { } sortDoctorIssues(report.Issues) } + +func doctorSyncBranchMatchesSpace(branch, targetSpaceKey string) bool { + parts := strings.Split(strings.TrimSpace(branch), "/") + return len(parts) == 3 && parts[0] == "sync" && parts[1] == targetSpaceKey +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index c1edd50..2db4600 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -177,6 +177,60 @@ func TestRunDoctor_RepairRemovesOnlySafeIssues(t *testing.T) { } } +func TestRunDoctor_RepairScopesStaleSyncBranchesToTargetSpace(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupGitRepo(t, repo) + chdirRepo(t, repo) + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("baseline\n"), 0o600); err != nil { + t.Fatalf("write baseline: %v", err) + } + runGitForTest(t, repo, "add", "README.md") + runGitForTest(t, repo, "commit", "-m", "baseline") + + spaceDir := filepath.Join(repo, "TEST") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space dir: %v", err) + } + + state := fs.NewSpaceState() + state.SpaceKey = "TEST" + if err := fs.SaveState(spaceDir, state); err != nil { + t.Fatalf("write state: %v", err) + } + + testBranch := "sync/TEST/20260305T211238Z" + otherBranch := "sync/OTHER/20260305T211239Z" + runGitForTest(t, repo, "branch", testBranch, "main") + runGitForTest(t, repo, "branch", otherBranch, "main") + + out := new(bytes.Buffer) + cmd := newDoctorCmd() + cmd.SetOut(out) + cmd.SetErr(new(bytes.Buffer)) + + target := config.Target{Value: spaceDir, Mode: config.TargetModeSpace} + if err := runDoctor(cmd, target, true); err != nil { + t.Fatalf("runDoctor repair failed: %v", err) + } + + if branchList := strings.TrimSpace(runGitForTest(t, repo, "branch", "--list", testBranch)); branchList != "" { + t.Fatalf("expected TEST sync branch to be deleted, got %q", branchList) + } + if branchList := strings.TrimSpace(runGitForTest(t, repo, "branch", "--list", otherBranch)); branchList == "" { + t.Fatalf("expected OTHER sync branch to be preserved") + } + + got := out.String() + if !strings.Contains(got, "deleted stale recovery branch "+testBranch) { + t.Fatalf("expected repair output to mention TEST branch deletion, got:\n%s", got) + } + if strings.Contains(got, otherBranch) { + t.Fatalf("expected repair output to exclude unrelated OTHER branch, got:\n%s", got) + } +} + func writeDoctorMarkdown(t *testing.T, path, id, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { diff --git a/cmd/push.go b/cmd/push.go index 97eb767..7e42213 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -276,17 +276,21 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun defer func() { if runErr == nil { - _ = deleteRecoveryMetadata(gitClient.RootDir, refKey, tsStr) + if err := deleteRecoveryMetadata(gitClient.RootDir, refKey, tsStr); err != nil { + _, _ = fmt.Fprintf(out, "warning: failed to clean up recovery metadata: %v\n", err) + } return } - _ = writeRecoveryMetadata(gitClient.RootDir, recoveryMetadata{ + if err := writeRecoveryMetadata(gitClient.RootDir, recoveryMetadata{ SpaceKey: refKey, Timestamp: tsStr, SyncBranch: syncBranchName, SnapshotRef: snapshotName, OriginalBranch: strings.TrimSpace(currentBranch), FailureReason: runErr.Error(), - }) + }); err != nil { + _, _ = fmt.Fprintf(out, "warning: failed to persist recovery metadata: %v\n", err) + } }() return runPushInWorktree(ctx, cmd, out, target, spaceKey, spaceDir, onConflict, tsStr, diff --git a/cmd/push_recovery_metadata_test.go b/cmd/push_recovery_metadata_test.go new file mode 100644 index 0000000..27e336c --- /dev/null +++ b/cmd/push_recovery_metadata_test.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rgonek/confluence-markdown-sync/internal/config" + "github.com/rgonek/confluence-markdown-sync/internal/fs" + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" + "github.com/spf13/cobra" +) + +func TestRunPush_WarnsWhenRecoveryMetadataWriteFails(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "Updated local content that will fail\n", + }) + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local change") + + fake := newCmdFakePushRemote(1) + failingFake := &failingPushRemote{cmdFakePushRemote: fake} + + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + oldNow := nowUTC + fixedNow := time.Date(2026, time.February, 1, 12, 34, 56, 0, time.UTC) + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return failingFake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return failingFake, nil } + nowUTC = func() time.Time { return fixedNow } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + nowUTC = oldNow + }) + + blockingDir := filepath.Join(repo, ".git", "confluence-recovery") + if err := os.MkdirAll(blockingDir, 0o750); err != nil { + t.Fatalf("mkdir recovery root: %v", err) + } + if err := os.WriteFile(filepath.Join(blockingDir, "ENG"), []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write blocking recovery path: %v", err) + } + + setupEnv(t) + chdirRepo(t, spaceDir) + + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + + err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, false) + if err == nil { + t.Fatal("runPush() expected error") + } + if !strings.Contains(err.Error(), "simulated update failure") { + t.Fatalf("expected primary push failure to be preserved, got: %v", err) + } + if !strings.Contains(out.String(), "warning: failed to persist recovery metadata") { + t.Fatalf("expected warning about recovery metadata write failure, got:\n%s", out.String()) + } +} + +func TestRunPush_WarnsWhenRecoveryMetadataCleanupFails(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "Updated local content\n", + }) + + fake := newCmdFakePushRemote(1) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + oldNow := nowUTC + fixedNow := time.Date(2026, time.February, 1, 12, 34, 57, 0, time.UTC) + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + nowUTC = func() time.Time { return fixedNow } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + nowUTC = oldNow + }) + + recoveryPath := filepath.Join(repo, ".git", "confluence-recovery", "ENG", fixedNow.Format("20060102T150405Z")+".json") + if err := os.MkdirAll(recoveryPath, 0o750); err != nil { + t.Fatalf("mkdir blocking recovery metadata path: %v", err) + } + if err := os.WriteFile(filepath.Join(recoveryPath, "keep.txt"), []byte("block deletion"), 0o600); err != nil { + t.Fatalf("write blocking recovery metadata child: %v", err) + } + + setupEnv(t) + chdirRepo(t, spaceDir) + + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, false); err != nil { + t.Fatalf("runPush() unexpected error: %v", err) + } + if !strings.Contains(out.String(), "warning: failed to clean up recovery metadata") { + t.Fatalf("expected warning about recovery metadata cleanup failure, got:\n%s", out.String()) + } +} diff --git a/cmd/recover.go b/cmd/recover.go index 0901df8..6543b58 100644 --- a/cmd/recover.go +++ b/cmd/recover.go @@ -57,13 +57,16 @@ func runRecover(cmd *cobra.Command, _ []string) error { } currentBranch = strings.TrimSpace(currentBranch) - runs, err := listRecoveryRuns(client, currentBranch) + runs, warnings, err := listRecoveryRuns(client, currentBranch) if err != nil { return err } _, _ = fmt.Fprintf(out, "Repository: %s\n", client.RootDir) _, _ = fmt.Fprintf(out, "Branch: %s\n", currentBranch) + for _, warning := range warnings { + _, _ = fmt.Fprintf(out, "warning: %s\n", warning) + } if len(runs) == 0 { _, _ = fmt.Fprintln(out, "recover: no retained failed push artifacts found") @@ -136,22 +139,22 @@ type recoveryRun struct { CurrentBranch bool } -func listRecoveryRuns(client *git.Client, currentBranch string) ([]recoveryRun, error) { +func listRecoveryRuns(client *git.Client, currentBranch string) ([]recoveryRun, []string, error) { snapshotRefs, err := listCleanSnapshotRefs(client) if err != nil { - return nil, err + return nil, nil, err } syncBranches, err := listCleanSyncBranches(client) if err != nil { - return nil, err + return nil, nil, err } worktreeBranches, err := listCleanWorktreeBranches(client) if err != nil { - return nil, err + return nil, nil, err } - metadataByKey, err := listRecoveryMetadata(client.RootDir) + metadataByKey, warnings, err := listRecoveryMetadata(client.RootDir) if err != nil { - return nil, err + return nil, nil, err } snapshotSet := make(map[string]struct{}, len(snapshotRefs)) @@ -237,7 +240,7 @@ func listRecoveryRuns(client *git.Client, currentBranch string) ([]recoveryRun, } return runs[i].Timestamp < runs[j].Timestamp }) - return runs, nil + return runs, warnings, nil } func selectRecoveryRuns(runs []recoveryRun, selector string) []recoveryRun { @@ -330,17 +333,18 @@ type recoveryMetadata struct { FailureReason string `json:"failure_reason,omitempty"` } -func listRecoveryMetadata(repoRoot string) (map[string]recoveryMetadata, error) { +func listRecoveryMetadata(repoRoot string) (map[string]recoveryMetadata, []string, error) { root := filepath.Join(repoRoot, ".git", "confluence-recovery") entries, err := os.ReadDir(root) if err != nil { if os.IsNotExist(err) { - return map[string]recoveryMetadata{}, nil + return map[string]recoveryMetadata{}, nil, nil } - return nil, fmt.Errorf("read recovery metadata root: %w", err) + return nil, nil, fmt.Errorf("read recovery metadata root: %w", err) } result := make(map[string]recoveryMetadata) + warnings := make([]string, 0) for _, entry := range entries { if !entry.IsDir() { continue @@ -349,19 +353,22 @@ func listRecoveryMetadata(repoRoot string) (map[string]recoveryMetadata, error) spaceDir := filepath.Join(root, spaceKey) files, err := os.ReadDir(spaceDir) if err != nil { - return nil, fmt.Errorf("read recovery metadata %s: %w", spaceDir, err) + return nil, nil, fmt.Errorf("read recovery metadata %s: %w", spaceDir, err) } for _, file := range files { if file.IsDir() || filepath.Ext(file.Name()) != ".json" { continue } - raw, err := os.ReadFile(filepath.Join(spaceDir, file.Name())) + metadataPath := filepath.Join(spaceDir, file.Name()) + raw, err := os.ReadFile(metadataPath) if err != nil { - return nil, fmt.Errorf("read recovery metadata %s: %w", file.Name(), err) + warnings = append(warnings, fmt.Sprintf("skipping unreadable recovery metadata %s: %v", file.Name(), err)) + continue } var metadata recoveryMetadata if err := json.Unmarshal(raw, &metadata); err != nil { - return nil, fmt.Errorf("decode recovery metadata %s: %w", file.Name(), err) + warnings = append(warnings, fmt.Sprintf("skipping unreadable recovery metadata %s: %v", file.Name(), err)) + continue } if metadata.SpaceKey == "" { metadata.SpaceKey = spaceKey @@ -372,7 +379,7 @@ func listRecoveryMetadata(repoRoot string) (map[string]recoveryMetadata, error) result[recoveryRunKey(metadata.SpaceKey, metadata.Timestamp)] = metadata } } - return result, nil + return result, warnings, nil } func writeRecoveryMetadata(repoRoot string, metadata recoveryMetadata) error { diff --git a/cmd/recover_test.go b/cmd/recover_test.go index 74b186d..e4a33dd 100644 --- a/cmd/recover_test.go +++ b/cmd/recover_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "os" "path/filepath" "strings" "testing" @@ -81,6 +82,35 @@ func TestRunRecover_DiscardAllPreservesCurrentRecoveryBranch(t *testing.T) { _ = spaceDir } +func TestRunRecover_SkipsCorruptRecoveryMetadataFiles(t *testing.T) { + runParallelCommandTest(t) + + repo, spaceDir, syncBranch, _ := createFailedPushRecoveryRun(t) + badMetadataDir := filepath.Join(repo, ".git", "confluence-recovery", "ENG") + if err := os.MkdirAll(badMetadataDir, 0o750); err != nil { + t.Fatalf("mkdir bad metadata dir: %v", err) + } + if err := os.WriteFile(filepath.Join(badMetadataDir, "bad.json"), []byte("{not valid json"), 0o600); err != nil { + t.Fatalf("write bad metadata: %v", err) + } + chdirRepo(t, spaceDir) + + out, err := runRecoverForTest(t) + if err != nil { + t.Fatalf("recover inspection failed: %v\nOutput:\n%s", err, out) + } + + if !strings.Contains(out, syncBranch) { + t.Fatalf("expected recover output to include valid sync branch %q, got:\n%s", syncBranch, out) + } + if !strings.Contains(out, "warning: skipping unreadable recovery metadata") { + t.Fatalf("expected warning for corrupt recovery metadata, got:\n%s", out) + } + if !strings.Contains(out, "bad.json") { + t.Fatalf("expected warning to mention corrupt metadata file, got:\n%s", out) + } +} + func createFailedPushRecoveryRun(t *testing.T) (repo string, spaceDir string, syncBranch string, snapshotRef string) { t.Helper() diff --git a/cmd/search.go b/cmd/search.go index 5d195b5..be457b4 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -120,6 +120,7 @@ type searchRunOptions struct { func runSearch(cmd *cobra.Command, query string, opts searchRunOptions) error { out := cmd.OutOrStdout() + errOut := cmd.ErrOrStderr() repoRoot, err := gitRepoRoot() if err != nil { @@ -154,12 +155,18 @@ func runSearch(cmd *cobra.Command, query string, opts searchRunOptions) error { indexer := search.NewIndexer(store, repoRoot) + format := resolveSearchFormat(opts.format, out) + if opts.reindex { count, err := indexer.Reindex() if err != nil { return fmt.Errorf("search: reindex: %w", err) } - _, _ = fmt.Fprintf(out, "Reindexed %d document(s)\n", count) + progressOut := out + if format == "json" { + progressOut = errOut + } + _, _ = fmt.Fprintf(progressOut, "Reindexed %d document(s)\n", count) } else { _, err := indexer.IncrementalUpdate() if err != nil { @@ -167,8 +174,6 @@ func runSearch(cmd *cobra.Command, query string, opts searchRunOptions) error { } } - format := resolveSearchFormat(opts.format, out) - if opts.listLabels { labels, err := store.ListLabels() if err != nil { @@ -254,8 +259,7 @@ func openSearchStore(engine, repoRoot string) (search.Store, error) { dbPath := filepath.Join(indexRoot, "search.db") return sqlitestore.Open(dbPath) case "bleve": - blevePath := filepath.Join(indexRoot, "bleve") - return blevestore.Open(blevePath) + return blevestore.Open(repoRoot) default: return nil, fmt.Errorf("search: unknown engine %q (valid values: sqlite, bleve)", engine) } @@ -342,13 +346,16 @@ func printSearchResults(out io.Writer, results []search.SearchResult, format str return nil } -// updateSearchIndexForSpace opens the default SQLite search store and runs an -// incremental update scoped to a single space directory. Errors are non-fatal -// from the caller's perspective — the function itself returns the error so the -// caller can emit a warning. +// updateSearchIndexForSpace opens the configured search store and refreshes a +// single pulled space. Errors are non-fatal from the caller's perspective — +// the function itself returns the error so the caller can emit a warning. func updateSearchIndexForSpace(repoRoot, spaceDir, spaceKey string, out io.Writer) error { - dbPath := filepath.Join(repoRoot, searchIndexDir, "search.db") - store, err := sqlitestore.Open(dbPath) + searchCfg, err := config.LoadSearchConfig(repoRoot) + if err != nil { + return fmt.Errorf("load search config: %w", err) + } + + store, err := openSearchStore(searchCfg.Engine, repoRoot) if err != nil { return fmt.Errorf("open search store: %w", err) } @@ -359,6 +366,9 @@ func updateSearchIndexForSpace(repoRoot, spaceDir, spaceKey string, out io.Write if err != nil { return fmt.Errorf("index space %s: %w", spaceKey, err) } + if err := store.UpdateMeta(); err != nil { + return fmt.Errorf("update search index metadata: %w", err) + } if count > 0 { _, _ = fmt.Fprintf(out, "Updated search index: %d document(s) for space %s\n", count, spaceKey) } diff --git a/cmd/search_test.go b/cmd/search_test.go index 84a022e..6968c46 100644 --- a/cmd/search_test.go +++ b/cmd/search_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/rgonek/confluence-markdown-sync/internal/search" + "github.com/rgonek/confluence-markdown-sync/internal/search/blevestore" "github.com/rgonek/confluence-markdown-sync/internal/search/sqlitestore" ) @@ -436,6 +437,28 @@ func TestOpenSearchStore_Bleve(t *testing.T) { defer func() { _ = store.Close() }() } +func TestOpenSearchStore_BleveUsesRepoIndexRoot(t *testing.T) { + repo := t.TempDir() + + store, err := openSearchStore("bleve", repo) + if err != nil { + t.Fatalf("unexpected error opening bleve store: %v", err) + } + defer func() { _ = store.Close() }() + + expectedIndexDir := filepath.Join(repo, searchIndexDir, "bleve") + if _, err := os.Stat(expectedIndexDir); err != nil { + t.Fatalf("expected Bleve index directory %s to exist: %v", expectedIndexDir, err) + } + + nestedIndexDir := filepath.Join(expectedIndexDir, searchIndexDir, "bleve") + if _, err := os.Stat(nestedIndexDir); err == nil { + t.Fatalf("expected no nested Bleve index directory, but found %s", nestedIndexDir) + } else if !os.IsNotExist(err) { + t.Fatalf("stat nested Bleve index directory: %v", err) + } +} + // --- projectResult tests --- func TestProjectResult_Full(t *testing.T) { @@ -640,6 +663,136 @@ func TestRunSearch_ConfigFileEngine(t *testing.T) { } } +func TestRunSearch_ReindexJSONFormatWritesOnlyJSON(t *testing.T) { + runParallelCommandTest(t) + + repo, _ := setupSearchTestRepo(t) + chdirRepo(t, repo) + + cmd := newSearchCmd() + out := new(bytes.Buffer) + errOut := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(errOut) + cmd.SetArgs([]string{"PKCE", "--reindex", "--format", "json", "--engine", "sqlite"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + var decoded []search.SearchResult + if err := json.Unmarshal(out.Bytes(), &decoded); err != nil { + t.Fatalf("expected valid JSON search output, got error %v\nstdout: %s\nstderr: %s", err, out.String(), errOut.String()) + } + if len(decoded) == 0 { + t.Fatal("expected reindex+search to return at least one result") + } +} + +func TestUpdateSearchIndexForSpace_UsesConfiguredBleveBackend(t *testing.T) { + repo := t.TempDir() + setupGitRepo(t, repo) + + spaceDir := filepath.Join(repo, "DOCS") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space dir: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, ".conf.yaml"), []byte("search:\n engine: bleve\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + writeMarkdown := `--- +id: "123" +title: Searchable Page +space: DOCS +--- + +Bleve backend should index this content. +` + if err := os.WriteFile(filepath.Join(spaceDir, "page.md"), []byte(writeMarkdown), 0o600); err != nil { + t.Fatalf("write markdown: %v", err) + } + + stateContent := `{"space_key":"DOCS","pages":{}}` + if err := os.WriteFile(filepath.Join(spaceDir, ".confluence-state.json"), []byte(stateContent), 0o600); err != nil { + t.Fatalf("write state: %v", err) + } + + if err := updateSearchIndexForSpace(repo, spaceDir, "DOCS", new(bytes.Buffer)); err != nil { + t.Fatalf("updateSearchIndexForSpace: %v", err) + } + + bleveStore, err := blevestore.Open(repo) + if err != nil { + t.Fatalf("open blevestore: %v", err) + } + defer func() { _ = bleveStore.Close() }() + + results, err := bleveStore.Search(search.SearchOptions{Query: "Bleve backend"}) + if err != nil { + t.Fatalf("search bleve index: %v", err) + } + if len(results) == 0 { + t.Fatal("expected document to be indexed in configured Bleve backend") + } + + sqlitePath := filepath.Join(repo, searchIndexDir, "search.db") + if _, err := os.Stat(sqlitePath); err == nil { + t.Fatalf("expected SQLite index %s to remain absent when Bleve is configured", sqlitePath) + } else if !os.IsNotExist(err) { + t.Fatalf("stat sqlite index: %v", err) + } +} + +func TestSearchRegistrationAndDocsAlignment(t *testing.T) { + hasSearchCommand := false + for _, sub := range rootCmd.Commands() { + if sub.Name() == "search" { + hasSearchCommand = true + break + } + } + if !hasSearchCommand { + t.Fatal("expected root command to register search subcommand") + } + + readme, err := os.ReadFile(filepath.Join("..", "README.md")) + if err != nil { + t.Fatalf("read README: %v", err) + } + usage, err := os.ReadFile(filepath.Join("..", "docs", "usage.md")) + if err != nil { + t.Fatalf("read usage guide: %v", err) + } + agents, err := os.ReadFile(filepath.Join("..", "AGENTS.md")) + if err != nil { + t.Fatalf("read AGENTS guide: %v", err) + } + + if !strings.Contains(string(readme), "`search QUERY`") { + t.Fatal("expected README to mention search in its command guidance") + } + + requiredFlagDocs := []string{ + "--result-detail", + "--created-by", + "--updated-by", + "--created-after", + "--created-before", + "--updated-after", + "--updated-before", + } + for _, flag := range requiredFlagDocs { + if !strings.Contains(string(usage), flag) { + t.Fatalf("expected docs/usage.md to document %s", flag) + } + if !strings.Contains(string(agents), flag) { + t.Fatalf("expected AGENTS.md to document %s", flag) + } + } +} + // --- author/date filter tests --- func TestSearch_CreatedByFlag(t *testing.T) { diff --git a/docs/automation.md b/docs/automation.md index bfd025a..9588e39 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -112,6 +112,149 @@ conf validate ENG conf push ENG --yes --non-interactive --on-conflict=cancel ``` +## Live Sandbox Smoke-Test Runbook + +Use this runbook for manual live verification against an explicit non-production Confluence space. It is intentionally operator-driven and repeatable; do **not** run it in the repository root and do **not** point it at production content. + +### Preconditions And Guardrails + +Before you start: + +- use a dedicated sandbox space key that is approved for destructive testing, +- run in a temporary workspace directory **outside** the `confluence-markdown-sync` repository, +- use a dedicated scratch page in that sandbox space (do not edit shared team pages), +- keep `id` and `space` frontmatter unchanged, and do not hand-edit `version`, +- expect to restore the scratch page to its original content before deleting the workspace, +- stop immediately if the target space is not clearly non-production. + +Recommended environment contract: + +```powershell +$RepoRoot = 'C:\Dev\confluence-markdown-sync' +$Conf = Join-Path $RepoRoot 'conf.exe' +$SandboxSpace = 'SANDBOX' +$SmokeRoot = Join-Path $env:TEMP ("conf-live-smoke-" + (Get-Date -Format 'yyyyMMdd-HHmmss')) +$WorkspaceA = Join-Path $SmokeRoot 'workspace-a' +$WorkspaceB = Join-Path $SmokeRoot 'workspace-b' +``` + +Credentials must already be available through `ATLASSIAN_DOMAIN`, `ATLASSIAN_EMAIL`, and `ATLASSIAN_API_TOKEN` (or the legacy `CONFLUENCE_*` names). Build `conf` from the repo root first if needed: + +```powershell +Set-Location $RepoRoot +make build +``` + +### 1. Bootstrap Two Isolated Sandbox Workspaces + +Use two workspaces so you can exercise both the happy path and a real remote-ahead conflict. + +```powershell +New-Item -ItemType Directory -Force -Path $WorkspaceA, $WorkspaceB | Out-Null + +Set-Location $WorkspaceA +& $Conf init +& $Conf pull $SandboxSpace --yes --non-interactive --skip-missing-assets --force + +Set-Location $WorkspaceB +& $Conf init +& $Conf pull $SandboxSpace --yes --non-interactive --skip-missing-assets --force +``` + +After the first pull, pick one existing scratch page in the sandbox and set its relative path explicitly in both workspaces. Example: + +```powershell +$ScratchRelative = 'SANDBOX\Smoke Tests\CLI Smoke Test Scratch.md' +$ScratchFileA = Join-Path $WorkspaceA $ScratchRelative +$ScratchFileB = Join-Path $WorkspaceB $ScratchRelative +Copy-Item $ScratchFileA "$ScratchFileA.pre-smoke.bak" -Force +``` + +If the scratch page does not already exist, create it manually in the sandbox first and rerun `conf pull`; do not improvise with a production page. + +### 2. Run The Pull -> Edit -> Validate -> Diff -> Push -> Pull Cycle + +Append a timestamped marker to the scratch page without touching frontmatter: + +```powershell +$StampA = Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK' +Add-Content -Path $ScratchFileA -Value "`nSmoke test marker A: $StampA`n" + +Set-Location $WorkspaceA +& $Conf validate $ScratchFileA +& $Conf diff $ScratchFileA +& $Conf push $ScratchFileA --yes --non-interactive --on-conflict=cancel +& $Conf pull $SandboxSpace --yes --non-interactive +git --no-pager status --short +``` + +Expected outcome: + +- `validate` succeeds (warnings may appear, but there should be no hard failures), +- `diff` shows only the intended scratch-page change, +- `push` succeeds without touching unrelated pages, +- the follow-up `pull` leaves the workspace clean except for the intentional scratch-page edit now reflected in Git history/state. + +### 3. Simulate A Real Remote-Ahead Conflict + +`WorkspaceB` is still based on the pre-push state, so it can be used to simulate a genuine conflict against the same page. + +```powershell +$StampB = Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK' +Add-Content -Path $ScratchFileB -Value "`nSmoke test marker B: $StampB`n" + +Set-Location $WorkspaceB +& $Conf validate $ScratchFileB +& $Conf push $ScratchFileB --yes --non-interactive --on-conflict=pull-merge +``` + +Expected outcome: + +- `push` detects that the remote page is ahead, +- `--on-conflict=pull-merge` triggers a pull of the newer remote state, +- the command stops for operator review instead of silently overwriting the remote page. + +Inspect the result before resolving: + +```powershell +git --no-pager status --short +git --no-pager diff -- $ScratchRelative +``` + +Then resolve the scratch page in `WorkspaceB` so it contains the final intended test content, validate again, preview it, and complete the push: + +```powershell +& $Conf validate $ScratchFileB +& $Conf diff $ScratchFileB +& $Conf push $ScratchFileB --yes --non-interactive --on-conflict=cancel +& $Conf pull $SandboxSpace --yes --non-interactive +``` + +If you specifically want to exercise interactive pull conflict handling, keep an uncommitted edit in `WorkspaceB`, run `conf pull $ScratchFileB` **without** `--non-interactive`, and verify the `Keep both` / `Use Remote` / `Use Local` prompt flow described earlier in this document. + +### 4. Cleanup And Restore Expectations + +The sandbox should end the smoke test in the same remote state it started from. Restore the original scratch-page content from the backup captured in `WorkspaceA`, then push that restoration before deleting the temporary workspaces. + +```powershell +Copy-Item "$ScratchFileA.pre-smoke.bak" $ScratchFileA -Force + +Set-Location $WorkspaceA +& $Conf validate $ScratchFileA +& $Conf diff $ScratchFileA +& $Conf push $ScratchFileA --yes --non-interactive --on-conflict=cancel +& $Conf pull $SandboxSpace --yes --non-interactive + +Remove-Item $SmokeRoot -Recurse -Force +``` + +Cleanup checklist: + +- the scratch page content is restored (or intentionally left in a known baseline state for the next run), +- no temporary workspace under `$SmokeRoot` remains, +- no live sandbox content was ever pulled into the repository root, +- any unexpected diagnostics or partial rollback messages are captured before the next release candidate is approved. + ## CI Pipeline Example ```yaml diff --git a/docs/usage.md b/docs/usage.md index 2285af0..d4140d5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -185,7 +185,10 @@ Highlights: - two backends available: `--engine sqlite` (default, SQLite FTS5) and `--engine bleve`, - index stored in `.confluence-search-index/` (local-only, gitignored), - index updated automatically after each `conf pull` (non-fatal), +- deleted Markdown paths are removed from the index during incremental updates and post-pull space refreshes, - results grouped by file with heading context and snippets, +- metadata filters support creator/updater and created/updated date windows, +- `--result-detail` controls whether JSON/text results include full document payloads or a smaller projection, - `--format auto` defaults to text on TTY, JSON when piped. Key flags: @@ -201,6 +204,13 @@ Key flags: | `--list-labels` | false | List all indexed labels and exit | | `--list-spaces` | false | List all indexed spaces and exit | | `--format` | auto | Output format: `text`, `json`, or `auto` | +| `--result-detail` | full | Result verbosity: `full`, `standard`, or `minimal` | +| `--created-by USER` | | Filter to pages created by this user | +| `--updated-by USER` | | Filter to pages last updated by this user | +| `--created-after DATE` | | Filter to pages created on or after a date (`YYYY-MM-DD` or RFC3339) | +| `--created-before DATE` | | Filter to pages created on or before a date | +| `--updated-after DATE` | | Filter to pages updated on or after a date | +| `--updated-before DATE` | | Filter to pages updated on or before a date | Examples: @@ -220,6 +230,9 @@ conf search --list-spaces # Agent-friendly (piped → JSON automatically) conf search "security review" --format json | ConvertFrom-Json + +# Use metadata filters and smaller result payloads +conf search "oauth" --created-by alice --updated-after 2024-01-01 --result-detail minimal ``` ## Metadata and State diff --git a/internal/search/blevestore/store.go b/internal/search/blevestore/store.go index d36fb68..132ea00 100644 --- a/internal/search/blevestore/store.go +++ b/internal/search/blevestore/store.go @@ -147,6 +147,46 @@ func (s *Store) ListSpaces() ([]string, error) { return s.listFacetTerms("space_key") } +// ListPathsBySpace returns all distinct indexed source paths for spaceKey, sorted. +func (s *Store) ListPathsBySpace(spaceKey string) ([]string, error) { + const pageSize = 1000 + + var q query.Query = query.NewMatchAllQuery() + if spaceKey != "" { + spaceQuery := query.NewTermQuery(spaceKey) + spaceQuery.SetField("space_key") + q = query.NewConjunctionQuery([]query.Query{q, spaceQuery}) + } + + seen := map[string]struct{}{} + for from := 0; ; from += pageSize { + req := bleve.NewSearchRequestOptions(q, pageSize, from, false) + req.Fields = []string{"path"} + + res, err := s.index.Search(req) + if err != nil { + return nil, fmt.Errorf("blevestore.ListPathsBySpace(%q): %w", spaceKey, err) + } + + for _, hit := range res.Hits { + if path := toString(hit.Fields["path"]); path != "" { + seen[path] = struct{}{} + } + } + + if len(res.Hits) < pageSize { + break + } + } + + paths := make([]string, 0, len(seen)) + for path := range seen { + paths = append(paths, path) + } + sort.Strings(paths) + return paths, nil +} + // metaKey is the internal key used to persist the last-indexed-at timestamp // via Bleve's internal key-value store (independent of the document mapping). var metaKey = []byte("confluence-sync:last-indexed-at") diff --git a/internal/search/indexer.go b/internal/search/indexer.go index 915223e..0fcf362 100644 --- a/internal/search/indexer.go +++ b/internal/search/indexer.go @@ -49,7 +49,8 @@ func (ix *Indexer) Reindex() (int, error) { // Any existing documents for those files are replaced. // It returns the number of documents indexed. func (ix *Indexer) IndexSpace(spaceDir, spaceKey string) (int, error) { - return ix.walkAndIndex(spaceDir, spaceKey, time.Time{}) + indexed, _, err := ix.syncSpace(spaceDir, spaceKey, time.Time{}) + return indexed, err } // IncrementalUpdate indexes only files whose mtime is newer than the last @@ -69,15 +70,17 @@ func (ix *Indexer) IncrementalUpdate() (int, error) { } total := 0 + changed := false for spaceDir, state := range states { - count, err := ix.walkAndIndex(spaceDir, state.SpaceKey, lastAt) + count, spaceChanged, err := ix.syncSpace(spaceDir, state.SpaceKey, lastAt) if err != nil { return total, fmt.Errorf("search indexer: incremental index space %s: %w", spaceDir, err) } total += count + changed = changed || spaceChanged } - if total > 0 { + if changed { if err := ix.store.UpdateMeta(); err != nil { return total, fmt.Errorf("search indexer: update meta: %w", err) } @@ -92,11 +95,12 @@ func (ix *Indexer) Close() error { // — private helpers — -// walkAndIndex walks spaceDir and indexes all .md files. +// syncSpace walks spaceDir, indexes changed .md files, and reconciles removed paths. // If cutoff is non-zero, only files with mtime > cutoff are re-indexed. -func (ix *Indexer) walkAndIndex(spaceDir, spaceKey string, cutoff time.Time) (int, error) { +func (ix *Indexer) syncSpace(spaceDir, spaceKey string, cutoff time.Time) (int, bool, error) { total := 0 spaceName := filepath.Base(spaceDir) + currentPaths := map[string]struct{}{} err := filepath.WalkDir(spaceDir, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { @@ -113,14 +117,6 @@ func (ix *Indexer) walkAndIndex(spaceDir, spaceKey string, cutoff time.Time) (in return nil } - // Mtime filter for incremental updates. - if !cutoff.IsZero() { - info, err := d.Info() - if err != nil || !info.ModTime().After(cutoff) { - return nil - } - } - relPath, err := filepath.Rel(spaceDir, path) if err != nil { return nil // skip; unexpected path @@ -129,6 +125,15 @@ func (ix *Indexer) walkAndIndex(spaceDir, spaceKey string, cutoff time.Time) (in // Build a repo-root-relative path: "/". docPath := spaceName + "/" + relPath + currentPaths[docPath] = struct{}{} + + // Mtime filter for incremental updates. + if !cutoff.IsZero() { + info, err := d.Info() + if err != nil || !info.ModTime().After(cutoff) { + return nil + } + } count, err := ix.indexFile(path, docPath, spaceKey) if err != nil { @@ -138,7 +143,15 @@ func (ix *Indexer) walkAndIndex(spaceDir, spaceKey string, cutoff time.Time) (in total += count return nil }) - return total, err + if err != nil { + return total, false, err + } + + removed, err := ix.removeDeletedPaths(spaceKey, currentPaths) + if err != nil { + return total, false, err + } + return total, removed > 0 || total > 0, nil } // indexFile reads the Markdown document at absPath, parses its structure, and @@ -238,6 +251,25 @@ func (ix *Indexer) indexFile(absPath, docPath, spaceKey string) (int, error) { return len(docs), nil } +func (ix *Indexer) removeDeletedPaths(spaceKey string, currentPaths map[string]struct{}) (int, error) { + indexedPaths, err := ix.store.ListPathsBySpace(spaceKey) + if err != nil { + return 0, fmt.Errorf("list indexed paths for %s: %w", spaceKey, err) + } + + removed := 0 + for _, path := range indexedPaths { + if _, ok := currentPaths[path]; ok { + continue + } + if err := ix.store.DeleteByPath(path); err != nil { + return removed, fmt.Errorf("delete stale docs for %s: %w", path, err) + } + removed++ + } + return removed, nil +} + // normalizeDate attempts to parse s using common date/datetime layouts and returns // an RFC3339-formatted string in UTC. Returns s unchanged if it is already RFC3339, // or the original string if it cannot be parsed at all. diff --git a/internal/search/indexer_test.go b/internal/search/indexer_test.go index 0c1753b..3f63a37 100644 --- a/internal/search/indexer_test.go +++ b/internal/search/indexer_test.go @@ -9,6 +9,7 @@ import ( "github.com/rgonek/confluence-markdown-sync/internal/fs" "github.com/rgonek/confluence-markdown-sync/internal/search" + "github.com/rgonek/confluence-markdown-sync/internal/search/blevestore" "github.com/rgonek/confluence-markdown-sync/internal/search/sqlitestore" ) @@ -28,6 +29,30 @@ func newTestIndexer(t *testing.T) (*search.Indexer, string) { return ix, repoDir } +func newTestIndexerForBackend(t *testing.T, backend string) (*search.Indexer, search.Store, string) { + t.Helper() + + repoDir := t.TempDir() + + var store search.Store + var err error + switch backend { + case "sqlite": + store, err = sqlitestore.Open(filepath.Join(repoDir, ".confluence-search-index", "search.db")) + case "bleve": + store, err = blevestore.Open(repoDir) + default: + t.Fatalf("unknown backend %q", backend) + } + if err != nil { + t.Fatalf("open %s store: %v", backend, err) + } + + ix := search.NewIndexer(store, repoDir) + t.Cleanup(func() { _ = ix.Close() }) + return ix, store, repoDir +} + // writeMarkdownFile writes a Markdown file with frontmatter + body into repoDir. func writeMarkdownFile(t *testing.T, repoDir, relPath, content string) { t.Helper() @@ -292,5 +317,79 @@ Body text here. t.Error("document DEV/author-test.md not found in results") } +func TestIndexer_IndexSpace_RemovesDeletedMarkdownPaths(t *testing.T) { + backends := []string{"sqlite", "bleve"} + for _, backend := range backends { + t.Run(backend, func(t *testing.T) { + ix, store, repoDir := newTestIndexerForBackend(t, backend) + + writeStateFile(t, repoDir, "DEV", "DEV") + writeMarkdownFile(t, repoDir, "DEV/keep.md", `--- +title: Keep +--- +Still searchable. +`) + writeMarkdownFile(t, repoDir, "DEV/delete.md", `--- +title: Delete +--- +Removed from disk and search. +`) + + spaceDir := filepath.Join(repoDir, "DEV") + if _, err := ix.IndexSpace(spaceDir, "DEV"); err != nil { + t.Fatalf("IndexSpace initial: %v", err) + } + + if err := os.Remove(filepath.Join(spaceDir, "delete.md")); err != nil { + t.Fatalf("remove markdown: %v", err) + } + + if _, err := ix.IndexSpace(spaceDir, "DEV"); err != nil { + t.Fatalf("IndexSpace second: %v", err) + } + + results, err := store.Search(search.SearchOptions{Query: "Removed from disk"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected deleted markdown path to be purged from %s index, got %d result(s)", backend, len(results)) + } + }) + } +} + +func TestIndexer_IncrementalUpdate_RemovesDeletedMarkdownPaths(t *testing.T) { + backends := []string{"sqlite", "bleve"} + for _, backend := range backends { + t.Run(backend, func(t *testing.T) { + ix, store, repoDir := newTestIndexerForBackend(t, backend) + + writeStateFile(t, repoDir, "DEV", "DEV") + writeMarkdownFile(t, repoDir, "DEV/overview.md", sampleMD) + + if _, err := ix.Reindex(); err != nil { + t.Fatalf("Reindex: %v", err) + } + + if err := os.Remove(filepath.Join(repoDir, "DEV", "overview.md")); err != nil { + t.Fatalf("remove markdown: %v", err) + } + + if _, err := ix.IncrementalUpdate(); err != nil { + t.Fatalf("IncrementalUpdate: %v", err) + } + + results, err := store.Search(search.SearchOptions{Query: "Refresh tokens are rotated"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected deleted markdown file to be absent from %s search results, got %d result(s)", backend, len(results)) + } + }) + } +} + // — compile-time interface check — var _ search.Store = (*sqlitestore.Store)(nil) diff --git a/internal/search/sqlitestore/store.go b/internal/search/sqlitestore/store.go index d0ab0a1..9e9f0b9 100644 --- a/internal/search/sqlitestore/store.go +++ b/internal/search/sqlitestore/store.go @@ -338,6 +338,29 @@ ORDER BY space_key`) return spaces, rows.Err() } +// ListPathsBySpace returns all distinct indexed source paths for spaceKey, sorted. +func (s *Store) ListPathsBySpace(spaceKey string) ([]string, error) { + rows, err := s.db.Query(` +SELECT DISTINCT path +FROM documents +WHERE space_key = ? +ORDER BY path`, spaceKey) + if err != nil { + return nil, fmt.Errorf("sqlitestore.ListPathsBySpace: %w", err) + } + defer func() { _ = rows.Close() }() + + var paths []string + for rows.Next() { + var path string + if err := rows.Scan(&path); err != nil { + return nil, fmt.Errorf("sqlitestore.ListPathsBySpace scan: %w", err) + } + paths = append(paths, path) + } + return paths, rows.Err() +} + // UpdateMeta records the current UTC timestamp as the last-indexed-at time. func (s *Store) UpdateMeta() error { ts := time.Now().UTC().Format(time.RFC3339) diff --git a/internal/search/store.go b/internal/search/store.go index 3d6efc6..974046b 100644 --- a/internal/search/store.go +++ b/internal/search/store.go @@ -23,6 +23,9 @@ type Store interface { // ListSpaces returns all distinct space key values present in the index, sorted. ListSpaces() ([]string, error) + // ListPathsBySpace returns distinct indexed source paths for a space. + ListPathsBySpace(spaceKey string) ([]string, error) + // UpdateMeta records the current UTC timestamp as the last-indexed-at time. UpdateMeta() error diff --git a/internal/sync/folder_fallback.go b/internal/sync/folder_fallback.go index b743df3..3acc2bd 100644 --- a/internal/sync/folder_fallback.go +++ b/internal/sync/folder_fallback.go @@ -3,6 +3,7 @@ package sync import ( "errors" "log/slog" + "net/url" "strconv" "strings" "sync" @@ -94,10 +95,41 @@ func folderFallbackFingerprint(err error) string { if errors.As(err, &apiErr) { return strings.Join([]string{ strings.TrimSpace(apiErr.Method), - strings.TrimSpace(apiErr.URL), + folderFallbackFailureClass(apiErr.URL), strconv.Itoa(apiErr.StatusCode), strings.TrimSpace(apiErr.Message), }, "|") } return strings.TrimSpace(err.Error()) } + +func folderFallbackFailureClass(rawURL string) string { + path := normalizeFolderFallbackURLPath(rawURL) + switch { + case path == "/wiki/api/v2/folders": + return "folder-list" + case strings.HasPrefix(path, "/wiki/api/v2/folders/"): + return "folder-item" + case path != "": + return path + default: + return "unknown-folder-api" + } +} + +func normalizeFolderFallbackURLPath(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + + if parsed, err := url.Parse(rawURL); err == nil && strings.TrimSpace(parsed.Path) != "" { + rawURL = parsed.Path + } else if idx := strings.Index(rawURL, "?"); idx >= 0 { + rawURL = rawURL[:idx] + } + + rawURL = strings.TrimSpace(rawURL) + rawURL = strings.TrimSuffix(rawURL, "/") + return rawURL +} diff --git a/internal/sync/pull.go b/internal/sync/pull.go index 51523a6..f94a658 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -109,6 +109,7 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, overlapWindow = DefaultPullOverlapWindow } diagnostics := []PullDiagnostic{} + capabilities := newTenantCapabilityCache() userCache := map[string]string{} getUserDisplayName := func(ctx context.Context, accountID string) string { @@ -169,7 +170,16 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, opts.Progress.SetTotal(len(pages)) } - folderByID, folderDiags, err := resolveFolderHierarchyFromPages(ctx, remote, pages) + folderMode, folderModeDiags, err := capabilities.detectPullFolderMode(ctx, remote, pages) + if err != nil { + return PullResult{}, fmt.Errorf("probe folder capability: %w", err) + } + diagnostics = append(diagnostics, folderModeDiags...) + + contentStatusMode, contentStatusDiags := capabilities.detectPullContentStatusMode(ctx, remote, pages) + diagnostics = append(diagnostics, contentStatusDiags...) + + folderByID, folderDiags, err := resolveFolderHierarchyFromPagesWithMode(ctx, remote, pages, folderMode) if err != nil { return PullResult{}, err } @@ -257,21 +267,28 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, return fmt.Errorf("fetch page %s: %w", pageID, err) } - status, err := remote.GetContentStatus(gCtx, pageID, page.Status) - if err != nil { + if contentStatusMode == tenantContentStatusModeDisabled { existingFM, ok := readExistingFrontmatter(pageID) if ok && existingFM.Status != "" { page.ContentStatus = existingFM.Status } - diagMu.Lock() - diagnostics = append(diagnostics, PullDiagnostic{ - Path: pageID, - Code: "CONTENT_STATUS_FETCH_FAILED", - Message: fmt.Sprintf("fetch content status for page %s: %v", pageID, err), - }) - diagMu.Unlock() } else { - page.ContentStatus = status + status, err := remote.GetContentStatus(gCtx, pageID, page.Status) + if err != nil { + existingFM, ok := readExistingFrontmatter(pageID) + if ok && existingFM.Status != "" { + page.ContentStatus = existingFM.Status + } + diagMu.Lock() + diagnostics = append(diagnostics, PullDiagnostic{ + Path: pageID, + Code: "CONTENT_STATUS_FETCH_FAILED", + Message: fmt.Sprintf("fetch content status for page %s: %v", pageID, err), + }) + diagMu.Unlock() + } else { + page.ContentStatus = status + } } labels, err := remote.GetLabels(gCtx, pageID) diff --git a/internal/sync/pull_pages.go b/internal/sync/pull_pages.go index 1845351..74b68af 100644 --- a/internal/sync/pull_pages.go +++ b/internal/sync/pull_pages.go @@ -109,8 +109,15 @@ func listAllPages(ctx context.Context, remote PullRemote, opts confluence.PageLi } func resolveFolderHierarchyFromPages(ctx context.Context, remote PullRemote, pages []confluence.Page) (map[string]confluence.Folder, []PullDiagnostic, error) { + return resolveFolderHierarchyFromPagesWithMode(ctx, remote, pages, tenantFolderModeNative) +} + +func resolveFolderHierarchyFromPagesWithMode(ctx context.Context, remote PullRemote, pages []confluence.Page, mode tenantFolderMode) (map[string]confluence.Folder, []PullDiagnostic, error) { folderByID := map[string]confluence.Folder{} diagnostics := []PullDiagnostic{} + if mode == tenantFolderModePageFallback { + return folderByID, diagnostics, nil + } fallbackTracker := NewFolderLookupFallbackTracker() queue := []string{} diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index 61f07fe..c93a930 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -428,6 +428,71 @@ func TestResolveFolderHierarchyFromPages_DeduplicatesFallbackDiagnostics(t *test } } +type folderLookupErrorByIDRemote struct { + *fakePullRemote + errorsByFolderID map[string]error +} + +func (r *folderLookupErrorByIDRemote) GetFolder(ctx context.Context, folderID string) (confluence.Folder, error) { + r.getFolderCalls = append(r.getFolderCalls, folderID) + if err, ok := r.errorsByFolderID[folderID]; ok { + return confluence.Folder{}, err + } + return r.fakePullRemote.GetFolder(ctx, folderID) +} + +func TestResolveFolderHierarchyFromPages_DeduplicatesFallbackDiagnosticsAcrossFolderURLs(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + pages := []confluence.Page{ + {ID: "1", Title: "Root", ParentPageID: "folder-1", ParentType: "folder"}, + {ID: "2", Title: "Child", ParentPageID: "folder-2", ParentType: "folder"}, + } + remote := &folderLookupErrorByIDRemote{ + fakePullRemote: &fakePullRemote{}, + errorsByFolderID: map[string]error{ + "folder-1": &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders/folder-1", + Message: "Internal Server Error", + }, + "folder-2": &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders/folder-2", + Message: "Internal Server Error", + }, + }, + } + + _, diagnostics, err := resolveFolderHierarchyFromPages(context.Background(), remote, pages) + if err != nil { + t.Fatalf("resolveFolderHierarchyFromPages() error: %v", err) + } + + fallbackDiagnostics := 0 + for _, diag := range diagnostics { + if diag.Code == "FOLDER_LOOKUP_UNAVAILABLE" { + fallbackDiagnostics++ + } + } + if fallbackDiagnostics != 1 { + t.Fatalf("expected one deduplicated fallback diagnostic across folder URLs, got %+v", diagnostics) + } + + gotLogs := logs.String() + if count := strings.Count(gotLogs, "folder_lookup_unavailable_falling_back_to_pages"); count != 1 { + t.Fatalf("expected one warning log across folder URLs, got %d:\n%s", count, gotLogs) + } + if count := strings.Count(gotLogs, "folder_lookup_unavailable_repeats_suppressed"); count != 1 { + t.Fatalf("expected one suppression log across folder URLs, got %d:\n%s", count, gotLogs) + } +} + func TestPull_ForceFullPullsAllPagesWithoutIncrementalChanges(t *testing.T) { tmpDir := t.TempDir() spaceDir := filepath.Join(tmpDir, "ENG") diff --git a/internal/sync/pull_testhelpers_test.go b/internal/sync/pull_testhelpers_test.go index 7ff06c8..d0d1603 100644 --- a/internal/sync/pull_testhelpers_test.go +++ b/internal/sync/pull_testhelpers_test.go @@ -15,6 +15,7 @@ type fakePullRemote struct { pages []confluence.Page folderByID map[string]confluence.Folder folderErr error + getFolderCalls []string changes []confluence.Change listChangesFunc func(opts confluence.ChangeListOptions) (confluence.ChangeListResult, error) pagesByID map[string]confluence.Page @@ -23,6 +24,8 @@ type fakePullRemote struct { labels map[string][]string users map[string]confluence.User contentStatuses map[string]string + contentStatusErr error + getStatusCalls []string lastChangeSince time.Time getPageHook func(pageID string) } @@ -47,6 +50,7 @@ func (f *fakePullRemote) ListPages(_ context.Context, _ confluence.PageListOptio } func (f *fakePullRemote) GetFolder(_ context.Context, folderID string) (confluence.Folder, error) { + f.getFolderCalls = append(f.getFolderCalls, folderID) if f.folderErr != nil { return confluence.Folder{}, f.folderErr } @@ -77,6 +81,10 @@ func (f *fakePullRemote) GetPage(_ context.Context, pageID string) (confluence.P } func (f *fakePullRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { + f.getStatusCalls = append(f.getStatusCalls, pageID) + if f.contentStatusErr != nil { + return "", f.contentStatusErr + } if f.contentStatuses == nil { return "", nil } diff --git a/internal/sync/push.go b/internal/sync/push.go index 02c050a..4bc9aa3 100644 --- a/internal/sync/push.go +++ b/internal/sync/push.go @@ -37,6 +37,8 @@ func Push(ctx context.Context, remote PushRemote, opts PushOptions) (PushResult, state := normalizePushState(opts.State) policy := normalizeConflictPolicy(opts.ConflictPolicy) opts.folderListTracker = newFolderListFallbackTracker() + capabilities := newTenantCapabilityCache() + diagnostics := make([]PushDiagnostic, 0) space, err := remote.GetSpace(ctx, opts.SpaceKey) if err != nil { @@ -54,10 +56,13 @@ func Push(ctx context.Context, remote PushRemote, opts PushOptions) (PushResult, } // Try to list folders, but don't fail the whole push if it's broken (Confluence bug) - remoteFolders, err := listAllPushFoldersWithTracking(ctx, remote, confluence.FolderListOptions{ + remoteFolders, folderListErr := listAllPushFoldersWithTracking(ctx, remote, confluence.FolderListOptions{ SpaceID: space.ID, }, opts.folderListTracker, "space-scan") - if err != nil { + folderMode, folderModeDiags := capabilities.detectPushFolderMode(opts.Changes, folderListErr) + diagnostics = append(diagnostics, folderModeDiags...) + opts.folderMode = folderMode + if folderListErr != nil { remoteFolders = nil } @@ -89,9 +94,16 @@ func Push(ctx context.Context, remote PushRemote, opts PushOptions) (PushResult, attachmentIDByPath := cloneStringMap(state.AttachmentIndex) folderIDByPath := cloneStringMap(state.FolderPathIndex) + if opts.folderMode == tenantFolderModePageFallback { + folderIDByPath = map[string]string{} + } changes := normalizePushChanges(opts.Changes) commits := make([]PushCommitPlan, 0, len(changes)) - diagnostics := make([]PushDiagnostic, 0) + opts.contentStatusMode, err = capabilities.detectPushContentStatusMode(ctx, remote, opts.SpaceDir, pages, changes) + if err != nil { + return PushResult{State: state, Diagnostics: diagnostics}, err + } + diagnostics = append(diagnostics, capabilities.pushContentStatusDiagnostics()...) if err := seedPendingPageIDsForPushChanges(opts.SpaceDir, changes, pageIDByPath); err != nil { return PushResult{}, fmt.Errorf("seed pending page ids: %w", err) } @@ -148,6 +160,7 @@ func Push(ctx context.Context, remote PushRemote, opts PushOptions) (PushResult, remote, space, opts, + capabilities, state, policy, pageIDByPath, @@ -304,6 +317,7 @@ func pushUpsertPage( remote PushRemote, space confluence.Space, opts PushOptions, + capabilities *tenantCapabilityCache, state fs.SpaceState, policy PushConflictPolicy, pageIDByPath PageIndex, @@ -322,9 +336,11 @@ func pushUpsertPage( } pageID := strings.TrimSpace(doc.Frontmatter.ID) + isExistingPage := pageID != "" normalizedRelPath := normalizeRelPath(relPath) precreatedPage, hasPrecreated := precreatedPages[normalizedRelPath] targetState := normalizePageLifecycleState(doc.Frontmatter.State) + trackContentStatus := shouldSyncContentStatus(isExistingPage, doc) dirPath := normalizeRelPath(filepath.ToSlash(filepath.Dir(filepath.FromSlash(relPath)))) title := resolveLocalTitle(doc, relPath) pageTitleByPath[normalizedRelPath] = title @@ -360,9 +376,9 @@ func pushUpsertPage( localVersion := doc.Frontmatter.Version fallbackParentID := strings.TrimSpace(doc.Frontmatter.ConfluenceParentPageID) var remotePage confluence.Page - isExistingPage := pageID != "" - rollback := newPushRollbackTracker(relPath, diagnostics) + contentStatusMode := capabilities.currentPushContentStatusMode() + rollback := newPushRollbackTracker(relPath, contentStatusMode, diagnostics) failWithRollback := func(opErr error) (PushCommitPlan, error) { slog.Warn("push_mutation_failed", "path", relPath, @@ -753,14 +769,14 @@ func pushUpsertPage( rollback.markContentRestoreRequired() if isExistingPage { - snapshot, snapshotErr := capturePageMetadataSnapshot(ctx, remote, pageID, remotePage.Status) + snapshot, snapshotErr := capturePageMetadataSnapshot(ctx, remote, pageID, remotePage.Status, contentStatusMode, trackContentStatus) if snapshotErr != nil { return failWithRollback(fmt.Errorf("capture metadata snapshot for %s: %w", relPath, snapshotErr)) } rollback.trackMetadataSnapshot(pageID, snapshot) } - if err := syncPageMetadata(ctx, remote, pageID, doc); err != nil { + if err := syncPageMetadata(ctx, remote, pageID, doc, isExistingPage, capabilities, diagnostics); err != nil { return failWithRollback(fmt.Errorf("sync metadata for %s: %w", relPath, err)) } rollback.clearMetadataSnapshot() diff --git a/internal/sync/push_adf.go b/internal/sync/push_adf.go index b30e201..65529e7 100644 --- a/internal/sync/push_adf.go +++ b/internal/sync/push_adf.go @@ -74,22 +74,39 @@ func walkAndFixMediaNodes(node any, pageID string) bool { return modified } -func syncPageMetadata(ctx context.Context, remote PushRemote, pageID string, doc fs.MarkdownDocument) error { +func syncPageMetadata(ctx context.Context, remote PushRemote, pageID string, doc fs.MarkdownDocument, existingPage bool, capabilities *tenantCapabilityCache, diagnostics *[]PushDiagnostic) error { // 1. Sync Content Status targetStatus := strings.TrimSpace(doc.Frontmatter.Status) pageStatus := normalizePageLifecycleState(doc.Frontmatter.State) - currentStatus, err := remote.GetContentStatus(ctx, pageID, pageStatus) - if err != nil { - return fmt.Errorf("get content status: %w", err) - } - if targetStatus != currentStatus { - if targetStatus == "" { - if err := remote.DeleteContentStatus(ctx, pageID, pageStatus); err != nil { - return fmt.Errorf("delete content status: %w", err) + contentStatusMode := capabilities.currentPushContentStatusMode() + if contentStatusMode != tenantContentStatusModeDisabled && shouldSyncContentStatus(existingPage, doc) { + currentStatus, err := remote.GetContentStatus(ctx, pageID, pageStatus) + if err != nil { + if !isCompatibilityProbeError(err) { + return fmt.Errorf("get content status: %w", err) } - } else { - if err := remote.SetContentStatus(ctx, pageID, pageStatus, targetStatus); err != nil { - return fmt.Errorf("set content status: %w", err) + for _, diag := range capabilities.disablePushContentStatusMode() { + appendPushDiagnostic(diagnostics, diag.Path, diag.Code, diag.Message) + } + } else if targetStatus != currentStatus { + if targetStatus == "" { + if err := remote.DeleteContentStatus(ctx, pageID, pageStatus); err != nil { + if !isCompatibilityProbeError(err) { + return fmt.Errorf("delete content status: %w", err) + } + for _, diag := range capabilities.disablePushContentStatusMode() { + appendPushDiagnostic(diagnostics, diag.Path, diag.Code, diag.Message) + } + } + } else { + if err := remote.SetContentStatus(ctx, pageID, pageStatus, targetStatus); err != nil { + if !isCompatibilityProbeError(err) { + return fmt.Errorf("set content status: %w", err) + } + for _, diag := range capabilities.disablePushContentStatusMode() { + appendPushDiagnostic(diagnostics, diag.Path, diag.Code, diag.Message) + } + } } } } @@ -135,3 +152,7 @@ func syncPageMetadata(ctx context.Context, remote PushRemote, pageID string, doc return nil } + +func shouldSyncContentStatus(existingPage bool, doc fs.MarkdownDocument) bool { + return existingPage || strings.TrimSpace(doc.Frontmatter.Status) != "" +} diff --git a/internal/sync/push_adf_test.go b/internal/sync/push_adf_test.go index ec0e6fe..f679c77 100644 --- a/internal/sync/push_adf_test.go +++ b/internal/sync/push_adf_test.go @@ -3,11 +3,30 @@ package sync import ( "context" "encoding/json" + "fmt" + "net/http" "testing" + "github.com/rgonek/confluence-markdown-sync/internal/confluence" "github.com/rgonek/confluence-markdown-sync/internal/fs" ) +func testTenantCapabilityCache(mode tenantContentStatusMode) *tenantCapabilityCache { + cache := newTenantCapabilityCache() + cache.pushContentStatusMode.resolved = true + cache.pushContentStatusMode.mode = mode + return cache +} + +func compatibilityNotImplementedError(pageID string) error { + return &confluence.APIError{ + StatusCode: http.StatusNotImplemented, + Method: "GET", + URL: fmt.Sprintf("/wiki/rest/api/content/%s/state", pageID), + Message: "Not Implemented", + } +} + func TestEnsureADFMediaCollection(t *testing.T) { testCases := []struct { name string @@ -76,7 +95,7 @@ func TestSyncPageMetadata_EquivalentLabelSetsDoNotChurn(t *testing.T) { }, } - if err := syncPageMetadata(context.Background(), remote, "1", doc); err != nil { + if err := syncPageMetadata(context.Background(), remote, "1", doc, true, testTenantCapabilityCache(tenantContentStatusModeEnabled), nil); err != nil { t.Fatalf("syncPageMetadata() error: %v", err) } @@ -98,7 +117,7 @@ func TestSyncPageMetadata_SetsContentStatusOnlyWhenPresent(t *testing.T) { }, } - if err := syncPageMetadata(context.Background(), remote, "1", doc); err != nil { + if err := syncPageMetadata(context.Background(), remote, "1", doc, true, testTenantCapabilityCache(tenantContentStatusModeEnabled), nil); err != nil { t.Fatalf("syncPageMetadata() error: %v", err) } @@ -113,7 +132,7 @@ func TestSyncPageMetadata_SetsContentStatusOnlyWhenPresent(t *testing.T) { } } -func TestSyncPageMetadata_RemovesContentStatusWhenCleared(t *testing.T) { +func TestSyncPageMetadata_ClearsContentStatusWhenExistingPageStatusRemoved(t *testing.T) { remote := newRollbackPushRemote() remote.contentStatuses["1"] = "Ready" @@ -123,17 +142,79 @@ func TestSyncPageMetadata_RemovesContentStatusWhenCleared(t *testing.T) { }, } - if err := syncPageMetadata(context.Background(), remote, "1", doc); err != nil { + if err := syncPageMetadata(context.Background(), remote, "1", doc, true, testTenantCapabilityCache(tenantContentStatusModeEnabled), nil); err != nil { t.Fatalf("syncPageMetadata() error: %v", err) } + if len(remote.getContentStatusCalls) != 1 { + t.Fatalf("get content status calls = %d, want 1", len(remote.getContentStatusCalls)) + } + if len(remote.setContentStatusArgs) != 0 { + t.Fatalf("set content status args = %d, want 0", len(remote.setContentStatusArgs)) + } if len(remote.deleteContentStatusArgs) != 1 { t.Fatalf("delete content status args = %d, want 1", len(remote.deleteContentStatusArgs)) } if got := remote.deleteContentStatusArgs[0]; got.PageStatus != "current" { t.Fatalf("unexpected delete content status call: %+v", got) } + if got := remote.contentStatuses["1"]; got != "" { + t.Fatalf("content status after clear = %q, want empty", got) + } +} + +func TestSyncPageMetadata_SkipsContentStatusForNewPageWhenStatusMissing(t *testing.T) { + remote := newRollbackPushRemote() + + doc := fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + State: "current", + }, + } + + if err := syncPageMetadata(context.Background(), remote, "", doc, false, testTenantCapabilityCache(tenantContentStatusModeEnabled), nil); err != nil { + t.Fatalf("syncPageMetadata() error: %v", err) + } + + if len(remote.getContentStatusCalls) != 0 { + t.Fatalf("get content status calls = %d, want 0", len(remote.getContentStatusCalls)) + } if len(remote.setContentStatusArgs) != 0 { t.Fatalf("set content status args = %d, want 0", len(remote.setContentStatusArgs)) } + if len(remote.deleteContentStatusArgs) != 0 { + t.Fatalf("delete content status args = %d, want 0", len(remote.deleteContentStatusArgs)) + } +} + +func TestSyncPageMetadata_DisablesContentStatusModeOnCompatibilityError(t *testing.T) { + remote := newRollbackPushRemote() + remote.getContentStatusErr = compatibilityNotImplementedError("new-page-1") + + doc := fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + State: "current", + Status: "Ready to review", + }, + } + cache := testTenantCapabilityCache(tenantContentStatusModeEnabled) + cache.pushContentStatusMode.resolved = false + var diagnostics []PushDiagnostic + + if err := syncPageMetadata(context.Background(), remote, "new-page-1", doc, false, cache, &diagnostics); err != nil { + t.Fatalf("syncPageMetadata() error: %v", err) + } + + if got := cache.currentPushContentStatusMode(); got != tenantContentStatusModeDisabled { + t.Fatalf("content status mode = %q, want disabled", got) + } + if len(diagnostics) != 1 || diagnostics[0].Code != "CONTENT_STATUS_COMPATIBILITY_MODE" { + t.Fatalf("diagnostics = %+v, want compatibility mode diagnostic", diagnostics) + } + if len(remote.setContentStatusArgs) != 0 { + t.Fatalf("set content status args = %d, want 0", len(remote.setContentStatusArgs)) + } + if len(remote.deleteContentStatusArgs) != 0 { + t.Fatalf("delete content status args = %d, want 0", len(remote.deleteContentStatusArgs)) + } } diff --git a/internal/sync/push_hierarchy.go b/internal/sync/push_hierarchy.go index 8458a7e..4e7ce7e 100644 --- a/internal/sync/push_hierarchy.go +++ b/internal/sync/push_hierarchy.go @@ -95,7 +95,34 @@ func ensureFolderHierarchy( if existingID, ok := folderIDByPath[currentPath]; ok && strings.TrimSpace(existingID) != "" { parentID = strings.TrimSpace(existingID) - parentType = "folder" + if opts.folderMode == tenantFolderModePageFallback { + parentType = "page" + } else { + parentType = "folder" + } + continue + } + + if opts.folderMode == tenantFolderModePageFallback { + pageCreated, pageErr := remote.CreatePage(ctx, confluence.PageUpsertInput{ + SpaceID: spaceID, + ParentPageID: parentID, + Title: seg, + Status: "current", + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + }) + if pageErr != nil { + return nil, fmt.Errorf("create compatibility hierarchy page %q: %w", currentPath, pageErr) + } + + createdID := strings.TrimSpace(pageCreated.ID) + if createdID == "" { + return nil, fmt.Errorf("create compatibility hierarchy page %q returned empty page ID", currentPath) + } + + folderIDByPath[currentPath] = createdID + parentID = createdID + parentType = "page" continue } diff --git a/internal/sync/push_page.go b/internal/sync/push_page.go index ae803b8..cf6ead5 100644 --- a/internal/sync/push_page.go +++ b/internal/sync/push_page.go @@ -78,10 +78,17 @@ func restorePageContentSnapshot(ctx context.Context, remote PushRemote, pageID s return nil } -func capturePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string, pageStatus string) (pushMetadataSnapshot, error) { - status, err := remote.GetContentStatus(ctx, pageID, pageStatus) - if err != nil { - return pushMetadataSnapshot{}, fmt.Errorf("get content status: %w", err) +func capturePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string, pageStatus string, contentStatusMode tenantContentStatusMode, trackContentStatus bool) (pushMetadataSnapshot, error) { + status := "" + if contentStatusMode != tenantContentStatusModeDisabled && trackContentStatus { + var err error + status, err = remote.GetContentStatus(ctx, pageID, pageStatus) + if err != nil { + if !isCompatibilityProbeError(err) { + return pushMetadataSnapshot{}, fmt.Errorf("get content status: %w", err) + } + trackContentStatus = false + } } labels, err := remote.GetLabels(ctx, pageID) @@ -90,9 +97,10 @@ func capturePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID } return pushMetadataSnapshot{ - ContentStatus: strings.TrimSpace(status), - PageStatus: normalizePageLifecycleState(pageStatus), - Labels: fs.NormalizeLabels(labels), + ContentStatus: strings.TrimSpace(status), + PageStatus: normalizePageLifecycleState(pageStatus), + TrackContentStatus: trackContentStatus && contentStatusMode != tenantContentStatusModeDisabled, + Labels: fs.NormalizeLabels(labels), }, nil } @@ -100,27 +108,39 @@ type metadataRestoreResult struct { ContentStatusRestored bool } -func restorePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string, snapshot pushMetadataSnapshot) (metadataRestoreResult, error) { +func restorePageMetadataSnapshot(ctx context.Context, remote PushRemote, pageID string, snapshot pushMetadataSnapshot, contentStatusMode tenantContentStatusMode) (metadataRestoreResult, error) { targetStatus := strings.TrimSpace(snapshot.ContentStatus) pageStatus := normalizePageLifecycleState(snapshot.PageStatus) - currentStatus, err := remote.GetContentStatus(ctx, pageID, pageStatus) - if err != nil { - return metadataRestoreResult{}, fmt.Errorf("get content status: %w", err) - } - currentStatus = strings.TrimSpace(currentStatus) result := metadataRestoreResult{} - - if currentStatus != targetStatus { - if targetStatus == "" { - if err := remote.DeleteContentStatus(ctx, pageID, pageStatus); err != nil { - return metadataRestoreResult{}, fmt.Errorf("delete content status: %w", err) + if contentStatusMode != tenantContentStatusModeDisabled && snapshot.TrackContentStatus { + currentStatus, err := remote.GetContentStatus(ctx, pageID, pageStatus) + if err != nil { + if !isCompatibilityProbeError(err) { + return metadataRestoreResult{}, fmt.Errorf("get content status: %w", err) } } else { - if err := remote.SetContentStatus(ctx, pageID, pageStatus, targetStatus); err != nil { - return metadataRestoreResult{}, fmt.Errorf("set content status: %w", err) + currentStatus = strings.TrimSpace(currentStatus) + + if currentStatus != targetStatus { + if targetStatus == "" { + if err := remote.DeleteContentStatus(ctx, pageID, pageStatus); err != nil { + if !isCompatibilityProbeError(err) { + return metadataRestoreResult{}, fmt.Errorf("delete content status: %w", err) + } + } else { + result.ContentStatusRestored = true + } + } else { + if err := remote.SetContentStatus(ctx, pageID, pageStatus, targetStatus); err != nil { + if !isCompatibilityProbeError(err) { + return metadataRestoreResult{}, fmt.Errorf("set content status: %w", err) + } + } else { + result.ContentStatusRestored = true + } + } } } - result.ContentStatusRestored = true } remoteLabels, err := remote.GetLabels(ctx, pageID) diff --git a/internal/sync/push_rollback.go b/internal/sync/push_rollback.go index 9e395c4..e2ddc02 100644 --- a/internal/sync/push_rollback.go +++ b/internal/sync/push_rollback.go @@ -10,10 +10,11 @@ import ( "github.com/rgonek/confluence-markdown-sync/internal/confluence" ) -func newPushRollbackTracker(relPath string, diagnostics *[]PushDiagnostic) *pushRollbackTracker { +func newPushRollbackTracker(relPath string, contentStatusMode tenantContentStatusMode, diagnostics *[]PushDiagnostic) *pushRollbackTracker { return &pushRollbackTracker{ - relPath: relPath, - diagnostics: diagnostics, + relPath: relPath, + contentStatusMode: contentStatusMode, + diagnostics: diagnostics, } } @@ -107,7 +108,7 @@ func (r *pushRollbackTracker) rollback(ctx context.Context, remote PushRemote) e if r.metadataRestoreReq && r.metadataSnapshot != nil && strings.TrimSpace(r.metadataPageID) != "" { slog.Info("push_rollback_step", "path", r.relPath, "step", "metadata", "page_id", r.metadataPageID) - restoreResult, err := restorePageMetadataSnapshot(ctx, remote, r.metadataPageID, *r.metadataSnapshot) + restoreResult, err := restorePageMetadataSnapshot(ctx, remote, r.metadataPageID, *r.metadataSnapshot, r.contentStatusMode) if err != nil { slog.Warn("push_rollback_step_failed", "path", r.relPath, "step", "metadata", "page_id", r.metadataPageID, "error", err.Error()) if strings.Contains(err.Error(), "content status") { diff --git a/internal/sync/push_rollback_test.go b/internal/sync/push_rollback_test.go index dda8b04..ea188cd 100644 --- a/internal/sync/push_rollback_test.go +++ b/internal/sync/push_rollback_test.go @@ -170,6 +170,70 @@ func TestPush_RollbackRestoresMetadataOnSyncFailure(t *testing.T) { } } +func TestPush_RollbackCompatibilityModeSkipsContentStatusRestoreButRestoresLabels(t *testing.T) { + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + remote.labelsByPage["1"] = []string{} + remote.getContentStatusErr = &confluence.APIError{ + StatusCode: 501, + Method: "GET", + URL: "/wiki/rest/api/content/1/state", + Message: "Not Implemented", + } + diagnostics := []PushDiagnostic{} + rollback := newPushRollbackTracker("root.md", tenantContentStatusModeDisabled, &diagnostics) + rollback.trackMetadataSnapshot("1", pushMetadataSnapshot{ + ContentStatus: "legacy-status", + PageStatus: "current", + Labels: []string{"legacy"}, + }) + + if err := rollback.rollback(context.Background(), remote); err != nil { + t.Fatalf("rollback() unexpected error: %v", err) + } + + if got := len(remote.getContentStatusCalls); got != 0 { + t.Fatalf("get content status calls = %d, want 0 in compatibility mode rollback", got) + } + if len(remote.setContentStatusArgs) != 0 { + t.Fatalf("set content status args = %d, want 0 in compatibility mode rollback", len(remote.setContentStatusArgs)) + } + if len(remote.deleteContentStatusArgs) != 0 { + t.Fatalf("delete content status args = %d, want 0 in compatibility mode rollback", len(remote.deleteContentStatusArgs)) + } + if got := remote.labelsByPage["1"]; len(got) != 1 || got[0] != "legacy" { + t.Fatalf("labels after rollback = %v, want [legacy]", got) + } + if len(remote.addLabelsCalls) != 1 { + t.Fatalf("add labels calls = %d, want 1 rollback label restore attempt", len(remote.addLabelsCalls)) + } + + hasMetadataRollback := false + hasContentStatusRollback := false + for _, diag := range diagnostics { + switch diag.Code { + case "ROLLBACK_METADATA_RESTORED": + hasMetadataRollback = true + case "ROLLBACK_CONTENT_STATUS_RESTORED": + hasContentStatusRollback = true + } + } + if !hasMetadataRollback { + t.Fatalf("expected ROLLBACK_METADATA_RESTORED diagnostic, got %+v", diagnostics) + } + if hasContentStatusRollback { + t.Fatalf("did not expect content-status rollback diagnostic in compatibility mode, got %+v", diagnostics) + } +} + func TestPush_RollbackDeletesCreatedPageWhenMetadataSyncStatusFails(t *testing.T) { spaceDir := t.TempDir() mdPath := filepath.Join(spaceDir, "new.md") diff --git a/internal/sync/push_testhelpers_test.go b/internal/sync/push_testhelpers_test.go index b7f7806..4ab1a84 100644 --- a/internal/sync/push_testhelpers_test.go +++ b/internal/sync/push_testhelpers_test.go @@ -133,15 +133,18 @@ type rollbackPushRemote struct { pagesByID map[string]confluence.Page contentStatuses map[string]string labelsByPage map[string][]string + folders []confluence.Folder nextPageID int nextAttachmentID int createPageCalls int + createFolderCalls int updatePageCalls int uploadAttachmentCalls int archiveTaskCalls []string deletePageCalls []string deletePageOpts []confluence.PageDeleteOptions deleteAttachmentCalls []string + getContentStatusCalls []string setContentStatusCalls []string setContentStatusArgs []contentStatusCall deleteContentStatusCalls []string @@ -151,6 +154,8 @@ type rollbackPushRemote struct { archiveTaskStatus confluence.ArchiveTaskStatus archivePagesErr error archiveTaskWaitErr error + listFoldersErr error + getContentStatusErr error failUpdate bool failAddLabels bool failSetContentStatus bool @@ -193,6 +198,10 @@ func (f *rollbackPushRemote) GetPage(_ context.Context, pageID string) (confluen } func (f *rollbackPushRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { + f.getContentStatusCalls = append(f.getContentStatusCalls, pageID) + if f.getContentStatusErr != nil { + return "", f.getContentStatusErr + } return f.contentStatuses[pageID], nil } @@ -356,11 +365,17 @@ func (f *rollbackPushRemote) DeleteAttachment(_ context.Context, attachmentID st } func (f *rollbackPushRemote) CreateFolder(_ context.Context, input confluence.FolderCreateInput) (confluence.Folder, error) { - return confluence.Folder{ID: "folder-1", SpaceID: input.SpaceID, Title: input.Title, ParentID: input.ParentID}, nil + f.createFolderCalls++ + folder := confluence.Folder{ID: fmt.Sprintf("folder-%d", f.createFolderCalls), SpaceID: input.SpaceID, Title: input.Title, ParentID: input.ParentID, ParentType: input.ParentType} + f.folders = append(f.folders, folder) + return folder, nil } func (f *rollbackPushRemote) ListFolders(_ context.Context, _ confluence.FolderListOptions) (confluence.FolderListResult, error) { - return confluence.FolderListResult{}, nil + if f.listFoldersErr != nil { + return confluence.FolderListResult{}, f.listFoldersErr + } + return confluence.FolderListResult{Folders: append([]confluence.Folder(nil), f.folders...)}, nil } func (f *rollbackPushRemote) DeleteFolder(_ context.Context, _ string) error { diff --git a/internal/sync/push_types.go b/internal/sync/push_types.go index 7bdd33f..28e8ec6 100644 --- a/internal/sync/push_types.go +++ b/internal/sync/push_types.go @@ -77,6 +77,8 @@ type PushOptions struct { ArchivePollInterval time.Duration Progress Progress folderListTracker *folderListFallbackTracker + folderMode tenantFolderMode + contentStatusMode tenantContentStatusMode } // PushCommitPlan describes local paths and metadata for one push commit. @@ -106,9 +108,10 @@ type PushResult struct { } type pushMetadataSnapshot struct { - ContentStatus string - PageStatus string - Labels []string + ContentStatus string + PageStatus string + TrackContentStatus bool + Labels []string } type pushContentSnapshot struct { @@ -127,6 +130,7 @@ type rollbackAttachment struct { type pushRollbackTracker struct { relPath string + contentStatusMode tenantContentStatusMode createdPageID string createdPageStatus string uploadedAssets []rollbackAttachment diff --git a/internal/sync/tenant_capabilities.go b/internal/sync/tenant_capabilities.go new file mode 100644 index 0000000..b851a0b --- /dev/null +++ b/internal/sync/tenant_capabilities.go @@ -0,0 +1,279 @@ +package sync + +import ( + "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/rgonek/confluence-markdown-sync/internal/confluence" + "github.com/rgonek/confluence-markdown-sync/internal/fs" +) + +type tenantFolderMode string + +const ( + tenantFolderModeNative tenantFolderMode = "native" + tenantFolderModePageFallback tenantFolderMode = "page-fallback" +) + +type tenantContentStatusMode string + +const ( + tenantContentStatusModeEnabled tenantContentStatusMode = "enabled" + tenantContentStatusModeDisabled tenantContentStatusMode = "disabled" +) + +type tenantCapabilityCache struct { + pullFolderMode struct { + resolved bool + mode tenantFolderMode + diags []PullDiagnostic + } + pullContentStatusMode struct { + resolved bool + mode tenantContentStatusMode + diags []PullDiagnostic + } + pushFolderMode struct { + resolved bool + mode tenantFolderMode + diags []PushDiagnostic + } + pushContentStatusMode struct { + resolved bool + mode tenantContentStatusMode + diags []PushDiagnostic + } +} + +func newTenantCapabilityCache() *tenantCapabilityCache { + return &tenantCapabilityCache{} +} + +func (c *tenantCapabilityCache) detectPullFolderMode(ctx context.Context, remote PullRemote, pages []confluence.Page) (tenantFolderMode, []PullDiagnostic, error) { + if c.pullFolderMode.resolved { + return c.pullFolderMode.mode, append([]PullDiagnostic(nil), c.pullFolderMode.diags...), nil + } + + mode := tenantFolderModeNative + diags := []PullDiagnostic{} + + for _, page := range pages { + if !strings.EqualFold(strings.TrimSpace(page.ParentType), "folder") { + continue + } + folderID := strings.TrimSpace(page.ParentPageID) + if folderID == "" { + continue + } + _, err := remote.GetFolder(ctx, folderID) + switch { + case err == nil, errors.Is(err, confluence.ErrNotFound): + case shouldIgnoreFolderHierarchyError(err): + mode = tenantFolderModePageFallback + diags = append(diags, PullDiagnostic{ + Path: folderLookupUnavailablePath, + Code: "FOLDER_LOOKUP_UNAVAILABLE", + Message: "compatibility mode active: folder lookup unavailable, falling back to page-only hierarchy for affected pages", + }) + default: + return "", nil, err + } + break + } + + c.pullFolderMode.resolved = true + c.pullFolderMode.mode = mode + c.pullFolderMode.diags = append([]PullDiagnostic(nil), diags...) + return mode, diags, nil +} + +func (c *tenantCapabilityCache) detectPullContentStatusMode(ctx context.Context, remote PullRemote, pages []confluence.Page) (tenantContentStatusMode, []PullDiagnostic) { + if c.pullContentStatusMode.resolved { + return c.pullContentStatusMode.mode, append([]PullDiagnostic(nil), c.pullContentStatusMode.diags...) + } + + mode := tenantContentStatusModeEnabled + diags := []PullDiagnostic{} + for _, page := range pages { + pageID := strings.TrimSpace(page.ID) + if pageID == "" { + continue + } + if _, err := remote.GetContentStatus(ctx, pageID, page.Status); isCompatibilityProbeError(err) { + mode = tenantContentStatusModeDisabled + diags = append(diags, PullDiagnostic{ + Path: "", + Code: "CONTENT_STATUS_COMPATIBILITY_MODE", + Message: "compatibility mode active: content-status fetch disabled for this pull because the tenant does not support the endpoint", + }) + } + break + } + + c.pullContentStatusMode.resolved = true + c.pullContentStatusMode.mode = mode + c.pullContentStatusMode.diags = append([]PullDiagnostic(nil), diags...) + return mode, diags +} + +func (c *tenantCapabilityCache) detectPushFolderMode(changes []PushFileChange, listErr error) (tenantFolderMode, []PushDiagnostic) { + if c.pushFolderMode.resolved { + return c.pushFolderMode.mode, append([]PushDiagnostic(nil), c.pushFolderMode.diags...) + } + + mode := tenantFolderModeNative + diags := []PushDiagnostic{} + if listErr != nil && pushChangesNeedFolderHierarchy(changes) && shouldIgnoreFolderHierarchyError(listErr) { + mode = tenantFolderModePageFallback + diags = append(diags, PushDiagnostic{ + Path: "", + Code: "FOLDER_COMPATIBILITY_MODE", + Message: "compatibility mode active: folder API unavailable; using page-based hierarchy mode for this push", + }) + } + + c.pushFolderMode.resolved = true + c.pushFolderMode.mode = mode + c.pushFolderMode.diags = append([]PushDiagnostic(nil), diags...) + return mode, diags +} + +func (c *tenantCapabilityCache) detectPushContentStatusMode(ctx context.Context, remote PushRemote, spaceDir string, pages []confluence.Page, changes []PushFileChange) (tenantContentStatusMode, error) { + if c.pushContentStatusMode.resolved { + return c.pushContentStatusMode.mode, nil + } + + mode := tenantContentStatusModeEnabled + if pageID, pageStatus, ok := pushContentStatusProbeTarget(spaceDir, pages, changes); ok { + if _, err := remote.GetContentStatus(ctx, pageID, pageStatus); err != nil { + if !isCompatibilityProbeError(err) { + return "", fmt.Errorf("get content status: %w", err) + } + mode = tenantContentStatusModeDisabled + c.pushContentStatusMode.diags = append(c.pushContentStatusMode.diags, PushDiagnostic{ + Path: "", + Code: "CONTENT_STATUS_COMPATIBILITY_MODE", + Message: "compatibility mode active: content-status metadata sync disabled for this push because the tenant does not support the endpoint", + }) + } + } + + c.pushContentStatusMode.resolved = true + c.pushContentStatusMode.mode = mode + return mode, nil +} + +func (c *tenantCapabilityCache) pushContentStatusDiagnostics() []PushDiagnostic { + return append([]PushDiagnostic(nil), c.pushContentStatusMode.diags...) +} + +func (c *tenantCapabilityCache) currentPushContentStatusMode() tenantContentStatusMode { + if c == nil { + return tenantContentStatusModeEnabled + } + if c.pushContentStatusMode.mode == "" { + return tenantContentStatusModeEnabled + } + return c.pushContentStatusMode.mode +} + +func (c *tenantCapabilityCache) disablePushContentStatusMode() []PushDiagnostic { + if c == nil { + return nil + } + if c.pushContentStatusMode.mode != tenantContentStatusModeDisabled { + c.pushContentStatusMode.mode = tenantContentStatusModeDisabled + } + c.pushContentStatusMode.resolved = true + if len(c.pushContentStatusMode.diags) == 0 { + c.pushContentStatusMode.diags = append(c.pushContentStatusMode.diags, PushDiagnostic{ + Path: "", + Code: "CONTENT_STATUS_COMPATIBILITY_MODE", + Message: "compatibility mode active: content-status metadata sync disabled for this push because the tenant does not support the endpoint", + }) + return []PushDiagnostic{c.pushContentStatusMode.diags[0]} + } + return nil +} + +func isCompatibilityProbeError(err error) bool { + if err == nil { + return false + } + var apiErr *confluence.APIError + if !errors.As(err, &apiErr) { + return false + } + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented: + return true + default: + return false + } +} + +func pushChangesNeedFolderHierarchy(changes []PushFileChange) bool { + for _, change := range changes { + if change.Type == PushChangeDelete { + continue + } + dirPath := normalizeRelPath(strings.TrimSpace(filepathDirFromRel(change.Path))) + if dirPath != "" && dirPath != "." { + return true + } + } + return false +} + +func pushChangesNeedMetadataSync(changes []PushFileChange) bool { + for _, change := range changes { + if change.Type == PushChangeAdd || change.Type == PushChangeModify { + return true + } + } + return false +} + +func filepathDirFromRel(relPath string) string { + return filepath.ToSlash(filepath.Dir(filepath.FromSlash(strings.TrimSpace(relPath)))) +} + +func pushContentStatusProbeTarget(spaceDir string, pages []confluence.Page, changes []PushFileChange) (string, string, bool) { + needsContentStatusSync := false + for _, change := range changes { + if change.Type != PushChangeAdd && change.Type != PushChangeModify { + continue + } + relPath := normalizeRelPath(change.Path) + if relPath == "" { + continue + } + frontmatter, err := fs.ReadFrontmatter(filepath.Join(spaceDir, filepath.FromSlash(relPath))) + if err != nil { + continue + } + pageID := strings.TrimSpace(frontmatter.ID) + if pageID == "" && strings.TrimSpace(frontmatter.Status) == "" { + continue + } + needsContentStatusSync = true + if pageID != "" { + return pageID, normalizePageLifecycleState(frontmatter.State), true + } + } + if !needsContentStatusSync { + return "", "", false + } + for _, page := range pages { + pageID := strings.TrimSpace(page.ID) + if pageID == "" { + continue + } + return pageID, normalizePageLifecycleState(page.Status), true + } + return "", "", false +} diff --git a/internal/sync/tenant_capabilities_test.go b/internal/sync/tenant_capabilities_test.go new file mode 100644 index 0000000..d267413 --- /dev/null +++ b/internal/sync/tenant_capabilities_test.go @@ -0,0 +1,592 @@ +package sync + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rgonek/confluence-markdown-sync/internal/confluence" + "github.com/rgonek/confluence-markdown-sync/internal/fs" +) + +func TestPull_FolderCapabilityFallbackSelectedBeforeHierarchyWalk(t *testing.T) { + tmpDir := t.TempDir() + spaceDir := filepath.Join(tmpDir, "ENG") + if err := fs.WriteMarkdownDocument(filepath.Join(spaceDir, "existing.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Existing", ID: "existing", Version: 1}, + Body: "existing\n", + }); err != nil { + t.Fatalf("write existing markdown: %v", err) + } + + remote := &fakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + { + ID: "1", + SpaceID: "space-1", + Title: "Start Here", + ParentPageID: "folder-1", + ParentType: "folder", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + }, + { + ID: "2", + SpaceID: "space-1", + Title: "Child", + ParentPageID: "folder-2", + ParentType: "folder", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 5, 0, 0, time.UTC), + }, + }, + pagesByID: map[string]confluence.Page{ + "1": {ID: "1", SpaceID: "space-1", Title: "Start Here", ParentPageID: "folder-1", ParentType: "folder", Version: 2, BodyADF: rawJSON(t, map[string]any{"version": 1, "type": "doc", "content": []any{}})}, + "2": {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 2, BodyADF: rawJSON(t, map[string]any{"version": 1, "type": "doc", "content": []any{}})}, + }, + folderErr: &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders/folder-1", + Message: "Internal Server Error", + }, + } + + result, err := Pull(context.Background(), remote, PullOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + }) + if err != nil { + t.Fatalf("Pull() unexpected error: %v", err) + } + + if len(remote.getFolderCalls) != 1 { + t.Fatalf("get folder calls = %d, want 1 capability probe before fallback", len(remote.getFolderCalls)) + } + + foundMode := false + for _, diag := range result.Diagnostics { + if diag.Code == "FOLDER_LOOKUP_UNAVAILABLE" && strings.Contains(diag.Message, "compatibility mode") { + foundMode = true + break + } + } + if !foundMode { + t.Fatalf("expected concise folder compatibility diagnostic, got %+v", result.Diagnostics) + } +} + +func TestPull_ContentStatusCapabilityFallbackEmitsCompatibilityDiagnostic(t *testing.T) { + tmpDir := t.TempDir() + spaceDir := filepath.Join(tmpDir, "ENG") + if err := fs.WriteMarkdownDocument(filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + Status: "Keep existing", + }, + Body: "existing\n", + }); err != nil { + t.Fatalf("write existing markdown: %v", err) + } + + remote := &fakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 2, + LastModified: time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), + }, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 2, + BodyADF: rawJSON(t, map[string]any{"version": 1, "type": "doc", "content": []any{}}), + }, + }, + contentStatusErr: &confluence.APIError{ + StatusCode: 501, + Method: "GET", + URL: "/wiki/rest/api/content/1/state", + Message: "Not Implemented", + }, + } + + result, err := Pull(context.Background(), remote, PullOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + }) + if err != nil { + t.Fatalf("Pull() unexpected error: %v", err) + } + + if got := len(remote.getStatusCalls); got != 1 { + t.Fatalf("get content status calls = %d, want 1 capability probe", got) + } + + foundMode := false + for _, diag := range result.Diagnostics { + if diag.Code == "CONTENT_STATUS_COMPATIBILITY_MODE" && strings.Contains(diag.Message, "disabled for this pull") { + foundMode = true + break + } + } + if !foundMode { + t.Fatalf("expected concise content-status compatibility diagnostic, got %+v", result.Diagnostics) + } +} + +func TestPush_ContentStatusCapabilityFallbackSkipsMetadataWrites(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + Status: "Ready to review", + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + remote.getContentStatusErr = &confluence.APIError{ + StatusCode: 501, + Method: "GET", + URL: "/wiki/rest/api/content/1/state", + Message: "Not Implemented", + } + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }) + if err != nil { + t.Fatalf("Push() unexpected error: %v", err) + } + + if got := len(remote.getContentStatusCalls); got != 1 { + t.Fatalf("get content status calls = %d, want 1 capability probe", got) + } + if len(remote.setContentStatusArgs) != 0 { + t.Fatalf("set content status args = %d, want 0 in compatibility mode", len(remote.setContentStatusArgs)) + } + if len(remote.deleteContentStatusArgs) != 0 { + t.Fatalf("delete content status args = %d, want 0 in compatibility mode", len(remote.deleteContentStatusArgs)) + } + if remote.updatePageCalls == 0 { + t.Fatal("expected page content update to continue in compatibility mode") + } + + foundMode := false + for _, diag := range result.Diagnostics { + if diag.Code == "CONTENT_STATUS_COMPATIBILITY_MODE" && strings.Contains(diag.Message, "metadata sync disabled") { + foundMode = true + break + } + } + if !foundMode { + t.Fatalf("expected content-status compatibility diagnostic, got %+v", result.Diagnostics) + } +} + +func TestPush_ContentStatusProbeRealErrorsSurface(t *testing.T) { + testCases := []struct { + name string + statusCode int + message string + }{ + {name: "forbidden", statusCode: 403, message: "Forbidden"}, + {name: "rate_limited", statusCode: 429, message: "Too Many Requests"}, + {name: "server_error", statusCode: 500, message: "Internal Server Error"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + Status: "Ready to review", + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + remote.getContentStatusErr = &confluence.APIError{ + StatusCode: tc.statusCode, + Method: "GET", + URL: "/wiki/rest/api/content/1/state", + Message: tc.message, + } + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }) + if err == nil { + t.Fatal("Push() error = nil, want content-status error to surface") + } + if !strings.Contains(err.Error(), "get content status") { + t.Fatalf("Push() error = %v, want get content status failure", err) + } + for _, diag := range result.Diagnostics { + if diag.Code == "CONTENT_STATUS_COMPATIBILITY_MODE" { + t.Fatalf("unexpected compatibility diagnostic: %+v", diag) + } + } + }) + } +} + +func TestPush_ContentStatusCompatibilityProbeCoversExistingPageClearOperations(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + remote.getContentStatusErr = &confluence.APIError{ + StatusCode: 501, + Method: "GET", + URL: "/wiki/rest/api/content/1/state", + Message: "Not Implemented", + } + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }) + if err != nil { + t.Fatalf("Push() unexpected error: %v", err) + } + + if got := len(remote.getContentStatusCalls); got != 1 { + t.Fatalf("get content status calls = %d, want 1 compatibility probe for clear-only update", got) + } + if len(remote.setContentStatusArgs) != 0 { + t.Fatalf("set content status args = %d, want 0 in compatibility mode", len(remote.setContentStatusArgs)) + } + if len(remote.deleteContentStatusArgs) != 0 { + t.Fatalf("delete content status args = %d, want 0 in compatibility mode", len(remote.deleteContentStatusArgs)) + } + if remote.updatePageCalls == 0 { + t.Fatal("expected page content update to continue in compatibility mode") + } + + foundMode := false + for _, diag := range result.Diagnostics { + if diag.Code == "CONTENT_STATUS_COMPATIBILITY_MODE" && strings.Contains(diag.Message, "metadata sync disabled") { + foundMode = true + break + } + } + if !foundMode { + t.Fatalf("expected content-status compatibility diagnostic, got %+v", result.Diagnostics) + } +} + +func TestPush_ContentStatusProbeRealErrorsSurfaceForExistingPageClearOperations(t *testing.T) { + testCases := []struct { + name string + statusCode int + message string + }{ + {name: "forbidden", statusCode: 403, message: "Forbidden"}, + {name: "rate_limited", statusCode: 429, message: "Too Many Requests"}, + {name: "server_error", statusCode: 500, message: "Internal Server Error"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "root.md") + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.pagesByID["1"] = confluence.Page{ + ID: "1", + SpaceID: "space-1", + Title: "Root", + Status: "current", + Version: 1, + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + } + remote.pages = append(remote.pages, remote.pagesByID["1"]) + remote.contentStatuses["1"] = "Ready to review" + remote.getContentStatusErr = &confluence.APIError{ + StatusCode: tc.statusCode, + Method: "GET", + URL: "/wiki/rest/api/content/1/state", + Message: tc.message, + } + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeModify, Path: "root.md"}}, + }) + if err == nil { + t.Fatal("Push() error = nil, want content-status error to surface") + } + if !strings.Contains(err.Error(), "get content status") { + t.Fatalf("Push() error = %v, want get content status failure", err) + } + if remote.updatePageCalls != 0 { + t.Fatalf("update page calls = %d, want 0 when capability probe fails", remote.updatePageCalls) + } + for _, diag := range result.Diagnostics { + if diag.Code == "CONTENT_STATUS_COMPATIBILITY_MODE" { + t.Fatalf("unexpected compatibility diagnostic: %+v", diag) + } + } + }) + } +} + +func TestPush_ContentStatusUnsupportedEndpointInEmptySpaceEmitsCompatibilityDiagnostic(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "new.md") + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "New Page", + Status: "Ready to review", + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.getContentStatusErr = &confluence.APIError{ + StatusCode: 501, + Method: "GET", + URL: "/wiki/rest/api/content/new-page-1/state", + Message: "Not Implemented", + } + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG"}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeAdd, Path: "new.md"}}, + }) + if err != nil { + t.Fatalf("Push() unexpected error: %v", err) + } + + if remote.createPageCalls != 1 { + t.Fatalf("create page calls = %d, want 1", remote.createPageCalls) + } + if got := len(remote.getContentStatusCalls); got != 1 { + t.Fatalf("get content status calls = %d, want 1 compatibility probe after page creation", got) + } + if len(remote.setContentStatusArgs) != 0 { + t.Fatalf("set content status args = %d, want 0 after compatibility fallback", len(remote.setContentStatusArgs)) + } + if len(remote.deleteContentStatusArgs) != 0 { + t.Fatalf("delete content status args = %d, want 0 after compatibility fallback", len(remote.deleteContentStatusArgs)) + } + if result.State.PagePathIndex["new.md"] == "" { + t.Fatalf("expected pushed page to be tracked, got state %+v", result.State.PagePathIndex) + } + + foundMode := false + for _, diag := range result.Diagnostics { + if diag.Code == "CONTENT_STATUS_COMPATIBILITY_MODE" && strings.Contains(diag.Message, "metadata sync disabled") { + foundMode = true + break + } + } + if !foundMode { + t.Fatalf("expected content-status compatibility diagnostic, got %+v", result.Diagnostics) + } +} + +func TestPush_ContentStatusRealErrorsSurfaceForNewPageInEmptySpace(t *testing.T) { + testCases := []struct { + name string + statusCode int + message string + }{ + {name: "forbidden", statusCode: 403, message: "Forbidden"}, + {name: "rate_limited", statusCode: 429, message: "Too Many Requests"}, + {name: "server_error", statusCode: 500, message: "Internal Server Error"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + spaceDir := t.TempDir() + mdPath := filepath.Join(spaceDir, "new.md") + if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "New Page", + Status: "Ready to review", + }, + Body: "content\n", + }); err != nil { + t.Fatalf("write markdown: %v", err) + } + + remote := newRollbackPushRemote() + remote.getContentStatusErr = &confluence.APIError{ + StatusCode: tc.statusCode, + Method: "GET", + URL: "/wiki/rest/api/content/new-page-1/state", + Message: tc.message, + } + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG"}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeAdd, Path: "new.md"}}, + }) + if err == nil { + t.Fatal("Push() error = nil, want content-status error to surface") + } + if !strings.Contains(err.Error(), "get content status") { + t.Fatalf("Push() error = %v, want get content status failure", err) + } + if got := len(remote.getContentStatusCalls); got != 1 { + t.Fatalf("get content status calls = %d, want 1 runtime probe", got) + } + for _, diag := range result.Diagnostics { + if diag.Code == "CONTENT_STATUS_COMPATIBILITY_MODE" { + t.Fatalf("unexpected compatibility diagnostic: %+v", diag) + } + } + }) + } +} + +func TestPush_FolderCapabilityFallbackUsesPageHierarchyMode(t *testing.T) { + spaceDir := t.TempDir() + nestedDir := filepath.Join(spaceDir, "Parent") + if err := fs.WriteMarkdownDocument(filepath.Join(nestedDir, "Child.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Child"}, + Body: "child\n", + }); err != nil { + t.Fatalf("write Child.md: %v", err) + } + + remote := newRollbackPushRemote() + remote.listFoldersErr = &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders", + Message: "Internal Server Error", + } + + result, err := Push(context.Background(), remote, PushOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + Domain: "https://example.atlassian.net", + State: fs.SpaceState{SpaceKey: "ENG"}, + ConflictPolicy: PushConflictPolicyCancel, + Changes: []PushFileChange{{Type: PushChangeAdd, Path: "Parent/Child.md"}}, + }) + if err != nil { + t.Fatalf("Push() unexpected error: %v", err) + } + + if remote.createFolderCalls != 0 { + t.Fatalf("create folder calls = %d, want 0 after compatibility mode selection", remote.createFolderCalls) + } + if remote.createPageCalls < 2 { + t.Fatalf("create page calls = %d, want at least 2 for parent compatibility page + child", remote.createPageCalls) + } + + foundMode := false + for _, diag := range result.Diagnostics { + if diag.Code == "FOLDER_COMPATIBILITY_MODE" && strings.Contains(diag.Message, "page-based hierarchy mode") { + foundMode = true + break + } + } + if !foundMode { + t.Fatalf("expected folder compatibility diagnostic, got %+v", result.Diagnostics) + } +} From 92b2628b1a54c21260c02248219e5a546f8f12e8 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 17:47:40 +0100 Subject: [PATCH 19/31] Clarify diagnostics and extension support Tighten pull/diff warning taxonomy, add actionable diagnostic metadata, and document extension support boundaries explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 5 + README.md | 11 +- ...26-03-05-live-workflow-polish-followups.md | 3 +- cmd/diagnostics.go | 34 +++--- cmd/diagnostics_test.go | 14 ++- cmd/diff.go | 13 ++- cmd/diff_render.go | 25 ++++- cmd/diff_test.go | 103 ++++++++++++++++++ docs/usage.md | 17 ++- internal/sync/diagnostics.go | 54 +++++++++ internal/sync/pull.go | 10 +- internal/sync/pull_test.go | 12 ++ 12 files changed, 269 insertions(+), 32 deletions(-) create mode 100644 internal/sync/diagnostics.go diff --git a/AGENTS.md b/AGENTS.md index f408c74..da60c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,11 @@ The agent manages the full sync cycle. - PlantUML is supported as a first-class `plantumlcloud` Confluence extension. - Mermaid is preserved as fenced code / ADF `codeBlock` content, not a rendered Confluence diagram macro. - `validate` should warn before push when Mermaid fences are present so the downgrade is explicit. +- Extension/macro support contract: + - PlantUML: rendered round-trip support via the custom `plantumlcloud` handler. + - Mermaid: preserved-but-not-rendered only; keep it as fenced code and expect an ADF `codeBlock` on push. + - Raw `adf:extension` payloads: best-effort, low-level preservation fallback for extension nodes without a repo-specific handler; not a verified end-to-end round-trip guarantee. + - Unknown Confluence macros/extensions: not a first-class supported authoring target; they may only survive through best-effort raw ADF preservation, and Confluence can still reject them on push. Validate any such workflow in a sandbox before relying on it. ## Git Workflow Requirements - `push` uses an ephemeral sync branch: `sync//`. diff --git a/README.md b/README.md index b1143a6..3020e0c 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ conf push ENG --on-conflict=cancel - Version: `conf version` or `conf --version` - Target rule: `.md` suffix means file mode; otherwise space mode (`SPACE_KEY`) - Required auth: `ATLASSIAN_DOMAIN`, `ATLASSIAN_EMAIL`, `ATLASSIAN_API_TOKEN` -- Diagram support: PlantUML is preserved as a Confluence extension; Mermaid is preserved as fenced code / ADF `codeBlock` and `validate` warns before push +- Extension support: PlantUML is the only first-class rendered extension handler; Mermaid is preserved as code, and raw `adf:extension` / unknown macro handling is best-effort and should be sandbox-validated before relying on it - Status scope: `conf status` reports Markdown page drift only; use `git status` or `conf diff` for attachment-only changes - Label rules: labels are trimmed, lowercased, deduplicated, and sorted; empty labels and labels containing whitespace are rejected - Search filters: `--space`, repeatable `--label`, `--heading`, `--created-by`, `--updated-by`, date bounds, and `--result-detail` @@ -76,6 +76,15 @@ conf push ENG --on-conflict=cancel - Support policy: `SUPPORT.md` - License: `LICENSE` +## Extension and macro support 🧩 + +| Item | Support level | What `conf` does | Notes | +|------|---------------|------------------|-------| +| PlantUML (`plantumlcloud`) | Rendered round-trip support | Pull converts the Confluence extension into Markdown with a managed `adf-extension` wrapper and `puml` body; push reconstructs the Confluence macro. | This is the only first-class custom extension handler in the repo today. | +| Mermaid | Preserved but not rendered | Keeps Mermaid as fenced code in Markdown and pushes it back as an ADF `codeBlock` with `language: mermaid`. | `conf validate`/`conf push` warn with `MERMAID_PRESERVED_AS_CODEBLOCK` so the render downgrade is explicit. | +| Raw ADF extension preservation | Best-effort preservation only | Unhandled extension nodes can fall back to raw ```` ```adf:extension ```` JSON blocks so the original ADF payload can be carried through Markdown with minimal interpretation. | This is a low-level escape hatch, not a rendered feature contract or a verified end-to-end round-trip guarantee. Validate any workflow that depends on it in a sandbox before relying on it. | +| Unknown Confluence macros/extensions | Unsupported as a first-class feature | `conf` does not ship custom handlers for unknown macros, beyond whatever best-effort raw ADF preservation may be possible for some remote extension payloads. | Do not assume unknown macros will round-trip or render correctly. Push can still fail if Confluence rejects the macro or if the instance does not have the required app installed; sandbox validation is recommended before depending on this path. | + ## Development 🧑‍💻 - `make build` - `make test` diff --git a/agents/plans/2026-03-05-live-workflow-polish-followups.md b/agents/plans/2026-03-05-live-workflow-polish-followups.md index eccb9a7..22354e5 100644 --- a/agents/plans/2026-03-05-live-workflow-polish-followups.md +++ b/agents/plans/2026-03-05-live-workflow-polish-followups.md @@ -15,8 +15,9 @@ Capture the non-blocking but high-value workflow, diagnostics, and operator-expe - [x] Batch 1 completed: items 1, 2, 5, and 7 are closed with regression coverage on this branch. - [x] Batch 2 completed: items 3, 6, 12, and 13 are closed with regression coverage on this branch. - [x] Batch 3 completed: items 10, 11, and 18 are closed, and follow-up hardening landed for items 6, 12, and 13. +- [x] Batch 4 completed: items 4 and 9 are closed with warning-taxonomy regression coverage and explicit extension-support documentation updates. - [x] Item 8 was re-verified as already complete on this branch. -- [ ] Remaining items: 4, 9, 14, 15, 16, and 17. +- [ ] Remaining items: 14, 15, 16, and 17. ## Improvements diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go index 1cde8fb..f7c3c07 100644 --- a/cmd/diagnostics.go +++ b/cmd/diagnostics.go @@ -14,29 +14,33 @@ func writeSyncDiagnostic(out io.Writer, diag syncflow.PullDiagnostic) error { } func formatSyncDiagnostic(diag syncflow.PullDiagnostic) string { - level, qualifier := classifySyncDiagnostic(diag.Code) + diag = syncflow.NormalizePullDiagnostic(diag) + level, qualifier := classifySyncDiagnostic(diag) message := strings.TrimSpace(diag.Message) if qualifier != "" { - message = qualifier + ": " + message + message = qualifier + "; action required: " + yesNo(diag.ActionRequired) + ": " + message } return fmt.Sprintf("%s: %s [%s] %s\n", level, diag.Path, diag.Code, message) } -func classifySyncDiagnostic(code string) (level string, qualifier string) { - switch strings.TrimSpace(code) { - case "CROSS_SPACE_LINK_PRESERVED": - return "note", "no action required" - case "unresolved_reference": - return "warning", "broken reference preserved as fallback output" - case "FOLDER_LOOKUP_UNAVAILABLE", - "CONTENT_STATUS_FETCH_FAILED", - "LABELS_FETCH_FAILED", - "UNKNOWN_MEDIA_ID_LOOKUP_FAILED", - "UNKNOWN_MEDIA_ID_RESOLVED", - "UNKNOWN_MEDIA_ID_UNRESOLVED", - "ATTACHMENT_DOWNLOAD_SKIPPED": +func classifySyncDiagnostic(diag syncflow.PullDiagnostic) (level string, qualifier string) { + switch strings.TrimSpace(diag.Category) { + case syncflow.DiagnosticCategoryPreservedExternalLink: + return "note", "preserved external/cross-space link" + case syncflow.DiagnosticCategoryDegradedReference: + return "warning", "unresolved but safely degraded reference" + case syncflow.DiagnosticCategoryBlockingReference: + return "error", "broken strict-path reference that blocks push" + case syncflow.DiagnosticCategoryDegradedContent: return "warning", "degraded but pullable content" default: return "warning", "" } } + +func yesNo(value bool) string { + if value { + return "yes" + } + return "no" +} diff --git a/cmd/diagnostics_test.go b/cmd/diagnostics_test.go index d1c7d40..a9c1c59 100644 --- a/cmd/diagnostics_test.go +++ b/cmd/diagnostics_test.go @@ -20,19 +20,25 @@ func TestFormatSyncDiagnostic_Classification(t *testing.T) { name: "preserved cross-space link is a note", diag: syncflow.PullDiagnostic{Path: "page.md", Code: "CROSS_SPACE_LINK_PRESERVED", Message: "preserved absolute cross-space link"}, wantStart: "note: page.md [CROSS_SPACE_LINK_PRESERVED]", - wantText: "no action required", + wantText: "preserved external/cross-space link; action required: no", }, { - name: "unresolved reference is called broken fallback", + name: "unresolved reference is actionable degraded output", diag: syncflow.PullDiagnostic{Path: "page.md", Code: "unresolved_reference", Message: "page id 404 could not be resolved"}, wantStart: "warning: page.md [unresolved_reference]", - wantText: "broken reference preserved as fallback output", + wantText: "unresolved but safely degraded reference; action required: yes", }, { name: "folder fallback is marked degraded but pullable", diag: syncflow.PullDiagnostic{Path: "folder-1", Code: "FOLDER_LOOKUP_UNAVAILABLE", Message: "falling back to page-only hierarchy"}, wantStart: "warning: folder-1 [FOLDER_LOOKUP_UNAVAILABLE]", - wantText: "degraded but pullable content", + wantText: "degraded but pullable content; action required: no", + }, + { + name: "blocking strict-path reference is an error", + diag: syncflow.PullDiagnostic{Path: "page.md", Code: "STRICT_PATH_REFERENCE_BROKEN", Message: "relative link ../missing.md does not resolve"}, + wantStart: "error: page.md [STRICT_PATH_REFERENCE_BROKEN]", + wantText: "broken strict-path reference that blocks push; action required: yes", }, } diff --git a/cmd/diff.go b/cmd/diff.go index d7d0073..7814745 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -154,6 +154,10 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { pagePathByIDAbs, pagePathByIDRel := syncflow.PlanPagePaths(diffCtx.spaceDir, state.PagePathIndex, pages, folderByID) attachmentPathByID := buildDiffAttachmentPathByID(diffCtx.spaceDir, state.AttachmentIndex) + globalPageIndex, err := buildWorkspaceGlobalPageIndex(diffCtx.spaceDir) + if err != nil { + return fmt.Errorf("build global page index: %w", err) + } tmpRoot, err := os.MkdirTemp("", "conf-diff-*") if err != nil { @@ -164,7 +168,7 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { }() if target.IsFile() { - return runDiffFileMode(ctx, out, remote, diffCtx, pagePathByIDAbs, attachmentPathByID, tmpRoot) + return runDiffFileMode(ctx, out, remote, diffCtx, pagePathByIDAbs, attachmentPathByID, globalPageIndex, tmpRoot) } return runDiffSpaceMode( @@ -176,6 +180,7 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { pagePathByIDAbs, pagePathByIDRel, attachmentPathByID, + globalPageIndex, tmpRoot, ) } @@ -187,6 +192,7 @@ func runDiffFileMode( diffCtx diffContext, pagePathByIDAbs map[string]string, attachmentPathByID map[string]string, + globalPageIndex syncflow.GlobalPageIndex, tmpRoot string, ) error { relPath := diffDisplayRelPath(diffCtx.spaceDir, diffCtx.targetFile) @@ -231,10 +237,12 @@ func runDiffFileMode( ctx, page, diffCtx.spaceKey, + diffCtx.spaceDir, diffCtx.targetFile, relPath, pagePathByIDAbs, attachmentPathByID, + globalPageIndex, ) if err != nil { return err @@ -266,6 +274,7 @@ func runDiffSpaceMode( pagePathByIDAbs map[string]string, pagePathByIDRel map[string]string, attachmentPathByID map[string]string, + globalPageIndex syncflow.GlobalPageIndex, tmpRoot string, ) error { localSnapshot := filepath.Join(tmpRoot, "local") @@ -313,10 +322,12 @@ func runDiffSpaceMode( ctx, page, diffCtx.spaceKey, + diffCtx.spaceDir, sourcePath, relPath, pagePathByIDAbs, attachmentPathByID, + globalPageIndex, ) if err != nil { return err diff --git a/cmd/diff_render.go b/cmd/diff_render.go index 3c502d2..bcc5f89 100644 --- a/cmd/diff_render.go +++ b/cmd/diff_render.go @@ -53,13 +53,25 @@ func renderDiffMarkdown( ctx context.Context, page confluence.Page, spaceKey string, + spaceDir string, sourcePath string, relPath string, pagePathByIDAbs map[string]string, attachmentPathByID map[string]string, + globalIndex syncflow.GlobalPageIndex, ) ([]byte, []syncflow.PullDiagnostic, error) { + linkNotices := make([]syncflow.ForwardLinkNotice, 0, 1) forward, err := converter.Forward(ctx, page.BodyADF, converter.ForwardConfig{ - LinkHook: syncflow.NewForwardLinkHook(sourcePath, pagePathByIDAbs, spaceKey), + LinkHook: syncflow.NewForwardLinkHookWithGlobalIndex( + sourcePath, + spaceDir, + pagePathByIDAbs, + globalIndex, + spaceKey, + func(notice syncflow.ForwardLinkNotice) { + linkNotices = append(linkNotices, notice) + }, + ), MediaHook: syncflow.NewForwardMediaHook(sourcePath, attachmentPathByID), }, sourcePath) if err != nil { @@ -83,7 +95,14 @@ func renderDiffMarkdown( return nil, nil, fmt.Errorf("format page %s markdown: %w", page.ID, err) } - diagnostics := make([]syncflow.PullDiagnostic, 0, len(forward.Warnings)) + diagnostics := make([]syncflow.PullDiagnostic, 0, len(linkNotices)+len(forward.Warnings)) + for _, notice := range linkNotices { + diagnostics = append(diagnostics, syncflow.PullDiagnostic{ + Path: filepath.ToSlash(relPath), + Code: notice.Code, + Message: notice.Message, + }) + } for _, warning := range forward.Warnings { diagnostics = append(diagnostics, syncflow.PullDiagnostic{ Path: filepath.ToSlash(relPath), @@ -92,7 +111,7 @@ func renderDiffMarkdown( }) } - return rendered, diagnostics, nil + return rendered, syncflow.NormalizePullDiagnostics(diagnostics), nil } func copyLocalMarkdownSnapshot(spaceDir, snapshotDir string) error { diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 98701dd..e8b795a 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -209,6 +209,109 @@ func TestRunDiff_ReportsBestEffortWarnings(t *testing.T) { if !strings.Contains(got, "[unresolved_reference]") { t.Fatalf("expected unresolved warning, got:\n%s", got) } + if !strings.Contains(got, "action required: yes") { + t.Fatalf("expected actionable unresolved warning, got:\n%s", got) + } + if !strings.Contains(got, "unresolved but safely degraded reference") { + t.Fatalf("expected degraded-reference classification, got:\n%s", got) + } +} + +func TestRunDiff_PreservedAbsoluteCrossSpaceLinkIsNotReportedAsUnresolved(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + engDir := filepath.Join(repo, "Engineering (ENG)") + tdDir := filepath.Join(repo, "Technical Docs (TD)") + if err := os.MkdirAll(engDir, 0o750); err != nil { + t.Fatalf("mkdir eng dir: %v", err) + } + if err := os.MkdirAll(tdDir, 0o750); err != nil { + t.Fatalf("mkdir td dir: %v", err) + } + + targetPath := filepath.Join(tdDir, "target.md") + writeMarkdown(t, targetPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Target", ID: "200", Version: 1}, + Body: "target body\n", + }) + + localFile := filepath.Join(engDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + }, + Body: "old body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Version: 2, LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC)}, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, map[string]any{ + "version": 1, + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "Cross Space", + "marks": []any{ + map[string]any{ + "type": "link", + "attrs": map[string]any{ + "href": "https://example.atlassian.net/wiki/pages/viewpage.action?pageId=200", + "pageId": "200", + "anchor": "section-a", + }, + }, + }, + }, + }, + }, + }, + }), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if strings.Contains(got, "[unresolved_reference]") { + t.Fatalf("did not expect unresolved warning for preserved cross-space link, got:\n%s", got) + } + if !strings.Contains(got, "[CROSS_SPACE_LINK_PRESERVED]") { + t.Fatalf("expected preserved cross-space diagnostic, got:\n%s", got) + } + if !strings.Contains(got, "preserved external/cross-space link; action required: no") { + t.Fatalf("expected informational preserved-link classification, got:\n%s", got) + } } func TestRunDiff_FolderListFailureFallsBackToPageHierarchy(t *testing.T) { diff --git a/docs/usage.md b/docs/usage.md index d4140d5..d37dcd1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -257,10 +257,21 @@ Local state file: - `.confluence-state.json` (per space, gitignored) -## Diagram Support +## Extension and Macro Support -- PlantUML: supported as a first-class Confluence extension through `plantumlcloud`, with round-trip preservation in pull and push. -- Mermaid: preserved as fenced code in Markdown and as ADF `codeBlock` content with language `mermaid` in Confluence. It does not render as a Mermaid macro, and `conf validate` warns before push so the downgrade is explicit. +| Item | Support level | Markdown / ADF behavior | Notes | +|------|---------------|-------------------------|-------| +| PlantUML (`plantumlcloud`) | Rendered round-trip support | Pull/diff use the custom extension handler to turn the Confluence macro into a managed `adf-extension` wrapper with a `puml` code body; validate/push rebuild the same Confluence extension. | This is the only first-class extension handler registered by `conf`. | +| Mermaid | Preserved but not rendered | Markdown keeps ` ```mermaid ` fences; push writes an ADF `codeBlock` with language `mermaid` instead of a Confluence diagram macro. | `conf validate` warns with `MERMAID_PRESERVED_AS_CODEBLOCK`, and push surfaces the same warning before writing. | +| Raw ADF extension preservation | Best-effort preservation only | When an extension node has no repo-specific handler, pull/diff can preserve it as a raw ```` ```adf:extension ```` JSON fence that validate/push can pass back through with minimal interpretation. | Treat this as a low-level escape hatch, not as a rendered or human-friendly authoring format. It is not a verified end-to-end round-trip contract; validate in a sandbox before relying on it. | +| Unknown Confluence macros/extensions | Unsupported as a first-class feature | `conf` does not add custom behavior for unknown macros beyond whatever best-effort raw ADF preservation may be possible for some remote payloads. | If Confluence rejects an unknown or uninstalled macro, push can still fail. Do not assume rendered round-trip support unless a handler is documented explicitly, and sandbox-validate any workflow that depends on this path. | + +Practical guidance: + +- Use PlantUML when the page must keep rendering as a Confluence diagram macro. +- Use Mermaid only when preserving the source as code is acceptable. +- Keep raw `adf:extension` fences unchanged if you need best-effort preservation of an unhandled extension node, and test that workflow in a sandbox before using it in a real space. +- Do not treat unknown macros/extensions as supported authoring targets just because they may survive a pull in raw ADF form. ## Typical Team Workflow diff --git a/internal/sync/diagnostics.go b/internal/sync/diagnostics.go new file mode 100644 index 0000000..08ec4e0 --- /dev/null +++ b/internal/sync/diagnostics.go @@ -0,0 +1,54 @@ +package sync + +import "strings" + +const ( + DiagnosticCategoryPreservedExternalLink = "preserved_external_link" + DiagnosticCategoryDegradedReference = "degraded_reference" + DiagnosticCategoryBlockingReference = "blocking_reference" + DiagnosticCategoryDegradedContent = "degraded_content" +) + +func NormalizePullDiagnostic(diag PullDiagnostic) PullDiagnostic { + category, actionRequired := classifyPullDiagnostic(diag.Code) + if strings.TrimSpace(diag.Category) == "" { + diag.Category = category + } + if actionRequired { + diag.ActionRequired = true + } + return diag +} + +func NormalizePullDiagnostics(diags []PullDiagnostic) []PullDiagnostic { + if len(diags) == 0 { + return diags + } + out := make([]PullDiagnostic, 0, len(diags)) + for _, diag := range diags { + out = append(out, NormalizePullDiagnostic(diag)) + } + return out +} + +func classifyPullDiagnostic(code string) (category string, actionRequired bool) { + switch strings.TrimSpace(code) { + case "CROSS_SPACE_LINK_PRESERVED": + return DiagnosticCategoryPreservedExternalLink, false + case "unresolved_reference": + return DiagnosticCategoryDegradedReference, true + case "STRICT_PATH_REFERENCE_BROKEN": + return DiagnosticCategoryBlockingReference, true + case "FOLDER_LOOKUP_UNAVAILABLE", + "CONTENT_STATUS_FETCH_FAILED", + "LABELS_FETCH_FAILED", + "UNKNOWN_MEDIA_ID_LOOKUP_FAILED", + "UNKNOWN_MEDIA_ID_RESOLVED", + "UNKNOWN_MEDIA_ID_UNRESOLVED", + "ATTACHMENT_DOWNLOAD_SKIPPED", + "MALFORMED_ADF": + return DiagnosticCategoryDegradedContent, false + default: + return DiagnosticCategoryDegradedContent, false + } +} diff --git a/internal/sync/pull.go b/internal/sync/pull.go index f94a658..341f339 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -68,9 +68,11 @@ type PullOptions struct { // PullDiagnostic captures non-fatal conversion diagnostics. type PullDiagnostic struct { - Path string - Code string - Message string + Path string + Code string + Message string + Category string + ActionRequired bool } // PullResult captures pull execution outputs. @@ -674,7 +676,7 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, return PullResult{ State: state, MaxVersion: maxVersion, - Diagnostics: diagnostics, + Diagnostics: NormalizePullDiagnostics(diagnostics), UpdatedMarkdown: updatedMarkdown, DeletedMarkdown: deletedMarkdown, DownloadedAssets: downloadedAssets, diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index c93a930..8216e04 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -172,6 +172,12 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { for _, d := range result.Diagnostics { if d.Code == "unresolved_reference" { foundUnresolved = true + if d.Category != "degraded_reference" { + t.Fatalf("unresolved_reference category = %q, want degraded_reference", d.Category) + } + if !d.ActionRequired { + t.Fatalf("unresolved_reference should require user action: %+v", d) + } break } } @@ -299,6 +305,12 @@ func TestPull_PreservesAbsoluteCrossSpaceLinksWithoutUnresolvedWarnings(t *testi } if d.Code == "CROSS_SPACE_LINK_PRESERVED" { foundPreserved = true + if d.Category != "preserved_external_link" { + t.Fatalf("CROSS_SPACE_LINK_PRESERVED category = %q, want preserved_external_link", d.Category) + } + if d.ActionRequired { + t.Fatalf("CROSS_SPACE_LINK_PRESERVED should not require user action: %+v", d) + } } } if !foundPreserved { From e8f44202a0fe5a08641fd20dbb4276d213279e9e Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 19:17:37 +0100 Subject: [PATCH 20/31] Add structured run reports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...26-03-05-live-workflow-polish-followups.md | 3 +- cmd/diff.go | 127 +- cmd/diff_git.go | 61 +- cmd/output.go | 10 + cmd/pull.go | 94 +- cmd/push.go | 66 +- cmd/push_conflict_test.go | 6 +- cmd/push_worktree.go | 64 +- cmd/relink.go | 162 ++- cmd/report.go | 374 ++++++ cmd/report_test.go | 1054 +++++++++++++++++ cmd/validate.go | 79 +- 12 files changed, 1946 insertions(+), 154 deletions(-) create mode 100644 cmd/report.go create mode 100644 cmd/report_test.go diff --git a/agents/plans/2026-03-05-live-workflow-polish-followups.md b/agents/plans/2026-03-05-live-workflow-polish-followups.md index 22354e5..4230059 100644 --- a/agents/plans/2026-03-05-live-workflow-polish-followups.md +++ b/agents/plans/2026-03-05-live-workflow-polish-followups.md @@ -17,7 +17,8 @@ Capture the non-blocking but high-value workflow, diagnostics, and operator-expe - [x] Batch 3 completed: items 10, 11, and 18 are closed, and follow-up hardening landed for items 6, 12, and 13. - [x] Batch 4 completed: items 4 and 9 are closed with warning-taxonomy regression coverage and explicit extension-support documentation updates. - [x] Item 8 was re-verified as already complete on this branch. -- [ ] Remaining items: 14, 15, 16, and 17. +- [x] Batch 5 completed: item 14 is closed with structured report regression coverage for success and failure paths. +- [ ] Remaining items: 15, 16, and 17. ## Improvements diff --git a/cmd/diff.go b/cmd/diff.go index 7814745..7622626 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -30,7 +30,7 @@ type diffContext struct { } func newDiffCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "diff [TARGET]", Short: "Show diff between local Markdown and remote Confluence content", Long: `Diff fetches remote Confluence content, converts it to Markdown, @@ -47,13 +47,25 @@ If omitted, the space is inferred from the current directory name.`, return runDiff(cmd, config.ParseTarget(raw)) }, } + addReportJSONFlag(cmd) + return cmd } func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { - _, restoreLogger := beginCommandRun("diff") + actualOut := cmd.OutOrStdout() + out := reportWriter(cmd, actualOut) + runID, restoreLogger := beginCommandRun("diff") defer restoreLogger() startedAt := time.Now() + report := newCommandRunReport(runID, "diff", target, startedAt) + defer func() { + if !commandRequestsJSONReport(cmd) { + return + } + report.finalize(runErr, time.Now()) + _ = writeCommandRunReport(actualOut, report) + }() telemetrySpaceKey := "unknown" slog.Info("diff_started", "target_mode", target.Mode, "target", target.Value) defer func() { @@ -79,8 +91,6 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { if err := ensureWorkspaceSyncReady("diff"); err != nil { return err } - out := cmd.OutOrStdout() - initialCtx, err := resolveInitialPullContext(target) if err != nil { return err @@ -114,12 +124,15 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { targetPageID: initialCtx.targetPageID, } telemetrySpaceKey = diffCtx.spaceKey + report.Target.SpaceKey = diffCtx.spaceKey + report.Target.SpaceDir = diffCtx.spaceDir if target.IsFile() { absPath, err := filepath.Abs(target.Value) if err == nil { diffCtx.targetFile = absPath } } + report.Target.File = diffCtx.targetFile state, err := fs.LoadState(diffCtx.spaceDir) if err != nil { @@ -146,6 +159,8 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { if err != nil { return err } + report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPull(folderDiags, diffCtx.spaceDir)...) + report.FallbackModes = append(report.FallbackModes, fallbackModesFromPullDiagnostics(folderDiags)...) for _, diag := range folderDiags { if err := writeSyncDiagnostic(out, diag); err != nil { return fmt.Errorf("write diagnostic output: %w", err) @@ -168,10 +183,13 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { }() if target.IsFile() { - return runDiffFileMode(ctx, out, remote, diffCtx, pagePathByIDAbs, attachmentPathByID, globalPageIndex, tmpRoot) + result, err := runDiffFileMode(ctx, out, remote, diffCtx, pagePathByIDAbs, attachmentPathByID, globalPageIndex, tmpRoot) + report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPull(result.Diagnostics, diffCtx.spaceDir)...) + report.MutatedFiles = append(report.MutatedFiles, result.ChangedFiles...) + return err } - return runDiffSpaceMode( + result, err := runDiffSpaceMode( ctx, out, remote, @@ -183,6 +201,9 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { globalPageIndex, tmpRoot, ) + report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPull(result.Diagnostics, diffCtx.spaceDir)...) + report.MutatedFiles = append(report.MutatedFiles, result.ChangedFiles...) + return err } func runDiffFileMode( @@ -194,42 +215,58 @@ func runDiffFileMode( attachmentPathByID map[string]string, globalPageIndex syncflow.GlobalPageIndex, tmpRoot string, -) error { +) (diffCommandResult, error) { + result := diffCommandResult{ + SpaceKey: diffCtx.spaceKey, + SpaceDir: diffCtx.spaceDir, + TargetFile: diffCtx.targetFile, + Diagnostics: []syncflow.PullDiagnostic{}, + ChangedFiles: []string{}, + } relPath := diffDisplayRelPath(diffCtx.spaceDir, diffCtx.targetFile) localFile := filepath.Join(tmpRoot, "local", filepath.FromSlash(relPath)) remoteFile := filepath.Join(tmpRoot, "remote", filepath.FromSlash(relPath)) if err := os.MkdirAll(filepath.Dir(localFile), 0o750); err != nil { - return fmt.Errorf("prepare local diff file: %w", err) + return result, fmt.Errorf("prepare local diff file: %w", err) } if err := os.MkdirAll(filepath.Dir(remoteFile), 0o750); err != nil { - return fmt.Errorf("prepare diff file: %w", err) + return result, fmt.Errorf("prepare diff file: %w", err) } localRaw, err := os.ReadFile(diffCtx.targetFile) //nolint:gosec // target file path is user-selected markdown inside workspace if err != nil { - return fmt.Errorf("read local file for diff: %w", err) + return result, fmt.Errorf("read local file for diff: %w", err) } localRaw, err = normalizeDiffMarkdown(localRaw) if err != nil { - return fmt.Errorf("normalize local file for diff: %w", err) + return result, fmt.Errorf("normalize local file for diff: %w", err) } if err := os.WriteFile(localFile, localRaw, 0o600); err != nil { - return fmt.Errorf("write local diff file: %w", err) + return result, fmt.Errorf("write local diff file: %w", err) } page, err := remote.GetPage(ctx, diffCtx.targetPageID) if err != nil { if errors.Is(err, confluence.ErrNotFound) { + result.Diagnostics = append(result.Diagnostics, syncflow.PullDiagnostic{ + Path: relPath, + Code: "missing_remote_page", + Message: fmt.Sprintf("remote page %s not found", diffCtx.targetPageID), + }) if _, err := fmt.Fprintf(out, "warning: %s [missing_remote_page] remote page %s not found\n", relPath, diffCtx.targetPageID); err != nil { - return fmt.Errorf("write diagnostic output: %w", err) + return result, fmt.Errorf("write diagnostic output: %w", err) } if err := os.WriteFile(remoteFile, []byte{}, 0o600); err != nil { - return fmt.Errorf("write diff file: %w", err) + return result, fmt.Errorf("write diff file: %w", err) } - return printNoIndexDiff(out, localFile, remoteFile) + changed, err := renderNoIndexDiff(out, localFile, remoteFile) + if changed { + result.ChangedFiles = append(result.ChangedFiles, relPath) + } + return result, err } - return fmt.Errorf("fetch page %s: %w", diffCtx.targetPageID, err) + return result, fmt.Errorf("fetch page %s: %w", diffCtx.targetPageID, err) } page, metadataDiags := hydrateDiffPageMetadata(ctx, remote, page, relPath) @@ -245,24 +282,29 @@ func runDiffFileMode( globalPageIndex, ) if err != nil { - return err + return result, err } diagnostics = append(metadataDiags, diagnostics...) + result.Diagnostics = append(result.Diagnostics, diagnostics...) for _, diag := range diagnostics { if err := writeSyncDiagnostic(out, diag); err != nil { - return fmt.Errorf("write diagnostic output: %w", err) + return result, fmt.Errorf("write diagnostic output: %w", err) } } if err := os.WriteFile(remoteFile, rendered, 0o600); err != nil { - return fmt.Errorf("write diff file: %w", err) + return result, fmt.Errorf("write diff file: %w", err) } if err := writeDiffMetadataSummary(out, []diffMetadataSummary{summarizeMetadataDrift(relPath, localRaw, rendered)}); err != nil { - return err + return result, err } - return printNoIndexDiff(out, localFile, remoteFile) + changed, err := renderNoIndexDiff(out, localFile, remoteFile) + if changed { + result.ChangedFiles = append(result.ChangedFiles, relPath) + } + return result, err } func runDiffSpaceMode( @@ -276,18 +318,25 @@ func runDiffSpaceMode( attachmentPathByID map[string]string, globalPageIndex syncflow.GlobalPageIndex, tmpRoot string, -) error { +) (diffCommandResult, error) { + result := diffCommandResult{ + SpaceKey: diffCtx.spaceKey, + SpaceDir: diffCtx.spaceDir, + TargetFile: diffCtx.targetFile, + Diagnostics: []syncflow.PullDiagnostic{}, + ChangedFiles: []string{}, + } localSnapshot := filepath.Join(tmpRoot, "local") remoteSnapshot := filepath.Join(tmpRoot, "remote") if err := os.MkdirAll(localSnapshot, 0o750); err != nil { - return fmt.Errorf("prepare local snapshot: %w", err) + return result, fmt.Errorf("prepare local snapshot: %w", err) } if err := os.MkdirAll(remoteSnapshot, 0o750); err != nil { - return fmt.Errorf("prepare remote snapshot: %w", err) + return result, fmt.Errorf("prepare remote snapshot: %w", err) } if err := copyLocalMarkdownSnapshot(diffCtx.spaceDir, localSnapshot); err != nil { - return err + return result, err } pageIDs := make([]string, 0, len(pages)) @@ -304,17 +353,17 @@ func runDiffSpaceMode( if errors.Is(err, confluence.ErrNotFound) || errors.Is(err, confluence.ErrArchived) { continue } - return fmt.Errorf("fetch page %s: %w", pageID, err) + return result, fmt.Errorf("fetch page %s: %w", pageID, err) } sourcePath, ok := pagePathByIDAbs[page.ID] if !ok { - return fmt.Errorf("planned path missing for page %s", page.ID) + return result, fmt.Errorf("planned path missing for page %s", page.ID) } relPath, ok := pagePathByIDRel[page.ID] if !ok { - return fmt.Errorf("planned relative path missing for page %s", page.ID) + return result, fmt.Errorf("planned relative path missing for page %s", page.ID) } page, metadataDiags := hydrateDiffPageMetadata(ctx, remote, page, relPath) @@ -330,17 +379,17 @@ func runDiffSpaceMode( globalPageIndex, ) if err != nil { - return err + return result, err } pageDiags = append(metadataDiags, pageDiags...) diagnostics = append(diagnostics, pageDiags...) dstPath := filepath.Join(remoteSnapshot, filepath.FromSlash(relPath)) if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil { - return fmt.Errorf("prepare remote snapshot path: %w", err) + return result, fmt.Errorf("prepare remote snapshot path: %w", err) } if err := os.WriteFile(dstPath, rendered, 0o600); err != nil { - return fmt.Errorf("write remote snapshot file: %w", err) + return result, fmt.Errorf("write remote snapshot file: %w", err) } localRaw, err := os.ReadFile(sourcePath) //nolint:gosec // planned path is scoped under the current workspace @@ -354,12 +403,20 @@ func runDiffSpaceMode( for _, diag := range diagnostics { if err := writeSyncDiagnostic(out, diag); err != nil { - return fmt.Errorf("write diagnostic output: %w", err) + return result, fmt.Errorf("write diagnostic output: %w", err) } } if err := writeDiffMetadataSummary(out, metadataSummaries); err != nil { - return err + return result, err + } + result.Diagnostics = append(result.Diagnostics, diagnostics...) + changed, err := renderNoIndexDiff(out, localSnapshot, remoteSnapshot) + if changed { + changedFiles, changedFilesErr := collectChangedSnapshotFiles(localSnapshot, remoteSnapshot) + if changedFilesErr != nil { + return result, changedFilesErr + } + result.ChangedFiles = append(result.ChangedFiles, changedFiles...) } - - return printNoIndexDiff(out, localSnapshot, remoteSnapshot) + return result, err } diff --git a/cmd/diff_git.go b/cmd/diff_git.go index d267e34..80ca45a 100644 --- a/cmd/diff_git.go +++ b/cmd/diff_git.go @@ -1,16 +1,23 @@ package cmd import ( + "bytes" "errors" "fmt" "io" "os" "os/exec" "path/filepath" + "sort" "strings" ) func printNoIndexDiff(out io.Writer, leftPath, rightPath string) error { + _, err := renderNoIndexDiff(out, leftPath, rightPath) + return err +} + +func renderNoIndexDiff(out io.Writer, leftPath, rightPath string) (bool, error) { workingDir, leftArg, rightArg := diffCommandPaths(leftPath, rightPath) cmd := exec.Command( //nolint:gosec // arguments are fixed git flags plus scoped local temp paths for display-only diff @@ -35,20 +42,20 @@ func printNoIndexDiff(out io.Writer, leftPath, rightPath string) error { if err == nil { if _, writeErr := fmt.Fprintln(out, "diff completed with no differences"); writeErr != nil { - return fmt.Errorf("write diff output: %w", writeErr) + return false, fmt.Errorf("write diff output: %w", writeErr) } - return nil + return false, nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { - return nil + return true, nil } if strings.TrimSpace(text) == "" { - return fmt.Errorf("git diff --no-index failed: %w", err) + return false, fmt.Errorf("git diff --no-index failed: %w", err) } - return fmt.Errorf("git diff --no-index failed: %s", strings.TrimSpace(text)) + return false, fmt.Errorf("git diff --no-index failed: %s", strings.TrimSpace(text)) } func diffCommandPaths(leftPath, rightPath string) (workingDir, leftArg, rightArg string) { @@ -117,3 +124,47 @@ func sanitizeNoIndexDiffOutput(text string) string { } return result } + +func collectChangedSnapshotFiles(leftRoot, rightRoot string) ([]string, error) { + files := map[string]struct{}{} + collect := func(root string) error { + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + files[filepath.ToSlash(rel)] = struct{}{} + return nil + }) + } + if err := collect(leftRoot); err != nil { + return nil, err + } + if err := collect(rightRoot); err != nil { + return nil, err + } + + changed := make([]string, 0) + for rel := range files { + leftPath := filepath.Join(leftRoot, filepath.FromSlash(rel)) + rightPath := filepath.Join(rightRoot, filepath.FromSlash(rel)) + + leftRaw, leftErr := os.ReadFile(leftPath) //nolint:gosec // snapshot path is created under temp dir + rightRaw, rightErr := os.ReadFile(rightPath) //nolint:gosec // snapshot path is created under temp dir + if leftErr != nil || rightErr != nil { + changed = append(changed, rel) + continue + } + if !bytes.Equal(leftRaw, rightRaw) { + changed = append(changed, rel) + } + } + sort.Strings(changed) + return changed, nil +} diff --git a/cmd/output.go b/cmd/output.go index 293f17c..1abfbf1 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -29,6 +29,16 @@ func ensureSynchronizedCmdOutput(cmd *cobra.Command) io.Writer { return out } +func ensureSynchronizedCmdError(cmd *cobra.Command) io.Writer { + if errOut, ok := cmd.ErrOrStderr().(*synchronizedWriter); ok { + return errOut + } + + errOut := &synchronizedWriter{w: cmd.ErrOrStderr()} + cmd.SetErr(errOut) + return errOut +} + type fdWriter interface { Fd() uintptr } diff --git a/cmd/pull.go b/cmd/pull.go index a077778..a4ffe5d 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -60,19 +60,34 @@ If omitted, the space is inferred from the current directory name.`, cmd.Flags().BoolVarP(&flagPullForce, "force", "f", false, "Force full space pull and refresh all tracked pages") cmd.Flags().BoolVar(&flagPullDiscardLocal, "discard-local", false, "Discard local uncommitted changes if they conflict with remote updates") cmd.Flags().BoolVarP(&flagPullRelink, "relink", "r", false, "Automatically relink references to this space from other spaces after pull") + addReportJSONFlag(cmd) return cmd } func runPull(cmd *cobra.Command, target config.Target) (runErr error) { + _, runErr = runPullWithReport(cmd, target, true) + return runErr +} + +func runPullWithReport(cmd *cobra.Command, target config.Target, emitJSONReport bool) (report commandRunReport, runErr error) { ctx := getCommandContext(cmd) - out := ensureSynchronizedCmdOutput(cmd) - _, restoreLogger := beginCommandRun("pull") + actualOut := ensureSynchronizedCmdOutput(cmd) + out := reportWriter(cmd, actualOut) + runID, restoreLogger := beginCommandRun("pull") defer restoreLogger() + startedAt := time.Now() + report = newCommandRunReport(runID, "pull", target, startedAt) + defer func() { + if !emitJSONReport || !commandRequestsJSONReport(cmd) { + return + } + report.finalize(runErr, time.Now()) + _ = writeCommandRunReport(actualOut, report) + }() if err := ensureWorkspaceSyncReady("pull"); err != nil { - return err + return report, err } - startedAt := time.Now() telemetrySpaceKey := "" telemetryUpdated := 0 telemetryDeleted := 0 @@ -106,29 +121,29 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { // 1. Initial resolution of key/dir initialCtx, err := resolveInitialPullContext(target) if err != nil { - return err + return report, err } if flagPullForce && strings.TrimSpace(initialCtx.targetPageID) != "" { - return errors.New("--force is only supported for space targets") + return report, errors.New("--force is only supported for space targets") } // 2. Load config to talk to Confluence envPath := findEnvPath(initialCtx.spaceDir) cfg, err := config.Load(envPath) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return report, fmt.Errorf("failed to load config: %w", err) } remote, err := newPullRemote(cfg) if err != nil { - return fmt.Errorf("create confluence client: %w", err) + return report, fmt.Errorf("create confluence client: %w", err) } defer closeRemoteIfPossible(remote) // 3. Resolve actual space metadata and final directory space, err := remote.GetSpace(ctx, initialCtx.spaceKey) if err != nil { - return fmt.Errorf("resolve space %q: %w", initialCtx.spaceKey, err) + return report, fmt.Errorf("resolve space %q: %w", initialCtx.spaceKey, err) } // Finalize space directory based on Space Name if we are creating it, @@ -143,17 +158,22 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { spaceDir: spaceDir, targetPageID: initialCtx.targetPageID, } + report.Target.SpaceKey = pullCtx.spaceKey + report.Target.SpaceDir = pullCtx.spaceDir + if target.IsFile() { + report.Target.File = target.Value + } telemetrySpaceKey = pullCtx.spaceKey scopeDirExisted := dirExists(pullCtx.spaceDir) if err := os.MkdirAll(pullCtx.spaceDir, 0o750); err != nil { - return fmt.Errorf("prepare space directory: %w", err) + return report, fmt.Errorf("prepare space directory: %w", err) } state, err := loadPullStateWithHealing(ctx, out, remote, space, pullCtx.spaceDir) if err != nil { - return err + return report, err } var progress syncflow.Progress @@ -163,27 +183,27 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { impact, err := estimatePullImpactWithSpace(ctx, remote, space, pullCtx.targetPageID, state, syncflow.DefaultPullOverlapWindow, flagPullForce, progress) if err != nil { - return err + return report, err } affectedCount := impact.changedMarkdown + impact.deletedMarkdown if err := requireSafetyConfirmation(cmd.InOrStdin(), out, "pull", affectedCount, impact.deletedMarkdown > 0); err != nil { - return err + return report, err } repoRoot, err := gitRepoRoot() if err != nil { - return err + return report, err } scopePath, err := gitScopePath(repoRoot, pullCtx.spaceDir) if err != nil { - return err + return report, err } dirtyMarkdownBeforePull := map[string]struct{}{} if !flagPullDiscardLocal { dirtyMarkdownBeforePull, err = listDirtyMarkdownPathsForScope(repoRoot, scopePath) if err != nil { - return fmt.Errorf("inspect local markdown changes: %w", err) + return report, fmt.Errorf("inspect local markdown changes: %w", err) } } @@ -193,7 +213,7 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { if scopeDirExisted { stashRef, err = stashScopeIfDirty(repoRoot, scopePath, pullCtx.spaceKey, pullStartedAt) if err != nil { - return translateWorkspaceGitError(err, "pull") + return report, translateWorkspaceGitError(err, "pull") } if stashRef != "" { defer func() { @@ -251,7 +271,7 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { globalPageIndex, err := syncflow.BuildGlobalPageIndex(repoRoot) if err != nil { - return fmt.Errorf("build global page index: %w", err) + return report, fmt.Errorf("build global page index: %w", err) } result, err = syncflow.Pull(ctx, remote, syncflow.PullOptions{ @@ -272,24 +292,33 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { }) if err != nil { - return err + return report, err } telemetryUpdated = len(result.UpdatedMarkdown) telemetryDeleted = len(result.DeletedMarkdown) telemetryAssetsDownloaded = len(result.DownloadedAssets) telemetryDiagnostics = len(result.Diagnostics) + report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPull(result.Diagnostics, pullCtx.spaceDir)...) + for _, path := range result.UpdatedMarkdown { + report.MutatedFiles = append(report.MutatedFiles, reportRelativePath(pullCtx.spaceDir, path)) + } + for _, path := range result.DeletedMarkdown { + report.MutatedFiles = append(report.MutatedFiles, reportRelativePath(pullCtx.spaceDir, path)) + } + report.AttachmentOperations = append(report.AttachmentOperations, reportAttachmentOpsFromPull(result, pullCtx.spaceDir)...) + report.FallbackModes = append(report.FallbackModes, fallbackModesFromPullDiagnostics(result.Diagnostics)...) if !flagPullDiscardLocal { warnSkippedDirtyDeletions(out, result.DeletedMarkdown, dirtyMarkdownBeforePull) } if err := fs.SaveState(pullCtx.spaceDir, result.State); err != nil { - return fmt.Errorf("save state: %w", err) + return report, fmt.Errorf("save state: %w", err) } for _, diag := range result.Diagnostics { if err := writeSyncDiagnostic(out, diag); err != nil { - return fmt.Errorf("write diagnostic output: %w", err) + return report, fmt.Errorf("write diagnostic output: %w", err) } } @@ -327,17 +356,17 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { if progress != nil { if err := runWithIndeterminateStatus(out, "Finalizing pull", finalizePullGit); err != nil { - return err + return report, err } } else { if err := finalizePullGit(); err != nil { - return err + return report, err } } if !hasChanges { _, _ = fmt.Fprintln(out, "pull completed with no scoped changes (no-op)") - return nil + return report, nil } _, _ = fmt.Fprintf(out, "pull completed: committed and tagged %s\n", tagName) @@ -349,18 +378,25 @@ func runPull(cmd *cobra.Command, target config.Target) (runErr error) { if flagPullRelink { index, err := syncflow.BuildGlobalPageIndex(repoRoot) if err != nil { - return fmt.Errorf("build global index for relink: %w", err) + return report, fmt.Errorf("build global index for relink: %w", err) } states, err := fs.FindAllStateFiles(repoRoot) if err != nil { - return fmt.Errorf("discover spaces for relink: %w", err) + return report, fmt.Errorf("discover spaces for relink: %w", err) } - if err := runTargetedRelink(cmd, repoRoot, pullCtx.spaceDir, index, states); err != nil { - return fmt.Errorf("auto-relink: %w", err) + relinkResult, err := runTargetedRelink(cmd, out, repoRoot, pullCtx.spaceDir, index, states) + if err != nil { + for _, path := range relinkResult.MutatedFiles { + report.MutatedFiles = append(report.MutatedFiles, reportRelativePath(pullCtx.spaceDir, path)) + } + return report, fmt.Errorf("auto-relink: %w", err) + } + for _, path := range relinkResult.MutatedFiles { + report.MutatedFiles = append(report.MutatedFiles, reportRelativePath(pullCtx.spaceDir, path)) } } - return nil + return report, nil } diff --git a/cmd/push.go b/cmd/push.go index 7e42213..8bbb728 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -27,7 +27,9 @@ var newPushRemote = func(cfg *config.Config) (syncflow.PushRemote, error) { return newConfluenceClientFromConfig(cfg) } -var runPullForPush = runPull +var runPullForPush = func(cmd *cobra.Command, target config.Target) (commandRunReport, error) { + return runPullWithReport(cmd, target, false) +} var flagPushPreflight bool var flagPushKeepOrphanAssets bool @@ -68,6 +70,7 @@ It uses an isolated worktree and a temporary branch to ensure safety.`, cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Auto-approve safety confirmations") cmd.Flags().BoolVar(&flagNonInteractive, "non-interactive", false, "Disable prompts; fail fast when a decision is required") cmd.Flags().StringVar(&onConflict, "on-conflict", "", "Non-interactive conflict policy: pull-merge|force|cancel") + addReportJSONFlag(cmd) return cmd } @@ -85,15 +88,24 @@ func validateOnConflict(v string) error { func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun bool) (runErr error) { ctx := getCommandContext(cmd) - out := ensureSynchronizedCmdOutput(cmd) - _, restoreLogger := beginCommandRun("push") + actualOut := ensureSynchronizedCmdOutput(cmd) + out := reportWriter(cmd, actualOut) + runID, restoreLogger := beginCommandRun("push") defer restoreLogger() + preflight := flagPushPreflight + startedAt := time.Now() + report := newCommandRunReport(runID, "push", target, startedAt) + defer func() { + if !commandRequestsJSONReport(cmd) { + return + } + report.finalize(runErr, time.Now()) + _ = writeCommandRunReport(actualOut, report) + }() if err := ensureWorkspaceSyncReady("push"); err != nil { return err } - preflight := flagPushPreflight - startedAt := time.Now() telemetrySpaceKey := "unknown" telemetryConflictPolicy := "" slog.Info("push_started", "target_mode", target.Mode, "target", target.Value, "dry_run", dryRun, "preflight", preflight) @@ -138,6 +150,7 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun spaceDir := initialCtx.spaceDir spaceKey := initialCtx.spaceKey telemetrySpaceKey = strings.TrimSpace(spaceKey) + report.Target.SpaceKey = strings.TrimSpace(spaceKey) envPath := findEnvPath(spaceDir) cfg, err := config.Load(envPath) @@ -155,6 +168,10 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun } } } + report.Target.SpaceDir = spaceDir + if target.IsFile() { + report.Target.File = target.Value + } gitClient, err := git.NewClient() if err != nil { @@ -240,12 +257,18 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun if err := gitClient.UpdateRef(snapshotName, snapshotCommit, "create snapshot"); err != nil { return fmt.Errorf("create snapshot ref: %w", err) } + report.setRecoveryArtifactStatus("snapshot_ref", snapshotName, "created") // Keep snapshot ref only on failure, delete on success defer func() { if runErr == nil { - _ = gitClient.DeleteRef(snapshotName) + if err := gitClient.DeleteRef(snapshotName); err == nil { + report.setRecoveryArtifactStatus("snapshot_ref", snapshotName, "cleaned_up") + } else { + report.setRecoveryArtifactStatus("snapshot_ref", snapshotName, "retained") + } } else { + report.setRecoveryArtifactStatus("snapshot_ref", snapshotName, "retained") _, _ = fmt.Fprintf(out, "\nSnapshot retained for recovery: %s\n", snapshotName) } }() @@ -255,12 +278,18 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun if err := gitClient.CreateBranch(syncBranchName, headCommit); err != nil { return fmt.Errorf("create sync branch: %w", err) } + report.setRecoveryArtifactStatus("sync_branch", syncBranchName, "created") // Keep sync branch only on failure, delete on success defer func() { if runErr == nil { - _ = gitClient.DeleteBranch(syncBranchName) + if err := gitClient.DeleteBranch(syncBranchName); err == nil { + report.setRecoveryArtifactStatus("sync_branch", syncBranchName, "cleaned_up") + } else { + report.setRecoveryArtifactStatus("sync_branch", syncBranchName, "retained") + } } else { + report.setRecoveryArtifactStatus("sync_branch", syncBranchName, "retained") _, _ = fmt.Fprintf(out, "Sync branch retained for recovery: %s\n", syncBranchName) } }() @@ -293,8 +322,29 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun } }() - return runPushInWorktree(ctx, cmd, out, target, spaceKey, spaceDir, onConflict, tsStr, + outcome, err := runPushInWorktree(ctx, cmd, out, target, spaceKey, spaceDir, onConflict, tsStr, gitClient, spaceScopePath, changeScopePath, worktreeDir, syncBranchName, snapshotName, &stashRef) + report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPush(outcome.Result.Diagnostics, spaceDir)...) + for _, commit := range outcome.Result.Commits { + report.MutatedFiles = append(report.MutatedFiles, reportRelativePath(spaceDir, commit.Path)) + report.MutatedPages = append(report.MutatedPages, commandRunReportPage{ + Path: reportRelativePath(spaceDir, commit.Path), + PageID: strings.TrimSpace(commit.PageID), + Title: strings.TrimSpace(commit.PageTitle), + Version: commit.Version, + Deleted: commit.Deleted, + }) + } + report.AttachmentOperations = append(report.AttachmentOperations, reportAttachmentOpsFromPush(outcome.Result, spaceDir)...) + report.FallbackModes = append(report.FallbackModes, fallbackModesFromPushDiagnostics(outcome.Result.Diagnostics)...) + if outcome.ConflictResolution != nil { + report.ConflictResolution = outcome.ConflictResolution + report.MutatedFiles = append(report.MutatedFiles, outcome.ConflictResolution.MutatedFiles...) + report.Diagnostics = append(report.Diagnostics, outcome.ConflictResolution.Diagnostics...) + report.AttachmentOperations = append(report.AttachmentOperations, outcome.ConflictResolution.AttachmentOperations...) + report.FallbackModes = append(report.FallbackModes, outcome.ConflictResolution.FallbackModes...) + } + return err } // runPushInWorktree executes validate → diff → push → commit → merge → tag diff --git a/cmd/push_conflict_test.go b/cmd/push_conflict_test.go index a3bbb2c..d36ac32 100644 --- a/cmd/push_conflict_test.go +++ b/cmd/push_conflict_test.go @@ -140,13 +140,13 @@ func TestRunPush_PullMergeRestoresStashedWorkspaceBeforePull(t *testing.T) { restoredBeforePull := false oldRunPullForPush := runPullForPush - runPullForPush = func(_ *cobra.Command, _ config.Target) error { + runPullForPush = func(_ *cobra.Command, _ config.Target) (commandRunReport, error) { doc, err := fs.ReadMarkdownDocument(rootPath) if err != nil { - return err + return commandRunReport{}, err } restoredBeforePull = strings.Contains(doc.Body, "local uncommitted content") - return errors.New("stop pull") + return commandRunReport{}, errors.New("stop pull") } t.Cleanup(func() { runPullForPush = oldRunPullForPush diff --git a/cmd/push_worktree.go b/cmd/push_worktree.go index cb969e4..5f8054f 100644 --- a/cmd/push_worktree.go +++ b/cmd/push_worktree.go @@ -27,7 +27,8 @@ func runPushInWorktree( spaceScopePath, changeScopePath string, worktreeDir, syncBranchName, snapshotRefName string, stashRef *string, -) error { +) (pushWorktreeOutcome, error) { + outcome := pushWorktreeOutcome{} warnings := make([]string, 0) addWarning := func(message string) { warnings = append(warnings, message) @@ -38,19 +39,19 @@ func runPushInWorktree( wtSpaceDir := filepath.Join(worktreeDir, spaceScopePath) wtClient := &git.Client{RootDir: worktreeDir} if err := os.MkdirAll(wtSpaceDir, 0o750); err != nil { - return fmt.Errorf("prepare worktree space directory: %w", err) + return outcome, fmt.Errorf("prepare worktree space directory: %w", err) } if strings.TrimSpace(*stashRef) != "" { if err := wtClient.StashApply(snapshotRefName); err != nil { - return fmt.Errorf("materialize snapshot in worktree: %w", err) + return outcome, fmt.Errorf("materialize snapshot in worktree: %w", err) } if err := restoreUntrackedFromStashParent(wtClient, snapshotRefName, spaceScopePath); err != nil { - return err + return outcome, err } } if err := os.MkdirAll(wtSpaceDir, 0o750); err != nil { - return fmt.Errorf("prepare worktree scope directory: %w", err) + return outcome, fmt.Errorf("prepare worktree scope directory: %w", err) } var wtTarget config.Target @@ -66,57 +67,58 @@ func runPushInWorktree( // 5. Diff (Snapshot vs Baseline) baselineRef, err := gitPushBaselineRef(gitClient, spaceKey) if err != nil { - return err + return outcome, err } wtClient = &git.Client{RootDir: worktreeDir} syncChanges, err := collectPushChangesForTarget(wtClient, baselineRef, target, spaceScopePath, changeScopePath) if err != nil { - return err + return outcome, err } // 4. Validate (in worktree) — only the changed files for space targets if target.IsFile() { if err := runValidateTargetWithContext(ctx, out, wtTarget); err != nil { - return fmt.Errorf("pre-push validate failed: %w", err) + return outcome, fmt.Errorf("pre-push validate failed: %w", err) } } else { changedAbsPaths := pushChangedAbsPaths(wtSpaceDir, syncChanges) if err := runValidateChangedPushFiles(ctx, out, wtSpaceDir, changedAbsPaths); err != nil { - return fmt.Errorf("pre-push validate failed: %w", err) + return outcome, fmt.Errorf("pre-push validate failed: %w", err) } } if len(syncChanges) == 0 { _, _ = fmt.Fprintln(out, "push completed with no in-scope markdown changes (no-op)") - return nil + outcome.NoChanges = true + return outcome, nil } if err := requireSafetyConfirmation(cmd.InOrStdin(), out, "push", len(syncChanges), pushHasDeleteChange(syncChanges)); err != nil { - return err + return outcome, err } // 6. Push (in worktree) envPath := findEnvPath(wtSpaceDir) cfg, err := config.Load(envPath) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return outcome, fmt.Errorf("failed to load config: %w", err) } remote, err := newPushRemote(cfg) if err != nil { - return fmt.Errorf("create confluence client: %w", err) + return outcome, fmt.Errorf("create confluence client: %w", err) } defer closeRemoteIfPossible(remote) state, err := fs.LoadState(spaceDir) if err != nil { - return fmt.Errorf("load state: %w", err) + return outcome, fmt.Errorf("load state: %w", err) } globalPageIndex, err := buildWorkspaceGlobalPageIndex(wtSpaceDir) if err != nil { - return fmt.Errorf("build global page index: %w", err) + return outcome, fmt.Errorf("build global page index: %w", err) } var progress syncflow.Progress @@ -137,6 +139,7 @@ func runPushInWorktree( ArchivePollInterval: normalizedArchiveTaskPollInterval(), Progress: progress, }) + outcome.Result = result if err != nil { var conflictErr *syncflow.PushConflictError if errors.As(err, &conflictErr) { @@ -152,7 +155,7 @@ func runPushInWorktree( _, _ = fmt.Fprintf(out, "conflict detected for %s; policy is %s, attempting automatic pull-merge...\n", conflictErr.Path, onConflict) if strings.TrimSpace(*stashRef) != "" { if err := gitClient.StashPop(*stashRef); err != nil { - return fmt.Errorf("restore local workspace before automatic pull-merge: %w", err) + return outcome, fmt.Errorf("restore local workspace before automatic pull-merge: %w", err) } *stashRef = "" } @@ -161,28 +164,38 @@ func runPushInWorktree( // instead of warning and skipping them. prevDiscardLocal := flagPullDiscardLocal flagPullDiscardLocal = true - pullErr := runPullForPush(cmd, target) + pullReport, pullErr := runPullForPush(cmd, target) + outcome.ConflictResolution = &commandRunReportConflictResolution{ + Policy: OnConflictPullMerge, + MutatedFiles: append([]string(nil), pullReport.MutatedFiles...), + Diagnostics: append([]commandRunReportDiagnostic(nil), pullReport.Diagnostics...), + AttachmentOperations: append([]commandRunReportAttachmentOp(nil), pullReport.AttachmentOperations...), + FallbackModes: append([]string(nil), pullReport.FallbackModes...), + } flagPullDiscardLocal = prevDiscardLocal if pullErr != nil { - return fmt.Errorf("automatic pull-merge failed: %w", pullErr) + outcome.ConflictResolution.Status = "failed" + return outcome, fmt.Errorf("automatic pull-merge failed: %w", pullErr) } + outcome.ConflictResolution.Status = "completed" retryCmd := "conf push" if target.IsFile() { retryCmd = fmt.Sprintf("conf push %q", target.Value) } _, _ = fmt.Fprintf(out, "automatic pull-merge completed. If there were no content conflicts, rerun `%s` to resume the push.\n", retryCmd) - return nil + return outcome, nil } - return formatPushConflictError(conflictErr) + return outcome, formatPushConflictError(conflictErr) } printPushDiagnostics(out, result.Diagnostics) - return err + return outcome, err } if len(result.Commits) == 0 { slog.Info("push_sync_result", "space_key", spaceKey, "commit_count", 0, "diagnostics", len(result.Diagnostics)) _, _ = fmt.Fprintln(out, "push completed with no pushable markdown changes (no-op)") - return nil + outcome.NoChanges = true + return outcome, nil } printPushDiagnostics(out, result.Diagnostics) @@ -262,11 +275,11 @@ func runPushInWorktree( if progress != nil { if err := runWithIndeterminateStatus(out, "Finalizing push", finalizePushGit); err != nil { - return err + return outcome, err } } else { if err := finalizePushGit(); err != nil { - return err + return outcome, err } } @@ -279,7 +292,8 @@ func runPushInWorktree( _, _ = fmt.Fprintf(out, "push completed: %d page change(s) synced\n", len(result.Commits)) slog.Info("push_sync_result", "space_key", spaceKey, "commit_count", len(result.Commits), "diagnostics", len(result.Diagnostics)) - return nil + outcome.Warnings = append(outcome.Warnings, warnings...) + return outcome, nil } func resolvePushScopePath(client *git.Client, spaceDir string, target config.Target, targetCtx validateTargetContext) (string, error) { diff --git a/cmd/relink.go b/cmd/relink.go index 844977a..d2b9e1c 100644 --- a/cmd/relink.go +++ b/cmd/relink.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "io" + "os" "path/filepath" "strings" @@ -34,44 +36,62 @@ If omitted, relink will attempt to resolve all possible links across all managed } func runRelink(cmd *cobra.Command, target string) error { + _, err := runRelinkWithResult(cmd, target) + return err +} + +type relinkRunResult struct { + MutatedFiles []string +} + +func runRelinkWithResult(cmd *cobra.Command, target string) (relinkRunResult, error) { if err := ensureWorkspaceSyncReady("relink"); err != nil { - return err + return relinkRunResult{}, err } repoRoot, err := gitRepoRoot() if err != nil { - return err + return relinkRunResult{}, err } index, err := sync.BuildGlobalPageIndex(repoRoot) if err != nil { - return fmt.Errorf("build global index: %w", err) + return relinkRunResult{}, fmt.Errorf("build global index: %w", err) } states, err := fs.FindAllStateFiles(repoRoot) if err != nil { - return fmt.Errorf("discover spaces: %w", err) + return relinkRunResult{}, fmt.Errorf("discover spaces: %w", err) } + out := reportWriter(cmd, ensureSynchronizedCmdOutput(cmd)) + if target != "" { - return runTargetedRelink(cmd, repoRoot, target, index, states) + return runTargetedRelink(cmd, out, repoRoot, target, index, states) } - return runGlobalRelink(cmd, repoRoot, index, states) + return runGlobalRelink(cmd, out, repoRoot, index, states) } -func runTargetedRelink(cmd *cobra.Command, repoRoot, target string, index sync.GlobalPageIndex, states map[string]fs.SpaceState) error { +func runTargetedRelink(cmd *cobra.Command, out io.Writer, _ string, target string, index sync.GlobalPageIndex, states map[string]fs.SpaceState) (relinkRunResult, error) { + runResult := relinkRunResult{MutatedFiles: []string{}} + // 1. Resolve target space targetSpaceDir := "" targetSpaceKey := "" + normalizedStates := make(map[string]string, len(states)) + for dir := range states { + normalizedStates[normalizeRelinkPath(dir)] = dir + } // Check if target is a directory in states absTarget, _ := filepath.Abs(target) - if state, ok := states[absTarget]; ok { - targetSpaceDir = absTarget + if resolvedDir, ok := normalizedStates[normalizeRelinkPath(absTarget)]; ok { + targetSpaceDir = resolvedDir + state := states[resolvedDir] // Extract space key from one of the files or something? // Better to resolve space key like pull does. - targetSpaceKey = getSpaceKeyFromState(absTarget, state) + targetSpaceKey = getSpaceKeyFromState(resolvedDir, state) } else { // Try to find by space key for dir, state := range states { @@ -85,7 +105,7 @@ func runTargetedRelink(cmd *cobra.Command, repoRoot, target string, index sync.G } if targetSpaceDir == "" { - return fmt.Errorf("could not find managed space for target %q", target) + return runResult, fmt.Errorf("could not find managed space for target %q", target) } // 2. Identify all PageIDs belonging to target space @@ -97,7 +117,7 @@ func runTargetedRelink(cmd *cobra.Command, repoRoot, target string, index sync.G } } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Relinking references to space %s (%s)...\n", targetSpaceKey, targetSpaceDir) + _, _ = fmt.Fprintf(out, "Relinking references to space %s (%s)...\n", targetSpaceKey, targetSpaceDir) // 3. Scan all OTHER spaces for dir, state := range states { @@ -108,71 +128,75 @@ func runTargetedRelink(cmd *cobra.Command, repoRoot, target string, index sync.G currentSpaceKey := getSpaceKeyFromState(dir, state) // 1. Dry run to see if there are changes - result, err := sync.ResolveLinksInSpace(dir, index, targetPageIDs, true) + spaceResult, err := relinkSpaceFiles(dir, index, targetPageIDs, true) if err != nil { - return err + return runResult, err } - if result.LinksConverted == 0 { + if spaceResult.Summary.LinksConverted == 0 { continue } // 2. Prompt msg := fmt.Sprintf("Found %d absolute links in %d files in space %s pointing to %s. Update %s?", - result.LinksConverted, result.FilesChanged, currentSpaceKey, targetSpaceKey, currentSpaceKey) - if err := requireSafetyConfirmation(cmd.InOrStdin(), cmd.OutOrStdout(), msg, result.FilesChanged, false); err != nil { + spaceResult.Summary.LinksConverted, spaceResult.Summary.FilesChanged, currentSpaceKey, targetSpaceKey, currentSpaceKey) + if err := requireSafetyConfirmation(cmd.InOrStdin(), out, msg, spaceResult.Summary.FilesChanged, false); err != nil { if flagNonInteractive { - return err + return runResult, err } // User said No or error, skip this space continue } // 3. Apply changes - result, err = sync.ResolveLinksInSpace(dir, index, targetPageIDs, false) + appliedResult, err := relinkSpaceFiles(dir, index, targetPageIDs, false) + runResult.MutatedFiles = append(runResult.MutatedFiles, appliedResult.MutatedFiles...) if err != nil { - return err + return runResult, err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Updated %d links in %d files in space %s.\n", result.LinksConverted, result.FilesChanged, currentSpaceKey) + _, _ = fmt.Fprintf(out, "Updated %d links in %d files in space %s.\n", appliedResult.Summary.LinksConverted, appliedResult.Summary.FilesChanged, currentSpaceKey) } - return nil + return runResult, nil } -func runGlobalRelink(cmd *cobra.Command, repoRoot string, index sync.GlobalPageIndex, states map[string]fs.SpaceState) error { +func runGlobalRelink(cmd *cobra.Command, out io.Writer, _ string, index sync.GlobalPageIndex, states map[string]fs.SpaceState) (relinkRunResult, error) { + result := relinkRunResult{MutatedFiles: []string{}} + for dir, state := range states { spaceKey := getSpaceKeyFromState(dir, state) // 1. Dry run - result, err := sync.ResolveLinksInSpace(dir, index, nil, true) + spaceResult, err := relinkSpaceFiles(dir, index, nil, true) if err != nil { - return err + return result, err } - if result.LinksConverted == 0 { + if spaceResult.Summary.LinksConverted == 0 { continue } // 2. Prompt msg := fmt.Sprintf("Found %d absolute links in %d files in space %s that can be resolved. Update %s?", - result.LinksConverted, result.FilesChanged, spaceKey, spaceKey) - if err := requireSafetyConfirmation(cmd.InOrStdin(), cmd.OutOrStdout(), msg, result.FilesChanged, false); err != nil { + spaceResult.Summary.LinksConverted, spaceResult.Summary.FilesChanged, spaceKey, spaceKey) + if err := requireSafetyConfirmation(cmd.InOrStdin(), out, msg, spaceResult.Summary.FilesChanged, false); err != nil { if flagNonInteractive { - return err + return result, err } continue } // 3. Apply - result, err = sync.ResolveLinksInSpace(dir, index, nil, false) + appliedResult, err := relinkSpaceFiles(dir, index, nil, false) + result.MutatedFiles = append(result.MutatedFiles, appliedResult.MutatedFiles...) if err != nil { - return err + return result, err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Updated %d links in %d files in space %s.\n", result.LinksConverted, result.FilesChanged, spaceKey) + _, _ = fmt.Fprintf(out, "Updated %d links in %d files in space %s.\n", appliedResult.Summary.LinksConverted, appliedResult.Summary.FilesChanged, spaceKey) } - return nil + return result, nil } func getSpaceKeyFromState(dir string, state fs.SpaceState) string { @@ -181,3 +205,75 @@ func getSpaceKeyFromState(dir string, state fs.SpaceState) string { } return inferSpaceKeyFromDirName(dir) } + +type relinkSpaceFilesResult struct { + Summary sync.RelinkResult + MutatedFiles []string +} + +func relinkSpaceFiles(spaceDir string, index sync.GlobalPageIndex, targetPageIDs map[string]struct{}, dryRun bool) (relinkSpaceFilesResult, error) { + result := relinkSpaceFilesResult{MutatedFiles: []string{}} + filteredIndex := filteredRelinkIndex(index, targetPageIDs) + err := filepath.WalkDir(spaceDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".md") { + return nil + } + result.Summary.FilesSeen++ + if dryRun { + changed, linksConverted, err := sync.ResolveLinksInFile(path, filteredIndex, true) + if err != nil { + return err + } + if changed { + result.Summary.FilesChanged++ + result.Summary.LinksConverted += linksConverted + result.MutatedFiles = append(result.MutatedFiles, path) + } + return nil + } + changed, linksConverted, err := sync.ResolveLinksInFile(path, filteredIndex, false) + if err != nil { + return err + } + if changed { + result.Summary.FilesChanged++ + result.Summary.LinksConverted += linksConverted + result.MutatedFiles = append(result.MutatedFiles, path) + } + return nil + }) + return result, err +} + +func filteredRelinkIndex(index sync.GlobalPageIndex, targetPageIDs map[string]struct{}) sync.GlobalPageIndex { + if len(targetPageIDs) == 0 { + return index + } + filtered := make(sync.GlobalPageIndex) + for id, path := range index { + if _, ok := targetPageIDs[id]; ok { + filtered[id] = path + } + } + return filtered +} + +func normalizeRelinkPath(path string) string { + normalized := filepath.Clean(strings.TrimSpace(path)) + if normalized == "" { + return "" + } + if resolved, err := filepath.EvalSymlinks(normalized); err == nil { + normalized = resolved + } + return strings.ToLower(filepath.Clean(normalized)) +} diff --git a/cmd/report.go b/cmd/report.go new file mode 100644 index 0000000..d721489 --- /dev/null +++ b/cmd/report.go @@ -0,0 +1,374 @@ +package cmd + +import ( + "encoding/json" + "io" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/rgonek/confluence-markdown-sync/internal/config" + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" + "github.com/spf13/cobra" +) + +const reportJSONFlagName = "report-json" + +type commandRunReport struct { + RunID string `json:"run_id"` + + Command string `json:"command"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + + Timing commandRunReportTiming `json:"timing"` + Target commandRunReportTarget `json:"target"` + + Diagnostics []commandRunReportDiagnostic `json:"diagnostics"` + MutatedFiles []string `json:"mutated_files"` + MutatedPages []commandRunReportPage `json:"mutated_pages"` + AttachmentOperations []commandRunReportAttachmentOp `json:"attachment_operations"` + FallbackModes []string `json:"fallback_modes"` + RecoveryArtifacts []commandRunReportRecoveryArtifact `json:"recovery_artifacts"` + ConflictResolution *commandRunReportConflictResolution `json:"conflict_resolution,omitempty"` +} + +type commandRunReportTiming struct { + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + DurationMs int64 `json:"duration_ms"` +} + +type commandRunReportTarget struct { + Mode string `json:"mode"` + Value string `json:"value"` + SpaceKey string `json:"space_key,omitempty"` + SpaceDir string `json:"space_dir,omitempty"` + File string `json:"file,omitempty"` +} + +type commandRunReportDiagnostic struct { + Path string `json:"path,omitempty"` + Code string `json:"code"` + Field string `json:"field,omitempty"` + Message string `json:"message"` + Category string `json:"category,omitempty"` + ActionRequired bool `json:"action_required,omitempty"` +} + +type commandRunReportPage struct { + Path string `json:"path,omitempty"` + PageID string `json:"page_id,omitempty"` + Title string `json:"title,omitempty"` + Version int `json:"version,omitempty"` + Deleted bool `json:"deleted,omitempty"` +} + +type commandRunReportAttachmentOp struct { + Type string `json:"type"` + Path string `json:"path,omitempty"` + PageID string `json:"page_id,omitempty"` + AttachmentID string `json:"attachment_id,omitempty"` +} + +type commandRunReportRecoveryArtifact struct { + Type string `json:"type"` + Name string `json:"name"` + Status string `json:"status"` +} + +type commandRunReportConflictResolution struct { + Policy string `json:"policy"` + Status string `json:"status"` + MutatedFiles []string `json:"mutated_files"` + Diagnostics []commandRunReportDiagnostic `json:"diagnostics"` + AttachmentOperations []commandRunReportAttachmentOp `json:"attachment_operations"` + FallbackModes []string `json:"fallback_modes"` +} + +type validateCommandResult struct { + SpaceKey string + SpaceDir string + TargetFile string + Diagnostics []commandRunReportDiagnostic +} + +type diffCommandResult struct { + SpaceKey string + SpaceDir string + TargetFile string + Diagnostics []syncflow.PullDiagnostic + ChangedFiles []string +} + +type pushWorktreeOutcome struct { + Result syncflow.PushResult + Warnings []string + NoChanges bool + ConflictResolution *commandRunReportConflictResolution +} + +func addReportJSONFlag(cmd *cobra.Command) { + cmd.Flags().Bool(reportJSONFlagName, false, "Emit a structured JSON run report") +} + +func commandRequestsJSONReport(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + flag := cmd.Flags().Lookup(reportJSONFlagName) + if flag == nil { + return false + } + enabled, err := cmd.Flags().GetBool(reportJSONFlagName) + return err == nil && enabled +} + +func newCommandRunReport(runID, command string, target config.Target, startedAt time.Time) commandRunReport { + return commandRunReport{ + RunID: strings.TrimSpace(runID), + Command: strings.TrimSpace(command), + Timing: commandRunReportTiming{ + StartedAt: startedAt.UTC().Format(time.RFC3339Nano), + }, + Target: commandRunReportTarget{ + Mode: reportTargetMode(target), + Value: target.Value, + }, + Diagnostics: []commandRunReportDiagnostic{}, + MutatedFiles: []string{}, + MutatedPages: []commandRunReportPage{}, + AttachmentOperations: []commandRunReportAttachmentOp{}, + FallbackModes: []string{}, + RecoveryArtifacts: []commandRunReportRecoveryArtifact{}, + } +} + +func reportTargetMode(target config.Target) string { + if target.IsFile() { + return "file" + } + return "space" +} + +func (r *commandRunReport) finalize(runErr error, finishedAt time.Time) { + r.Success = runErr == nil + if runErr != nil { + r.Error = runErr.Error() + } + r.Timing.FinishedAt = finishedAt.UTC().Format(time.RFC3339Nano) + r.Timing.DurationMs = finishedAt.Sub(parseReportTime(r.Timing.StartedAt)).Milliseconds() + if r.Timing.DurationMs < 0 { + r.Timing.DurationMs = 0 + } + r.FallbackModes = sortedUniqueStrings(r.FallbackModes) + r.MutatedFiles = sortedUniqueStrings(r.MutatedFiles) + if r.ConflictResolution != nil { + r.ConflictResolution.FallbackModes = sortedUniqueStrings(r.ConflictResolution.FallbackModes) + r.ConflictResolution.MutatedFiles = sortedUniqueStrings(r.ConflictResolution.MutatedFiles) + } +} + +func parseReportTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{} + } + return parsed +} + +func writeCommandRunReport(out io.Writer, report commandRunReport) error { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(report) +} + +func reportWriter(cmd *cobra.Command, actual io.Writer) io.Writer { + if commandRequestsJSONReport(cmd) { + return ensureSynchronizedCmdError(cmd) + } + return actual +} + +func reportRelativePath(spaceDir, value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if filepath.IsAbs(value) && strings.TrimSpace(spaceDir) != "" { + spaceDirForRel := filepath.Clean(spaceDir) + valueForRel := filepath.Clean(value) + if resolved, err := filepath.EvalSymlinks(spaceDirForRel); err == nil { + spaceDirForRel = resolved + } + if resolved, err := filepath.EvalSymlinks(valueForRel); err == nil { + valueForRel = resolved + } + if rel, err := filepath.Rel(spaceDirForRel, valueForRel); err == nil { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(value) +} + +func pageIDFromAttachmentPath(path string) string { + parts := strings.Split(filepath.ToSlash(strings.TrimSpace(path)), "/") + if len(parts) >= 2 && parts[0] == "assets" { + return parts[1] + } + return "" +} + +func fallbackModesFromPullDiagnostics(diags []syncflow.PullDiagnostic) []string { + modes := make([]string, 0) + for _, diag := range diags { + switch strings.TrimSpace(diag.Code) { + case "FOLDER_LOOKUP_UNAVAILABLE": + modes = append(modes, "folder_lookup_unavailable") + case "CONTENT_STATUS_COMPATIBILITY_MODE": + modes = append(modes, "content_status_compatibility_mode") + } + } + return sortedUniqueStrings(modes) +} + +func fallbackModesFromPushDiagnostics(diags []syncflow.PushDiagnostic) []string { + modes := make([]string, 0) + for _, diag := range diags { + switch strings.TrimSpace(diag.Code) { + case "FOLDER_COMPATIBILITY_MODE": + modes = append(modes, "folder_lookup_unavailable") + case "CONTENT_STATUS_COMPATIBILITY_MODE": + modes = append(modes, "content_status_compatibility_mode") + } + } + return sortedUniqueStrings(modes) +} + +func reportDiagnosticsFromPull(diags []syncflow.PullDiagnostic, spaceDir string) []commandRunReportDiagnostic { + out := make([]commandRunReportDiagnostic, 0, len(diags)) + for _, diag := range diags { + out = append(out, commandRunReportDiagnostic{ + Path: reportRelativePath(spaceDir, diag.Path), + Code: strings.TrimSpace(diag.Code), + Message: strings.TrimSpace(diag.Message), + Category: strings.TrimSpace(diag.Category), + ActionRequired: diag.ActionRequired, + }) + } + return out +} + +func reportDiagnosticsFromPush(diags []syncflow.PushDiagnostic, spaceDir string) []commandRunReportDiagnostic { + out := make([]commandRunReportDiagnostic, 0, len(diags)) + for _, diag := range diags { + out = append(out, commandRunReportDiagnostic{ + Path: reportRelativePath(spaceDir, diag.Path), + Code: strings.TrimSpace(diag.Code), + Message: strings.TrimSpace(diag.Message), + }) + } + return out +} + +func reportAttachmentOpsFromPull(result syncflow.PullResult, spaceDir string) []commandRunReportAttachmentOp { + out := make([]commandRunReportAttachmentOp, 0, len(result.DownloadedAssets)+len(result.DeletedAssets)) + for _, path := range result.DownloadedAssets { + rel := reportRelativePath(spaceDir, path) + out = append(out, commandRunReportAttachmentOp{ + Type: "download", + Path: rel, + PageID: pageIDFromAttachmentPath(rel), + AttachmentID: strings.TrimSpace(result.State.AttachmentIndex[rel]), + }) + } + for _, path := range result.DeletedAssets { + rel := reportRelativePath(spaceDir, path) + out = append(out, commandRunReportAttachmentOp{ + Type: "delete", + Path: rel, + PageID: pageIDFromAttachmentPath(rel), + }) + } + return out +} + +func reportAttachmentOpsFromPush(result syncflow.PushResult, spaceDir string) []commandRunReportAttachmentOp { + out := make([]commandRunReportAttachmentOp, 0) + for _, diag := range result.Diagnostics { + rel := reportRelativePath(spaceDir, diag.Path) + switch strings.TrimSpace(diag.Code) { + case "ATTACHMENT_CREATED": + out = append(out, commandRunReportAttachmentOp{ + Type: "upload", + Path: rel, + PageID: pageIDFromAttachmentPath(rel), + AttachmentID: strings.TrimSpace(result.State.AttachmentIndex[rel]), + }) + case "ATTACHMENT_DELETED": + out = append(out, commandRunReportAttachmentOp{ + Type: "delete", + Path: rel, + PageID: pageIDFromAttachmentPath(rel), + }) + case "ATTACHMENT_PRESERVED": + out = append(out, commandRunReportAttachmentOp{ + Type: "preserve", + Path: rel, + PageID: pageIDFromAttachmentPath(rel), + AttachmentID: strings.TrimSpace(result.State.AttachmentIndex[rel]), + }) + } + } + return out +} + +func sortedUniqueStrings(values []string) []string { + if len(values) == 0 { + return []string{} + } + set := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + set[value] = struct{}{} + } + out := make([]string, 0, len(set)) + for value := range set { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func (r *commandRunReport) absorb(other commandRunReport) { + r.Diagnostics = append(r.Diagnostics, other.Diagnostics...) + r.MutatedFiles = append(r.MutatedFiles, other.MutatedFiles...) + r.MutatedPages = append(r.MutatedPages, other.MutatedPages...) + r.AttachmentOperations = append(r.AttachmentOperations, other.AttachmentOperations...) + r.FallbackModes = append(r.FallbackModes, other.FallbackModes...) + r.RecoveryArtifacts = append(r.RecoveryArtifacts, other.RecoveryArtifacts...) +} + +func (r *commandRunReport) setRecoveryArtifactStatus(artifactType, name, status string) { + artifactType = strings.TrimSpace(artifactType) + name = strings.TrimSpace(name) + status = strings.TrimSpace(status) + if artifactType == "" || name == "" || status == "" { + return + } + for i := range r.RecoveryArtifacts { + if r.RecoveryArtifacts[i].Type == artifactType && r.RecoveryArtifacts[i].Name == name { + r.RecoveryArtifacts[i].Status = status + return + } + } + r.RecoveryArtifacts = append(r.RecoveryArtifacts, commandRunReportRecoveryArtifact{ + Type: artifactType, + Name: name, + Status: status, + }) +} diff --git a/cmd/report_test.go b/cmd/report_test.go new file mode 100644 index 0000000..28d65c3 --- /dev/null +++ b/cmd/report_test.go @@ -0,0 +1,1054 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rgonek/confluence-markdown-sync/internal/config" + "github.com/rgonek/confluence-markdown-sync/internal/confluence" + "github.com/rgonek/confluence-markdown-sync/internal/fs" + syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" + "github.com/spf13/cobra" +) + +type commandReportJSON struct { + RunID string `json:"run_id"` + + Command string `json:"command"` + Success bool `json:"success"` + Error string `json:"error"` + + Timing struct { + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + DurationMs int64 `json:"duration_ms"` + } `json:"timing"` + + Target struct { + Mode string `json:"mode"` + Value string `json:"value"` + SpaceKey string `json:"space_key"` + SpaceDir string `json:"space_dir"` + File string `json:"file"` + } `json:"target"` + + Diagnostics []struct { + Path string `json:"path"` + Code string `json:"code"` + Field string `json:"field"` + Message string `json:"message"` + Category string `json:"category"` + ActionRequired bool `json:"action_required"` + } `json:"diagnostics"` + + MutatedFiles []string `json:"mutated_files"` + + MutatedPages []struct { + Path string `json:"path"` + PageID string `json:"page_id"` + Title string `json:"title"` + Version int `json:"version"` + Deleted bool `json:"deleted"` + } `json:"mutated_pages"` + + AttachmentOperations []struct { + Type string `json:"type"` + Path string `json:"path"` + PageID string `json:"page_id"` + AttachmentID string `json:"attachment_id"` + } `json:"attachment_operations"` + + FallbackModes []string `json:"fallback_modes"` + + RecoveryArtifacts []struct { + Type string `json:"type"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"recovery_artifacts"` + + ConflictResolution *struct { + Policy string `json:"policy"` + Status string `json:"status"` + MutatedFiles []string `json:"mutated_files"` + Diagnostics []struct { + Path string `json:"path"` + Code string `json:"code"` + Field string `json:"field"` + Message string `json:"message"` + Category string `json:"category"` + ActionRequired bool `json:"action_required"` + } `json:"diagnostics"` + AttachmentOperations []struct { + Type string `json:"type"` + Path string `json:"path"` + PageID string `json:"page_id"` + AttachmentID string `json:"attachment_id"` + } `json:"attachment_operations"` + FallbackModes []string `json:"fallback_modes"` + } `json:"conflict_resolution"` +} + +func TestRunPull_ReportJSONSuccessIsStable(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupGitRepo(t, repo) + setupEnv(t) + + spaceDir := filepath.Join(repo, "Engineering (ENG)") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "Root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + ConfluenceParentPageID: "folder-1", + }, + Body: "old body\n", + }) + if err := fs.SaveState(spaceDir, fs.SpaceState{ + SpaceKey: "ENG", + PagePathIndex: map[string]string{"Root.md": "1"}, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save state: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(".env\n.confluence-state.json\n"), 0o600); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "baseline") + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + ParentType: "folder", + ParentPageID: "folder-1", + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + }, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + ParentType: "folder", + ParentPageID: "folder-1", + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("new body")), + }, + }, + folderErr: &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders/folder-1", + Message: "folder api unavailable", + }, + attachmentsByPage: map[string][]confluence.Attachment{ + "1": { + {ID: "att-1", PageID: "1", Filename: "diagram.png"}, + }, + }, + attachments: map[string][]byte{ + "att-1": []byte("png"), + }, + } + + oldFactory := newPullRemote + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newPullRemote = oldFactory }) + + chdirRepo(t, repo) + + cmd := newPullCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "--force", "Engineering (ENG)"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("pull command failed: %v", err) + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "pull", true) + if report.Target.SpaceKey != "ENG" { + t.Fatalf("target space key = %q, want ENG", report.Target.SpaceKey) + } + if !containsString(report.MutatedFiles, "Root.md") { + t.Fatalf("mutated files = %v, want Root.md", report.MutatedFiles) + } + if !containsString(report.FallbackModes, "folder_lookup_unavailable") { + t.Fatalf("fallback modes = %v, want folder_lookup_unavailable", report.FallbackModes) + } + if !containsDiagnosticCode(report, "FOLDER_LOOKUP_UNAVAILABLE") { + t.Fatalf("diagnostics = %+v, want FOLDER_LOOKUP_UNAVAILABLE", report.Diagnostics) + } +} + +func TestRunPull_ReportJSONFailureIsStructured(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupEnv(t) + chdirRepo(t, repo) + + oldFactory := newPullRemote + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { + return nil, errors.New("simulated client failure") + } + t.Cleanup(func() { newPullRemote = oldFactory }) + + cmd := newPullCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "ENG"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected pull command to fail") + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "pull", false) + if !strings.Contains(report.Error, "simulated client failure") { + t.Fatalf("error = %q, want simulated client failure", report.Error) + } +} + +func TestRunPull_ReportJSONFailureOnWorkspaceSyncStateIsStructured(t *testing.T) { + runParallelCommandTest(t) + + repo := createUnmergedWorkspaceRepo(t) + chdirRepo(t, repo) + + cmd := newPullCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "ENG"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected pull command to fail") + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "pull", false) + if !strings.Contains(report.Error, "syncing state with unresolved files") { + t.Fatalf("error = %q, want syncing-state failure", report.Error) + } +} + +func TestRunValidate_ReportJSONFailureIsStable(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupGitRepo(t, repo) + setupEnv(t) + + spaceDir := filepath.Join(repo, "Engineering (ENG)") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space dir: %v", err) + } + + rootPath := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, rootPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1}, + Body: "content\n", + }) + if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}); err != nil { + t.Fatalf("save state: %v", err) + } + + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "baseline") + + writeMarkdown(t, rootPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Root", ID: "2", Version: 1}, + Body: "content\n", + }) + + chdirRepo(t, repo) + + cmd := newValidateCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "Engineering (ENG)"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected validate command to fail") + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "validate", false) + if !containsDiagnostic(report, "immutable", "id") { + t.Fatalf("diagnostics = %+v, want immutable id entry", report.Diagnostics) + } + if len(report.MutatedFiles) != 0 { + t.Fatalf("mutated files = %v, want empty", report.MutatedFiles) + } +} + +func TestRunDiff_ReportJSONSuccessIsStable(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + setupEnv(t) + + localFile := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "old body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, diffUnresolvedADF()), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + chdirRepo(t, repo) + + cmd := newDiffCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", localFile}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("diff command failed: %v", err) + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "diff", true) + if !containsString(report.MutatedFiles, "root.md") { + t.Fatalf("mutated files = %v, want root.md", report.MutatedFiles) + } + if !containsDiagnosticCode(report, "unresolved_reference") { + t.Fatalf("diagnostics = %+v, want unresolved_reference", report.Diagnostics) + } +} + +func TestRunDiff_ReportJSONIncludesFolderFallbackDiagnosticsAndModes(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + setupEnv(t) + + localFile := filepath.Join(spaceDir, "root.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "old body\n", + }) + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "1", SpaceID: "space-1", Title: "Root", ParentPageID: "folder-1", ParentType: "folder", Version: 2, LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC)}, + }, + folderErr: &confluence.APIError{ + StatusCode: 500, + Method: "GET", + URL: "/wiki/api/v2/folders", + Message: "Internal Server Error", + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + ParentPageID: "folder-1", + ParentType: "folder", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("new body")), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + chdirRepo(t, repo) + + cmd := newDiffCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", localFile}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("diff command failed: %v", err) + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "diff", true) + if !containsDiagnosticCode(report, "FOLDER_LOOKUP_UNAVAILABLE") { + t.Fatalf("diagnostics = %+v, want FOLDER_LOOKUP_UNAVAILABLE", report.Diagnostics) + } + if !containsString(report.FallbackModes, "folder_lookup_unavailable") { + t.Fatalf("fallback modes = %v, want folder_lookup_unavailable", report.FallbackModes) + } +} + +func TestRunPush_ReportJSONSuccessIsStable(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + setupEnv(t) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "![asset](assets/new.png)\n", + }) + + assetPath := filepath.Join(spaceDir, "assets", "new.png") + if err := os.MkdirAll(filepath.Dir(assetPath), 0o750); err != nil { + t.Fatalf("mkdir assets dir: %v", err) + } + if err := os.WriteFile(assetPath, []byte("png"), 0o600); err != nil { + t.Fatalf("write asset: %v", err) + } + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local change") + + fake := newCmdFakePushRemote(1) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + chdirRepo(t, spaceDir) + + cmd := newPushCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "--on-conflict=cancel"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("push command failed: %v", err) + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "push", true) + if !containsString(report.MutatedFiles, "root.md") { + t.Fatalf("mutated files = %v, want root.md", report.MutatedFiles) + } + if len(report.MutatedPages) != 1 || report.MutatedPages[0].PageID != "1" || report.MutatedPages[0].Version != 2 { + t.Fatalf("mutated pages = %+v, want page 1 version 2", report.MutatedPages) + } + if len(report.AttachmentOperations) != 1 { + t.Fatalf("attachment operations = %+v, want one upload", report.AttachmentOperations) + } + if got := report.AttachmentOperations[0]; got.Type != "upload" || got.PageID != "1" || got.Path != "assets/1/new.png" { + t.Fatalf("unexpected attachment operation: %+v", got) + } + if !containsRecoveryArtifact(report, "snapshot_ref", "cleaned_up") { + t.Fatalf("recovery artifacts = %+v, want cleaned-up snapshot ref", report.RecoveryArtifacts) + } + if !containsRecoveryArtifact(report, "sync_branch", "cleaned_up") { + t.Fatalf("recovery artifacts = %+v, want cleaned-up sync branch", report.RecoveryArtifacts) + } +} + +func TestRunPush_ReportJSONFailureOnWorkspaceSyncStateIsStructured(t *testing.T) { + runParallelCommandTest(t) + + repo := createUnmergedWorkspaceRepo(t) + chdirRepo(t, repo) + + cmd := newPushCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "--on-conflict=cancel"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected push command to fail") + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "push", false) + if !strings.Contains(report.Error, "syncing state with unresolved files") { + t.Fatalf("error = %q, want syncing-state failure", report.Error) + } +} + +func TestRunPull_ReportJSONWithRelinkKeepsStdoutJSONAndCapturesRelinkedFiles(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupGitRepo(t, repo) + setupEnv(t) + + targetDir := filepath.Join(repo, "Target (TGT)") + sourceDir := filepath.Join(repo, "Source (SRC)") + if err := os.MkdirAll(targetDir, 0o750); err != nil { + t.Fatalf("mkdir target dir: %v", err) + } + if err := os.MkdirAll(sourceDir, 0o750); err != nil { + t.Fatalf("mkdir source dir: %v", err) + } + + writeMarkdown(t, filepath.Join(targetDir, "target.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Target", + ID: "42", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "old target body\n", + }) + if err := fs.SaveState(targetDir, fs.SpaceState{ + SpaceKey: "TGT", + PagePathIndex: map[string]string{"target.md": "42"}, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save target state: %v", err) + } + + sourceDocPath := filepath.Join(sourceDir, "doc.md") + writeMarkdown(t, sourceDocPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Source", ID: "101", Version: 1}, + Body: "[Target](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=42)\n", + }) + if err := fs.SaveState(sourceDir, fs.SpaceState{ + SpaceKey: "SRC", + PagePathIndex: map[string]string{"doc.md": "101"}, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save source state: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(".env\n.confluence-state.json\n"), 0o600); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "baseline") + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-2", Key: "TGT", Name: "Target"}, + pages: []confluence.Page{ + { + ID: "42", + SpaceID: "space-2", + Title: "Target", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + }, + }, + pagesByID: map[string]confluence.Page{ + "42": { + ID: "42", + SpaceID: "space-2", + Title: "Target", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("new target body")), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newPullRemote + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newPullRemote = oldFactory }) + + chdirRepo(t, repo) + + cmd := newPullCmd() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "--relink", "--force", "Target (TGT)"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("pull command failed: %v", err) + } + + report := decodeCommandReportJSON(t, stdout.Bytes()) + assertReportMetadata(t, report, "pull", true) + if !containsString(report.MutatedFiles, "target.md") { + t.Fatalf("mutated files = %v, want target.md", report.MutatedFiles) + } + if !containsString(report.MutatedFiles, "../Source (SRC)/doc.md") { + t.Fatalf("mutated files = %v, want relinked source doc", report.MutatedFiles) + } + + raw, err := os.ReadFile(sourceDocPath) //nolint:gosec // test path is controlled in temp repo + if err != nil { + t.Fatalf("read source doc: %v", err) + } + if !strings.Contains(string(raw), "../Target%20%28TGT%29/target.md") { + t.Fatalf("expected source doc to be relinked, got:\n%s", string(raw)) + } +} + +func TestRunPull_ReportJSONWithRelinkPreservesAppliedFilesOnLaterError(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupGitRepo(t, repo) + setupEnv(t) + + targetDir := filepath.Join(repo, "Target (TGT)") + sourceDir := filepath.Join(repo, "Source (SRC)") + if err := os.MkdirAll(targetDir, 0o750); err != nil { + t.Fatalf("mkdir target dir: %v", err) + } + if err := os.MkdirAll(sourceDir, 0o750); err != nil { + t.Fatalf("mkdir source dir: %v", err) + } + + writeMarkdown(t, filepath.Join(targetDir, "target.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Target", + ID: "42", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "old target body\n", + }) + if err := fs.SaveState(targetDir, fs.SpaceState{ + SpaceKey: "TGT", + PagePathIndex: map[string]string{"target.md": "42"}, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save target state: %v", err) + } + + appliedPath := filepath.Join(sourceDir, "a.md") + readOnlyPath := filepath.Join(sourceDir, "b.md") + writeMarkdown(t, appliedPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Applied", ID: "101", Version: 1}, + Body: "[Target](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=42)\n", + }) + writeMarkdown(t, readOnlyPath, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{Title: "Blocked", ID: "102", Version: 1}, + Body: "[Target](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=42)\n", + }) + if err := os.Chmod(readOnlyPath, 0o400); err != nil { + t.Fatalf("chmod read-only relink file: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(readOnlyPath, 0o600) }) + + if err := fs.SaveState(sourceDir, fs.SpaceState{ + SpaceKey: "SRC", + PagePathIndex: map[string]string{"a.md": "101", "b.md": "102"}, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save source state: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(".env\n.confluence-state.json\n"), 0o600); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "baseline") + + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-2", Key: "TGT", Name: "Target"}, + pages: []confluence.Page{ + { + ID: "42", + SpaceID: "space-2", + Title: "Target", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + }, + }, + pagesByID: map[string]confluence.Page{ + "42": { + ID: "42", + SpaceID: "space-2", + Title: "Target", + Version: 2, + LastModified: time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("new target body")), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newPullRemote + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newPullRemote = oldFactory }) + + chdirRepo(t, repo) + + cmd := newPullCmd() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "--relink", "--force", "Target (TGT)"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected pull command to fail") + } + + report := decodeCommandReportJSON(t, stdout.Bytes()) + assertReportMetadata(t, report, "pull", false) + if !strings.Contains(report.Error, "auto-relink") { + t.Fatalf("error = %q, want auto-relink failure", report.Error) + } + if !containsString(report.MutatedFiles, "target.md") { + t.Fatalf("mutated files = %v, want target.md", report.MutatedFiles) + } + if !containsString(report.MutatedFiles, "../Source (SRC)/a.md") { + t.Fatalf("mutated files = %v, want applied relink file", report.MutatedFiles) + } + + appliedRaw, err := os.ReadFile(appliedPath) //nolint:gosec // test path is controlled in temp repo + if err != nil { + t.Fatalf("read applied relink file: %v", err) + } + if !strings.Contains(string(appliedRaw), "../Target%20%28TGT%29/target.md") { + t.Fatalf("expected applied relink file to be rewritten, got:\n%s", string(appliedRaw)) + } +} + +func TestRunPush_ReportJSONPullMergeEmitsSingleObjectAndCapturesPullMergeReport(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + setupEnv(t) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "Updated local content\n", + }) + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local change") + + fake := newCmdFakePushRemote(3) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + chdirRepo(t, spaceDir) + + cmd := newPushCmd() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "--on-conflict=pull-merge"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("push command failed: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + + report := decodeCommandReportJSON(t, stdout.Bytes()) + assertReportMetadata(t, report, "push", true) + if report.ConflictResolution == nil { + t.Fatalf("conflict resolution = nil, want pull-merge details; report=%+v", report) + } + if report.ConflictResolution.Policy != OnConflictPullMerge { + t.Fatalf("conflict resolution policy = %q, want %q", report.ConflictResolution.Policy, OnConflictPullMerge) + } + if report.ConflictResolution.Status != "completed" { + t.Fatalf("conflict resolution status = %q, want completed", report.ConflictResolution.Status) + } + if !containsString(report.ConflictResolution.MutatedFiles, "root.md") { + t.Fatalf("conflict resolution mutated files = %v, want root.md", report.ConflictResolution.MutatedFiles) + } + if !containsString(report.MutatedFiles, "root.md") { + t.Fatalf("outer mutated files = %v, want root.md from pull-merge", report.MutatedFiles) + } +} + +func TestRunPush_ReportJSONFailureAroundWorktreeSetupIncludesRecoveryArtifacts(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + setupEnv(t) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "Updated local content\n", + }) + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local change") + + oldNow := nowUTC + fixedNow := time.Date(2026, time.February, 1, 12, 34, 58, 0, time.UTC) + nowUTC = func() time.Time { return fixedNow } + t.Cleanup(func() { nowUTC = oldNow }) + + worktreeDir := filepath.Join(repo, ".confluence-worktrees", "ENG-"+fixedNow.Format("20060102T150405Z")) + if err := os.MkdirAll(worktreeDir, 0o750); err != nil { + t.Fatalf("mkdir blocking worktree dir: %v", err) + } + if err := os.WriteFile(filepath.Join(worktreeDir, "keep.txt"), []byte("block worktree"), 0o600); err != nil { + t.Fatalf("write blocking worktree file: %v", err) + } + + chdirRepo(t, spaceDir) + + cmd := newPushCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--report-json", "--on-conflict=cancel"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected push command to fail") + } + + report := decodeCommandReportJSON(t, out.Bytes()) + assertReportMetadata(t, report, "push", false) + if !containsRecoveryArtifact(report, "snapshot_ref", "retained") { + t.Fatalf("recovery artifacts = %+v, want retained snapshot ref", report.RecoveryArtifacts) + } + if !containsRecoveryArtifact(report, "sync_branch", "retained") { + t.Fatalf("recovery artifacts = %+v, want retained sync branch", report.RecoveryArtifacts) + } +} + +func TestReportWriter_JSONModeRoutesPromptsToStderr(t *testing.T) { + runParallelCommandTest(t) + + cmd := &cobra.Command{} + addReportJSONFlag(cmd) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + if err := cmd.Flags().Set(reportJSONFlagName, "true"); err != nil { + t.Fatalf("set report-json flag: %v", err) + } + + actualOut := ensureSynchronizedCmdOutput(cmd) + policy, err := resolvePushConflictPolicy(strings.NewReader("cancel\n"), reportWriter(cmd, actualOut), "", false) + if err != nil { + t.Fatalf("resolve conflict policy: %v", err) + } + if policy != OnConflictCancel { + t.Fatalf("policy = %q, want %q", policy, OnConflictCancel) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "Conflict policy for remote-ahead pages") { + t.Fatalf("stderr = %q, want visible prompt", stderr.String()) + } +} + +func decodeCommandReportJSON(t *testing.T, raw []byte) commandReportJSON { + t.Helper() + + var report commandReportJSON + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatalf("output is not valid JSON report: %v\n%s", err, string(raw)) + } + return report +} + +func assertReportMetadata(t *testing.T, report commandReportJSON, command string, success bool) { + t.Helper() + + if report.Command != command { + t.Fatalf("command = %q, want %q", report.Command, command) + } + if report.Success != success { + t.Fatalf("success = %v, want %v", report.Success, success) + } + if strings.TrimSpace(report.RunID) == "" { + t.Fatal("run_id should not be empty") + } + if strings.TrimSpace(report.Timing.StartedAt) == "" { + t.Fatal("timing.started_at should not be empty") + } + if strings.TrimSpace(report.Timing.FinishedAt) == "" { + t.Fatal("timing.finished_at should not be empty") + } + if report.Timing.DurationMs < 0 { + t.Fatalf("timing.duration_ms = %d, want >= 0", report.Timing.DurationMs) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func containsDiagnostic(report commandReportJSON, code, field string) bool { + for _, diag := range report.Diagnostics { + if diag.Code == code && diag.Field == field { + return true + } + } + return false +} + +func containsDiagnosticCode(report commandReportJSON, code string) bool { + for _, diag := range report.Diagnostics { + if diag.Code == code { + return true + } + } + return false +} + +func containsRecoveryArtifact(report commandReportJSON, artifactType, status string) bool { + for _, artifact := range report.RecoveryArtifacts { + if artifact.Type == artifactType && artifact.Status == status { + return true + } + } + return false +} + +func createUnmergedWorkspaceRepo(t *testing.T) string { + t.Helper() + + repo := t.TempDir() + setupGitRepo(t, repo) + + path := filepath.Join(repo, "conflict.md") + if err := os.WriteFile(path, []byte("base\n"), 0o600); err != nil { + t.Fatalf("write base file: %v", err) + } + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "base") + + runGitForTest(t, repo, "checkout", "-b", "topic") + if err := os.WriteFile(path, []byte("topic\n"), 0o600); err != nil { + t.Fatalf("write topic file: %v", err) + } + runGitForTest(t, repo, "commit", "-am", "topic change") + + runGitForTest(t, repo, "checkout", "main") + if err := os.WriteFile(path, []byte("main\n"), 0o600); err != nil { + t.Fatalf("write main file: %v", err) + } + runGitForTest(t, repo, "commit", "-am", "main change") + + cmd := exec.Command("git", "merge", "topic") //nolint:gosec // test helper intentionally creates merge conflict + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err == nil { + t.Fatalf("expected git merge topic to fail, output:\n%s", string(out)) + } + + return repo +} diff --git a/cmd/validate.go b/cmd/validate.go index ea43da3..385d9fa 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -55,7 +55,7 @@ type validateFileResult struct { } func newValidateCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "validate [TARGET]", Short: "Validate local Markdown files against sync invariants", Long: `Validate checks frontmatter schema, immutable key integrity, @@ -73,13 +73,25 @@ If omitted, the space is inferred from the current directory name.`, return runValidateCommand(cmd, target) }, } + addReportJSONFlag(cmd) + return cmd } func runValidateCommand(cmd *cobra.Command, target config.Target) (runErr error) { - _, restoreLogger := beginCommandRun("validate") + actualOut := cmd.OutOrStdout() + out := reportWriter(cmd, actualOut) + runID, restoreLogger := beginCommandRun("validate") defer restoreLogger() startedAt := time.Now() + report := newCommandRunReport(runID, "validate", target, startedAt) + defer func() { + if !commandRequestsJSONReport(cmd) { + return + } + report.finalize(runErr, time.Now()) + _ = writeCommandRunReport(actualOut, report) + }() slog.Info("validate_started", "target_mode", target.Mode, "target", target.Value) defer func() { duration := time.Since(startedAt) @@ -93,52 +105,75 @@ func runValidateCommand(cmd *cobra.Command, target config.Target) (runErr error) slog.Info("validate_finished", "duration_ms", duration.Milliseconds()) }() - return runValidateTargetWithContext(getCommandContext(cmd), cmd.OutOrStdout(), target) + result, err := runValidateTargetWithContextReport(getCommandContext(cmd), out, target) + report.Target.SpaceKey = result.SpaceKey + report.Target.SpaceDir = result.SpaceDir + report.Target.File = result.TargetFile + report.Diagnostics = append(report.Diagnostics, result.Diagnostics...) + return err } func runValidateTargetWithContext(ctx context.Context, out io.Writer, target config.Target) error { + _, err := runValidateTargetWithContextReport(ctx, out, target) + return err +} + +func runValidateTargetWithContextReport(ctx context.Context, out io.Writer, target config.Target) (validateCommandResult, error) { if err := ensureWorkspaceSyncReady("validate"); err != nil { - return err + return validateCommandResult{}, err } targetCtx, err := resolveValidateTargetContext(target) if err != nil { - return err + return validateCommandResult{}, err + } + result := validateCommandResult{ + SpaceKey: targetCtx.spaceKey, + SpaceDir: targetCtx.spaceDir, + Diagnostics: []commandRunReportDiagnostic{}, + } + if target.IsFile() && len(targetCtx.files) == 1 { + result.TargetFile = targetCtx.files[0] } if err := ctx.Err(); err != nil { - return err + return result, err } envPath := findEnvPath(targetCtx.spaceDir) cfg, err := config.Load(envPath) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return result, fmt.Errorf("failed to load config: %w", err) } _, _ = fmt.Fprintf(out, "Building index for space: %s\n", targetCtx.spaceDir) index, err := syncflow.BuildPageIndexWithPending(targetCtx.spaceDir, targetCtx.files) if err != nil { - return fmt.Errorf("failed to build page index: %w", err) + return result, fmt.Errorf("failed to build page index: %w", err) } if dupErrs := detectDuplicatePageIDs(index); len(dupErrs) > 0 { for _, msg := range dupErrs { _, _ = fmt.Fprintf(out, "Validation failed: %s\n", msg) + result.Diagnostics = append(result.Diagnostics, commandRunReportDiagnostic{ + Code: "duplicate_page_id", + Message: msg, + }) } - return fmt.Errorf("validation failed: duplicate page IDs detected - rename each file to have a unique id or remove the duplicate id") + return result, fmt.Errorf("validation failed: duplicate page IDs detected - rename each file to have a unique id or remove the duplicate id") } globalIndex, err := buildWorkspaceGlobalPageIndex(targetCtx.spaceDir) if err != nil { - return fmt.Errorf("failed to build global page index: %w", err) + return result, fmt.Errorf("failed to build global page index: %w", err) } state, err := fs.LoadState(targetCtx.spaceDir) if err != nil { - return fmt.Errorf("failed to load state: %w", err) + return result, fmt.Errorf("failed to load state: %w", err) } if strings.TrimSpace(targetCtx.spaceKey) == "" { targetCtx.spaceKey = strings.TrimSpace(state.SpaceKey) + result.SpaceKey = targetCtx.spaceKey } immutableResolver := newValidateImmutableFrontmatterResolver(targetCtx.spaceDir, targetCtx.spaceKey, state) @@ -148,31 +183,45 @@ func runValidateTargetWithContext(ctx context.Context, out io.Writer, target con hasErrors := false for _, file := range targetCtx.files { if err := ctx.Err(); err != nil { - return err + return result, err } rel, _ := filepath.Rel(targetCtx.spaceDir, file) + rel = filepath.ToSlash(rel) fileResult := validateFile(ctx, file, targetCtx.spaceDir, linkHook, state.AttachmentIndex) issues := append(fileResult.Issues, immutableResolver.validate(file)...) printValidateWarnings(out, rel, fileResult.Warnings) + for _, warning := range fileResult.Warnings { + result.Diagnostics = append(result.Diagnostics, commandRunReportDiagnostic{ + Path: rel, + Code: warning.Code, + Message: warning.Message, + }) + } if len(issues) == 0 { continue } hasErrors = true - _, _ = fmt.Fprintf(out, "Validation failed for %s:\n", filepath.ToSlash(rel)) + _, _ = fmt.Fprintf(out, "Validation failed for %s:\n", rel) for _, issue := range issues { _, _ = fmt.Fprintf(out, " - [%s] %s: %s\n", issue.Code, issue.Field, issue.Message) + result.Diagnostics = append(result.Diagnostics, commandRunReportDiagnostic{ + Path: rel, + Code: issue.Code, + Field: issue.Field, + Message: issue.Message, + }) } } if hasErrors { - return fmt.Errorf("validation failed: please fix the issues listed above before retrying") + return result, fmt.Errorf("validation failed: please fix the issues listed above before retrying") } _, _ = fmt.Fprintln(out, "Validation successful") - return nil + return result, nil } func resolveValidateTargetContext(target config.Target) (validateTargetContext, error) { From 2dd34dc8bf4a133a2a989f5f78035d50b8a76f2f Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Fri, 6 Mar 2026 20:42:40 +0100 Subject: [PATCH 21/31] Clarify path stability policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- ...26-03-05-live-workflow-polish-followups.md | 3 +- cmd/diagnostics.go | 2 + cmd/diagnostics_test.go | 6 + cmd/diff.go | 22 +- cmd/diff_pages.go | 12 +- cmd/diff_test.go | 270 ++++++++++++++++++ cmd/status.go | 66 +++-- cmd/status_run_test.go | 85 ++++++ cmd/status_test.go | 23 +- docs/usage.md | 5 + internal/sync/diagnostics.go | 3 + internal/sync/pull.go | 8 +- internal/sync/pull_paths.go | 73 ++++- internal/sync/pull_paths_test.go | 39 +++ internal/sync/pull_test.go | 141 +++++++++ 16 files changed, 726 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3020e0c..2f45ecd 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ conf init `conf init` prepares Git metadata, `.gitignore`, and `.env` scaffolding, and creates an initial commit when it initializes a new Git repository. If `ATLASSIAN_*` or legacy `CONFLUENCE_*` credentials are already set in the environment, `conf init` writes `.env` from them without prompting. -`conf pull` mirrors Confluence hierarchy locally by placing folders and child pages in nested directories. Pages with children use `/.md` so they are distinct from pure folders. +`conf pull` mirrors Confluence hierarchy locally by placing folders and child pages in nested directories. Pages with children use `/.md` so they are distinct from pure folders. Leaf-page title renames can keep the existing Markdown path when the effective parent directory is unchanged, but pages that own subtree directories move when their self-owned directory segment changes. Hierarchy moves and ancestor/path-segment sanitization changes are surfaced as `PAGE_PATH_MOVED` notes in `conf pull`/`conf diff`, and `conf status` previews tracked moves before the next pull. ## Quick flow 🔄 > ⚠️ **IMPORTANT**: If you are developing `conf` itself, NEVER run sync commands against real Confluence spaces in the repository root. This prevents accidental commits of synced documentation. Use a separate sandbox folder. diff --git a/agents/plans/2026-03-05-live-workflow-polish-followups.md b/agents/plans/2026-03-05-live-workflow-polish-followups.md index 4230059..a08135b 100644 --- a/agents/plans/2026-03-05-live-workflow-polish-followups.md +++ b/agents/plans/2026-03-05-live-workflow-polish-followups.md @@ -18,7 +18,8 @@ Capture the non-blocking but high-value workflow, diagnostics, and operator-expe - [x] Batch 4 completed: items 4 and 9 are closed with warning-taxonomy regression coverage and explicit extension-support documentation updates. - [x] Item 8 was re-verified as already complete on this branch. - [x] Batch 5 completed: item 14 is closed with structured report regression coverage for success and failure paths. -- [ ] Remaining items: 15, 16, and 17. +- [x] Batch 6 completed: item 15 is closed with explicit path-move visibility in pull/diff/status plus rename-policy documentation and regression coverage. +- [ ] Remaining items: 16 and 17. ## Improvements diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go index f7c3c07..6360374 100644 --- a/cmd/diagnostics.go +++ b/cmd/diagnostics.go @@ -27,6 +27,8 @@ func classifySyncDiagnostic(diag syncflow.PullDiagnostic) (level string, qualifi switch strings.TrimSpace(diag.Category) { case syncflow.DiagnosticCategoryPreservedExternalLink: return "note", "preserved external/cross-space link" + case syncflow.DiagnosticCategoryPathChange: + return "note", "planned markdown path changed" case syncflow.DiagnosticCategoryDegradedReference: return "warning", "unresolved but safely degraded reference" case syncflow.DiagnosticCategoryBlockingReference: diff --git a/cmd/diagnostics_test.go b/cmd/diagnostics_test.go index a9c1c59..0958949 100644 --- a/cmd/diagnostics_test.go +++ b/cmd/diagnostics_test.go @@ -40,6 +40,12 @@ func TestFormatSyncDiagnostic_Classification(t *testing.T) { wantStart: "error: page.md [STRICT_PATH_REFERENCE_BROKEN]", wantText: "broken strict-path reference that blocks push; action required: yes", }, + { + name: "page path move is a note", + diag: syncflow.PullDiagnostic{Path: "legacy/page.md", Code: "PAGE_PATH_MOVED", Message: "planned markdown path changed from legacy/page.md to archive/page.md"}, + wantStart: "note: legacy/page.md [PAGE_PATH_MOVED]", + wantText: "planned markdown path changed; action required: no", + }, } for _, tc := range cases { diff --git a/cmd/diff.go b/cmd/diff.go index 7622626..328d257 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strings" "time" "github.com/rgonek/confluence-markdown-sync/internal/config" @@ -168,6 +169,7 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { } pagePathByIDAbs, pagePathByIDRel := syncflow.PlanPagePaths(diffCtx.spaceDir, state.PagePathIndex, pages, folderByID) + pathMoves := syncflow.PlannedPagePathMoves(state.PagePathIndex, pagePathByIDRel) attachmentPathByID := buildDiffAttachmentPathByID(diffCtx.spaceDir, state.AttachmentIndex) globalPageIndex, err := buildWorkspaceGlobalPageIndex(diffCtx.spaceDir) if err != nil { @@ -183,7 +185,7 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { }() if target.IsFile() { - result, err := runDiffFileMode(ctx, out, remote, diffCtx, pagePathByIDAbs, attachmentPathByID, globalPageIndex, tmpRoot) + result, err := runDiffFileMode(ctx, out, remote, diffCtx, pagePathByIDAbs, pathMoves, attachmentPathByID, globalPageIndex, tmpRoot) report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPull(result.Diagnostics, diffCtx.spaceDir)...) report.MutatedFiles = append(report.MutatedFiles, result.ChangedFiles...) return err @@ -197,6 +199,7 @@ func runDiff(cmd *cobra.Command, target config.Target) (runErr error) { pages, pagePathByIDAbs, pagePathByIDRel, + pathMoves, attachmentPathByID, globalPageIndex, tmpRoot, @@ -212,6 +215,7 @@ func runDiffFileMode( remote syncflow.PullRemote, diffCtx diffContext, pagePathByIDAbs map[string]string, + pathMoves []syncflow.PlannedPagePathMove, attachmentPathByID map[string]string, globalPageIndex syncflow.GlobalPageIndex, tmpRoot string, @@ -270,12 +274,16 @@ func runDiffFileMode( } page, metadataDiags := hydrateDiffPageMetadata(ctx, remote, page, relPath) + renderSourcePath := diffCtx.targetFile + if plannedSourcePath, ok := pagePathByIDAbs[page.ID]; ok && strings.TrimSpace(plannedSourcePath) != "" { + renderSourcePath = plannedSourcePath + } rendered, diagnostics, err := renderDiffMarkdown( ctx, page, diffCtx.spaceKey, diffCtx.spaceDir, - diffCtx.targetFile, + renderSourcePath, relPath, pagePathByIDAbs, attachmentPathByID, @@ -285,6 +293,12 @@ func runDiffFileMode( return result, err } diagnostics = append(metadataDiags, diagnostics...) + for _, move := range pathMoves { + if move.PageID == diffCtx.targetPageID { + diagnostics = append([]syncflow.PullDiagnostic{syncflow.PagePathMoveDiagnostic(move)}, diagnostics...) + break + } + } result.Diagnostics = append(result.Diagnostics, diagnostics...) for _, diag := range diagnostics { @@ -315,6 +329,7 @@ func runDiffSpaceMode( pages []confluence.Page, pagePathByIDAbs map[string]string, pagePathByIDRel map[string]string, + pathMoves []syncflow.PlannedPagePathMove, attachmentPathByID map[string]string, globalPageIndex syncflow.GlobalPageIndex, tmpRoot string, @@ -346,6 +361,9 @@ func runDiffSpaceMode( sort.Strings(pageIDs) diagnostics := make([]syncflow.PullDiagnostic, 0) + for _, move := range pathMoves { + diagnostics = append(diagnostics, syncflow.PagePathMoveDiagnostic(move)) + } metadataSummaries := make([]diffMetadataSummary, 0, len(pageIDs)) for _, pageID := range pageIDs { page, err := remote.GetPage(ctx, pageID) diff --git a/cmd/diff_pages.go b/cmd/diff_pages.go index 88feab7..e528c5f 100644 --- a/cmd/diff_pages.go +++ b/cmd/diff_pages.go @@ -11,6 +11,14 @@ import ( syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" ) +type diffFolderLookupRemote interface { + GetFolder(ctx context.Context, folderID string) (confluence.Folder, error) +} + +type diffPageLookupRemote interface { + GetPage(ctx context.Context, pageID string) (confluence.Page, error) +} + func listAllDiffPages(ctx context.Context, remote syncflow.PullRemote, opts confluence.PageListOptions) ([]confluence.Page, error) { result := []confluence.Page{} cursor := opts.Cursor @@ -29,7 +37,7 @@ func listAllDiffPages(ctx context.Context, remote syncflow.PullRemote, opts conf return result, nil } -func recoverMissingPagesForDiff(ctx context.Context, remote syncflow.PullRemote, spaceID string, localPageIDs map[string]string, remotePages []confluence.Page) ([]confluence.Page, error) { +func recoverMissingPagesForDiff(ctx context.Context, remote diffPageLookupRemote, spaceID string, localPageIDs map[string]string, remotePages []confluence.Page) ([]confluence.Page, error) { remoteByID := make(map[string]struct{}, len(remotePages)) for _, p := range remotePages { remoteByID[p.ID] = struct{}{} @@ -69,7 +77,7 @@ func recoverMissingPagesForDiff(ctx context.Context, remote syncflow.PullRemote, return result, nil } -func resolveDiffFolderHierarchyFromPages(ctx context.Context, remote syncflow.PullRemote, pages []confluence.Page) (map[string]confluence.Folder, []syncflow.PullDiagnostic, error) { +func resolveDiffFolderHierarchyFromPages(ctx context.Context, remote diffFolderLookupRemote, pages []confluence.Page) (map[string]confluence.Folder, []syncflow.PullDiagnostic, error) { folderByID := map[string]confluence.Folder{} diagnostics := []syncflow.PullDiagnostic{} fallbackTracker := syncflow.NewFolderLookupFallbackTracker() diff --git a/cmd/diff_test.go b/cmd/diff_test.go index e8b795a..b7edb12 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -217,6 +217,276 @@ func TestRunDiff_ReportsBestEffortWarnings(t *testing.T) { } } +func TestRunDiff_SpaceModeReportsPlannedPathMoves(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(filepath.Join(spaceDir, "Policies"), 0o750); err != nil { + t.Fatalf("mkdir policies dir: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "Policies", "Child.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Child", + ID: "2", + Version: 1, + }, + Body: "same body\n", + }) + + if err := fs.SaveState(spaceDir, fs.SpaceState{ + PagePathIndex: map[string]string{ + "Policies/Child.md": "2", + }, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + modifiedAt := time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC) + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 2, LastModified: modifiedAt}, + }, + folderByID: map[string]confluence.Folder{ + "folder-2": {ID: "folder-2", Title: "Archive"}, + }, + pagesByID: map[string]confluence.Page{ + "2": { + ID: "2", + SpaceID: "space-1", + Title: "Child", + ParentPageID: "folder-2", + ParentType: "folder", + Version: 2, + LastModified: modifiedAt, + BodyADF: rawJSON(t, simpleADF("same body")), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeSpace, Value: "ENG"}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "[PAGE_PATH_MOVED]") { + t.Fatalf("expected path move diagnostic, got:\n%s", got) + } + if !strings.Contains(got, "Policies/Child.md") || !strings.Contains(got, "Archive/Child.md") { + t.Fatalf("expected old and new paths in diff output, got:\n%s", got) + } +} + +func TestRunDiff_FileModeReportsPlannedPathMoves(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(filepath.Join(spaceDir, "Policies"), 0o750); err != nil { + t.Fatalf("mkdir policies dir: %v", err) + } + + localFile := filepath.Join(spaceDir, "Policies", "Child.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Child", + ID: "2", + Version: 1, + }, + Body: "same body\n", + }) + + if err := fs.SaveState(spaceDir, fs.SpaceState{ + PagePathIndex: map[string]string{ + "Policies/Child.md": "2", + }, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + modifiedAt := time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC) + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 2, LastModified: modifiedAt}, + }, + folderByID: map[string]confluence.Folder{ + "folder-2": {ID: "folder-2", Title: "Archive"}, + }, + pagesByID: map[string]confluence.Page{ + "2": { + ID: "2", + SpaceID: "space-1", + Title: "Child", + ParentPageID: "folder-2", + ParentType: "folder", + Version: 2, + LastModified: modifiedAt, + BodyADF: rawJSON(t, simpleADF("same body")), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "[PAGE_PATH_MOVED]") { + t.Fatalf("expected path move diagnostic, got:\n%s", got) + } + if !strings.Contains(got, "Policies/Child.md") || !strings.Contains(got, "Archive/Child.md") { + t.Fatalf("expected old and new paths in diff output, got:\n%s", got) + } +} + +func TestRunDiff_FileModeUsesPlannedPathContextForMovedPageLinks(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + spaceDir := filepath.Join(repo, "ENG") + if err := os.MkdirAll(filepath.Join(spaceDir, "Policies"), 0o750); err != nil { + t.Fatalf("mkdir policies dir: %v", err) + } + if err := os.MkdirAll(filepath.Join(spaceDir, "Archive"), 0o750); err != nil { + t.Fatalf("mkdir archive dir: %v", err) + } + + localFile := filepath.Join(spaceDir, "Policies", "Child.md") + writeMarkdown(t, localFile, fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Child", + ID: "2", + Version: 1, + }, + Body: "old body\n", + }) + writeMarkdown(t, filepath.Join(spaceDir, "Archive", "Reference.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Reference", + ID: "3", + Version: 1, + }, + Body: "reference body\n", + }) + + if err := fs.SaveState(spaceDir, fs.SpaceState{ + PagePathIndex: map[string]string{ + "Policies/Child.md": "2", + "Archive/Reference.md": "3", + }, + AttachmentIndex: map[string]string{}, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + modifiedAt := time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC) + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 2, LastModified: modifiedAt}, + {ID: "3", SpaceID: "space-1", Title: "Reference", ParentPageID: "folder-2", ParentType: "folder", Version: 1, LastModified: modifiedAt}, + }, + folderByID: map[string]confluence.Folder{ + "folder-2": {ID: "folder-2", Title: "Archive"}, + }, + pagesByID: map[string]confluence.Page{ + "2": { + ID: "2", + SpaceID: "space-1", + Title: "Child", + ParentPageID: "folder-2", + ParentType: "folder", + Version: 2, + LastModified: modifiedAt, + BodyADF: rawJSON(t, map[string]any{ + "version": 1, + "type": "doc", + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": "Reference", + "marks": []any{ + map[string]any{ + "type": "link", + "attrs": map[string]any{ + "href": "https://example.atlassian.net/wiki/pages/viewpage.action?pageId=3", + "pageId": "3", + }, + }, + }, + }, + }, + }, + }, + }), + }, + "3": { + ID: "3", + SpaceID: "space-1", + Title: "Reference", + ParentPageID: "folder-2", + ParentType: "folder", + Version: 1, + LastModified: modifiedAt, + BodyADF: rawJSON(t, simpleADF("reference body")), + }, + }, + attachments: map[string][]byte{}, + } + + oldFactory := newDiffRemote + newDiffRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newDiffRemote = oldFactory }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runDiff(cmd, config.Target{Mode: config.TargetModeFile, Value: localFile}); err != nil { + t.Fatalf("runDiff() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "+[Reference](Reference.md)") { + t.Fatalf("expected moved page to render relative link from planned path context, got:\n%s", got) + } + if strings.Contains(got, "../Archive/Reference.md") { + t.Fatalf("expected moved page diff to avoid old-path link context, got:\n%s", got) + } +} + func TestRunDiff_PreservedAbsoluteCrossSpaceLinkIsNotReportedAsUnresolved(t *testing.T) { runParallelCommandTest(t) repo := t.TempDir() diff --git a/cmd/status.go b/cmd/status.go index cc47648..5d3c263 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -22,18 +22,20 @@ type StatusRemote interface { GetSpace(ctx context.Context, spaceKey string) (confluence.Space, error) ListPages(ctx context.Context, opts confluence.PageListOptions) (confluence.PageListResult, error) GetPage(ctx context.Context, pageID string) (confluence.Page, error) + GetFolder(ctx context.Context, folderID string) (confluence.Folder, error) } // StatusReport contains the results of a sync drift inspection. type StatusReport struct { - LocalAdded []string - LocalModified []string - LocalDeleted []string - RemoteAdded []string - RemoteModified []string - RemoteDeleted []string - ConflictAhead []string // pages that are both locally modified AND ahead on remote - MaxVersionDrift int + LocalAdded []string + LocalModified []string + LocalDeleted []string + RemoteAdded []string + RemoteModified []string + RemoteDeleted []string + PlannedPathMoves []syncflow.PlannedPagePathMove + ConflictAhead []string // pages that are both locally modified AND ahead on remote + MaxVersionDrift int } const statusScopeNote = "Scope: markdown/page drift only; attachment-only drift is excluded from `conf status` output. Use `git status` or `conf diff` to inspect assets." @@ -135,6 +137,7 @@ func runStatus(cmd *cobra.Command, target config.Target) error { printStatusSection(out, "Local not pushed", report.LocalAdded, report.LocalModified, report.LocalDeleted) printStatusSection(out, "Remote not pulled", report.RemoteAdded, report.RemoteModified, report.RemoteDeleted) + printPlannedPathMoves(out, report.PlannedPathMoves) if len(report.ConflictAhead) > 0 { _, _ = fmt.Fprintf(out, "\nConflict ahead (%d) — locally modified AND remote is ahead:\n", len(report.ConflictAhead)) @@ -175,6 +178,10 @@ func buildStatusReport( if err != nil { return StatusReport{}, fmt.Errorf("list remote pages: %w", err) } + remotePages, err = recoverMissingPagesForDiff(ctx, remote, space.ID, state.PagePathIndex, remotePages) + if err != nil { + return StatusReport{}, fmt.Errorf("recover tracked pages for status: %w", err) + } pathByID := make(map[string]string, len(state.PagePathIndex)) trackedPathByID := make(map[string]string, len(state.PagePathIndex)) @@ -254,19 +261,36 @@ func buildStatusReport( sort.Strings(remoteModified) sort.Strings(remoteDeleted) + folderByID, _, err := resolveDiffFolderHierarchyFromPages(ctx, remote, remotePages) + if err != nil { + return StatusReport{}, fmt.Errorf("resolve folder hierarchy: %w", err) + } + _, plannedPathByID := syncflow.PlanPagePaths(initialCtx.spaceDir, state.PagePathIndex, remotePages, folderByID) + plannedPathMoves := syncflow.PlannedPagePathMoves(state.PagePathIndex, plannedPathByID) + if targetRelPath != "" { + filteredMoves := make([]syncflow.PlannedPagePathMove, 0, len(plannedPathMoves)) + for _, move := range plannedPathMoves { + if move.PreviousPath == targetRelPath { + filteredMoves = append(filteredMoves, move) + } + } + plannedPathMoves = filteredMoves + } + // ConflictAhead = pages that are BOTH locally modified AND ahead on remote. conflictAhead := computeConflictAhead(localModified, remoteModified) sort.Strings(conflictAhead) return StatusReport{ - LocalAdded: localAdded, - LocalModified: localModified, - LocalDeleted: localDeleted, - RemoteAdded: remoteAdded, - RemoteModified: remoteModified, - RemoteDeleted: remoteDeleted, - ConflictAhead: conflictAhead, - MaxVersionDrift: maxVersionDrift, + LocalAdded: localAdded, + LocalModified: localModified, + LocalDeleted: localDeleted, + RemoteAdded: remoteAdded, + RemoteModified: remoteModified, + RemoteDeleted: remoteDeleted, + PlannedPathMoves: plannedPathMoves, + ConflictAhead: conflictAhead, + MaxVersionDrift: maxVersionDrift, }, nil } @@ -357,6 +381,16 @@ func printStatusList(out io.Writer, label string, items []string) { } } +func printPlannedPathMoves(out io.Writer, moves []syncflow.PlannedPagePathMove) { + if len(moves) == 0 { + return + } + _, _ = fmt.Fprintf(out, "\nPlanned path moves (%d) — next pull would relocate tracked markdown:\n", len(moves)) + for _, move := range moves { + _, _ = fmt.Fprintf(out, " - %s -> %s\n", move.PreviousPath, move.PlannedPath) + } +} + func isNotFoundError(err error) bool { if err == nil { return false diff --git a/cmd/status_run_test.go b/cmd/status_run_test.go index 187f5bc..0c00e42 100644 --- a/cmd/status_run_test.go +++ b/cmd/status_run_test.go @@ -371,6 +371,91 @@ func TestRunStatus_PageAndAssetScopeCases(t *testing.T) { } } +func TestRunStatus_ShowsPlannedPathMoves(t *testing.T) { + runParallelCommandTest(t) + repo := t.TempDir() + setupGitRepo(t, repo) + chdirRepo(t, repo) + setupEnv(t) + + spaceDir := filepath.Join(repo, "TEST") + if err := os.MkdirAll(filepath.Join(spaceDir, "Policies"), 0o750); err != nil { + t.Fatalf("mkdir policies: %v", err) + } + if err := os.MkdirAll(filepath.Join(spaceDir, "Archive"), 0o750); err != nil { + t.Fatalf("mkdir archive: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "Policies", "Child.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Child", + ID: "2", + Version: 1, + }, + Body: "body\n", + }) + writeMarkdown(t, filepath.Join(spaceDir, "Archive", "Reference.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Reference", + ID: "3", + Version: 1, + }, + Body: "reference\n", + }) + if err := fs.SaveState(spaceDir, fs.SpaceState{ + SpaceKey: "TEST", + PagePathIndex: map[string]string{ + "Policies/Child.md": "2", + "Archive/Reference.md": "3", + }, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + runGitForStatus(t, repo, "add", ".") + runGitForStatus(t, repo, "commit", "-m", "baseline") + tagTime := time.Now().UTC().Format("20060102T150405Z") + runGitForStatus(t, repo, "tag", "-a", "confluence-sync/pull/TEST/"+tagTime, "-m", "pull") + + modifiedAt := time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC) + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "TEST", Name: "Test Space"}, + pages: []confluence.Page{ + {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 1, LastModified: modifiedAt}, + {ID: "3", SpaceID: "space-1", Title: "Reference", ParentPageID: "folder-2", ParentType: "folder", Version: 1, LastModified: modifiedAt}, + }, + folderByID: map[string]confluence.Folder{ + "folder-2": {ID: "folder-2", Title: "Archive"}, + }, + pagesByID: map[string]confluence.Page{ + "2": {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 1, LastModified: modifiedAt, Status: "current"}, + "3": {ID: "3", SpaceID: "space-1", Title: "Reference", ParentPageID: "folder-2", ParentType: "folder", Version: 1, LastModified: modifiedAt, Status: "current"}, + }, + } + + oldNewStatusRemote := newStatusRemote + newStatusRemote = func(cfg *config.Config) (StatusRemote, error) { + return fake, nil + } + t.Cleanup(func() { newStatusRemote = oldNewStatusRemote }) + + cmd := newStatusCmd() + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runStatus(cmd, config.Target{Value: "TEST", Mode: config.TargetModeSpace}); err != nil { + t.Fatalf("runStatus() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "Planned path moves (1)") { + t.Fatalf("expected planned path move section, got:\n%s", got) + } + if !strings.Contains(got, "Policies/Child.md -> Archive/Child.md") { + t.Fatalf("expected planned move detail, got:\n%s", got) + } +} + func setupStatusScopeRepo(t *testing.T) (string, string) { t.Helper() diff --git a/cmd/status_test.go b/cmd/status_test.go index 6662304..7d0fc52 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -49,10 +49,11 @@ func TestStatusCmd(t *testing.T) { // mockStatusRemote implements the StatusRemote interface for testing type mockStatusRemote struct { - space confluence.Space - pages confluence.PageListResult - page confluence.Page - err error + space confluence.Space + pages confluence.PageListResult + page confluence.Page + folders map[string]confluence.Folder + err error } func (m *mockStatusRemote) GetSpace(_ context.Context, _ string) (confluence.Space, error) { @@ -67,6 +68,20 @@ func (m *mockStatusRemote) GetPage(_ context.Context, _ string) (confluence.Page return m.page, m.err } +func (m *mockStatusRemote) GetFolder(_ context.Context, folderID string) (confluence.Folder, error) { + if m.err != nil { + return confluence.Folder{}, m.err + } + if m.folders == nil { + return confluence.Folder{}, confluence.ErrNotFound + } + folder, ok := m.folders[folderID] + if !ok { + return confluence.Folder{}, confluence.ErrNotFound + } + return folder, nil +} + func TestListAllPagesForStatus(t *testing.T) { mock := &mockStatusRemote{ pages: confluence.PageListResult{ diff --git a/docs/usage.md b/docs/usage.md index d37dcd1..a12ffbc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -92,6 +92,9 @@ Highlights: - diagnostics distinguish preserved cross-space links (`note`), degraded-but-pullable fallbacks, and broken references left as fallback output, - page files follow Confluence hierarchy (folders and parent/child pages become nested directories), - pages that have children are written as `/.md` so they are distinguishable from folders, +- leaf-page title renames can keep the existing Markdown path when the effective parent directory is unchanged, +- pages that own subtree directories move when their self-owned directory segment changes, +- hierarchy moves and ancestor/path-segment sanitization changes move the Markdown file and emit `PAGE_PATH_MOVED` notes with old/new paths, - same-space links rewritten to relative Markdown links, - attachments downloaded into `assets//-`, - `--force` (`-f`) forces a full-space refresh (all tracked pages are re-pulled even when incremental changes are empty), @@ -124,6 +127,7 @@ Highlights: - compares local Markdown drift against the last sync baseline, - checks whether tracked remote pages are ahead, missing, or newly added, +- surfaces planned tracked-page path relocations that would happen on the next pull, - focuses on Markdown page files only. Attachment-only changes are intentionally excluded from `conf status`. Use `git status` or `conf diff` when you need asset visibility. @@ -136,6 +140,7 @@ Highlights: - fetches remote content, - converts using best-effort forward conversion, +- reports planned Markdown path moves before showing the diff so hierarchy-driven renames are explicit, - includes synced frontmatter parity such as `state`, `status`, and `labels`, - strips read-only author/timestamp metadata so the diff stays focused on actionable drift, - compares using `git diff --no-index`, diff --git a/internal/sync/diagnostics.go b/internal/sync/diagnostics.go index 08ec4e0..cfcd77b 100644 --- a/internal/sync/diagnostics.go +++ b/internal/sync/diagnostics.go @@ -7,6 +7,7 @@ const ( DiagnosticCategoryDegradedReference = "degraded_reference" DiagnosticCategoryBlockingReference = "blocking_reference" DiagnosticCategoryDegradedContent = "degraded_content" + DiagnosticCategoryPathChange = "path_change" ) func NormalizePullDiagnostic(diag PullDiagnostic) PullDiagnostic { @@ -39,6 +40,8 @@ func classifyPullDiagnostic(code string) (category string, actionRequired bool) return DiagnosticCategoryDegradedReference, true case "STRICT_PATH_REFERENCE_BROKEN": return DiagnosticCategoryBlockingReference, true + case "PAGE_PATH_MOVED": + return DiagnosticCategoryPathChange, false case "FOLDER_LOOKUP_UNAVAILABLE", "CONTENT_STATUS_FETCH_FAILED", "LABELS_FETCH_FAILED", diff --git a/internal/sync/pull.go b/internal/sync/pull.go index 341f339..fd41b83 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -204,6 +204,10 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, sort.Strings(pageIDs) pagePathByIDAbs, pagePathByIDRel := PlanPagePaths(spaceDir, state.PagePathIndex, pages, folderByID) + pathMoves := PlannedPagePathMoves(state.PagePathIndex, pagePathByIDRel) + for _, move := range pathMoves { + diagnostics = append(diagnostics, pagePathMoveDiagnostic(move)) + } if opts.Progress != nil { opts.Progress.SetDescription("Identifying changed pages") @@ -217,8 +221,8 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, for _, pageID := range changedPageIDs { changedSet[pageID] = struct{}{} } - for _, pageID := range movedPageIDs(state.PagePathIndex, pagePathByIDRel) { - changedSet[pageID] = struct{}{} + for _, move := range pathMoves { + changedSet[move.PageID] = struct{}{} } changedPageIDs = sortedStringKeys(changedSet) } diff --git a/internal/sync/pull_paths.go b/internal/sync/pull_paths.go index 6ab8b7a..aafe169 100644 --- a/internal/sync/pull_paths.go +++ b/internal/sync/pull_paths.go @@ -224,16 +224,77 @@ func deletedPageIDs(previousPageIndex map[string]string, remotePages map[string] func movedPageIDs(previousPageIndex map[string]string, nextPathByID map[string]string) []string { set := map[string]struct{}{} - for previousPath, pageID := range previousPageIndex { - nextPath, exists := nextPathByID[pageID] - if !exists { + for _, move := range PlannedPagePathMoves(previousPageIndex, nextPathByID) { + set[move.PageID] = struct{}{} + } + return sortedStringKeys(set) +} + +// PlannedPagePathMove describes a tracked page whose planned markdown path changed. +type PlannedPagePathMove struct { + PageID string + PreviousPath string + PlannedPath string +} + +// PlannedPagePathMoves returns tracked pages whose planned relative markdown path changed. +func PlannedPagePathMoves(previousPageIndex map[string]string, nextPathByID map[string]string) []PlannedPagePathMove { + previousPathByID := map[string]string{} + for _, previousPath := range sortedStringKeys(previousPageIndex) { + pageID := strings.TrimSpace(previousPageIndex[previousPath]) + if pageID == "" { continue } - if normalizeRelPath(previousPath) != normalizeRelPath(nextPath) { - set[pageID] = struct{}{} + if _, exists := nextPathByID[pageID]; !exists { + continue + } + normalizedPath := normalizeRelPath(previousPath) + if normalizedPath == "" { + continue + } + if _, exists := previousPathByID[pageID]; !exists { + previousPathByID[pageID] = normalizedPath } } - return sortedStringKeys(set) + + moves := make([]PlannedPagePathMove, 0, len(previousPathByID)) + for pageID, previousPath := range previousPathByID { + nextPath := normalizeRelPath(nextPathByID[pageID]) + if previousPath == nextPath { + continue + } + moves = append(moves, PlannedPagePathMove{ + PageID: pageID, + PreviousPath: previousPath, + PlannedPath: nextPath, + }) + } + + sort.Slice(moves, func(i, j int) bool { + if moves[i].PreviousPath == moves[j].PreviousPath { + if moves[i].PlannedPath == moves[j].PlannedPath { + return moves[i].PageID < moves[j].PageID + } + return moves[i].PlannedPath < moves[j].PlannedPath + } + return moves[i].PreviousPath < moves[j].PreviousPath + }) + + return moves +} + +func pagePathMoveDiagnostic(move PlannedPagePathMove) PullDiagnostic { + return PullDiagnostic{ + Path: move.PreviousPath, + Code: "PAGE_PATH_MOVED", + Message: fmt.Sprintf("planned markdown path changed from %s to %s", move.PreviousPath, move.PlannedPath), + Category: DiagnosticCategoryPathChange, + } +} + +// PagePathMoveDiagnostic reports a tracked page whose planned markdown path changed. +func PagePathMoveDiagnostic(move PlannedPagePathMove) PullDiagnostic { + return pagePathMoveDiagnostic(move) } func invertPathByID(pathByID map[string]string) map[string]string { diff --git a/internal/sync/pull_paths_test.go b/internal/sync/pull_paths_test.go index b91ebd5..7495c71 100644 --- a/internal/sync/pull_paths_test.go +++ b/internal/sync/pull_paths_test.go @@ -59,3 +59,42 @@ func TestPlanPagePaths_UsesFolderHierarchy(t *testing.T) { t.Fatalf("folder-based path = %q, want Policies/Onboarding/Start-Here.md", got) } } + +func TestPlanPagePaths_PreservesExistingPathWhenTitleChangesInSameParent(t *testing.T) { + spaceDir := t.TempDir() + + pages := []confluence.Page{ + {ID: "1", Title: "Renamed Page"}, + } + previousPageIndex := map[string]string{ + "custom-title.md": "1", + } + + _, relByID := PlanPagePaths(spaceDir, previousPageIndex, pages, nil) + + if got := relByID["1"]; got != "custom-title.md" { + t.Fatalf("preserved path = %q, want custom-title.md", got) + } +} + +func TestPlanPagePaths_SubtreeRootTitleRenameMovesOwnedDirectory(t *testing.T) { + spaceDir := t.TempDir() + + pages := []confluence.Page{ + {ID: "1", Title: "Renamed Root"}, + {ID: "2", Title: "Child", ParentPageID: "1"}, + } + previousPageIndex := map[string]string{ + "Original-Root/Original-Root.md": "1", + "Original-Root/Child.md": "2", + } + + _, relByID := PlanPagePaths(spaceDir, previousPageIndex, pages, nil) + + if got := relByID["1"]; got != "Renamed-Root/Renamed-Root.md" { + t.Fatalf("root path = %q, want Renamed-Root/Renamed-Root.md", got) + } + if got := relByID["2"]; got != "Renamed-Root/Child.md" { + t.Fatalf("child path = %q, want Renamed-Root/Child.md", got) + } +} diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index 8216e04..b96d18c 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -15,6 +15,147 @@ import ( "github.com/rgonek/confluence-markdown-sync/internal/fs" ) +func TestPull_ReportsHierarchyPathMoveDiagnostics(t *testing.T) { + tmpDir := t.TempDir() + spaceDir := filepath.Join(tmpDir, "ENG") + if err := os.MkdirAll(filepath.Join(spaceDir, "Policies"), 0o750); err != nil { + t.Fatalf("mkdir policies dir: %v", err) + } + + writeDoc := fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Child", + ID: "2", + Version: 1, + }, + Body: "local child\n", + } + if err := fs.WriteMarkdownDocument(filepath.Join(spaceDir, "Policies", "Child.md"), writeDoc); err != nil { + t.Fatalf("write old child doc: %v", err) + } + + modifiedAt := time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC) + emptyADF := map[string]any{"version": 1, "type": "doc", "content": []any{}} + fake := &fakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 2, LastModified: modifiedAt}, + }, + folderByID: map[string]confluence.Folder{ + "folder-2": {ID: "folder-2", Title: "Archive"}, + }, + pagesByID: map[string]confluence.Page{ + "2": {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-2", ParentType: "folder", Version: 2, LastModified: modifiedAt, BodyADF: rawJSON(t, emptyADF)}, + }, + } + + result, err := Pull(context.Background(), fake, PullOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + State: fs.SpaceState{ + PagePathIndex: map[string]string{ + "Policies/Child.md": "2", + }, + }, + }) + if err != nil { + t.Fatalf("Pull() error: %v", err) + } + + if _, err := os.Stat(filepath.Join(spaceDir, "Archive", "Child.md")); err != nil { + t.Fatalf("expected moved markdown at Archive/Child.md: %v", err) + } + if _, err := os.Stat(filepath.Join(spaceDir, "Policies", "Child.md")); !os.IsNotExist(err) { + t.Fatalf("old markdown path should be deleted, stat error=%v", err) + } + + movedDiag := findPullDiagnostic(result.Diagnostics, "PAGE_PATH_MOVED") + if movedDiag == nil { + t.Fatalf("expected PAGE_PATH_MOVED diagnostic, got %+v", result.Diagnostics) + } + if movedDiag.Path != "Policies/Child.md" { + t.Fatalf("moved diagnostic path = %q, want Policies/Child.md", movedDiag.Path) + } + if !strings.Contains(movedDiag.Message, "Policies/Child.md") || !strings.Contains(movedDiag.Message, "Archive/Child.md") { + t.Fatalf("move diagnostic message = %q, want old and new paths", movedDiag.Message) + } +} + +func TestPull_ReportsSanitizedPathMoveDiagnostics(t *testing.T) { + tmpDir := t.TempDir() + spaceDir := filepath.Join(tmpDir, "ENG") + if err := os.MkdirAll(filepath.Join(spaceDir, "Ops"), 0o750); err != nil { + t.Fatalf("mkdir ops dir: %v", err) + } + + writeDoc := fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Child", + ID: "2", + Version: 1, + }, + Body: "local child\n", + } + if err := fs.WriteMarkdownDocument(filepath.Join(spaceDir, "Ops", "Child.md"), writeDoc); err != nil { + t.Fatalf("write old child doc: %v", err) + } + + modifiedAt := time.Date(2026, time.March, 6, 13, 0, 0, 0, time.UTC) + emptyADF := map[string]any{"version": 1, "type": "doc", "content": []any{}} + fake := &fakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-1", ParentType: "folder", Version: 2, LastModified: modifiedAt}, + }, + folderByID: map[string]confluence.Folder{ + "folder-1": {ID: "folder-1", Title: "Ops!"}, + }, + pagesByID: map[string]confluence.Page{ + "2": {ID: "2", SpaceID: "space-1", Title: "Child", ParentPageID: "folder-1", ParentType: "folder", Version: 2, LastModified: modifiedAt, BodyADF: rawJSON(t, emptyADF)}, + }, + } + + result, err := Pull(context.Background(), fake, PullOptions{ + SpaceKey: "ENG", + SpaceDir: spaceDir, + State: fs.SpaceState{ + PagePathIndex: map[string]string{ + "Ops/Child.md": "2", + }, + }, + }) + if err != nil { + t.Fatalf("Pull() error: %v", err) + } + + if _, err := os.Stat(filepath.Join(spaceDir, "Ops!", "Child.md")); err != nil { + t.Fatalf("expected moved markdown at Ops!/Child.md: %v", err) + } + if _, err := os.Stat(filepath.Join(spaceDir, "Ops", "Child.md")); !os.IsNotExist(err) { + t.Fatalf("old markdown path should be deleted, stat error=%v", err) + } + + movedDiag := findPullDiagnostic(result.Diagnostics, "PAGE_PATH_MOVED") + if movedDiag == nil { + t.Fatalf("expected PAGE_PATH_MOVED diagnostic, got %+v", result.Diagnostics) + } + if movedDiag.Path != "Ops/Child.md" { + t.Fatalf("moved diagnostic path = %q, want Ops/Child.md", movedDiag.Path) + } + if !strings.Contains(movedDiag.Message, "Ops/Child.md") || !strings.Contains(movedDiag.Message, "Ops!/Child.md") { + t.Fatalf("move diagnostic message = %q, want old and new paths", movedDiag.Message) + } +} + +func findPullDiagnostic(diags []PullDiagnostic, code string) *PullDiagnostic { + for i := range diags { + if diags[i].Code == code { + return &diags[i] + } + } + return nil +} + func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { tmpDir := t.TempDir() spaceDir := filepath.Join(tmpDir, "ENG") From bda5a91c4057710c01320b850eb1e0dd350431a8 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sat, 7 Mar 2026 19:44:22 +0100 Subject: [PATCH 22/31] Enhance dry-run output handling and add report operations for push results --- cmd/dry_run_remote.go | 104 ++++++++++++++--------- cmd/push_dryrun_test.go | 175 +++++++++++++++++++++++++++++++++++++-- cmd/release_docs_test.go | 33 ++++++++ cmd/report.go | 58 +++++++++++-- 4 files changed, 315 insertions(+), 55 deletions(-) create mode 100644 cmd/release_docs_test.go diff --git a/cmd/dry_run_remote.go b/cmd/dry_run_remote.go index 2d77dee..482d79e 100644 --- a/cmd/dry_run_remote.go +++ b/cmd/dry_run_remote.go @@ -15,9 +15,10 @@ import ( ) type dryRunPushRemote struct { - inner syncflow.PushRemote - out io.Writer - domain string + inner syncflow.PushRemote + out io.Writer + domain string + emitOperations bool } func (d *dryRunPushRemote) GetSpace(ctx context.Context, spaceKey string) (confluence.Space, error) { @@ -40,13 +41,13 @@ func (d *dryRunPushRemote) GetContentStatus(ctx context.Context, pageID string, } func (d *dryRunPushRemote) SetContentStatus(ctx context.Context, pageID string, pageStatus string, statusName string) error { - fmt.Fprintf(d.out, "[DRY-RUN] SET CONTENT STATUS (PUT %s/wiki/rest/api/content/%s/state?status=%s)\n", d.domain, pageID, pageStatus) - fmt.Fprintf(d.out, " Name: %s\n\n", statusName) + d.printf("[DRY-RUN] SET CONTENT STATUS (PUT %s/wiki/rest/api/content/%s/state?status=%s)\n", d.domain, pageID, pageStatus) + d.printf(" Name: %s\n\n", statusName) return nil } func (d *dryRunPushRemote) DeleteContentStatus(ctx context.Context, pageID string, pageStatus string) error { - fmt.Fprintf(d.out, "[DRY-RUN] DELETE CONTENT STATUS (DELETE %s/wiki/rest/api/content/%s/state?status=%s)\n\n", d.domain, pageID, pageStatus) + d.printf("[DRY-RUN] DELETE CONTENT STATUS (DELETE %s/wiki/rest/api/content/%s/state?status=%s)\n\n", d.domain, pageID, pageStatus) return nil } @@ -58,25 +59,25 @@ func (d *dryRunPushRemote) GetLabels(ctx context.Context, pageID string) ([]stri } func (d *dryRunPushRemote) AddLabels(ctx context.Context, pageID string, labels []string) error { - fmt.Fprintf(d.out, "[DRY-RUN] ADD LABELS (POST %s/wiki/rest/api/content/%s/label)\n", d.domain, pageID) - fmt.Fprintf(d.out, " Labels: %v\n\n", labels) + d.printf("[DRY-RUN] ADD LABELS (POST %s/wiki/rest/api/content/%s/label)\n", d.domain, pageID) + d.printf(" Labels: %v\n\n", labels) return nil } func (d *dryRunPushRemote) RemoveLabel(ctx context.Context, pageID string, labelName string) error { - fmt.Fprintf(d.out, "[DRY-RUN] REMOVE LABEL (DELETE %s/wiki/rest/api/content/%s/label?name=%s)\n\n", d.domain, pageID, labelName) + d.printf("[DRY-RUN] REMOVE LABEL (DELETE %s/wiki/rest/api/content/%s/label?name=%s)\n\n", d.domain, pageID, labelName) return nil } func (d *dryRunPushRemote) CreatePage(ctx context.Context, input confluence.PageUpsertInput) (confluence.Page, error) { - fmt.Fprintf(d.out, "[DRY-RUN] CREATE PAGE (POST %s/wiki/api/v2/pages)\n", d.domain) - fmt.Fprintf(d.out, " Title: %s\n", input.Title) + d.printf("[DRY-RUN] CREATE PAGE (POST %s/wiki/api/v2/pages)\n", d.domain) + d.printf(" Title: %s\n", input.Title) if input.ParentPageID != "" { - fmt.Fprintf(d.out, " ParentPageID: %s\n", input.ParentPageID) + d.printf(" ParentPageID: %s\n", input.ParentPageID) } - fmt.Fprintf(d.out, " Status: %s\n", input.Status) - printDryRunBodyPreview(ctx, d.out, input.BodyADF) - _, _ = fmt.Fprintln(d.out) + d.printf(" Status: %s\n", input.Status) + d.printBodyPreview(ctx, input.BodyADF) + d.println() return confluence.Page{ ID: "dry-run-new-page-id", @@ -91,14 +92,14 @@ func (d *dryRunPushRemote) CreatePage(ctx context.Context, input confluence.Page func (d *dryRunPushRemote) UpdatePage(ctx context.Context, pageID string, input confluence.PageUpsertInput) (confluence.Page, error) { - fmt.Fprintf(d.out, "[DRY-RUN] UPDATE PAGE (PUT %s/wiki/api/v2/pages/%s)\n", d.domain, pageID) - fmt.Fprintf(d.out, " Title: %s\n", input.Title) + d.printf("[DRY-RUN] UPDATE PAGE (PUT %s/wiki/api/v2/pages/%s)\n", d.domain, pageID) + d.printf(" Title: %s\n", input.Title) if input.ParentPageID != "" { - fmt.Fprintf(d.out, " ParentPageID: %s\n", input.ParentPageID) + d.printf(" ParentPageID: %s\n", input.ParentPageID) } - fmt.Fprintf(d.out, " Version: %d\n", input.Version) - printDryRunBodyPreview(ctx, d.out, input.BodyADF) - _, _ = fmt.Fprintln(d.out) + d.printf(" Version: %d\n", input.Version) + d.printBodyPreview(ctx, input.BodyADF) + d.println() return confluence.Page{ ID: pageID, @@ -138,23 +139,23 @@ func printDryRunBodyPreview(ctx context.Context, out io.Writer, adfJSON []byte) } func (d *dryRunPushRemote) ArchivePages(ctx context.Context, pageIDs []string) (confluence.ArchiveResult, error) { - fmt.Fprintf(d.out, "[DRY-RUN] ARCHIVE PAGES (POST %s/wiki/rest/api/content/archive)\n", d.domain) + d.printf("[DRY-RUN] ARCHIVE PAGES (POST %s/wiki/rest/api/content/archive)\n", d.domain) for _, id := range pageIDs { - fmt.Fprintf(d.out, " PageID: %s\n", id) + d.printf(" PageID: %s\n", id) } - _, _ = fmt.Fprintln(d.out) + d.println() return confluence.ArchiveResult{TaskID: "dry-run-task-id"}, nil } func (d *dryRunPushRemote) WaitForArchiveTask(ctx context.Context, taskID string, opts confluence.ArchiveTaskWaitOptions) (confluence.ArchiveTaskStatus, error) { - fmt.Fprintf(d.out, "[DRY-RUN] WAIT ARCHIVE TASK (GET %s/wiki/rest/api/longtask/%s)\n", d.domain, taskID) + d.printf("[DRY-RUN] WAIT ARCHIVE TASK (GET %s/wiki/rest/api/longtask/%s)\n", d.domain, taskID) if opts.Timeout > 0 { - fmt.Fprintf(d.out, " Timeout: %s\n", opts.Timeout) + d.printf(" Timeout: %s\n", opts.Timeout) } if opts.PollInterval > 0 { - fmt.Fprintf(d.out, " PollInterval: %s\n", opts.PollInterval) + d.printf(" PollInterval: %s\n", opts.PollInterval) } - _, _ = fmt.Fprintln(d.out) + d.println() return confluence.ArchiveTaskStatus{TaskID: taskID, State: confluence.ArchiveTaskStateSucceeded, RawStatus: "DRY_RUN"}, nil } @@ -166,15 +167,15 @@ func (d *dryRunPushRemote) DeletePage(ctx context.Context, pageID string, opts c case opts.Purge: query = "?purge=true" } - fmt.Fprintf(d.out, "[DRY-RUN] DELETE PAGE (DELETE %s/wiki/api/v2/pages/%s%s)\n\n", d.domain, pageID, query) + d.printf("[DRY-RUN] DELETE PAGE (DELETE %s/wiki/api/v2/pages/%s%s)\n\n", d.domain, pageID, query) return nil } func (d *dryRunPushRemote) UploadAttachment(ctx context.Context, input confluence.AttachmentUploadInput) (confluence.Attachment, error) { - fmt.Fprintf(d.out, "[DRY-RUN] UPLOAD ATTACHMENT (POST %s/wiki/rest/api/content/%s/child/attachment)\n", d.domain, input.PageID) - fmt.Fprintf(d.out, " Filename: %s\n", input.Filename) - fmt.Fprintf(d.out, " ContentType: %s\n", input.ContentType) - fmt.Fprintf(d.out, " Size: %d bytes\n\n", len(input.Data)) + d.printf("[DRY-RUN] UPLOAD ATTACHMENT (POST %s/wiki/rest/api/content/%s/child/attachment)\n", d.domain, input.PageID) + d.printf(" Filename: %s\n", input.Filename) + d.printf(" ContentType: %s\n", input.ContentType) + d.printf(" Size: %d bytes\n\n", len(input.Data)) return confluence.Attachment{ ID: "dry-run-attachment-id-" + input.Filename, @@ -186,19 +187,19 @@ func (d *dryRunPushRemote) UploadAttachment(ctx context.Context, input confluenc } func (d *dryRunPushRemote) DeleteAttachment(ctx context.Context, attachmentID string, pageID string) error { - fmt.Fprintf(d.out, "[DRY-RUN] DELETE ATTACHMENT (DELETE %s/wiki/api/v2/attachments/%s, page %s)\n\n", d.domain, attachmentID, pageID) + d.printf("[DRY-RUN] DELETE ATTACHMENT (DELETE %s/wiki/api/v2/attachments/%s, page %s)\n\n", d.domain, attachmentID, pageID) return nil } func (d *dryRunPushRemote) CreateFolder(ctx context.Context, input confluence.FolderCreateInput) (confluence.Folder, error) { - fmt.Fprintf(d.out, "[DRY-RUN] CREATE FOLDER (POST %s/wiki/api/v2/folders)\n", d.domain) - fmt.Fprintf(d.out, " Title: %s\n", input.Title) - fmt.Fprintf(d.out, " SpaceID: %s\n", input.SpaceID) + d.printf("[DRY-RUN] CREATE FOLDER (POST %s/wiki/api/v2/folders)\n", d.domain) + d.printf(" Title: %s\n", input.Title) + d.printf(" SpaceID: %s\n", input.SpaceID) if input.ParentID != "" { - fmt.Fprintf(d.out, " ParentID: %s\n", input.ParentID) - fmt.Fprintf(d.out, " ParentType: %s\n", input.ParentType) + d.printf(" ParentID: %s\n", input.ParentID) + d.printf(" ParentType: %s\n", input.ParentType) } - _, _ = fmt.Fprintln(d.out) + d.println() return confluence.Folder{ ID: "dry-run-folder-id", @@ -214,7 +215,7 @@ func (d *dryRunPushRemote) ListFolders(ctx context.Context, opts confluence.Fold } func (d *dryRunPushRemote) MovePage(ctx context.Context, pageID string, targetID string) error { - fmt.Fprintf(d.out, "[DRY-RUN] MOVE PAGE (PUT %s/wiki/rest/api/content/%s/move/append/%s)\n\n", d.domain, pageID, targetID) + d.printf("[DRY-RUN] MOVE PAGE (PUT %s/wiki/rest/api/content/%s/move/append/%s)\n\n", d.domain, pageID, targetID) return nil } @@ -222,3 +223,24 @@ func (d *dryRunPushRemote) Close() error { closeRemoteIfPossible(d.inner) return nil } + +func (d *dryRunPushRemote) printf(format string, args ...any) { + if !d.emitOperations { + return + } + _, _ = fmt.Fprintf(d.out, format, args...) +} + +func (d *dryRunPushRemote) println() { + if !d.emitOperations { + return + } + _, _ = fmt.Fprintln(d.out) +} + +func (d *dryRunPushRemote) printBodyPreview(ctx context.Context, adfJSON []byte) { + if !d.emitOperations { + return + } + printDryRunBodyPreview(ctx, d.out, adfJSON) +} diff --git a/cmd/push_dryrun_test.go b/cmd/push_dryrun_test.go index 553e64b..afc033e 100644 --- a/cmd/push_dryrun_test.go +++ b/cmd/push_dryrun_test.go @@ -2,16 +2,31 @@ package cmd import ( "bytes" + "context" + "os" "path/filepath" "strings" "testing" "github.com/rgonek/confluence-markdown-sync/internal/config" + "github.com/rgonek/confluence-markdown-sync/internal/confluence" "github.com/rgonek/confluence-markdown-sync/internal/fs" syncflow "github.com/rgonek/confluence-markdown-sync/internal/sync" "github.com/spf13/cobra" ) +type preflightCapabilityFakePushRemote struct { + *cmdFakePushRemote + contentStatusErr error +} + +func (f *preflightCapabilityFakePushRemote) GetContentStatus(_ context.Context, _ string, _ string) (string, error) { + if f.contentStatusErr != nil { + return "", f.contentStatusErr + } + return "", nil +} + func TestRunPush_DryRunDoesNotMutateFrontmatter(t *testing.T) { runParallelCommandTest(t) @@ -142,22 +157,100 @@ func TestRunPush_DryRunShowsMarkdownPreviewNotRawADF(t *testing.T) { } } -func TestRunPush_PreflightShowsPlanWithoutRemoteWrites(t *testing.T) { +func TestRunPush_DryRunResolvesCrossSpaceRelativeLinks(t *testing.T) { runParallelCommandTest(t) repo := t.TempDir() spaceDir := preparePushRepoWithBaseline(t, repo) + siblingSpaceDir := filepath.Join(repo, "Technical Docs (TD)") + if err := os.MkdirAll(siblingSpaceDir, 0o750); err != nil { + t.Fatalf("mkdir sibling space: %v", err) + } + writeMarkdown(t, filepath.Join(siblingSpaceDir, "Target Page.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Target Page", + ID: "77", + Version: 2, + }, + Body: "Target content\n", + }) + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "See [Target](../Technical%20Docs%20(TD)/Target%20Page.md)\n", + }) + + fake := newCmdFakePushRemote(1) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + setupEnv(t) + chdirRepo(t, spaceDir) + setAutomationFlags(t, true, true) + + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictPullMerge, true); err != nil { + t.Fatalf("runPush dry-run should resolve cross-space relative links: %v\noutput:\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "push completed: 1 page change(s) would be synced") { + t.Fatalf("dry-run output missing success summary:\n%s", out.String()) + } + if len(fake.updateCalls) != 0 { + t.Fatalf("dry-run should not perform remote writes, got %d update calls", len(fake.updateCalls)) + } +} + +func TestRunPush_PreflightShowsPlanWithoutRemoteWrites(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Status: "Ready to review", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, - Body: "Updated local content\n", + Body: "Updated local content with ![diagram](assets/new.png)\n", }) + assetDir := filepath.Join(spaceDir, "assets") + if err := fs.SaveState(spaceDir, fs.SpaceState{ + PagePathIndex: map[string]string{ + "root.md": "1", + }, + AttachmentIndex: map[string]string{ + "assets/1/old.png": "att-stale", + }, + }); err != nil { + t.Fatalf("save state: %v", err) + } + if err := os.MkdirAll(filepath.Join(assetDir, "1"), 0o750); err != nil { + t.Fatalf("mkdir old asset dir: %v", err) + } + if err := os.WriteFile(filepath.Join(assetDir, "1", "old.png"), []byte("old"), 0o600); err != nil { + t.Fatalf("write stale asset: %v", err) + } + if err := os.WriteFile(filepath.Join(assetDir, "new.png"), []byte("new"), 0o600); err != nil { + t.Fatalf("write new asset: %v", err) + } runGitForTest(t, repo, "add", ".") runGitForTest(t, repo, "commit", "-m", "local change") @@ -170,7 +263,10 @@ func TestRunPush_PreflightShowsPlanWithoutRemoteWrites(t *testing.T) { oldPullFactory := newPullRemote newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { factoryCalls++ - return newCmdFakePushRemote(1), nil + return &preflightCapabilityFakePushRemote{ + cmdFakePushRemote: newCmdFakePushRemote(1), + contentStatusErr: &confluence.APIError{StatusCode: 404, Message: "missing"}, + }, nil } newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return newCmdFakePushRemote(1), nil @@ -190,16 +286,77 @@ func TestRunPush_PreflightShowsPlanWithoutRemoteWrites(t *testing.T) { if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, "", false); err != nil { t.Fatalf("runPush() preflight unexpected error: %v", err) } - if factoryCalls != 0 { - t.Fatalf("preflight should not require remote factory here, got %d calls", factoryCalls) + if factoryCalls != 1 { + t.Fatalf("preflight should use remote factory once, got %d calls", factoryCalls) } text := out.String() if !strings.Contains(text, "preflight for space ENG") { t.Fatalf("preflight output missing header:\n%s", text) } - if !strings.Contains(text, "changes: 1 (A:0 M:1 D:0)") { - t.Fatalf("preflight output missing change summary:\n%s", text) + if !strings.Contains(text, "Remote capability concerns:") { + t.Fatalf("preflight output missing remote capability section:\n%s", text) + } + if !strings.Contains(text, "content-status metadata sync disabled for this push") { + t.Fatalf("preflight output missing degraded-mode detail:\n%s", text) + } + if !strings.Contains(text, "Planned page mutations:") || !strings.Contains(text, "update root.md") { + t.Fatalf("preflight output missing planned page mutations:\n%s", text) + } + if !strings.Contains(text, "Planned attachment mutations:") || !strings.Contains(text, "upload assets/1/new.png") || !strings.Contains(text, "delete assets/1/old.png") { + t.Fatalf("preflight output missing planned attachment mutations:\n%s", text) + } +} + +func TestRunPush_PreflightHonorsExplicitForceConflictPolicy(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 1, + ConfluenceLastModified: "2026-02-01T10:00:00Z", + }, + Body: "Updated local content\n", + }) + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "local change") + + previousPreflight := flagPushPreflight + flagPushPreflight = true + t.Cleanup(func() { flagPushPreflight = previousPreflight }) + + fake := newCmdFakePushRemote(3) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + setupEnv(t) + chdirRepo(t, spaceDir) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictForce, false); err != nil { + t.Fatalf("runPush() preflight with force unexpected error: %v", err) + } + + text := out.String() + if !strings.Contains(text, "update root.md (page 1, \"Root\", version 4)") { + t.Fatalf("preflight output missing forced remote-ahead version plan:\n%s", text) + } + if len(fake.updateCalls) != 0 { + t.Fatalf("preflight should not perform remote writes, got %d update calls", len(fake.updateCalls)) } } diff --git a/cmd/release_docs_test.go b/cmd/release_docs_test.go new file mode 100644 index 0000000..fa16306 --- /dev/null +++ b/cmd/release_docs_test.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReleaseDocsMarkBetaStatus(t *testing.T) { + readme, err := os.ReadFile(filepath.Join("..", "README.md")) + if err != nil { + t.Fatalf("read README: %v", err) + } + usage, err := os.ReadFile(filepath.Join("..", "docs", "usage.md")) + if err != nil { + t.Fatalf("read usage guide: %v", err) + } + automation, err := os.ReadFile(filepath.Join("..", "docs", "automation.md")) + if err != nil { + t.Fatalf("read automation guide: %v", err) + } + + for path, content := range map[string]string{ + "README.md": string(readme), + "docs/usage.md": string(usage), + "docs/automation.md": string(automation), + } { + if !strings.Contains(strings.ToLower(content), "beta") { + t.Fatalf("expected %s to clearly label the product as beta", path) + } + } +} diff --git a/cmd/report.go b/cmd/report.go index d721489..a3d50b9 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -58,11 +58,12 @@ type commandRunReportDiagnostic struct { } type commandRunReportPage struct { - Path string `json:"path,omitempty"` - PageID string `json:"page_id,omitempty"` - Title string `json:"title,omitempty"` - Version int `json:"version,omitempty"` - Deleted bool `json:"deleted,omitempty"` + Operation string `json:"operation,omitempty"` + Path string `json:"path,omitempty"` + PageID string `json:"page_id,omitempty"` + Title string `json:"title,omitempty"` + Version int `json:"version,omitempty"` + Deleted bool `json:"deleted,omitempty"` } type commandRunReportAttachmentOp struct { @@ -324,6 +325,53 @@ func reportAttachmentOpsFromPush(result syncflow.PushResult, spaceDir string) [] return out } +func appendPushResultToReport(report *commandRunReport, result syncflow.PushResult, changes []syncflow.PushFileChange, spaceDir string) { + if report == nil { + return + } + report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPush(result.Diagnostics, spaceDir)...) + report.AttachmentOperations = append(report.AttachmentOperations, reportAttachmentOpsFromPush(result, spaceDir)...) + report.FallbackModes = append(report.FallbackModes, fallbackModesFromPushDiagnostics(result.Diagnostics)...) + + operationsByPath := pushOperationByPath(changes) + for _, commit := range result.Commits { + relPath := reportRelativePath(spaceDir, commit.Path) + report.MutatedFiles = append(report.MutatedFiles, relPath) + report.MutatedPages = append(report.MutatedPages, commandRunReportPage{ + Operation: operationsByPath[normalizeReportPushPath(commit.Path)], + Path: relPath, + PageID: strings.TrimSpace(commit.PageID), + Title: strings.TrimSpace(commit.PageTitle), + Version: commit.Version, + Deleted: commit.Deleted, + }) + } +} + +func pushOperationByPath(changes []syncflow.PushFileChange) map[string]string { + operations := make(map[string]string, len(changes)) + for _, change := range changes { + switch change.Type { + case syncflow.PushChangeAdd: + operations[normalizeReportPushPath(change.Path)] = "create" + case syncflow.PushChangeDelete: + operations[normalizeReportPushPath(change.Path)] = "delete" + case syncflow.PushChangeModify: + operations[normalizeReportPushPath(change.Path)] = "update" + } + } + return operations +} + +func normalizeReportPushPath(path string) string { + path = filepath.ToSlash(filepath.Clean(strings.TrimSpace(path))) + path = strings.TrimPrefix(path, "./") + if path == "." { + return "" + } + return path +} + func sortedUniqueStrings(values []string) []string { if len(values) == 0 { return []string{} From fb1bfc1b3ef4c90cea4ed0de4ccd73949a57909d Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sat, 7 Mar 2026 20:51:48 +0100 Subject: [PATCH 23/31] Enhance push preflight with remote capability probing and mutation plan Preflight now creates a remote client to detect degraded modes (content-status API unavailability), reports capability concerns, and shows exact planned page and attachment mutations before execution. README updated with beta maturity notice. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 + cmd/push.go | 2 +- cmd/push_changes.go | 182 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 182 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2f45ecd..d1d8316 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Write docs like code. Publish to Confluence with confidence. ✍️ +> **Beta** — `conf` is under active development. Core sync workflows (pull, push, validate, diff, status) are tested against live Confluence tenants, but edge cases remain. Pin a specific version for production use and test changes in a sandbox space before relying on new releases. + `conf` is a Go CLI that keeps Confluence pages and local Markdown in sync, so teams can use editor + Git + CI workflows without giving up Confluence as the publishing platform. ## Why teams use `conf` ✨ diff --git a/cmd/push.go b/cmd/push.go index 8bbb728..2ac6819 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -198,7 +198,7 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun } if preflight { - return runPushPreflight(ctx, out, target, spaceKey, spaceDir, gitClient, spaceScopePath, changeScopePath) + return runPushPreflight(ctx, out, target, spaceKey, spaceDir, gitClient, spaceScopePath, changeScopePath, onConflict) } ts := nowUTC() diff --git a/cmd/push_changes.go b/cmd/push_changes.go index 11387ba..22484d7 100644 --- a/cmd/push_changes.go +++ b/cmd/push_changes.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "io" + "net/http" "os" "path/filepath" + "sort" "strings" "time" @@ -25,6 +27,7 @@ func runPushPreflight( spaceKey, spaceDir string, gitClient *git.Client, spaceScopePath, changeScopePath string, + onConflict string, ) error { baselineRef, err := gitPushBaselineRef(gitClient, spaceKey) if err != nil { @@ -54,17 +57,190 @@ func runPushPreflight( } } - addCount, modifyCount, deleteCount := summarizePushChanges(syncChanges) - _, _ = fmt.Fprintf(out, "changes: %d (A:%d M:%d D:%d)\n", len(syncChanges), addCount, modifyCount, deleteCount) + // Load config and create remote to probe capabilities and list remote pages. + envPath := findEnvPath(spaceDir) + cfg, err := config.Load(envPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + remote, err := newPushRemote(cfg) + if err != nil { + return fmt.Errorf("create confluence client: %w", err) + } + defer closeRemoteIfPossible(remote) + + // Probe GetContentStatus to detect degraded modes. + _, probeErr := remote.GetContentStatus(ctx, "", "") + if isPreflightCapabilityProbeError(probeErr) { + _, _ = fmt.Fprintln(out, "Remote capability concerns:") + _, _ = fmt.Fprintln(out, " content-status metadata sync disabled for this push") + } + + // List remote pages for mutation planning. + remotePageByID := map[string]confluence.Page{} + space, spaceErr := remote.GetSpace(ctx, spaceKey) + if spaceErr == nil { + listResult, listErr := remote.ListPages(ctx, confluence.PageListOptions{ + SpaceID: space.ID, + SpaceKey: spaceKey, + Status: "current", + Limit: 100, + }) + if listErr == nil { + for _, page := range listResult.Pages { + remotePageByID[strings.TrimSpace(page.ID)] = page + } + } + } + + // Load local state for attachment comparison. + state, _ := fs.LoadState(spaceDir) + + // Build planned page mutations. + _, _ = fmt.Fprintln(out, "Planned page mutations:") for _, change := range syncChanges { - _, _ = fmt.Fprintf(out, " %s %s\n", change.Type, change.Path) + absPath := filepath.Join(spaceDir, filepath.FromSlash(change.Path)) + switch change.Type { + case syncflow.PushChangeAdd: + _, _ = fmt.Fprintf(out, " add %s\n", change.Path) + case syncflow.PushChangeDelete: + fm, fmErr := fs.ReadFrontmatter(absPath) + pageID := "" + if fmErr == nil { + pageID = strings.TrimSpace(fm.ID) + } + if pageID != "" { + _, _ = fmt.Fprintf(out, " delete %s (page %s)\n", change.Path, pageID) + } else { + _, _ = fmt.Fprintf(out, " delete %s\n", change.Path) + } + case syncflow.PushChangeModify: + fm, fmErr := fs.ReadFrontmatter(absPath) + if fmErr != nil { + _, _ = fmt.Fprintf(out, " update %s\n", change.Path) + continue + } + pageID := strings.TrimSpace(fm.ID) + if pageID == "" { + _, _ = fmt.Fprintf(out, " update %s\n", change.Path) + continue + } + remotePage, hasRemote := remotePageByID[pageID] + if !hasRemote { + _, _ = fmt.Fprintf(out, " update %s (page %s)\n", change.Path, pageID) + continue + } + // Compute planned version based on conflict policy. + var plannedVersion int + if onConflict == OnConflictForce { + plannedVersion = remotePage.Version + 1 + } else { + plannedVersion = fm.Version + 1 + } + _, _ = fmt.Fprintf(out, " update %s (page %s, %q, version %d)\n", + change.Path, pageID, remotePage.Title, plannedVersion) + } } + + // Build planned attachment mutations. + uploads, deletes := preflightAttachmentMutations(ctx, spaceDir, syncChanges, state) + if len(uploads) > 0 || len(deletes) > 0 { + _, _ = fmt.Fprintln(out, "Planned attachment mutations:") + for _, u := range uploads { + _, _ = fmt.Fprintf(out, " upload %s\n", u) + } + for _, d := range deletes { + _, _ = fmt.Fprintf(out, " delete %s\n", d) + } + } + + addCount, modifyCount, deleteCount := summarizePushChanges(syncChanges) + _, _ = fmt.Fprintf(out, "changes: %d (A:%d M:%d D:%d)\n", len(syncChanges), addCount, modifyCount, deleteCount) if len(syncChanges) > 10 || deleteCount > 0 { _, _ = fmt.Fprintln(out, "safety confirmation would be required") } return nil } +// isPreflightCapabilityProbeError reports whether err indicates the remote +// does not support the probed API endpoint (404, 405, or 501). +func isPreflightCapabilityProbeError(err error) bool { + if err == nil { + return false + } + var apiErr *confluence.APIError + if !errors.As(err, &apiErr) { + return false + } + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented: + return true + default: + return false + } +} + +// preflightAttachmentMutations returns planned upload and delete paths for +// attachments based on changed markdown files and the current state. +func preflightAttachmentMutations( + _ context.Context, + spaceDir string, + syncChanges []syncflow.PushFileChange, + state fs.SpaceState, +) (uploads, deletes []string) { + plannedUploadKeys := map[string]struct{}{} + + for _, change := range syncChanges { + if change.Type == syncflow.PushChangeDelete { + continue + } + + absPath := filepath.Join(spaceDir, filepath.FromSlash(change.Path)) + doc, err := fs.ReadMarkdownDocument(absPath) + if err != nil { + continue + } + + pageID := strings.TrimSpace(doc.Frontmatter.ID) + if pageID == "" { + continue + } + + referencedPaths, err := syncflow.CollectReferencedAssetPaths(spaceDir, absPath, doc.Body) + if err != nil { + continue + } + + for _, assetPath := range referencedPaths { + // Compute the planned state key: assets// + plannedKey := filepath.ToSlash(filepath.Join("assets", pageID, filepath.Base(assetPath))) + plannedUploadKeys[plannedKey] = struct{}{} + + // If this key is not in the state, it's a new upload. + if strings.TrimSpace(state.AttachmentIndex[plannedKey]) == "" { + uploads = append(uploads, plannedKey) + } + } + + // Check existing state entries for this page — anything not covered + // by a planned upload is stale and will be deleted. + prefix := "assets/" + pageID + "/" + for stateKey := range state.AttachmentIndex { + if !strings.HasPrefix(stateKey, prefix) { + continue + } + if _, covered := plannedUploadKeys[stateKey]; !covered { + deletes = append(deletes, stateKey) + } + } + } + + sort.Strings(uploads) + sort.Strings(deletes) + return uploads, deletes +} + func runPushDryRun( ctx context.Context, cmd *cobra.Command, From 15ba5be72e99d004a831388ffeee6b5efecb18e3 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sat, 7 Mar 2026 20:56:42 +0100 Subject: [PATCH 24/31] Align generated AGENTS.md templates with current workflow and frontmatter model Replace split Human-in-the-Loop / Full Agentic workflow with a single unified pull -> validate -> diff -> push workflow. Remove stale `space` frontmatter references, add content support contract and documentation strategy sections. Add golden-style tests verifying template alignment. Co-Authored-By: Claude Opus 4.6 --- cmd/agents.go | 12 +++-- cmd/agents_test.go | 126 +++++++++++++++++++++++++++++++++++++++++++++ cmd/init.go | 49 ++++++++---------- 3 files changed, 157 insertions(+), 30 deletions(-) diff --git a/cmd/agents.go b/cmd/agents.go index 3c3fc85..13800e8 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -102,11 +102,13 @@ This space directory contains technical documentation for [%s]. You are a technical writer and software engineer. Your goal is to maintain high-quality, accurate, and developer-friendly documentation. ## Space-Specific Rules -- **Diagrams**: Use PlantUML when the page needs a rendered Confluence diagram. Mermaid fences are preserved as code blocks and will not render as Mermaid macros. +- **Diagrams**: Use PlantUML when the page needs a rendered Confluence diagram. Mermaid fences are preserved as fenced code blocks (ADF `+"`codeBlock`"+`) and will not render as Confluence diagram macros. +- **Cross-space links**: Relative Markdown links to pages in sibling space directories are resolved at push time and are fully supported. - **Code Snippets**: Always specify the language for syntax highlighting. - **API Docs**: Ensure all endpoints include request/response examples. -- **Links**: Use relative Markdown links for cross-references between pages. +- **Links**: Use relative Markdown links for cross-references between pages in the same space. - **Assets**: Store all images in the `+"`assets/`"+` directory. +- **Frontmatter**: Do not edit `+"`id`"+` or `+"`version`"+` in frontmatter. `+"`state`"+`, `+"`status`"+`, and `+"`labels`"+` are user-editable. ## Sync Workflow 1. `+"`conf pull`"+` to get the latest state. @@ -129,6 +131,7 @@ You are an internal communications specialist. Your goal is to ensure documentat - **Formatting**: Use bold text for key terms and bullet points for readability. - **Tone**: Maintain a professional yet welcoming tone. - **Links**: Ensure all links to external portals (Workday, etc.) are up to date. +- **Frontmatter**: Do not edit `+"`id`"+` or `+"`version`"+` in frontmatter. `+"`state`"+`, `+"`status`"+`, and `+"`labels`"+` are user-editable. ## Sync Workflow 1. `+"`conf pull`"+` @@ -151,6 +154,7 @@ You are a project manager. Your goal is to keep stakeholders informed and ensure - **Action Items**: Use checklist format `+"`- [ ]`"+` for tasks. - **Roadmaps**: Use tables for high-level project timelines and milestones. - **Status Updates**: Use traffic light emojis (🟢, 🟡, 🔴) to indicate project health. +- **Frontmatter**: Do not edit `+"`id`"+` or `+"`version`"+` in frontmatter. `+"`state`"+`, `+"`status`"+`, and `+"`labels`"+` are user-editable. ## Sync Workflow 1. `+"`conf pull`"+` @@ -173,6 +177,7 @@ You are a product manager. Your goal is to define clear, actionable requirements - **Acceptance Criteria**: Use numbered lists for explicit testable conditions. - **Design Links**: Always include links to Figma/Sketch prototypes where applicable. - **Prioritization**: Clearly mark "Must Have", "Should Have", and "Could Have" features. +- **Frontmatter**: Do not edit `+"`id`"+` or `+"`version`"+` in frontmatter. `+"`state`"+`, `+"`status`"+`, and `+"`labels`"+` are user-editable. ## Sync Workflow 1. `+"`conf pull`"+` @@ -195,6 +200,7 @@ You are a support specialist and technical communicator. Your goal is to solve u - **Step-by-Step**: Use numbered lists for procedures. - **Troubleshooting**: Always include a "Symptoms" and "Resolution" section. - **Callouts**: Use bolding or blockquotes for critical warnings or tips. +- **Frontmatter**: Do not edit `+"`id`"+` or `+"`version`"+` in frontmatter. `+"`state`"+`, `+"`status`"+`, and `+"`labels`"+` are user-editable. ## Sync Workflow 1. `+"`conf pull`"+` @@ -210,7 +216,7 @@ func getGeneralAgentsTemplate(spaceKey string) string { This space directory is managed by `+"`conf`"+`. ## Rules -- Do not edit `+"`id`"+` or `+"`space`"+` in frontmatter. +- Do not edit `+"`id`"+` or `+"`version`"+` in frontmatter. - `+"`state`"+` (draft/current), `+"`status`"+` (lozenge), and `+"`labels`"+` are user-editable. - Always `+"`pull`"+` before `+"`push`"+`. - Run `+"`validate`"+` before publishing. diff --git a/cmd/agents_test.go b/cmd/agents_test.go index 8e7d784..aa8291f 100644 --- a/cmd/agents_test.go +++ b/cmd/agents_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/rgonek/confluence-markdown-sync/internal/config" @@ -107,3 +108,128 @@ func TestRunAgentsInit(t *testing.T) { t.Fatalf("runAgentsInit general failed: %v", err) } } + +func TestAgentsMDTemplateAlignment(t *testing.T) { + runParallelCommandTest(t) + content := agentsMDTemplate + + // Must NOT contain old split workflow references. + for _, banned := range []string{"Human-in-the-Loop", "Full Agentic Use"} { + if strings.Contains(content, banned) { + t.Errorf("workspace AGENTS.md still contains stale %q reference", banned) + } + } + + // Must contain unified workflow. + for _, required := range []string{"Recommended Workflow", "validate", "diff", "push"} { + if !strings.Contains(content, required) { + t.Errorf("workspace AGENTS.md missing required %q", required) + } + } + + // Frontmatter: id immutable, version managed, space NOT listed as immutable frontmatter key. + if strings.Contains(content, "`space`: Immutable") || strings.Contains(content, "`space`"+": Immutable") { + t.Error("workspace AGENTS.md still lists space as immutable frontmatter") + } + if !strings.Contains(content, "`id`") { + t.Error("workspace AGENTS.md missing id frontmatter reference") + } + if !strings.Contains(content, "`version`") { + t.Error("workspace AGENTS.md missing version frontmatter reference") + } + + // Must contain Content Support Contract section. + if !strings.Contains(content, "Content Support Contract") { + t.Error("workspace AGENTS.md missing Content Support Contract section") + } + + // Must contain Documentation Strategy section. + if !strings.Contains(content, "Documentation Strategy") { + t.Error("workspace AGENTS.md missing Documentation Strategy section") + } + + // Must mention Mermaid preservation behavior. + if !strings.Contains(content, "MERMAID_PRESERVED_AS_CODEBLOCK") { + t.Error("workspace AGENTS.md missing MERMAID_PRESERVED_AS_CODEBLOCK diagnostic reference") + } +} + +func TestReadmeMDTemplateAlignment(t *testing.T) { + runParallelCommandTest(t) + content := readmeMDTemplate + + // space must NOT be listed as an immutable key. + if strings.Contains(content, "`id`, `space`: Immutable") || strings.Contains(content, "`space`: Immutable") { + t.Error("README.md template still lists space as an immutable frontmatter key") + } + + // id must still be documented as immutable. + if !strings.Contains(content, "`id`") { + t.Error("README.md template missing id frontmatter reference") + } +} + +func TestSpaceTemplates_NoStaleSpaceImmutableReference(t *testing.T) { + runParallelCommandTest(t) + + type templateCase struct { + name string + content string + } + + cases := []templateCase{ + {"tech", getTechAgentsTemplate("TEST")}, + {"hr", getHRAgentsTemplate("TEST")}, + {"pm", getPMAgentsTemplate("TEST")}, + {"prd", getPRDAgentsTemplate("TEST")}, + {"support", getSupportAgentsTemplate("TEST")}, + {"general", getGeneralAgentsTemplate("TEST")}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // No template should reference `space` as an immutable frontmatter key. + if strings.Contains(tc.content, "`id` or `space`") || + strings.Contains(tc.content, "`space`: Immutable") { + t.Errorf("%s template still references space as an immutable frontmatter key", tc.name) + } + + // Each template must mention id and version in its frontmatter guidance. + if !strings.Contains(tc.content, "`id`") { + t.Errorf("%s template missing id reference", tc.name) + } + if !strings.Contains(tc.content, "`version`") { + t.Errorf("%s template missing version reference", tc.name) + } + }) + } +} + +func TestTechAgentsTemplate_ContentNotes(t *testing.T) { + runParallelCommandTest(t) + content := getTechAgentsTemplate("TEST") + + // Must mention Mermaid preservation behavior. + if !strings.Contains(content, "codeBlock") { + t.Error("tech template missing Mermaid/codeBlock note") + } + + // Must mention cross-space link support. + if !strings.Contains(content, "Cross-space") { + t.Error("tech template missing cross-space links note") + } +} + +func TestGeneralAgentsTemplate_FrontmatterGuidance(t *testing.T) { + runParallelCommandTest(t) + content := getGeneralAgentsTemplate("TEST") + + // Must say "id or version", not "id or space". + if strings.Contains(content, "`id` or `space`") { + t.Error("general template still says 'id or space' instead of 'id or version'") + } + if !strings.Contains(content, "`id`") || !strings.Contains(content, "`version`") { + t.Error("general template missing id or version reference") + } +} diff --git a/cmd/init.go b/cmd/init.go index a01503d..69b8315 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -37,29 +37,13 @@ const agentsMDTemplate = `# AGENTS This repository uses ` + "`conf`" + ` (confluence-sync) to manage Confluence documentation as Markdown. -## Intended Usages - -### 1. Human-in-the-Loop (Agent as Writer) -In this mode, the agent edits Markdown files, and a human performs the sync commands. -- **Workflow**: - - Edit ` + "`.md`" + ` files in the space directories. - - Run ` + "`conf validate [TARGET]`" + ` to ensure your changes are compatible with Confluence. - - Inform the human when changes are ready for ` + "`conf push`" + `. -- **Rules**: - - NEVER manually edit ` + "`id`" + ` or ` + "`space`" + ` in frontmatter. - - Add images to the correct ` + "`assets/`" + ` subfolder. - -### 2. Full Agentic Use (Autonomous Sync) -In this mode, the agent is responsible for the entire lifecycle. -- **Workflow**: - - ` + "`conf pull [SPACE]`" + ` — Always pull first to avoid conflicts. - - Edit/Create Markdown files. - - ` + "`conf validate [SPACE]`" + ` — Verify all links and assets. - - ` + "`conf diff [SPACE]`" + ` — Preview changes. - - ` + "`conf push [SPACE] --on-conflict=pull-merge`" + ` — Publish changes. -- **Automation**: Use ` + "`--yes`" + ` and ` + "`--non-interactive`" + ` in CI/CD or automated scripts. - -### 3. Search (Read-Only, Zero API Calls) +## Recommended Workflow + +` + "`conf pull [TARGET]`" + ` → edit Markdown → ` + "`conf validate [TARGET]`" + ` → ` + "`conf diff [TARGET]`" + ` → ` + "`conf push [TARGET]`" + ` + +Humans may review or approve specific steps, but the workflow is the same regardless of who runs each command. + +## Search (Read-Only, Zero API Calls) Use ` + "`conf search`" + ` to find content without reading entire files. - **Workflow**: ` + "`conf search \"term\" --format json | `" + ` for structured reads. - **Filters**: ` + "`--space KEY`" + `, ` + "`--label LABEL`" + `, ` + "`--heading TEXT`" + `, ` + "`--created-by USER`" + `, ` + "`--updated-by USER`" + `, ` + "`--created-after DATE`" + `, ` + "`--created-before DATE`" + `, ` + "`--updated-after DATE`" + `, ` + "`--updated-before DATE`" + `. @@ -70,9 +54,9 @@ Use ` + "`conf search`" + ` to find content without reading entire files. - **Source of Truth**: Confluence is the primary source of truth for IDs and versions. Local Markdown is the source of truth for content between syncs. - **Validation**: ` + "`push`" + ` will fail if ` + "`validate`" + ` fails. - **Frontmatter**: - - ` + "`id`" + `, ` + "`space`" + `: Immutable. - - ` + "`version`" + `: Managed by ` + "`conf`" + `. - - ` + "`state`" + `: Lifecycle state (` + "`draft`" + ` or ` + "`current`" + `). Omitted means ` + "`current`" + `. + - ` + "`id`" + `: Immutable — do not edit. + - ` + "`version`" + `: Managed by ` + "`conf`" + ` — do not edit. + - ` + "`state`" + `: Lifecycle state (` + "`draft`" + ` or ` + "`current`" + `). Omitted means ` + "`current`" + `. Cannot revert to ` + "`draft`" + ` once published. - ` + "`status`" + `: Confluence visual lozenge (e.g., "Ready to review"). - ` + "`labels`" + `: Confluence page labels (array of strings). - **State**: ` + "`.confluence-state.json`" + ` tracks sync state. Do not delete. @@ -96,6 +80,17 @@ Space/ - Sibling ` + "`.md`" + ` files inside the directory become subpages of that parent. - This mirrors the Confluence page tree hierarchy in the local filesystem. +## Content Support Contract +- **Same-space links**: Relative Markdown links between pages in the same space are fully supported. +- **Cross-space links**: Relative links to pages in sibling space directories are resolved at push time. +- **Attachments**: Images and files stored in ` + "`assets/`" + ` are uploaded as Confluence page attachments. +- **PlantUML**: Rendered round-trip support via the ` + "`plantumlcloud`" + ` Confluence macro. +- **Mermaid**: Preserved as fenced code blocks; pushed as ADF ` + "`codeBlock`" + ` (not rendered as a Confluence diagram). ` + "`validate`" + ` warns with ` + "`MERMAID_PRESERVED_AS_CODEBLOCK`" + `. +- **Hierarchy**: Pages with children use the ` + "`ParentPage/ParentPage.md`" + ` convention; moves are surfaced as ` + "`PAGE_PATH_MOVED`" + ` diagnostics. + +## Documentation Strategy +Specs and PRDs generated in this workspace should be maintained as the working source of truth for feature behavior and product intent. When behavior or requirements are unclear, refer to the primary plan (if one exists) or to the relevant Spec/PRD document. + ## Space-Specific Rules Each space directory (e.g., ` + "`Technical documentation (TD)/`" + `) may contain its own ` + "`AGENTS.md`" + ` with space-specific content rules (e.g., required templates, PII guidelines). Check those if they exist. ` @@ -133,7 +128,7 @@ ATLASSIAN_API_TOKEN= ## Notes - Frontmatter fields: - - ` + "`id`" + `, ` + "`space`" + `: Immutable — do not edit. + - ` + "`id`" + `: Immutable — do not edit. - ` + "`version`" + `: Managed by ` + "`conf`" + `. - ` + "`state`" + `: Lifecycle state (` + "`draft`" + ` or ` + "`current`" + `). - ` + "`status`" + `: Confluence visual lozenge (e.g., "Ready to review"). From 5200a557885bdb85ca0f8b786849cb317ede94c2 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 8 Mar 2026 19:37:17 +0100 Subject: [PATCH 25/31] Improve no-op explainability and fix dry-run cross-space link resolution No-op pull/push/preflight commands now explain why nothing changed (no remote changes, no local changes, out-of-scope updates, etc.). Fix dry-run to use the original space dir instead of a temp copy so sibling space directories remain accessible for cross-space link resolution. Enable emitOperations on dry-run remote so body preview output is visible. Co-Authored-By: Claude Opus 4.6 --- cmd/pull.go | 6 ++- cmd/pull_test.go | 87 +++++++++++++++++++++++++++++++++++ cmd/push.go | 2 +- cmd/push_changes.go | 104 +++++++++++++++++++++++++++++++++++++----- cmd/push_test.go | 77 +++++++++++++++++++++++++++++++ cmd/push_worktree.go | 11 ++++- internal/sync/pull.go | 19 +++++--- 7 files changed, 283 insertions(+), 23 deletions(-) diff --git a/cmd/pull.go b/cmd/pull.go index a4ffe5d..a3a6886 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -365,7 +365,11 @@ func runPullWithReport(cmd *cobra.Command, target config.Target, emitJSONReport } if !hasChanges { - _, _ = fmt.Fprintln(out, "pull completed with no scoped changes (no-op)") + if result.RemotePagesChecked == 0 { + _, _ = fmt.Fprintln(out, "pull completed with no remote changes since last sync (no-op)") + } else { + _, _ = fmt.Fprintln(out, "pull completed with no scoped changes: all remote updates were outside the target scope (no-op)") + } return report, nil } diff --git a/cmd/pull_test.go b/cmd/pull_test.go index 612f6c9..a9c41db 100644 --- a/cmd/pull_test.go +++ b/cmd/pull_test.go @@ -459,3 +459,90 @@ func TestRunPull_DraftSpaceListing(t *testing.T) { t.Errorf("draft.md status = %q, want draft", doc.Frontmatter.State) } } + +func TestPullNoOp_ExplainsReason_NoRemoteChanges(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + setupGitRepo(t, repo) + + spaceDir := filepath.Join(repo, "Engineering (ENG)") + if err := os.MkdirAll(spaceDir, 0o750); err != nil { + t.Fatalf("mkdir space: %v", err) + } + + writeMarkdown(t, filepath.Join(spaceDir, "Root.md"), fs.MarkdownDocument{ + Frontmatter: fs.Frontmatter{ + Title: "Root", + ID: "1", + Version: 2, + ConfluenceLastModified: "2026-02-01T11:00:00Z", + }, + Body: "body\n", + }) + // Save state with a watermark set after the remote page's last-modified time + // so the incremental change detection finds nothing new. + if err := fs.SaveState(spaceDir, fs.SpaceState{ + SpaceKey: "ENG", + LastPullHighWatermark: "2026-02-01T12:00:00Z", + PagePathIndex: map[string]string{"Root.md": "1"}, + }); err != nil { + t.Fatalf("save state: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(".env\n.confluence-state.json\n"), 0o600); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + runGitForTest(t, repo, "add", ".") + runGitForTest(t, repo, "commit", "-m", "initial") + + // ListChanges returns no changes since the watermark — remote is up-to-date. + fake := &cmdFakePullRemote{ + space: confluence.Space{ID: "space-1", Key: "ENG", Name: "Engineering"}, + pages: []confluence.Page{ + { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + }, + }, + pagesByID: map[string]confluence.Page{ + "1": { + ID: "1", + SpaceID: "space-1", + Title: "Root", + Version: 2, + LastModified: time.Date(2026, time.February, 1, 11, 0, 0, 0, time.UTC), + BodyADF: rawJSON(t, simpleADF("body")), + }, + }, + // Empty changes list means ListChanges returns nothing → no changed page IDs. + changes: []confluence.Change{}, + attachments: map[string][]byte{}, + } + + oldFactory := newPullRemote + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { newPullRemote = oldFactory }) + + oldNow := nowUTC + nowUTC = func() time.Time { return time.Date(2026, time.February, 1, 13, 0, 0, 0, time.UTC) } + t.Cleanup(func() { nowUTC = oldNow }) + + setupEnv(t) + chdirRepo(t, repo) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runPull(cmd, config.Target{Mode: config.TargetModeSpace, Value: "Engineering (ENG)"}); err != nil { + t.Fatalf("runPull() error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "no remote changes since last sync") { + t.Fatalf("expected no-op message to explain reason (no remote changes), got:\n%s", got) + } +} diff --git a/cmd/push.go b/cmd/push.go index 2ac6819..8130b9b 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -219,7 +219,7 @@ func runPush(cmd *cobra.Command, target config.Target, onConflict string, dryRun } if len(preSnapshotChanges) == 0 { - _, _ = fmt.Fprintln(out, "push completed with no in-scope markdown changes (no-op)") + _, _ = fmt.Fprintln(out, "push completed: no local markdown changes detected since last sync (no-op)") return nil } diff --git a/cmd/push_changes.go b/cmd/push_changes.go index 22484d7..bbc7edf 100644 --- a/cmd/push_changes.go +++ b/cmd/push_changes.go @@ -40,7 +40,7 @@ func runPushPreflight( _, _ = fmt.Fprintf(out, "preflight for space %s\n", spaceKey) if len(syncChanges) == 0 { - _, _ = fmt.Fprintln(out, "no in-scope markdown changes") + _, _ = fmt.Fprintf(out, "preflight for space %s: no local markdown changes detected since last sync (no-op)\n", spaceKey) return nil } @@ -105,15 +105,25 @@ func runPushPreflight( case syncflow.PushChangeAdd: _, _ = fmt.Fprintf(out, " add %s\n", change.Path) case syncflow.PushChangeDelete: - fm, fmErr := fs.ReadFrontmatter(absPath) + // Look up page ID: try frontmatter first (file may still exist in worktree), + // then fall back to the state index. pageID := "" + fm, fmErr := fs.ReadFrontmatter(absPath) if fmErr == nil { pageID = strings.TrimSpace(fm.ID) } + if pageID == "" { + pageID = strings.TrimSpace(state.PagePathIndex[change.Path]) + } if pageID != "" { - _, _ = fmt.Fprintf(out, " delete %s (page %s)\n", change.Path, pageID) + remotePage, hasRemote := remotePageByID[pageID] + if hasRemote && strings.TrimSpace(remotePage.Title) != "" { + _, _ = fmt.Fprintf(out, " ⚠ Destructive: delete %s (page %s, %q)\n", change.Path, pageID, remotePage.Title) + } else { + _, _ = fmt.Fprintf(out, " ⚠ Destructive: delete %s (page %s)\n", change.Path, pageID) + } } else { - _, _ = fmt.Fprintf(out, " delete %s\n", change.Path) + _, _ = fmt.Fprintf(out, " ⚠ Destructive: delete %s\n", change.Path) } case syncflow.PushChangeModify: fm, fmErr := fs.ReadFrontmatter(absPath) @@ -157,6 +167,33 @@ func runPushPreflight( addCount, modifyCount, deleteCount := summarizePushChanges(syncChanges) _, _ = fmt.Fprintf(out, "changes: %d (A:%d M:%d D:%d)\n", len(syncChanges), addCount, modifyCount, deleteCount) + if deleteCount > 0 { + _, _ = fmt.Fprintln(out, "Destructive operations in this push:") + for _, change := range syncChanges { + if change.Type != syncflow.PushChangeDelete { + continue + } + absPath := filepath.Join(spaceDir, filepath.FromSlash(change.Path)) + pageID := "" + fm, fmErr := fs.ReadFrontmatter(absPath) + if fmErr == nil { + pageID = strings.TrimSpace(fm.ID) + } + if pageID == "" { + pageID = strings.TrimSpace(state.PagePathIndex[change.Path]) + } + if pageID != "" { + remotePage, hasRemote := remotePageByID[pageID] + if hasRemote && strings.TrimSpace(remotePage.Title) != "" { + _, _ = fmt.Fprintf(out, " archive %s %q (%s)\n", pageID, remotePage.Title, change.Path) + } else { + _, _ = fmt.Fprintf(out, " archive %s (%s)\n", pageID, change.Path) + } + } else { + _, _ = fmt.Fprintf(out, " delete %s\n", change.Path) + } + } + } if len(syncChanges) > 10 || deleteCount > 0 { _, _ = fmt.Fprintln(out, "safety confirmation would be required") } @@ -263,7 +300,7 @@ func runPushDryRun( } if len(syncChanges) == 0 { - _, _ = fmt.Fprintln(out, "push completed with no in-scope markdown changes (no-op)") + _, _ = fmt.Fprintln(out, "push completed: no local markdown changes detected since last sync (no-op)") return nil } @@ -292,17 +329,18 @@ func runPushDryRun( } defer closeRemoteIfPossible(realRemote) - remote := &dryRunPushRemote{inner: realRemote, out: out, domain: cfg.Domain} + remote := &dryRunPushRemote{inner: realRemote, out: out, domain: cfg.Domain, emitOperations: true} - dryRunSpaceDir, cleanupDryRun, err := prepareDryRunSpaceDir(spaceDir) + state, err := fs.LoadState(spaceDir) if err != nil { - return err + return fmt.Errorf("load state: %w", err) } - defer cleanupDryRun() - state, err := fs.LoadState(dryRunSpaceDir) + // Build global page index from the original space dir so cross-space + // links resolve correctly. + globalPageIndex, err := buildWorkspaceGlobalPageIndex(spaceDir) if err != nil { - return fmt.Errorf("load state: %w", err) + return fmt.Errorf("build global page index: %w", err) } var progress syncflow.Progress @@ -310,11 +348,16 @@ func runPushDryRun( progress = newConsoleProgress(out, "[DRY-RUN] Syncing to Confluence") } + // Use the original space dir directly — the DryRun flag prevents local + // file writes, and the dryRunPushRemote prevents remote writes. This + // keeps sibling space directories accessible for cross-space link + // resolution. result, err := syncflow.Push(ctx, remote, syncflow.PushOptions{ SpaceKey: spaceKey, - SpaceDir: dryRunSpaceDir, + SpaceDir: spaceDir, Domain: cfg.Domain, State: state, + GlobalPageIndex: globalPageIndex, Changes: syncChanges, ConflictPolicy: toSyncConflictPolicy(onConflict), KeepOrphanAssets: flagPushKeepOrphanAssets, @@ -558,6 +601,43 @@ func pushHasDeleteChange(changes []syncflow.PushFileChange) bool { return false } +// printDestructivePushPreview prints a human-readable list of pages that will +// be archived/deleted by the push. It is called before the safety confirmation +// prompt so the operator can see exactly what will be removed. +func printDestructivePushPreview(out io.Writer, changes []syncflow.PushFileChange, spaceDir string, state fs.SpaceState) { + hasDelete := false + for _, change := range changes { + if change.Type == syncflow.PushChangeDelete { + hasDelete = true + break + } + } + if !hasDelete { + return + } + + _, _ = fmt.Fprintln(out, "Destructive operations in this push:") + for _, change := range changes { + if change.Type != syncflow.PushChangeDelete { + continue + } + pageID := "" + absPath := filepath.Join(spaceDir, filepath.FromSlash(change.Path)) + fm, fmErr := fs.ReadFrontmatter(absPath) + if fmErr == nil { + pageID = strings.TrimSpace(fm.ID) + } + if pageID == "" { + pageID = strings.TrimSpace(state.PagePathIndex[change.Path]) + } + if pageID != "" { + _, _ = fmt.Fprintf(out, " archive page %s (%s)\n", pageID, change.Path) + } else { + _, _ = fmt.Fprintf(out, " delete %s\n", change.Path) + } + } +} + func printPushDiagnostics(out io.Writer, diagnostics []syncflow.PushDiagnostic) { if len(diagnostics) == 0 { return diff --git a/cmd/push_test.go b/cmd/push_test.go index 0ead58b..6bb9564 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -714,3 +714,80 @@ func firstOrDefault(value, fallback string) string { } return value } + +func TestPushNoOp_ExplainsReason(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + // No local changes after baseline — push should be a no-op. + factoryCalls := 0 + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { + factoryCalls++ + return newCmdFakePushRemote(1), nil + } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { + return newCmdFakePushRemote(1), nil + } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + setupEnv(t) + chdirRepo(t, spaceDir) + + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, false); err != nil { + t.Fatalf("runPush() unexpected error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "no local markdown changes detected since last sync") { + t.Fatalf("expected no-op message to explain reason, got:\n%s", got) + } + if factoryCalls != 0 { + t.Fatalf("expected no remote calls for early no-op push, got %d", factoryCalls) + } +} + +func TestPushNoOp_DryRunExplainsReason(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + // No local changes after baseline — dry-run push should be a no-op. + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { + return newCmdFakePushRemote(1), nil + } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { + return newCmdFakePushRemote(1), nil + } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + setupEnv(t) + chdirRepo(t, spaceDir) + + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, true); err != nil { + t.Fatalf("runPush() dry-run unexpected error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "no local markdown changes detected since last sync") { + t.Fatalf("expected dry-run no-op message to explain reason, got:\n%s", got) + } +} diff --git a/cmd/push_worktree.go b/cmd/push_worktree.go index 5f8054f..30616ca 100644 --- a/cmd/push_worktree.go +++ b/cmd/push_worktree.go @@ -89,11 +89,18 @@ func runPushInWorktree( } if len(syncChanges) == 0 { - _, _ = fmt.Fprintln(out, "push completed with no in-scope markdown changes (no-op)") + _, _ = fmt.Fprintln(out, "push completed: no in-scope markdown changes found in worktree (no-op)") outcome.NoChanges = true return outcome, nil } + if pushHasDeleteChange(syncChanges) { + wtState, stateErr := fs.LoadState(wtSpaceDir) + if stateErr == nil { + printDestructivePushPreview(out, syncChanges, wtSpaceDir, wtState) + } + } + if err := requireSafetyConfirmation(cmd.InOrStdin(), out, "push", len(syncChanges), pushHasDeleteChange(syncChanges)); err != nil { return outcome, err } @@ -193,7 +200,7 @@ func runPushInWorktree( if len(result.Commits) == 0 { slog.Info("push_sync_result", "space_key", spaceKey, "commit_count", 0, "diagnostics", len(result.Diagnostics)) - _, _ = fmt.Fprintln(out, "push completed with no pushable markdown changes (no-op)") + _, _ = fmt.Fprintln(out, "push completed: changed files produced no pushable content after validation (no-op)") outcome.NoChanges = true return outcome, nil } diff --git a/internal/sync/pull.go b/internal/sync/pull.go index fd41b83..5a934a0 100644 --- a/internal/sync/pull.go +++ b/internal/sync/pull.go @@ -84,6 +84,10 @@ type PullResult struct { DeletedMarkdown []string DownloadedAssets []string DeletedAssets []string + // RemotePagesChecked is the number of remote pages that were identified as + // changed (or potentially changed) and fetched during this pull. Zero means + // the remote reported no new changes since the last sync watermark. + RemotePagesChecked int } // Pull executes end-to-end pull orchestration in local filesystem scope. @@ -678,13 +682,14 @@ func Pull(ctx context.Context, remote PullRemote, opts PullOptions) (PullResult, state.LastPullHighWatermark = highWatermark.Format(time.RFC3339) return PullResult{ - State: state, - MaxVersion: maxVersion, - Diagnostics: NormalizePullDiagnostics(diagnostics), - UpdatedMarkdown: updatedMarkdown, - DeletedMarkdown: deletedMarkdown, - DownloadedAssets: downloadedAssets, - DeletedAssets: deletedAssets, + State: state, + MaxVersion: maxVersion, + Diagnostics: NormalizePullDiagnostics(diagnostics), + UpdatedMarkdown: updatedMarkdown, + DeletedMarkdown: deletedMarkdown, + DownloadedAssets: downloadedAssets, + DeletedAssets: deletedAssets, + RemotePagesChecked: len(changedPageIDs), }, nil } From bbd297a82de7d546cc5761c57409113faf02bf1c Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 8 Mar 2026 19:37:23 +0100 Subject: [PATCH 26/31] Add destructive operation previews and recovery artifact inspection Preflight and push now show exact pages targeted for archive/delete before safety confirmation. Recovery inspection (conf recover) uses a sectioned output format showing snapshot refs, sync branches, and failed run details. Co-Authored-By: Claude Opus 4.6 --- cmd/push_dryrun_test.go | 59 +++++++++++++++++++++++++++++++++++++++ cmd/recover.go | 47 +++++++++++++++++++++++++------ cmd/recover_test.go | 62 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 9 deletions(-) diff --git a/cmd/push_dryrun_test.go b/cmd/push_dryrun_test.go index afc033e..32912a3 100644 --- a/cmd/push_dryrun_test.go +++ b/cmd/push_dryrun_test.go @@ -376,3 +376,62 @@ func TestRunPush_PreflightRejectsDryRunCombination(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestRunPush_PreflightShowsDestructiveDeleteProminent(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + + // Delete the tracked file to produce a PushChangeDelete entry. + if err := os.Remove(filepath.Join(spaceDir, "root.md")); err != nil { + t.Fatalf("remove root.md: %v", err) + } + runGitForTest(t, repo, "rm", filepath.Join("Engineering (ENG)", "root.md")) + runGitForTest(t, repo, "commit", "-m", "delete root page") + + previousPreflight := flagPushPreflight + flagPushPreflight = true + t.Cleanup(func() { flagPushPreflight = previousPreflight }) + + fake := newCmdFakePushRemote(1) + oldPushFactory := newPushRemote + oldPullFactory := newPullRemote + newPushRemote = func(_ *config.Config) (syncflow.PushRemote, error) { return fake, nil } + newPullRemote = func(_ *config.Config) (syncflow.PullRemote, error) { return fake, nil } + t.Cleanup(func() { + newPushRemote = oldPushFactory + newPullRemote = oldPullFactory + }) + + setupEnv(t) + chdirRepo(t, spaceDir) + + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + + if err := runPush(cmd, config.Target{Mode: config.TargetModeSpace, Value: ""}, OnConflictCancel, false); err != nil { + t.Fatalf("runPush() preflight unexpected error: %v", err) + } + + text := out.String() + + // Delete entries in the planned mutations section must be prominent. + if !strings.Contains(text, "Destructive: delete") { + t.Fatalf("preflight output missing prominent destructive delete marker:\n%s", text) + } + if !strings.Contains(text, "root.md") { + t.Fatalf("preflight output missing deleted file name:\n%s", text) + } + + // A dedicated destructive operations summary section must be present. + if !strings.Contains(text, "Destructive operations in this push:") { + t.Fatalf("preflight output missing Destructive operations section:\n%s", text) + } + + // safety confirmation notice must appear. + if !strings.Contains(text, "safety confirmation would be required") { + t.Fatalf("preflight output missing safety confirmation notice:\n%s", text) + } +} diff --git a/cmd/recover.go b/cmd/recover.go index 6543b58..1fb4393 100644 --- a/cmd/recover.go +++ b/cmd/recover.go @@ -258,26 +258,55 @@ func selectRecoveryRuns(runs []recoveryRun, selector string) []recoveryRun { } func renderRecoveryRuns(out io.Writer, runs []recoveryRun) { - _, _ = fmt.Fprintf(out, "Retained recovery runs: %d\n", len(runs)) + _, _ = fmt.Fprintln(out, "Recovery artifacts:") + + _, _ = fmt.Fprintln(out, "\nSnapshot refs:") + snapshotCount := 0 + for _, run := range runs { + if run.SnapshotRef != "" { + _, _ = fmt.Fprintf(out, " %s\n", run.SnapshotRef) + snapshotCount++ + } + } + if snapshotCount == 0 { + _, _ = fmt.Fprintln(out, " (none)") + } + + _, _ = fmt.Fprintln(out, "\nSync branches:") + branchCount := 0 for _, run := range runs { - _, _ = fmt.Fprintf(out, "- %s\n", run.SyncBranch) + if run.SyncBranch != "" { + _, _ = fmt.Fprintf(out, " %s\n", run.SyncBranch) + branchCount++ + } + } + if branchCount == 0 { + _, _ = fmt.Fprintln(out, " (none)") + } + + _, _ = fmt.Fprintln(out, "\nFailed runs:") + for _, run := range runs { + _, _ = fmt.Fprintf(out, " %s %s\n", run.SpaceKey, run.Timestamp) + if run.SyncBranch != "" { + _, _ = fmt.Fprintf(out, " Branch: %s\n", run.SyncBranch) + } if run.SnapshotRef != "" { - _, _ = fmt.Fprintf(out, " snapshot: %s\n", run.SnapshotRef) + _, _ = fmt.Fprintf(out, " Snapshot: %s\n", run.SnapshotRef) } if run.OriginalBranch != "" { - _, _ = fmt.Fprintf(out, " original branch: %s\n", run.OriginalBranch) + _, _ = fmt.Fprintf(out, " Original branch: %s\n", run.OriginalBranch) } if strings.TrimSpace(run.FailureReason) == "" { - _, _ = fmt.Fprintln(out, " failure: unavailable") + _, _ = fmt.Fprintln(out, " Failure: unavailable") } else { - _, _ = fmt.Fprintf(out, " failure: %s\n", run.FailureReason) + _, _ = fmt.Fprintf(out, " Failure: %s\n", run.FailureReason) } if run.CurrentBranch { - _, _ = fmt.Fprintln(out, " status: current HEAD is on this recovery branch") + _, _ = fmt.Fprintln(out, " Status: current HEAD is on this recovery branch") } else if run.WorktreeBlockReason != "" { - _, _ = fmt.Fprintf(out, " status: %s\n", run.WorktreeBlockReason) + _, _ = fmt.Fprintf(out, " Status: %s\n", run.WorktreeBlockReason) } else { - _, _ = fmt.Fprintln(out, " status: safe to discard") + _, _ = fmt.Fprintln(out, " Status: safe to discard") } } } diff --git a/cmd/recover_test.go b/cmd/recover_test.go index e4a33dd..1e59809 100644 --- a/cmd/recover_test.go +++ b/cmd/recover_test.go @@ -111,6 +111,68 @@ func TestRunRecover_SkipsCorruptRecoveryMetadataFiles(t *testing.T) { } } +func TestRunRecover_NoArtifactsReportsClean(t *testing.T) { + runParallelCommandTest(t) + + repo := t.TempDir() + spaceDir := preparePushRepoWithBaseline(t, repo) + chdirRepo(t, spaceDir) + + out, err := runRecoverForTest(t) + if err != nil { + t.Fatalf("recover on clean repo failed: %v\nOutput:\n%s", err, out) + } + + if !strings.Contains(out, "no retained failed push artifacts found") { + t.Fatalf("expected clean-state message, got:\n%s", out) + } +} + +func TestRunRecover_SectionedOutputFormat(t *testing.T) { + runParallelCommandTest(t) + + _, spaceDir, syncBranch, snapshotRef := createFailedPushRecoveryRun(t) + chdirRepo(t, spaceDir) + + out, err := runRecoverForTest(t) + if err != nil { + t.Fatalf("recover inspection failed: %v\nOutput:\n%s", err, out) + } + + // Output must use the sectioned format. + if !strings.Contains(out, "Recovery artifacts:") { + t.Fatalf("expected 'Recovery artifacts:' section header, got:\n%s", out) + } + if !strings.Contains(out, "Snapshot refs:") { + t.Fatalf("expected 'Snapshot refs:' section, got:\n%s", out) + } + if !strings.Contains(out, "Sync branches:") { + t.Fatalf("expected 'Sync branches:' section, got:\n%s", out) + } + if !strings.Contains(out, "Failed runs:") { + t.Fatalf("expected 'Failed runs:' section, got:\n%s", out) + } + + // Each section must include the actual artifact identifiers. + if !strings.Contains(out, snapshotRef) { + t.Fatalf("expected snapshot ref %q in output, got:\n%s", snapshotRef, out) + } + if !strings.Contains(out, syncBranch) { + t.Fatalf("expected sync branch %q in output, got:\n%s", syncBranch, out) + } + + // The Failed runs section must include structured detail lines. + if !strings.Contains(out, "Branch: "+syncBranch) { + t.Fatalf("expected 'Branch:' detail line in Failed runs section, got:\n%s", out) + } + if !strings.Contains(out, "Snapshot: "+snapshotRef) { + t.Fatalf("expected 'Snapshot:' detail line in Failed runs section, got:\n%s", out) + } + if !strings.Contains(out, "simulated update failure") { + t.Fatalf("expected failure reason in Failed runs section, got:\n%s", out) + } +} + func createFailedPushRecoveryRun(t *testing.T) (repo string, spaceDir string, syncBranch string, snapshotRef string) { t.Helper() From ea3a4f56e18bd1816236ee6c03f93423e8d176e6 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 8 Mar 2026 19:37:29 +0100 Subject: [PATCH 27/31] Add compatibility matrix, changelog, and beta notices to docs New docs/compatibility.md with feature/tenant compatibility matrix. New CHANGELOG.md with sync semantics change tracking discipline. Beta maturity notices added to usage.md and automation.md. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 26 ++++++++++---- CHANGELOG.md | 42 ++++++++++++++++++++++ README.md | 2 ++ docs/automation.md | 2 ++ docs/compatibility.md | 84 +++++++++++++++++++++++++++++++++++++++++++ docs/usage.md | 6 ++++ 6 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/compatibility.md diff --git a/AGENTS.md b/AGENTS.md index da60c94..b91c716 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,15 @@ This repository builds `conf` (`confluence-sync`), a Go CLI that syncs Confluence pages with local Markdown files. ## Source Of Truth -- Primary plan: `agents/plans/confluence_sync_cli.md` -- If implementation details are unclear, update the plan first, then implement. +- Canonical specs: + - `openspec/project.md` + - `openspec/specs/*/spec.md` +- Narrative summaries: + - `docs/specs/prd.md` + - `docs/specs/technical-spec.md` + - `docs/specs/README.md` +- Treat `agents/plans/*.md` as implementation history, delivery plans, and backlog notes rather than the live behavior contract. +- If behavior changes, update the canonical specs first, then implement. ## Intended Usages @@ -14,7 +21,7 @@ This project supports two primary sync workflows for agents: ### 1. Human-in-the-Loop (Agent as Writer) The agent focus on Markdown content; the human runs `conf` commands. - **Agent Task**: Edit `.md` files, run `conf validate` to check work. -- **Safety**: Do not touch `id`, `space`, or `version` in frontmatter. +- **Safety**: Do not touch sync-managed frontmatter keys such as `id`, `version`, `created_by`, `created_at`, `updated_by`, or `updated_at`. ### 2. Full Agentic Use (Autonomous Sync) The agent manages the full sync cycle. @@ -26,13 +33,18 @@ The agent manages the full sync cycle. - Immutable frontmatter keys: - `id` - - `space` - Mutable-by-sync frontmatter keys: - `version` + - `created_by` + - `created_at` + - `updated_by` + - `updated_at` - User-editable frontmatter keys: + - `title` - `state` (can be `draft` or `current`. Omitted means `current`. Cannot be set back to `draft` once published remotely). - `status` (Confluence "Content Status" visual lozenge, e.g., "Ready to review"). - `labels` (array of strings for Confluence page labels). +- Space identity is stored in `.confluence-state.json` and workspace context, not in frontmatter. - Remote deletions are hard-deleted locally during `pull` (recovery is via Git history). - `.confluence-state.json` is local state and must stay gitignored. @@ -81,7 +93,7 @@ The agent manages the full sync cycle. Validation failures must stop `push` immediately. ## Command Model -- Commands: `init`, `pull`, `push`, `status`, `clean`, `prune`, `validate`, `diff`, `relink`, `version`, `doctor`, `search`. +- Commands: `init`, `pull`, `push`, `recover`, `status`, `clean`, `prune`, `validate`, `diff`, `relink`, `version`, `doctor`, `search`. - `status` reports Markdown page drift only; attachment-only changes should be checked with `git status` or `conf diff`. - `[TARGET]` parsing rule: - Ends with `.md` => file mode. @@ -141,5 +153,5 @@ Validation failures must stop `push` immediately. - Round-trip Markdown <-> ADF golden tests. ## Docs Maintenance -- Keep `README.md` aligned with current plan and command behavior. -- Keep this file aligned with `agents/plans/confluence_sync_cli.md`. +- Keep `README.md`, `docs/usage.md`, `docs/automation.md`, and `docs/compatibility.md` aligned with the OpenSpec files. +- Keep this file aligned with `openspec/project.md` and `openspec/specs/*/spec.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..42ac88c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to `conf` are documented here. This changelog focuses on +user-visible sync semantics — hierarchy rules, attachment handling, validation +strictness, and cleanup/recovery behavior. + +## [Unreleased] + +### Added +- Push preflight (`--preflight`) now probes remote capabilities and reports + degraded modes (content-status, folder API) before execution. +- Push preflight shows exact planned page and attachment mutations. +- Generated AGENTS.md uses a single unified workflow instead of split + human-in-the-loop / autonomous modes. +- Recovery artifact inspection via `conf recover` command. +- No-op commands now explain why nothing changed. +- Destructive operation previews show exact pages/attachments targeted. +- Feature/tenant compatibility matrix in documentation (`docs/compatibility.md`). + +### Changed +- Generated AGENTS.md frontmatter guidance no longer lists `space` as an + immutable key (it was removed from frontmatter). +- README includes beta maturity notice. + +### Fixed +- (none yet) + +### Removed +- (none yet) + +## Sync Semantics Change Tracking + +Changes in the following categories are always noted explicitly: + +- **Hierarchy rules**: How pages are organized in directories, folder vs page + parent handling, path move detection. +- **Attachment handling**: Upload/delete/preserve logic, orphan asset cleanup, + asset path conventions. +- **Validation strictness**: What `validate` and `push` reject, Mermaid + warnings, frontmatter schema enforcement. +- **Cleanup/recovery semantics**: Sync branch lifecycle, snapshot ref + retention, recovery metadata, `clean` behavior. diff --git a/README.md b/README.md index d1d8316..0a2b5e0 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,9 @@ conf push ENG --on-conflict=cancel ## Docs 📚 - Usage and command reference: `docs/usage.md` +- Feature and tenant compatibility matrix: `docs/compatibility.md` - Automation, CI behavior, and live sandbox smoke-test runbook: `docs/automation.md` +- Changelog: `CHANGELOG.md` - Security policy: `SECURITY.md` - Support policy: `SUPPORT.md` - License: `LICENSE` diff --git a/docs/automation.md b/docs/automation.md index 9588e39..a2026af 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -2,6 +2,8 @@ This document explains how to run `conf` safely in scripts and CI pipelines. +> **Beta** — `conf` is under active development. Test automation workflows in a sandbox space before targeting production. + ## Automation Flags Supported on `pull` and `push`: diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..0c83247 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,84 @@ +# Feature and Tenant Compatibility Matrix + +This document describes which `conf` features are fully supported, which depend +on optional Confluence tenant APIs, and what degraded fallback behavior applies +when a dependency is unavailable. + +## Matrix + +| Feature | Support Level | Tenant Dependency | Degraded Fallback | +|---------|--------------|-------------------|-------------------| +| Page sync (pull/push) | Full | None | — | +| Page hierarchy (folders) | Full | Folder API | Page-based hierarchy when folder API returns any API error (`FOLDER_COMPATIBILITY_MODE` / `FOLDER_LOOKUP_UNAVAILABLE`) | +| Content status (lozenges) | Full | Content Status API | Status sync disabled when API returns 404/405/501 (`CONTENT_STATUS_COMPATIBILITY_MODE`) | +| Labels | Full | None | — | +| Attachments (images/files) | Full | None | — | +| PlantUML diagrams | Rendered round-trip | `plantumlcloud` macro | — | +| Mermaid diagrams | Preserved as code | None | Pushed as ADF `codeBlock`; `MERMAID_PRESERVED_AS_CODEBLOCK` warning emitted by `validate` and `push` | +| Same-space links | Full | None | — | +| Cross-space links | Full | Sibling space directories | Unresolved links produce conversion warnings (`preserved_external_link` / `degraded_reference` diagnostics) | +| Raw ADF extension | Best-effort | None | Low-level preservation only; not a verified round-trip guarantee | +| Unknown macros | Unsupported | App-specific | May fail on push if Confluence rejects the macro; sandbox validation recommended | +| Page archiving | Full | Archive API | — | +| Dry-run simulation | Full | Read-only API access | — | +| Preflight capability check | Full | Content Status API | Reports degraded modes before execution | + +## Compatibility Mode Details + +### Folder API (`FOLDER_COMPATIBILITY_MODE` / `FOLDER_LOOKUP_UNAVAILABLE`) + +`conf` uses the Confluence Folder API to resolve page hierarchy during `pull` +and `push`. If the tenant does not expose this API (any API-level error is +returned), `conf` automatically falls back to page-based hierarchy: + +- **Pull**: hierarchy is derived from page parent relationships only; folder + nodes are treated as regular parent pages. Emits `FOLDER_LOOKUP_UNAVAILABLE`. +- **Push**: folder creation is skipped; pages are nested under page parents + instead. Emits `FOLDER_COMPATIBILITY_MODE`. + +No configuration change is needed. The mode is detected automatically on the +first folder lookup attempt each run. + +### Content Status API (`CONTENT_STATUS_COMPATIBILITY_MODE`) + +`conf` syncs the Confluence "Content Status" visual lozenge (frontmatter key +`status`) through the Content Status API. If the tenant returns 404, 405, or +501 for the probe request, `conf` disables content-status sync for the current +run and emits `CONTENT_STATUS_COMPATIBILITY_MODE`. + +The page body and all other metadata continue to sync normally. Only the +`status` lozenge value is skipped. + +### Mermaid (`MERMAID_PRESERVED_AS_CODEBLOCK`) + +Mermaid diagrams are not rendered as Confluence diagram macros. `conf` keeps +Mermaid fenced code blocks in Markdown and writes them back as ADF `codeBlock` +nodes with `language: mermaid` on push. `conf validate` and `conf push` emit +`MERMAID_PRESERVED_AS_CODEBLOCK` so the downgrade is explicit before the write +happens. + +Use PlantUML (`plantumlcloud`) when a page must keep rendering as a first-class +Confluence diagram macro. + +### PlantUML (`plantumlcloud`) + +PlantUML is the only first-class rendered extension handler in `conf`. Pull and +diff convert the `plantumlcloud` Confluence macro into a managed +`adf-extension` wrapper with a `puml` code body. Validate and push reconstruct +the Confluence macro from the same wrapper. + +### Raw ADF Extension and Unknown Macros + +Extension nodes without a repo-specific handler can be preserved as raw +```` ```adf:extension ```` JSON fences. This is a low-level escape hatch and +not a verified end-to-end round-trip contract. Unknown macros may survive a +pull in raw ADF form but can still be rejected by Confluence on push if the +app is not installed or if the tenant rejects the payload. Always sandbox- +validate any workflow that relies on raw ADF preservation. + +## Preflight Capability Check + +Running `conf push --preflight` probes the remote tenant before any write and +reports which compatibility modes are active. This surfaces degraded behavior +(folder API, content status API) ahead of time so operators can decide whether +to proceed. diff --git a/docs/usage.md b/docs/usage.md index a12ffbc..3776613 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,6 +2,8 @@ This guide covers day-to-day usage of `conf`. +> **Beta** — `conf` is under active development. Core workflows are tested against live tenants, but edge cases remain. Pin a specific version for production use. + ## What `conf` does `conf` synchronizes Confluence pages with local Markdown files. @@ -264,6 +266,10 @@ Local state file: ## Extension and Macro Support +For a full breakdown of which features depend on optional tenant APIs and what +fallback behavior applies when those APIs are unavailable, see +[docs/compatibility.md](compatibility.md). + | Item | Support level | Markdown / ADF behavior | Notes | |------|---------------|-------------------------|-------| | PlantUML (`plantumlcloud`) | Rendered round-trip support | Pull/diff use the custom extension handler to turn the Confluence macro into a managed `adf-extension` wrapper with a `puml` code body; validate/push rebuild the same Confluence extension. | This is the only first-class extension handler registered by `conf`. | From a72125f4569b664a00333f0aed55ea16bb00c930 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 8 Mar 2026 20:29:15 +0100 Subject: [PATCH 28/31] Fix TestRunSearch_ListSpaces by using an on-disk path The test pre-seeded the index with path "DOCS/page.md" which doesn't exist on disk. IncrementalUpdate's removeDeletedPaths purged it as stale, leaving ListSpaces with nothing to return. Changed to "DOCS/overview.md", the file setupSearchTestRepo actually creates. Co-Authored-By: Claude Sonnet 4.6 --- ...2026-03-07-remaining-polish-and-backlog.md | 241 ++++++++++++++ cmd/search_test.go | 4 +- docs/specs/README.md | 17 + docs/specs/prd.md | 143 ++++++++ docs/specs/technical-spec.md | 310 ++++++++++++++++++ openspec/README.md | 17 + openspec/project.md | 65 ++++ openspec/specs/compatibility/spec.md | 76 +++++ openspec/specs/discovery/spec.md | 117 +++++++ openspec/specs/frontmatter-and-state/spec.md | 111 +++++++ openspec/specs/pull-and-validate/spec.md | 140 ++++++++ openspec/specs/push/spec.md | 150 +++++++++ .../specs/recovery-and-maintenance/spec.md | 97 ++++++ 13 files changed, 1486 insertions(+), 2 deletions(-) create mode 100644 agents/plans/2026-03-07-remaining-polish-and-backlog.md create mode 100644 docs/specs/README.md create mode 100644 docs/specs/prd.md create mode 100644 docs/specs/technical-spec.md create mode 100644 openspec/README.md create mode 100644 openspec/project.md create mode 100644 openspec/specs/compatibility/spec.md create mode 100644 openspec/specs/discovery/spec.md create mode 100644 openspec/specs/frontmatter-and-state/spec.md create mode 100644 openspec/specs/pull-and-validate/spec.md create mode 100644 openspec/specs/push/spec.md create mode 100644 openspec/specs/recovery-and-maintenance/spec.md diff --git a/agents/plans/2026-03-07-remaining-polish-and-backlog.md b/agents/plans/2026-03-07-remaining-polish-and-backlog.md new file mode 100644 index 0000000..93882ab --- /dev/null +++ b/agents/plans/2026-03-07-remaining-polish-and-backlog.md @@ -0,0 +1,241 @@ +# Remaining Polish & Backlog Plan + +## Objective + +Consolidate all unfinished work from the March 5 live-workflow polish follow-ups into a single actionable plan. Everything listed here is either a remaining P1 polish item or a P2 backlog item that was deferred during the remediation and polish passes. + +## Relationship To Completed Work + +- `2026-03-05-live-workflow-findings-remediation.md` — production blockers, fully landed on `codex/live-workflow-findings-remediation`. +- `2026-03-05-live-workflow-polish-followups.md` — 16 of 18 main items completed; items 16 and 17 remain. P2 backlog (items 19–28) was untouched. + +## Remaining P1 Items + +### 1. Strengthen Push Preflight And Release Messaging (was item 16) + +#### Problem + +Some failures are still surprising because capability mismatches or degraded behavior are only discovered during execution. Also, the current maturity level should be communicated more explicitly. + +#### Plan + +- Expand preflight so it can optionally report: + - remote capability concerns + - exact planned page and attachment mutations + - known degraded modes before write execution +- Review release docs, README language, and versioning guidance so the product is clearly labeled beta until blocker workstreams are done. +- Keep maturity messaging aligned with actual tested behavior. + +#### Validation + +- Add preflight coverage for degraded-mode reporting. +- Update release-facing docs to match the current maturity contract. + +### 2. Align Generated `AGENTS.md` With Actual Workflow And Documentation Strategy (was item 17) + +#### Problem + +The generated `AGENTS.md` scaffolding is no longer fully aligned with the current codebase, the live-tested behavior, or the desired documentation process: + +- it still splits usage into human-in-the-loop and autonomous modes, even though one general workflow is sufficient +- it still refers to `space` as a normal frontmatter key users must not edit, which is stale relative to the current frontmatter model +- technical templates overstate support for Mermaid and relative cross-space links +- it does not explain the intended direction that generated Specs/PRDs should become the working source of truth for feature behavior and product intent + +#### Plan + +- Update generated workspace and space-level `AGENTS.md` templates in: + - `cmd/init.go` + - `cmd/agents.go` +- Replace the split workflow sections with one general recommended workflow: + - `pull -> edit -> validate -> diff -> push` + - mention that humans may still review or approve specific steps, but do not model that as a separate mode +- Remove stale frontmatter guidance and align template language with the real model: + - `id` remains immutable + - `version` remains sync-managed + - `state`, `status`, and `labels` remain user-editable + - do not present `space` as a normal active frontmatter field +- Add a concise support-contract note or link to docs covering: + - same-space links + - cross-space links + - attachments + - PlantUML + - Mermaid + - hierarchy behavior +- Add explicit guidance that new Specs/PRDs should be generated and maintained as the intended source of truth. +- Ensure generated `AGENTS.md` points readers to the primary plan and any future Specs/PRDs when behavior or requirements are unclear. + +#### Validation + +- Add golden-style tests for generated `AGENTS.md` output. +- Verify generated templates do not mention the old split workflow model. +- Verify generated templates align with current frontmatter behavior and current documented support boundaries. + +--- + +## P2 Backlog + +### 3. Add Release Gating With Live Sandbox Smoke Tests (was item 19) + +#### Problem + +Current quality signals are still too synthetic to fully protect releases from workflow regressions that only appear against live Confluence tenants. + +#### Plan + +- Require an explicit sandbox live smoke-test check before promoting release candidates. +- Keep it gated to sandbox-configured spaces only. +- Separate release-blocking live checks from ordinary developer CI to avoid accidental production-space execution. + +### 4. Add Upgrade And Migration Coverage For Older Workspaces (was item 20) + +#### Problem + +Fixes to state files, hierarchy layout, and metadata handling may unintentionally break existing user workspaces created by older versions. + +#### Plan + +- Add migration fixtures for older `.confluence-state.json` and markdown layouts. +- Verify pull/push/status/doctor behavior stays safe after upgrade. +- Document any migration semantics if automatic normalization changes persisted files. + +### 5. Make `--dry-run` Closer To Real Execution (was item 21) + +#### Problem + +`--dry-run` is useful, but it should validate more of the real execution path so operators can trust it as a genuine preflight. + +#### Plan + +- Validate final payload shape, attachment mutation plan, and cleanup plan in dry-run mode. +- Show the exact remote operations that would occur, including page/archive/attachment changes. +- Preserve the guarantee that no local or remote state is mutated. + +### 6. Add Read-Only Inspection For Recovery Artifacts (was item 22) + +#### Problem + +Even before a full `recover` workflow exists, operators need an easy way to inspect failed-run artifacts without dropping into Git internals. + +#### Plan + +- Add a read-only inspection command or submode to list: + - retained `sync/*` branches + - snapshot refs + - failed run timestamps + - associated failure reasons when available + +### 7. Improve No-Op Explainability (was item 23) + +#### Problem + +No-op runs succeed quietly, but they often do not explain why nothing changed, which makes troubleshooting harder. + +#### Plan + +- Improve no-op output for `pull`, `push`, and `clean` so it states why the command was a no-op. +- Distinguish cases such as: + - no local changes + - no remote changes + - changes existed but were intentionally skipped + +### 8. Add Performance And Scale Tests (was item 24) + +#### Problem + +Live validation covered correctness, but not scale. Large spaces, pagination stress, and attachment-heavy pages may still expose bottlenecks or edge-case failures. + +#### Plan + +- Add scale-oriented tests for: + - larger page counts + - attachment-heavy pages + - long pagination chains + - rate-limit and retry pressure + +### 9. Strengthen Destructive Operation Previews (was item 25) + +#### Problem + +Archive/delete pushes should make the exact destructive target set obvious before execution. + +#### Plan + +- Expand preflight and confirmation flows to show exact pages and attachments that will be archived or deleted. +- Keep summaries concise, but make destructive targets explicit. + +### 10. Add A Feature/Tenant Compatibility Matrix (was item 26) + +#### Problem + +Operators need a clearer understanding of what behavior is guaranteed, what is best-effort, and what depends on tenant capability. + +#### Plan + +- Publish a compatibility matrix covering: + - core sync features + - macro/extension support + - tenant capability dependencies + - degraded fallback modes + +### 11. Add Changelog Discipline For Sync Semantics (was item 27) + +#### Problem + +Behavior changes in sync semantics are especially important to operators, but they are easy to lose in generic release notes. + +#### Plan + +- Track user-visible sync behavior changes explicitly in changelog or release-note guidance. +- Highlight changes to: + - hierarchy rules + - attachment handling + - validation strictness + - cleanup/recovery semantics + +### 12. Add Sanitized Golden Live Fixtures (was item 28) + +#### Problem + +Synthetic fixtures are not catching enough real-world edge cases from Confluence content. + +#### Plan + +- Build a sanitized fixture corpus from real pulled pages. +- Use it for round-trip, pull, push, and diff regression tests. +- Keep private or tenant-specific details removed while preserving structure that triggered bugs in the live run. + +--- + +## Suggested Order + +### Priority 1 — Complete remaining polish +1. Push preflight and release messaging (item 1) +2. Generated `AGENTS.md` alignment (item 2) + +### Priority 2 — Backlog, grouped by theme + +**Operator experience:** +3. No-op explainability (item 7) +4. Destructive operation previews (item 9) +5. Recovery artifact inspection (item 6) + +**Testing and quality gates:** +6. Dry-run fidelity (item 5) +7. Performance and scale tests (item 8) +8. Sanitized golden live fixtures (item 12) +9. Release gating with sandbox smoke tests (item 3) + +**Documentation and contracts:** +10. Feature/tenant compatibility matrix (item 10) +11. Changelog discipline (item 11) + +**Migration safety:** +12. Upgrade and migration coverage (item 4) + +## Success Criteria + +- Preflight makes degraded modes and risky capabilities visible before remote writes start. +- Release docs accurately reflect the current beta maturity contract. +- Generated `AGENTS.md` scaffolding reflects one general workflow, current product constraints, and the intended Specs/PRDs documentation direction. +- Backlog items are tracked and prioritized for post-beta execution. diff --git a/cmd/search_test.go b/cmd/search_test.go index 6968c46..718dfea 100644 --- a/cmd/search_test.go +++ b/cmd/search_test.go @@ -347,9 +347,9 @@ func TestRunSearch_ListSpaces(t *testing.T) { docs := []search.Document{ { - ID: "page:DOCS/page.md", + ID: "page:DOCS/overview.md", Type: search.DocTypePage, - Path: "DOCS/page.md", + Path: "DOCS/overview.md", SpaceKey: "DOCS", Content: "some content", }, diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 0000000..f72d4b0 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,17 @@ +# Specs Summary + +These documents summarize the canonical OpenSpec requirements for `conf`. + +The live behavior contract now lives under `openspec/`: + +- `openspec/project.md` +- `openspec/specs/*/spec.md` + +- [prd.md](/D:/Dev/confluence-markdown-sync/docs/specs/prd.md): product scope, users, workflows, and acceptance criteria. +- [technical-spec.md](/D:/Dev/confluence-markdown-sync/docs/specs/technical-spec.md): command contracts, data model, sync invariants, and Git/runtime behavior. + +How to use this folder: + +- Treat these docs as narrative summaries for humans who want a higher-level walkthrough. +- Update the OpenSpec files first when product behavior or invariants change. +- Keep operator-facing docs (`README.md`, `docs/usage.md`, `docs/automation.md`, `docs/compatibility.md`) aligned with the OpenSpec files. diff --git a/docs/specs/prd.md b/docs/specs/prd.md new file mode 100644 index 0000000..16e67eb --- /dev/null +++ b/docs/specs/prd.md @@ -0,0 +1,143 @@ +# Product Requirements Document + +This PRD is a narrative summary of the canonical OpenSpec requirements for `conf`. + +For the live behavior contract, see `openspec/project.md` and `openspec/specs/*/spec.md`. +For the narrative technical summary, see [technical-spec.md](/D:/Dev/confluence-markdown-sync/docs/specs/technical-spec.md). + +## Product Summary + +`conf` is a local-first Go CLI that synchronizes Confluence Cloud pages with a Git-managed Markdown workspace. It exists so humans and agents can author in Markdown, review changes locally, and publish back to Confluence without losing operational safety. + +## Problem + +Teams want the editing ergonomics, searchability, automation, and history of local Markdown and Git, but they still need Confluence as the published destination. Native Confluence editing does not provide the same local authoring, repo workflows, or agent-friendly interfaces. A sync layer is required, but it must be safe enough that a bad local edit or automation mistake does not silently corrupt remote content. + +## Primary Users + +- Technical writers and engineers maintaining documentation in Git. +- Product, project, and support teams authoring structured Markdown pages. +- AI agents that draft or refactor Markdown content. +- CI or scheduled automation that validates or syncs docs non-interactively. + +## Primary Workflows + +### 1. Human-in-the-Loop Authoring + +The agent or human edits Markdown locally, then a human runs: + +`pull -> edit -> validate -> diff -> push` + +This is the default safety model for production content. + +### 2. Autonomous Sync + +An agent or CI job performs the full workflow, including remote writes, using explicit non-interactive flags and conflict policy settings. + +### 3. Local Discovery And Review + +Users query and inspect the workspace without mutating Confluence: + +- `search` for full-text discovery +- `status` for page drift +- `diff` for local-vs-remote comparison +- `doctor` for workspace consistency + +### 4. Recovery And Maintenance + +Users recover from failed sync runs or local state drift with: + +- `recover` +- `clean` +- `doctor --repair` +- `prune` + +## Product Goals + +- Markdown is the primary authoring format. +- Confluence remains the publishing system of record. +- `push` never writes remotely without a successful `validate`. +- Local review is first-class via `diff`, `status`, Git history, and structured run reports. +- Failed push runs remain recoverable through retained refs, branches, worktrees, and metadata. +- The product supports both interactive human workflows and scripted automation. +- Search works entirely from the local workspace with no API calls during query execution. + +## Non-Goals + +- Replacing Confluence with a Git-only publishing workflow. +- Supporting every Confluence macro as a first-class authoring primitive. +- Requiring users to run Git recovery commands manually for normal sync failures. +- Treating raw ADF preservation as a guaranteed, user-friendly authoring format. + +## Product Requirements + +### Workspace And Setup + +- `conf init` must bootstrap a usable workspace, including Git initialization when needed, `.gitignore` updates, `.env` scaffolding, and template helper docs. +- A Git remote must not be required. +- Each synced space must have local state in `.confluence-state.json`, and that state must remain gitignored. + +### Content Model + +- Markdown files must carry sync metadata in frontmatter. +- `space` is not part of frontmatter. Space identity is derived from workspace context and per-space state. +- `id` is immutable after assignment. +- `version`, `created_by`, `created_at`, `updated_by`, and `updated_at` are sync-managed. +- `state`, `status`, and `labels` are user-editable. +- Unknown extra frontmatter keys should be preserved unless they collide with reserved sync keys. + +### Pull + +- `pull` must convert remote ADF to Markdown using best-effort resolution. +- Same-space page links must rewrite to relative Markdown links when local targets are known. +- Attachments must be downloaded to deterministic local asset paths. +- Remote deletions must remove tracked local Markdown and asset files. +- Non-fatal degradation must surface as diagnostics instead of silently disappearing. + +### Validate + +- `validate` must be strict and use the same reverse-conversion profile as `push`. +- Validation must catch frontmatter schema issues, immutable metadata edits, broken link/media resolution, and strict Markdown-to-ADF conversion failures. +- Mermaid content must trigger a warning before push because it is preserved as code, not rendered as a Confluence diagram macro. + +### Diff And Status + +- `diff` must show what would change between local Markdown and current remote content. +- `status` must report Markdown page drift only; attachment-only drift remains a `git status` / `diff` concern. + +### Push + +- `push` must always validate before any remote write. +- Push execution must be isolated from the user workspace through a temporary worktree and ephemeral sync branch. +- Push must operate against the full captured workspace state, including uncommitted in-scope changes. +- Conflict policy must be explicit and automatable. +- Successful non-no-op push runs must create audit tags and update the local baseline used for later status/diff/push calculations. +- Failed runs must retain enough state for `recover` to inspect and discard safely later. + +### Search + +- `search` must index local Markdown only. +- The default backend is SQLite FTS5, with Bleve as an alternative backend. +- Search indexing must happen automatically on `pull` when possible and incrementally during `search`. +- Search must support filters for space, labels, headings, creator/updater, and created/updated dates. + +### Recovery And Maintenance + +- `recover` must expose retained failed-push artifacts without requiring manual Git inspection. +- `clean` must remove stale sync artifacts that are safe to delete. +- `doctor` must detect state/file/index inconsistencies and offer repair for repairable cases. +- `prune` must delete orphaned local assets only after confirmation or `--yes`. + +### Automation + +- `pull` and `push` must support `--yes` and `--non-interactive`. +- Safety confirmation is required for destructive or large operations unless explicitly auto-approved. +- `push --dry-run` and `push --preflight` must provide safe pre-write inspection paths. +- Commands that emit structured reports must support machine-readable JSON. + +## Acceptance Criteria + +- A production-safe user can review and publish a single page or whole space without editing Git internals directly. +- A CI job can validate or push deterministically with explicit flags and no prompts. +- The source-of-truth metadata contract is unambiguous: `space` is not stored in frontmatter. +- Pull, validate, diff, push, search, and recovery behavior can be described from the current implementation without depending on historical plan docs. diff --git a/docs/specs/technical-spec.md b/docs/specs/technical-spec.md new file mode 100644 index 0000000..d66a95e --- /dev/null +++ b/docs/specs/technical-spec.md @@ -0,0 +1,310 @@ +# Technical Specification + +This document is a narrative technical summary of the canonical OpenSpec requirements for `conf`. + +For the live behavior contract, see `openspec/project.md` and `openspec/specs/*/spec.md`. +For product scope and user-facing goals, see [prd.md](/D:/Dev/confluence-markdown-sync/docs/specs/prd.md). + +## System Model + +`conf` is a Go CLI that synchronizes Confluence Cloud pages and attachments with a local Git workspace: + +- forward path: Confluence ADF -> Markdown (`pull`, `diff`) +- reverse path: Markdown -> ADF (`validate`, `push`) +- local discovery path: workspace indexing and query (`search`, `status`, `doctor`) + +Local Git is required. A Git remote is optional. + +## Workspace Model + +### Repository Root + +The workspace root is the Git repository root where `conf init` was run or where an existing Git repository is reused. + +### Space Directories + +Each managed Confluence space lives in its own directory. When `pull` creates a new space directory, it uses a sanitized `Name (KEY)` directory name unless the space was already resolved to an existing tracked directory. + +### Local State + +Each managed space stores local sync state in `.confluence-state.json`. + +State schema: + +| Key | Type | Meaning | +|---|---|---| +| `space_key` | string | Canonical Confluence space key for the local space directory | +| `last_pull_high_watermark` | RFC3339 string | High-watermark timestamp used for incremental pull planning | +| `page_path_index` | map[path]pageID | Tracked Markdown path -> Confluence page ID | +| `attachment_index` | map[path]attachmentID | Tracked local asset path -> Confluence attachment ID | +| `folder_path_index` | map[path]folderID | Tracked local folder path -> Confluence folder ID | + +Rules: + +- The state file is local-only and must remain gitignored. +- `space_key` lives in state, not in frontmatter. +- State paths are normalized to repo-style forward-slash relative paths. + +## Markdown Frontmatter Contract + +Reserved frontmatter keys: + +| Key | Ownership | Notes | +|---|---|---| +| `title` | user-authored | Preferred page title. Push falls back to first H1, then filename stem when absent. Pull writes the remote title here. | +| `id` | sync-owned, immutable after assignment | Empty is allowed for a new page before first push. | +| `version` | sync-owned | Must be `> 0` for existing pages with an `id`. | +| `state` | user-authored | `draft` or `current`. Omitted means `current`. Existing published pages cannot be set back to `draft`. | +| `status` | user-authored | Confluence content-status lozenge. | +| `labels` | user-authored | Normalized to lowercase, trimmed, deduplicated, and sorted. Labels containing whitespace are invalid. | +| `created_by` | sync-owned | Remote author metadata for search/reporting. | +| `created_at` | sync-owned | Remote creation timestamp. | +| `updated_by` | sync-owned | Remote last-updater metadata. | +| `updated_at` | sync-owned | Remote last-updated timestamp. | + +Additional rules: + +- `space` is not stored in frontmatter. +- Unknown extra keys are preserved unless they collide with reserved sync keys. +- Legacy keys such as `confluence_page_id`, `confluence_space_key`, `confluence_version`, `confluence_last_modified`, and `confluence_parent_page_id` may be parsed for compatibility but are not emitted in normalized output. + +## Target Resolution + +Commands that accept `[TARGET]` follow one parsing rule: + +- ends with `.md` -> file mode +- otherwise -> space mode + +When `[TARGET]` is omitted, space context is inferred from the current working directory. + +## Command Surface + +Top-level commands: + +- `init` +- `pull` +- `push` +- `recover` +- `status` +- `clean` +- `prune` +- `validate` +- `diff` +- `relink` +- `version` +- `doctor` +- `search` + +Subcommands: + +- `init agents` + +Structured run reports: + +- `pull`, `push`, `validate`, and `diff` support `--report-json`. + +## Conversion And Hook Contract + +### Forward Conversion + +- Used by `pull` and `diff`. +- Implementation: `github.com/rgonek/jira-adf-converter/converter`. +- Call shape: `ConvertWithContext(..., converter.ConvertOptions{SourcePath: ...})`. +- Resolution mode: best effort. +- Behavior on unresolved references: produce warnings/diagnostics and fallback Markdown output rather than fail the run. + +### Reverse Conversion + +- Used by `validate` and `push`. +- Implementation: `github.com/rgonek/jira-adf-converter/mdconverter`. +- Call shape: `ConvertWithContext(..., mdconverter.ConvertOptions{SourcePath: ...})`. +- Resolution mode: strict. +- Behavior on unresolved references: fail conversion before any remote write. + +### Hook Boundary + +- Hooks return mapping decisions only. +- Sync orchestration owns all network and filesystem side effects such as downloads, uploads, file writes, and deletes. + +## Extension And Macro Contract + +| Feature | Contract | +|---|---| +| PlantUML (`plantumlcloud`) | First-class rendered round-trip support via the custom handler | +| Mermaid | Preserved as fenced code / ADF `codeBlock`, not a rendered Confluence macro | +| Raw `adf:extension` fences | Best-effort preservation only | +| Unknown Confluence macros/extensions | Not a first-class supported authoring target | + +`validate` must warn for Mermaid content before push. + +## Pull Contract + +`pull` behavior: + +1. Resolve the target space or page and load config/state. +2. Fetch remote space metadata and normalize the space directory. +3. Estimate impact for confirmation, including delete count and total changed Markdown files. +4. Stash dirty in-scope workspace state unless `--discard-local` is used. +5. Build global link index and run `sync.Pull`. +6. In `sync.Pull`: + - load all relevant pages for the space + - probe tenant compatibility modes for folders and content status + - plan deterministic page paths and attachment paths + - identify changed pages using the last pull watermark plus overlap window, unless `--force` + - fetch changed pages, labels, content status, and attachments + - convert ADF to Markdown with best-effort hooks + - write Markdown files with normalized frontmatter + - delete tracked local files/assets removed remotely + - update state indexes and watermark +7. Save state, print diagnostics, create a scoped commit and `confluence-sync/pull//` tag when changes exist. +8. Restore stashed workspace content and repair pulled `version` fields if the stash reintroduced old values. +9. Update the local search index for the pulled space on a best-effort basis. +10. Optionally run targeted cross-space relinking with `pull --relink`. + +Pull-specific rules: + +- `--force` is valid only for space targets. +- `--skip-missing-assets` turns missing-attachment failures into diagnostics. +- Pull no-ops create no commit and no sync tag. +- Remote deletions are hard-deleted locally. + +## Validate Contract + +`validate` must: + +- build the space page index and global page index +- detect duplicate page IDs +- validate frontmatter schema +- validate immutable metadata against local state and push baseline +- resolve link/media references with the same strict hook profile used by `push` +- emit Mermaid downgrade warnings + +Validation failure must stop `push` immediately. + +## Push Contract + +`push` behavior: + +1. Resolve target scope, config, and current branch. +2. Determine the comparison baseline from the latest sync tag for the space; if no sync tag exists, fall back to the repository root commit. +3. Support three non-write inspection modes: + - normal pre-validation before real push + - `--preflight` for concise change/validation planning + - `--dry-run` for simulated remote operations without local Git mutation +4. For a real push: + - capture the current in-scope workspace state by stashing dirty changes when needed + - create a snapshot ref at `refs/confluence-sync/snapshots//` + - create a sync branch `sync//` + - create a temporary worktree under `.confluence-worktrees/` +5. In the worktree: + - materialize the snapshot + - compute in-scope changes against the baseline + - run strict validation on changed files + - require safety confirmation for destructive or large runs + - execute `sync.Push` +6. `sync.Push` must: + - resolve page, folder, and attachment identity maps + - handle remote version conflicts according to `pull-merge`, `force`, or `cancel` + - convert Markdown to ADF strictly + - upload missing assets + - update, create, archive, or delete remote content as required + - sync labels and content status where supported + - emit rollback diagnostics when a partial failure needs recovery work +7. Finalize Git state: + - create one commit per pushed page with Confluence trailers + - merge the sync branch back into the original branch + - tag successful non-no-op runs as `confluence-sync/push//` + - restore the user stash, warning if conflicts remain + - save updated local state +8. On failure: + - keep snapshot ref and sync branch + - record recovery metadata under `.git/confluence-recovery/` + - leave cleanup to `recover` or `clean` + +Push-specific rules: + +- `push` always validates before any remote write. +- `--preflight` and `--dry-run` are mutually exclusive. +- Space-wide pushes default to `pull-merge` if `--on-conflict` is omitted. +- Single-file pushes require an explicit conflict policy or an interactive choice. +- `--keep-orphan-assets` suppresses deletion of unreferenced remote attachments. +- Archive operations respect `--archive-task-timeout` and `--archive-task-poll-interval`. + +## Status, Diff, Relink, And Search + +### `status` + +- Reports Markdown page drift only. +- Uses the current workspace plus remote state. +- Surfaces tracked page path moves planned by the next pull. + +### `diff` + +- Fetches remote content. +- Converts with best-effort forward conversion. +- Shows local-vs-remote changes using `git diff --no-index`. +- Emits planned page-path move notes before the diff when hierarchy changes are involved. + +### `relink` + +- Rewrites absolute Confluence URLs in Markdown to relative local links when the target page is managed in the repository. +- Can operate globally or focus on a space/space directory target. + +### `search` + +- Stores the local index in `.confluence-search-index/`. +- Supports SQLite FTS5 (`sqlite`, default) and Bleve (`bleve`) backends. +- Reindexes fully with `--reindex` and otherwise updates incrementally. +- Indexes page, section, and code-block documents with path, title, labels, page ID, and author/timestamp metadata. + +## Recovery And Maintenance Commands + +### `recover` + +- Lists retained failed-push artifacts. +- Can discard a specific run or all safe retained runs. +- Must never discard the current recovery branch or a run that still has an active linked worktree. + +### `clean` + +- Removes stale worktrees, stale snapshot refs, safe stale sync branches, and normalizes readable state files. +- Alias: `repair`. + +### `doctor` + +- Checks consistency between `.confluence-state.json`, Markdown files, and Git state. +- Detects missing files, ID mismatches, duplicate/tracked-state issues, unresolved conflict markers, degraded placeholders, and hierarchy layout issues. +- `--repair` fixes repairable cases only. + +### `prune` + +- Deletes orphaned local assets under `assets/` after confirmation or `--yes`. + +## Safety And Automation Rules + +- `pull`, `push`, `prune`, and destructive `recover`/`clean` flows require confirmation unless auto-approved. +- `--yes` auto-approves safety prompts but does not choose a push conflict strategy. +- `--non-interactive` must fail fast when a required decision is missing. +- Safety confirmation is required when an operation affects more than 10 Markdown files or includes delete operations. + +## Git And Audit Model + +- Local Git history is the operational audit log. +- Successful non-no-op runs create annotated sync tags. +- Failed push runs retain recovery refs/branches and metadata for later inspection. +- Push commits include: + - `Confluence-Page-ID` + - `Confluence-Version` + - `Confluence-Space-Key` + - `Confluence-URL` + +## Compatibility And Fallback Modes + +Important degraded modes already implemented: + +- folder API unavailable -> page-based hierarchy fallback +- content-status API unavailable -> skip content-status sync for the run +- Mermaid -> preserved as code block +- unresolved best-effort references during pull/diff -> diagnostics plus fallback output + +See [docs/compatibility.md](/D:/Dev/confluence-markdown-sync/docs/compatibility.md) for the operator-facing matrix. diff --git a/openspec/README.md b/openspec/README.md new file mode 100644 index 0000000..a7ae455 --- /dev/null +++ b/openspec/README.md @@ -0,0 +1,17 @@ +# OpenSpec Index + +This directory contains the canonical source-of-truth requirements for `conf`. + +Project context: + +- [project.md](/D:/Dev/confluence-markdown-sync/openspec/project.md) + +Capability specs: + +- [workspace](/D:/Dev/confluence-markdown-sync/openspec/specs/workspace/spec.md) +- [frontmatter-and-state](/D:/Dev/confluence-markdown-sync/openspec/specs/frontmatter-and-state/spec.md) +- [pull-and-validate](/D:/Dev/confluence-markdown-sync/openspec/specs/pull-and-validate/spec.md) +- [push](/D:/Dev/confluence-markdown-sync/openspec/specs/push/spec.md) +- [discovery](/D:/Dev/confluence-markdown-sync/openspec/specs/discovery/spec.md) +- [recovery-and-maintenance](/D:/Dev/confluence-markdown-sync/openspec/specs/recovery-and-maintenance/spec.md) +- [compatibility](/D:/Dev/confluence-markdown-sync/openspec/specs/compatibility/spec.md) diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 0000000..668f33f --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,65 @@ +# Project Context + +## Summary + +`conf` is a Go CLI for synchronizing Confluence Cloud pages with a local Git-managed Markdown workspace. + +The project is local-first: + +- humans and agents author Markdown locally +- Confluence remains the publishing destination +- local Git provides history, review, and recovery + +## Canonical Spec Layout + +The canonical behavior contract lives in `openspec/specs/*/spec.md`. + +Narrative docs such as `README.md`, `docs/usage.md`, `docs/automation.md`, `docs/compatibility.md`, and `docs/specs/*` are secondary summaries and operator guides. They must stay aligned with the OpenSpec files. + +Implementation plans under `agents/plans/*.md` are historical delivery plans and backlog notes, not the live product contract. + +## Domain Terms + +- Workspace root: the local Git repository root used by `conf` +- Space directory: the directory that stores one managed Confluence space +- State file: `.confluence-state.json` in a space directory +- Baseline: the latest successful sync tag used to compare later local changes +- Snapshot ref: a retained hidden Git ref used during failed push recovery +- Sync branch: the ephemeral `sync//` branch used during push + +## Technical Constraints + +- Local Git is required. +- A Git remote is optional. +- Forward conversion uses the Jira ADF converter in best-effort mode. +- Reverse conversion uses the Markdown-to-ADF converter in strict mode. +- `push` MUST validate before any remote write. + +## Metadata Rules + +- Space identity is derived from workspace context and `.confluence-state.json`, not frontmatter. +- `id` is the only immutable frontmatter key after assignment. +- `version`, `created_by`, `created_at`, `updated_by`, and `updated_at` are sync-managed. +- `state`, `status`, `labels`, and `title` are user-authored. + +## Command Surface + +Top-level commands: + +- `init` +- `pull` +- `push` +- `recover` +- `status` +- `clean` +- `prune` +- `validate` +- `diff` +- `relink` +- `version` +- `doctor` +- `search` + +Subcommand: + +- `init agents` diff --git a/openspec/specs/compatibility/spec.md b/openspec/specs/compatibility/spec.md new file mode 100644 index 0000000..859dbd0 --- /dev/null +++ b/openspec/specs/compatibility/spec.md @@ -0,0 +1,76 @@ +# Compatibility Specification + +## Purpose + +Define the supported capability matrix and degraded fallback behavior when Confluence tenant features or content types are only partially supported. + +## Requirements + +### Requirement: Folder API fallback + +The system SHALL degrade safely when the Confluence Folder API is unavailable. + +#### Scenario: Pull falls back to page-based hierarchy + +- GIVEN a tenant does not support the folder lookup API required for hierarchy resolution +- WHEN `pull` or `diff` resolves page paths +- THEN the system SHALL continue with page-based hierarchy fallback +- AND the system SHALL emit compatibility diagnostics + +#### Scenario: Push skips folder-specific behavior when unsupported + +- GIVEN a tenant does not support folder operations needed for hierarchy writes +- WHEN `push` resolves remote hierarchy +- THEN the system SHALL fall back to page-based hierarchy behavior +- AND the system SHALL emit compatibility diagnostics + +### Requirement: Content status API fallback + +The system SHALL keep syncing page content even when the tenant does not support content-status operations. + +#### Scenario: Content-status sync is disabled for unsupported tenants + +- GIVEN a tenant returns compatibility probe errors for content-status operations +- WHEN `pull` or `push` handles content-status metadata +- THEN the system SHALL disable content-status syncing for that run +- AND the system SHALL continue syncing page content and labels + +### Requirement: PlantUML first-class support + +The system SHALL treat PlantUML as the only first-class rendered extension handler currently implemented. + +#### Scenario: PlantUML round-trips as a managed extension + +- GIVEN page content contains a supported `plantumlcloud` extension +- WHEN `pull`, `diff`, `validate`, or `push` process that content +- THEN the system SHALL round-trip it through the managed PlantUML handler + +### Requirement: Mermaid preserved as code + +The system SHALL preserve Mermaid content without claiming rendered Confluence macro support. + +#### Scenario: Mermaid fence warns before push + +- GIVEN a Markdown document contains a Mermaid fenced code block +- WHEN `validate` or `push` process the document +- THEN the system SHALL warn that the content will be preserved as a Confluence code block rather than a rendered Mermaid macro + +### Requirement: Raw ADF preservation is best-effort only + +The system SHALL treat raw `adf:extension` preservation as a low-level escape hatch rather than a guaranteed authoring contract. + +#### Scenario: Unhandled extension node is preserved best-effort + +- GIVEN pulled content contains an extension node without a repo-specific handler +- WHEN forward conversion preserves it as raw `adf:extension` content +- THEN the system SHALL treat that path as best-effort preservation only + +### Requirement: Unknown macros are not first-class authoring targets + +The system SHALL not promise round-trip support for unknown Confluence macros or app-specific extensions. + +#### Scenario: Unknown macro may still fail on push + +- GIVEN content depends on an unknown or app-specific Confluence macro +- WHEN the user pushes the content +- THEN the system SHALL not guarantee success even if the content survived a prior pull in raw ADF form diff --git a/openspec/specs/discovery/spec.md b/openspec/specs/discovery/spec.md new file mode 100644 index 0000000..f52b543 --- /dev/null +++ b/openspec/specs/discovery/spec.md @@ -0,0 +1,117 @@ +# Discovery Specification + +## Purpose + +Define the non-mutating discovery and inspection capabilities of `conf`: `status`, `diff`, `relink`, and `search`. + +## Requirements + +### Requirement: Status reports page drift only + +The system SHALL make `status` a high-level Markdown page drift view rather than an attachment inventory. + +#### Scenario: Attachment-only changes are excluded from status + +- GIVEN the workspace contains only attachment drift and no Markdown page drift +- WHEN the user runs `conf status` +- THEN the system SHALL not report those attachment-only changes as page drift +- AND the user SHALL need `git status` or `conf diff` for asset inspection + +### Requirement: Diff compares local Markdown to current remote content + +The system SHALL provide a best-effort remote comparison without mutating local or remote state. + +#### Scenario: Diff fetches and converts remote content + +- GIVEN the user runs `conf diff` +- WHEN the command builds the comparison +- THEN the system SHALL fetch the relevant remote content +- AND the system SHALL convert it with best-effort forward conversion +- AND the system SHALL compare it with local Markdown using `git diff --no-index` + +#### Scenario: Planned page moves are surfaced before content diff + +- GIVEN pull planning would move one or more tracked Markdown paths +- WHEN `conf diff` renders the comparison +- THEN the system SHALL report those planned path moves explicitly + +### Requirement: Relink rewrites absolute Confluence URLs to local paths + +The system SHALL rewrite local Markdown links when the target page is managed locally. + +#### Scenario: Managed Confluence link becomes relative Markdown link + +- GIVEN a Markdown file contains an absolute Confluence URL that points to a page managed in the same repository +- WHEN `conf relink` runs +- THEN the system SHALL rewrite the URL to a relative Markdown path + +#### Scenario: Unresolvable link is left unchanged + +- GIVEN a Markdown file contains an absolute Confluence URL that cannot be mapped to a managed local page +- WHEN `conf relink` runs +- THEN the system SHALL leave the link unchanged + +### Requirement: Search is local-only + +The system SHALL provide full-text search over the local workspace without making Confluence API calls at query time. + +#### Scenario: Search runs against the local index + +- GIVEN the local search index exists or can be built +- WHEN the user runs `conf search` +- THEN the system SHALL answer the query from the local index only + +### Requirement: Search backends and index storage + +The system SHALL support multiple local index backends behind a shared contract. + +#### Scenario: SQLite is the default backend + +- GIVEN the user does not pass `--engine` +- WHEN `conf search` opens the index +- THEN the system SHALL use the SQLite FTS5 backend by default + +#### Scenario: Index storage is local-only + +- GIVEN search indexing is enabled for the workspace +- WHEN the index is created or updated +- THEN the system SHALL store it under `.confluence-search-index/` + +### Requirement: Search indexing lifecycle + +The system SHALL keep the search index reasonably current without making pull fail if indexing fails. + +#### Scenario: Pull updates search index best-effort + +- GIVEN a pull run completes successfully with scoped changes +- WHEN post-pull indexing runs +- THEN the system SHALL attempt to refresh the affected space in the search index +- AND pull SHALL continue even if search indexing fails + +#### Scenario: Search updates incrementally by default + +- GIVEN a prior search index exists +- WHEN the user runs `conf search` without `--reindex` +- THEN the system SHALL perform an incremental update before searching + +#### Scenario: Reindex forces a full rebuild + +- GIVEN the user passes `--reindex` +- WHEN `conf search` runs +- THEN the system SHALL rebuild the index from all discovered managed spaces + +### Requirement: Search filters and output + +The system SHALL support structured querying and automation-friendly output. + +#### Scenario: Search supports metadata filters + +- GIVEN the user passes `--space`, `--label`, `--heading`, `--created-by`, `--updated-by`, or date-window filters +- WHEN `conf search` executes +- THEN the system SHALL apply those filters to the indexed documents + +#### Scenario: Piped output defaults to JSON + +- GIVEN `conf search` output is not a TTY +- WHEN the user leaves `--format` as `auto` +- THEN the system SHALL emit JSON output diff --git a/openspec/specs/frontmatter-and-state/spec.md b/openspec/specs/frontmatter-and-state/spec.md new file mode 100644 index 0000000..d924df3 --- /dev/null +++ b/openspec/specs/frontmatter-and-state/spec.md @@ -0,0 +1,111 @@ +# Frontmatter And State Specification + +## Purpose + +Define the Markdown metadata contract and per-space state model used by sync, validation, search, and recovery. + +## Requirements + +### Requirement: Reserved frontmatter schema + +The system SHALL recognize and normalize the reserved frontmatter keys used by `conf`. + +#### Scenario: Existing page frontmatter includes sync metadata + +- GIVEN a Markdown file represents an existing remote page +- WHEN `conf` parses its frontmatter +- THEN the system SHALL recognize `title`, `id`, `version`, `state`, `status`, `labels`, `created_by`, `created_at`, `updated_by`, and `updated_at` + +#### Scenario: New page frontmatter omits remote identity + +- GIVEN a Markdown file represents a new page that has not been pushed +- WHEN `conf` parses its frontmatter +- THEN the system SHALL allow `id` to be empty +- AND the system SHALL not require `version` until a remote page identity exists + +### Requirement: Space identity is not frontmatter + +The system MUST derive space identity from workspace context and local state rather than from Markdown frontmatter. + +#### Scenario: Markdown file has no `space` key + +- GIVEN a managed Markdown file in a tracked space directory +- WHEN `conf` validates or pushes the file +- THEN the system SHALL resolve the space from directory and state context +- AND the system SHALL not require a `space` frontmatter field + +#### Scenario: Legacy `confluence_space_key` metadata is present + +- GIVEN a Markdown file contains legacy `confluence_space_key` metadata +- WHEN `conf` rewrites normalized frontmatter +- THEN the system SHALL not emit `space` or `confluence_space_key` as part of the canonical schema + +### Requirement: Immutable and sync-managed metadata + +The system SHALL enforce ownership rules for frontmatter fields. + +#### Scenario: Immutable page ID edit is rejected + +- GIVEN a file already tracks a page ID +- WHEN the local `id` differs from the tracked or baseline ID +- THEN `validate` SHALL fail + +#### Scenario: Published page cannot be set back to draft + +- GIVEN a tracked page previously synced as `current` +- WHEN a user changes `state` to `draft` +- THEN `validate` SHALL fail + +#### Scenario: Sync-managed timestamps are preserved by sync + +- GIVEN pull or push updates a tracked page +- WHEN frontmatter is rewritten +- THEN the system SHALL control `version`, `created_by`, `created_at`, `updated_by`, and `updated_at` + +### Requirement: Label normalization + +The system SHALL normalize labels deterministically. + +#### Scenario: Labels are trimmed and normalized + +- GIVEN frontmatter labels contain mixed case, duplicates, and surrounding whitespace +- WHEN the document is normalized for sync +- THEN the system SHALL lowercase, trim, deduplicate, and sort the labels + +#### Scenario: Labels containing whitespace are invalid + +- GIVEN a frontmatter label contains internal whitespace +- WHEN `validate` checks the schema +- THEN the system SHALL report a validation error + +### Requirement: Unknown frontmatter preservation + +The system SHALL preserve non-reserved frontmatter keys. + +#### Scenario: Custom metadata survives normalization + +- GIVEN a Markdown document contains custom frontmatter keys that do not collide with reserved sync keys +- WHEN `conf` reads and rewrites the document +- THEN the system SHALL preserve those custom keys + +### Requirement: Per-space state file + +The system SHALL keep local sync state in `.confluence-state.json` inside each managed space directory. + +#### Scenario: State file tracks core indexes + +- GIVEN a managed space directory +- WHEN `conf` loads or saves state +- THEN the state file SHALL support `space_key`, `last_pull_high_watermark`, `page_path_index`, `attachment_index`, and `folder_path_index` + +#### Scenario: Missing state initializes cleanly + +- GIVEN a managed space directory without `.confluence-state.json` +- WHEN a command loads state +- THEN the system SHALL treat the state as empty and initialized + +#### Scenario: State file remains local-only + +- GIVEN `conf init` or later workspace maintenance runs +- WHEN ignore rules are ensured +- THEN the system SHALL keep `.confluence-state.json` gitignored diff --git a/openspec/specs/pull-and-validate/spec.md b/openspec/specs/pull-and-validate/spec.md new file mode 100644 index 0000000..4a65858 --- /dev/null +++ b/openspec/specs/pull-and-validate/spec.md @@ -0,0 +1,140 @@ +# Pull And Validate Specification + +## Purpose + +Define the read-path sync contract: pulling remote content into Markdown and validating local content before any remote write. + +## Requirements + +### Requirement: Incremental pull planning + +The system SHALL use the per-space watermark to plan incremental pulls, with a bounded overlap window for safety. + +#### Scenario: Pull uses the stored watermark + +- GIVEN `.confluence-state.json` contains `last_pull_high_watermark` +- WHEN the user runs `conf pull` +- THEN the system SHALL use that timestamp with an overlap window to identify potentially changed remote content + +#### Scenario: Force pull bypasses incremental optimization + +- GIVEN the user runs `conf pull --force` +- WHEN pull planning begins +- THEN the system SHALL refresh the full tracked space rather than relying on incremental change detection + +### Requirement: Best-effort forward conversion + +The system SHALL convert Confluence ADF to Markdown in best-effort mode for `pull` and `diff`. + +#### Scenario: Unresolved same-space reference degrades instead of failing pull + +- GIVEN a pulled page contains an unresolved reference during forward conversion +- WHEN `pull` or `diff` converts the page +- THEN the system SHALL preserve fallback output +- AND the system SHALL emit diagnostics instead of failing the whole run + +### Requirement: Hierarchy-preserving page layout + +The system SHALL map Confluence hierarchy into deterministic Markdown paths. + +#### Scenario: Parent pages with children own a directory + +- GIVEN a page has child pages +- WHEN pull plans local Markdown paths +- THEN the page SHALL be written as `/.md` + +#### Scenario: Missing or cyclic ancestry falls back safely + +- GIVEN a page's parent or folder ancestry cannot be resolved cleanly +- WHEN pull plans local paths +- THEN the system SHALL continue with a safe fallback path +- AND the system SHALL emit diagnostics describing the degraded hierarchy resolution + +### Requirement: Link and attachment rewrite on pull + +The system SHALL rewrite same-space references to local Markdown and asset paths whenever the local targets are known. + +#### Scenario: Same-space page link becomes relative Markdown link + +- GIVEN a Confluence page link points to another managed page in the same workspace +- WHEN pull converts the source page +- THEN the system SHALL rewrite the link to a relative Markdown path + +#### Scenario: Attachment becomes local asset reference + +- GIVEN a Confluence attachment is referenced from pulled content +- WHEN pull downloads the attachment +- THEN the system SHALL store it under `assets//-` +- AND the converted Markdown SHALL point to the local relative asset path + +### Requirement: Delete reconciliation + +The system SHALL hard-delete tracked local files and assets removed remotely. + +#### Scenario: Removed remote page is deleted locally + +- GIVEN `page_path_index` tracks a page that no longer exists remotely +- WHEN pull reconciles tracked content +- THEN the system SHALL delete the corresponding local Markdown file + +#### Scenario: Removed remote attachment is deleted locally + +- GIVEN `attachment_index` tracks an attachment that is no longer referenced or no longer exists remotely +- WHEN pull reconciles tracked content +- THEN the system SHALL delete the corresponding local asset file + +### Requirement: Pull workspace protection + +The system SHALL protect dirty local workspace state while applying pull results. + +#### Scenario: Dirty scope is stashed before pull + +- GIVEN the target space scope has local changes +- WHEN `conf pull` begins +- THEN the system SHALL stash in-scope changes before mutating pulled files unless `--discard-local` is set + +#### Scenario: Successful pull restores stashed workspace state + +- GIVEN pull previously stashed local changes +- WHEN the pull completes successfully +- THEN the system SHALL reapply the stashed state +- AND the system SHALL repair pulled `version` metadata if the stash reintroduced an older value + +### Requirement: Pull commit and tagging + +The system SHALL create audit artifacts only for non-no-op pull runs. + +#### Scenario: Pull with changes creates commit and tag + +- GIVEN pull changes scoped Markdown or tracked assets +- WHEN the run finalizes +- THEN the system SHALL create a scoped commit +- AND the system SHALL create an annotated tag named `confluence-sync/pull//` + +#### Scenario: No-op pull creates no audit tag + +- GIVEN pull produces no scoped changes +- WHEN the run finalizes +- THEN the system SHALL not create a pull commit or sync tag + +### Requirement: Strict validation before push + +The system SHALL validate local Markdown with the same strict reverse-conversion profile used by push. + +#### Scenario: Strict conversion failure blocks validation + +- GIVEN a Markdown file contains an unresolved strict link or media reference +- WHEN `conf validate` runs +- THEN the system SHALL fail validation + +#### Scenario: Duplicate page IDs block validation + +- GIVEN two Markdown files in the same validation scope declare the same `id` +- WHEN `conf validate` builds the page index +- THEN the system SHALL fail validation + +#### Scenario: Mermaid content produces warning, not failure + +- GIVEN a Markdown file contains a Mermaid fenced code block +- WHEN `conf validate` runs +- THEN the system SHALL emit a warning indicating the content will be preserved as a code block on push diff --git a/openspec/specs/push/spec.md b/openspec/specs/push/spec.md new file mode 100644 index 0000000..b0d37f1 --- /dev/null +++ b/openspec/specs/push/spec.md @@ -0,0 +1,150 @@ +# Push Specification + +## Purpose + +Define the write-path sync contract for publishing local Markdown changes back to Confluence safely. + +## Requirements + +### Requirement: Push is always gated by validation + +The system MUST complete validation successfully before any remote write during a real push. + +#### Scenario: Validation failure aborts push + +- GIVEN one or more changed files fail strict validation +- WHEN the user runs `conf push` +- THEN the system SHALL stop before any remote write occurs + +### Requirement: Baseline-based change detection + +The system SHALL compare local changes against the latest successful sync baseline for the space. + +#### Scenario: Latest sync tag defines the baseline + +- GIVEN the repository contains pull or push sync tags for a space +- WHEN push computes in-scope changes +- THEN the system SHALL use the latest timestamped sync tag for that space as the baseline + +#### Scenario: No sync tags fall back to root commit + +- GIVEN the repository has no prior sync tag for the space +- WHEN push computes its baseline +- THEN the system SHALL fall back to the repository root commit + +### Requirement: Isolated push execution + +The system SHALL isolate real push execution from the active user workspace. + +#### Scenario: Real push creates snapshot ref, sync branch, and worktree + +- GIVEN `conf push` detects in-scope changes +- WHEN a real push begins +- THEN the system SHALL create a snapshot ref at `refs/confluence-sync/snapshots//` +- AND the system SHALL create a sync branch `sync//` +- AND the system SHALL create a temporary worktree for the sync run + +#### Scenario: No-op push creates no recovery artifacts + +- GIVEN push detects no in-scope Markdown changes +- WHEN the command exits +- THEN the system SHALL not create a snapshot ref, sync branch, merge commit, or sync tag + +### Requirement: Conflict policy control + +The system SHALL make remote-ahead conflict handling explicit. + +#### Scenario: Space push defaults to pull-merge + +- GIVEN the user runs a space-scoped push without `--on-conflict` +- WHEN push resolves the conflict policy +- THEN the system SHALL default to `pull-merge` + +#### Scenario: File push requires an explicit policy or prompt + +- GIVEN the user runs a single-file push +- WHEN `--on-conflict` is not supplied +- THEN the system SHALL require an interactive choice or fail in non-interactive mode + +#### Scenario: Pull-merge conflict policy stops for review after pull + +- GIVEN push detects a remote-ahead conflict +- AND the policy is `pull-merge` +- WHEN push handles the conflict +- THEN the system SHALL run pull for the target scope +- AND the system SHALL stop so the user can review and rerun push + +### Requirement: Strict remote publishing + +The system SHALL publish Markdown to Confluence using strict conversion and explicit attachment/link resolution. + +#### Scenario: Push creates or updates remote content + +- GIVEN a changed Markdown file validates successfully +- WHEN push processes the file +- THEN the system SHALL resolve page identity, links, and attachments +- AND the system SHALL create, update, archive, or delete remote content as required + +#### Scenario: Orphan attachment deletion can be suppressed + +- GIVEN a push would otherwise delete unreferenced remote attachments +- WHEN the user passes `--keep-orphan-assets` +- THEN the system SHALL keep those orphaned attachments + +### Requirement: Preflight and dry-run inspection + +The system SHALL provide safe non-write inspection modes for push. + +#### Scenario: Preflight returns a push plan without writes + +- GIVEN the user runs `conf push --preflight` +- WHEN the command evaluates the target scope +- THEN the system SHALL show the planned changes and validation outcome +- AND the system SHALL not modify remote content or local Git state + +#### Scenario: Dry-run simulates remote work without mutation + +- GIVEN the user runs `conf push --dry-run` +- WHEN the command simulates the push +- THEN the system SHALL evaluate conversion and planned remote actions +- AND the system SHALL not modify remote content or local Git state + +#### Scenario: Preflight and dry-run cannot be combined + +- GIVEN the user passes both `--preflight` and `--dry-run` +- WHEN the command validates flags +- THEN the system SHALL fail + +### Requirement: Per-page Git audit trail + +The system SHALL preserve per-page audit detail within each successful push run. + +#### Scenario: Successful push creates per-page commits with trailers + +- GIVEN push successfully syncs one or more pages +- WHEN the worktree finalizes commits +- THEN the system SHALL create one commit per pushed page +- AND each commit SHALL include `Confluence-Page-ID`, `Confluence-Version`, `Confluence-Space-Key`, and `Confluence-URL` trailers + +#### Scenario: Successful non-no-op push creates sync tag + +- GIVEN push successfully merges the sync branch +- WHEN the run finalizes +- THEN the system SHALL create an annotated tag named `confluence-sync/push//` + +### Requirement: Failure retention and recovery metadata + +The system SHALL retain enough information to inspect and clean up failed push runs later. + +#### Scenario: Failed push retains recovery artifacts + +- GIVEN a real push fails after snapshot creation +- WHEN the command exits +- THEN the system SHALL retain the snapshot ref and sync branch +- AND the system SHALL record recovery metadata under `.git/confluence-recovery/` + +#### Scenario: Successful push cleans recovery artifacts + +- GIVEN a real push completes successfully +- WHEN the command exits +- THEN the system SHALL delete temporary recovery metadata and cleanup the worktree diff --git a/openspec/specs/recovery-and-maintenance/spec.md b/openspec/specs/recovery-and-maintenance/spec.md new file mode 100644 index 0000000..fb0986e --- /dev/null +++ b/openspec/specs/recovery-and-maintenance/spec.md @@ -0,0 +1,97 @@ +# Recovery And Maintenance Specification + +## Purpose + +Define the operational commands used to inspect, repair, and clean the local sync workspace after failures or drift. + +## Requirements + +### Requirement: Recover lists retained failed-push artifacts + +The system SHALL let operators inspect retained push recovery artifacts without mutating them by default. + +#### Scenario: Recover lists snapshot refs and sync branches + +- GIVEN failed push artifacts exist in the repository +- WHEN the user runs `conf recover` +- THEN the system SHALL list retained sync branches, snapshot refs, and any recorded failure metadata + +### Requirement: Recover only discards safe artifacts + +The system SHALL prevent accidental deletion of active recovery state. + +#### Scenario: Current recovery branch is protected + +- GIVEN the current `HEAD` is on a retained sync branch +- WHEN the user tries to discard that recovery run +- THEN the system SHALL refuse to discard it + +#### Scenario: Linked worktree blocks discard + +- GIVEN a retained recovery run still has an active linked worktree +- WHEN the user tries to discard that run +- THEN the system SHALL retain it and explain why it is blocked + +### Requirement: Clean removes stale managed artifacts + +The system SHALL remove abandoned managed sync artifacts that are safe to delete. + +#### Scenario: Clean removes stale snapshot refs and worktrees + +- GIVEN the repository contains stale managed worktrees or stale snapshot refs +- WHEN the user runs `conf clean` +- THEN the system SHALL remove the stale artifacts that are safe to clean + +### Requirement: Doctor detects local consistency issues + +The system SHALL inspect the relationship between `.confluence-state.json`, Markdown files, and Git workspace state. + +#### Scenario: Doctor reports missing tracked file + +- GIVEN `page_path_index` tracks a page whose Markdown file no longer exists +- WHEN the user runs `conf doctor` +- THEN the system SHALL report a missing-file issue + +#### Scenario: Doctor reports unresolved conflict markers + +- GIVEN a tracked Markdown file contains Git conflict markers +- WHEN the user runs `conf doctor` +- THEN the system SHALL report a conflict-marker issue + +#### Scenario: Doctor reports hierarchy layout problem + +- GIVEN a parent page has nested child Markdown under a directory layout that violates the page-with-children rule +- WHEN the user runs `conf doctor` +- THEN the system SHALL report a hierarchy layout issue + +### Requirement: Doctor repairs repairable issues only + +The system SHALL support conservative automatic repair for issues that can be fixed safely. + +#### Scenario: Doctor repairs stale state entry + +- GIVEN `doctor` finds a missing-file state entry that is safe to remove +- WHEN the user runs `conf doctor --repair` +- THEN the system SHALL remove the stale state entry + +#### Scenario: Doctor does not auto-repair ambiguous content issues + +- GIVEN `doctor` finds an ID mismatch or unresolved conflict markers +- WHEN the user runs `conf doctor --repair` +- THEN the system SHALL leave those issues for manual resolution + +### Requirement: Prune deletes orphaned local assets safely + +The system SHALL remove only local assets that are no longer referenced by any Markdown file in the space. + +#### Scenario: Prune lists orphaned assets before deletion + +- GIVEN orphaned local assets exist under `assets/` +- WHEN the user runs `conf prune` +- THEN the system SHALL list the orphaned assets before deletion + +#### Scenario: Prune requires approval + +- GIVEN `conf prune` would delete orphaned assets +- WHEN the user has not passed `--yes` +- THEN the system SHALL require confirmation or fail in non-interactive mode From 50c4b9bdc4a8ff3a3e0ed6171b4a2aa7e637ca34 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 8 Mar 2026 20:48:07 +0100 Subject: [PATCH 29/31] Fix four CI test failures - TestRunInit_ExistingDotEnvRemainsUnchanged: add setupGitRepo so git user identity is configured before runInit attempts an initial commit - TestSaveAndLoadState_NormalizesPathSeparators: prepend strings.ReplaceAll to normalizeStatePath so backslash literals are converted to forward slashes on Linux (filepath.ToSlash is a no-op there) - TestNormalizePullAndPushState_NormalizeAllPathIndexes: same fix in normalizeRelPath in pull_paths.go - TestPull_ConcurrentPageDetails: guard GetFolder and GetContentStatus in fakePullRemote with a mutex to eliminate the data race detected under -race Co-Authored-By: Claude Sonnet 4.6 --- cmd/init_test.go | 1 + internal/fs/state.go | 1 + internal/sync/pull_paths.go | 1 + internal/sync/pull_testhelpers_test.go | 6 ++++++ 4 files changed, 9 insertions(+) diff --git a/cmd/init_test.go b/cmd/init_test.go index 9f2b9d9..46b7936 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -177,6 +177,7 @@ func TestRunInit_PartialEnvironmentStillPromptsForCredentials(t *testing.T) { func TestRunInit_ExistingDotEnvRemainsUnchanged(t *testing.T) { runParallelCommandTest(t) repo := t.TempDir() + setupGitRepo(t, repo) chdirRepo(t, repo) originalDotEnv := "# existing credentials\nATLASSIAN_DOMAIN=https://existing.atlassian.net\nATLASSIAN_EMAIL=existing-user@example.com\nATLASSIAN_API_TOKEN=existing-token\n" diff --git a/internal/fs/state.go b/internal/fs/state.go index e9ea141..b52e3a8 100644 --- a/internal/fs/state.go +++ b/internal/fs/state.go @@ -143,6 +143,7 @@ func normalizeStatePath(path string) string { return "" } + path = strings.ReplaceAll(path, `\`, "/") path = filepath.ToSlash(filepath.Clean(path)) path = strings.TrimPrefix(path, "./") if path == "." { diff --git a/internal/sync/pull_paths.go b/internal/sync/pull_paths.go index aafe169..59a2543 100644 --- a/internal/sync/pull_paths.go +++ b/internal/sync/pull_paths.go @@ -306,6 +306,7 @@ func invertPathByID(pathByID map[string]string) map[string]string { } func normalizeRelPath(path string) string { + path = strings.ReplaceAll(path, `\`, "/") path = filepath.ToSlash(filepath.Clean(path)) path = strings.TrimPrefix(path, "./") if path == "." { diff --git a/internal/sync/pull_testhelpers_test.go b/internal/sync/pull_testhelpers_test.go index d0d1603..655c3d0 100644 --- a/internal/sync/pull_testhelpers_test.go +++ b/internal/sync/pull_testhelpers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + gosync "sync" "testing" "time" @@ -11,6 +12,7 @@ import ( ) type fakePullRemote struct { + mu gosync.Mutex space confluence.Space pages []confluence.Page folderByID map[string]confluence.Folder @@ -50,7 +52,9 @@ func (f *fakePullRemote) ListPages(_ context.Context, _ confluence.PageListOptio } func (f *fakePullRemote) GetFolder(_ context.Context, folderID string) (confluence.Folder, error) { + f.mu.Lock() f.getFolderCalls = append(f.getFolderCalls, folderID) + f.mu.Unlock() if f.folderErr != nil { return confluence.Folder{}, f.folderErr } @@ -81,7 +85,9 @@ func (f *fakePullRemote) GetPage(_ context.Context, pageID string) (confluence.P } func (f *fakePullRemote) GetContentStatus(_ context.Context, pageID string, _ string) (string, error) { + f.mu.Lock() f.getStatusCalls = append(f.getStatusCalls, pageID) + f.mu.Unlock() if f.contentStatusErr != nil { return "", f.contentStatusErr } From bd2c82f6f73a24ab05fa700172aceae5dc3368ef Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 8 Mar 2026 20:50:51 +0100 Subject: [PATCH 30/31] Refactor: Remove outdated implementation plans and documentation - Deleted full-text search plan as it is no longer relevant. - Removed refactor plan for large files to streamline codebase. - Eliminated self-healing sync and deterministic hierarchy plan, as features have been implemented. - Discarded test coverage extension plan to focus on current testing strategies. - Updated AGENTS.md template to clarify guidance on copying pages. - Adjusted init command documentation to remove references to documentation strategy. - Cleaned up project.md to clarify the purpose of implementation plans and their historical context. --- AGENTS.md | 1 - .../2026-02-25-charmbracelet-migration.md | 42 -- .../2026-02-25-frontmatter-metadata-update.md | 56 -- ...02-25-production-hardening-followups-v1.md | 194 ------ ...5-production-readiness-comprehensive-v1.md | 67 -- ...3-05-live-workflow-findings-remediation.md | 297 --------- ...26-03-05-live-workflow-polish-followups.md | 631 ------------------ ...2026-03-07-remaining-polish-and-backlog.md | 241 ------- agents/plans/confluence_sync_cli.md | 361 ---------- agents/plans/full-text-search.md | 327 --------- agents/plans/refactor-large-files.md | 226 ------- ...ealing_sync_and_deterministic_hierarchy.md | 150 ----- agents/plans/test_coverage_extension.md | 48 -- cmd/agents_test.go | 11 +- cmd/init.go | 4 +- openspec/project.md | 2 - 16 files changed, 9 insertions(+), 2649 deletions(-) delete mode 100644 agents/plans/2026-02-25-charmbracelet-migration.md delete mode 100644 agents/plans/2026-02-25-frontmatter-metadata-update.md delete mode 100644 agents/plans/2026-02-25-production-hardening-followups-v1.md delete mode 100644 agents/plans/2026-02-25-production-readiness-comprehensive-v1.md delete mode 100644 agents/plans/2026-03-05-live-workflow-findings-remediation.md delete mode 100644 agents/plans/2026-03-05-live-workflow-polish-followups.md delete mode 100644 agents/plans/2026-03-07-remaining-polish-and-backlog.md delete mode 100644 agents/plans/confluence_sync_cli.md delete mode 100644 agents/plans/full-text-search.md delete mode 100644 agents/plans/refactor-large-files.md delete mode 100644 agents/plans/self_healing_sync_and_deterministic_hierarchy.md delete mode 100644 agents/plans/test_coverage_extension.md diff --git a/AGENTS.md b/AGENTS.md index b91c716..62d884d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,6 @@ This repository builds `conf` (`confluence-sync`), a Go CLI that syncs Confluenc - `docs/specs/prd.md` - `docs/specs/technical-spec.md` - `docs/specs/README.md` -- Treat `agents/plans/*.md` as implementation history, delivery plans, and backlog notes rather than the live behavior contract. - If behavior changes, update the canonical specs first, then implement. ## Intended Usages diff --git a/agents/plans/2026-02-25-charmbracelet-migration.md b/agents/plans/2026-02-25-charmbracelet-migration.md deleted file mode 100644 index a65ee8c..0000000 --- a/agents/plans/2026-02-25-charmbracelet-migration.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plan: Modernize CLI with Charmbracelet - -This plan outlines the migration of interactive CLI components from standard library prompts and third-party progress bars to the **Charmbracelet** ecosystem (`huh`, `bubbles`, `lipgloss`). - -## Objectives -- Improve User Experience (UX) with styled, accessible interactive components. -- Standardize on a modern TUI framework. -- Maintain compatibility with non-interactive and automated workflows. - -## Proposed Changes - -### 1. Dependencies -- Add `github.com/charmbracelet/huh` for forms and prompts. -- Add `github.com/charmbracelet/bubbles` for progress bars. -- Add `github.com/charmbracelet/lipgloss` for styling. -- Add `github.com/charmbracelet/bubbletea` as the TUI engine. -- Remove `github.com/schollz/progressbar/v3`. - -### 2. Interaction Migration (`cmd/automation.go` & `cmd/pull.go`) -- **Safety Confirmations**: Replace `requireSafetyConfirmation` and `askToContinueOnDownloadError` text prompts with `huh.Confirm`. -- **Conflict Resolution**: Replace the manual choice input in `handlePullConflict` with a `huh.Select` component. -- **Conflict Policies**: Update `resolvePushConflictPolicy` to use `huh.Select`. - -### 3. Workspace Initialization (`cmd/init.go`) -- **Guided Setup**: Replace sequential `promptField` calls with a unified `huh.Form`. -- **Security**: Use `huh.Input` with `.EchoMode(huh.EchoModePassword)` for `ATLASSIAN_API_TOKEN`. - -### 4. Progress Reporting (`cmd/progress.go`) -- **Component Swap**: Replace `schollz/progressbar` with `bubbles/progress`. -- **UI Logic**: Implement a `bubbletea` model to handle concurrent progress updates and status descriptions. - -### 5. Styling and Layout -- Define standard colors and styles using `lipgloss` for consistent warnings, success messages, and headers across the CLI. - -## Invariants & Safety -- **Automation**: All `huh` forms and components must respect `flagNonInteractive` and `flagYes`, falling back to default values or failing appropriately without attempting to render a TUI. -- **Output**: Ensure TUI components write to the correct output stream (standard error vs standard out) to avoid polluting piped data. - -## Verification -- Run existing E2E tests (`cmd/e2e_test.go`) to ensure sync logic remains intact. -- Manually verify TUI rendering on Windows (PowerShell/CMD). -- Verify that `--non-interactive` still works for CI/CD environments. diff --git a/agents/plans/2026-02-25-frontmatter-metadata-update.md b/agents/plans/2026-02-25-frontmatter-metadata-update.md deleted file mode 100644 index e6c70c8..0000000 --- a/agents/plans/2026-02-25-frontmatter-metadata-update.md +++ /dev/null @@ -1,56 +0,0 @@ -# Frontmatter Metadata Standardization Plan - -## Objective -Standardize the naming convention of frontmatter properties in `confluence-markdown-sync` to strictly reflect system API metadata. This ensures perfect symmetry and intuitively separates system-generated metadata from user-editable content. - -We are adopting the `_by` and `_at` matrix: -* **Creation:** `created_by` / `created_at` -* **Modification:** `updated_by` / `updated_at` - -*Note: Since the tool is not yet broadly used, backward compatibility (migrating old properties) is not required.* - -## Target Changes - -### 1. Update Core Structs (`internal/fs/frontmatter.go`) -Update the `Frontmatter` and `frontmatterYAML` structs: -- `Author` -> `CreatedBy` (`yaml:"created_by,omitempty"`) -- `LastModifiedBy` -> `UpdatedBy` (`yaml:"updated_by,omitempty"`) -- `LastModifiedAt` -> `UpdatedAt` (`yaml:"updated_at,omitempty"`) -- `CreatedAt` -> Remains unchanged (`yaml:"created_at,omitempty"`) - -### 2. Update YAML Marshaling/Unmarshaling (`internal/fs/frontmatter.go`) -- Update `MarshalYAML`'s switch statement to filter out the new keys (`created_by`, `updated_by`, `updated_at`) from the `Extra` map. -- Update `UnmarshalYAML` to correctly map the new YAML fields into the `Frontmatter` struct. - -### 3. Update Sync Logic (`internal/sync/` packages) -- Find where Confluence page history is mapped to frontmatter properties (likely in `pull.go` or a `mapper.go`). -- Change assignments: - - Map `page.History.CreatedBy.DisplayName` to `CreatedBy`. - - Map `page.History.CreatedDate` to `CreatedAt`. - - Map `page.History.LastUpdated.By.DisplayName` to `UpdatedBy`. - - Map `page.History.LastUpdated.When` to `UpdatedAt`. - -### 4. Update Tests & Test Data -- `internal/fs/frontmatter_test.go`: Update mock YAML frontmatter strings to use the new field names. -- `internal/sync/*_test.go`: Update any tests validating the frontmatter metadata. -- Any golden files in `testdata/` folders that include `author`, `last_modified_by`, or `last_modified_at`. - -### 5. Update Documentation -- Check `README.md` for references to `author` or `last_modified_*` and update to `created_by`, `updated_by`, etc. -- Check `agents/plans/confluence_sync_cli.md` for any mentions of these frontmatter properties. - -## Execution Steps -1. Run a global `grep` across the repository for `Author`, `author:`, `LastModifiedBy`, `last_modified_by`, `LastModifiedAt`, and `last_modified_at`. -2. Apply the renames in Go files using structural search and replace or direct edits. -3. Update `.md` files in `testdata` if applicable. -4. Run `make test` locally to ensure no compilation errors or test failures. -5. Commit the changes. - -## Implementation Progress - -- [x] Searched the repository for legacy metadata names and YAML keys. -- [x] Renamed frontmatter fields and YAML tags in `internal/fs/frontmatter.go` to `created_by` / `created_at` / `updated_by` / `updated_at`. -- [x] Updated pull mapping to populate `CreatedBy`, `CreatedAt`, `UpdatedBy`, and `UpdatedAt`. -- [x] Updated tests in `internal/fs/frontmatter_test.go` and `cmd/pull_test.go`. -- [x] Updated docs metadata key reference in `docs/usage.md`. -- [x] Verified no remaining references in `README.md` or `agents/plans/confluence_sync_cli.md` required edits for legacy metadata keys. diff --git a/agents/plans/2026-02-25-production-hardening-followups-v1.md b/agents/plans/2026-02-25-production-hardening-followups-v1.md deleted file mode 100644 index ab59fce..0000000 --- a/agents/plans/2026-02-25-production-hardening-followups-v1.md +++ /dev/null @@ -1,194 +0,0 @@ -# Production Hardening Follow-up Plan (Excluding CI Sandbox E2E) - -## Objective - -Close the remaining production-readiness gaps identified in the recent review, excluding improvement #1 (adding sandbox E2E execution to CI). This plan focuses on stronger failure compensation, safer archive semantics, higher relink correctness, stricter label hygiene, and better operational observability. - -## Explicitly Out Of Scope - -- Add sandbox E2E workflow job in CI (the previously listed improvement #1). - -## Assumptions - -- Command model (`init`, `pull`, `push`, `validate`, `diff`, `relink`) remains unchanged. -- Backward compatibility for existing markdown/state layouts is required. -- Confluence API behavior can vary by tenant; all remote operations must stay fail-safe. -- Safety and diagnosability are prioritized over throughput. - -## Workstream A: Stronger Push Compensation For Post-Update Failures (Improvement #2) - -### Problem - -`push` can still leave partial remote state when page content update succeeds but a later step fails (for example metadata sync), because rollback currently targets metadata, uploaded attachments, and created pages, not content reversion of existing pages. - -### Plan - -- Capture a pre-mutation snapshot for existing pages before `UpdatePage` mutates content: - - current page title - - parent ID - - status/lifecycle state - - ADF body -- Extend rollback tracker in `internal/sync/push.go` to include a content restore phase for existing pages. -- Add best-effort content restore that updates the page back to the captured snapshot using the current remote head version rules. -- Emit explicit diagnostics for content rollback outcomes: - - `ROLLBACK_PAGE_CONTENT_RESTORED` - - `ROLLBACK_PAGE_CONTENT_FAILED` -- Keep current behavior for newly created pages (hard delete on rollback). - -### Validation - -- Add tests that force failures after successful `UpdatePage` and verify content rollback attempt and diagnostics. -- Verify no rollback attempts happen in dry-run mode. - -## Workstream B: Verify Archive Completion Before Finalizing Local State (Improvement #3) - -### Problem - -`ArchivePages` returns an async task ID, but push delete flow currently treats archive submission as success without polling task completion. - -### Plan - -- Add archive task polling support in `internal/confluence/client.go` via long-task endpoint handling. -- Extend confluence interfaces/types with archive task status model. -- Update delete path in `internal/sync/push.go` so local state/index removal and commit planning occur only after archive task succeeds. -- Add configurable timeout/backoff (defaults + env/flag wiring if needed). -- Emit clear diagnostics/errors for timeout and failure: - - `ARCHIVE_TASK_TIMEOUT` - - `ARCHIVE_TASK_FAILED` - -### Validation - -- Unit tests for task polling success/failure/timeout paths. -- Integration tests proving local delete commit is blocked when archive does not complete. - -## Workstream C: Replace Regex Relink With Markdown AST Rewriter (Improvement #4) - -### Problem - -Current relink implementation uses a regex over raw markdown, which can rewrite links in edge cases incorrectly and does not robustly handle markdown constructs. - -### Plan - -- Reimplement `internal/sync/relink.go` using a markdown parser/AST traversal (Goldmark-based). -- Rewrite only actual link destinations (not code blocks/spans or non-link text). -- Preserve anchors/fragments and retain original text around links. -- Keep dry-run behavior and statistics unchanged from the CLI perspective. - -### Validation - -- Add relink tests for: - - inline code and fenced code containing URL-like text - - links with anchors and titles - - escaped/nested bracket content - - documents with no rewritable links -- Ensure existing command-level relink tests still pass. - -## Workstream D: Harden Label Validation And Normalization (Improvement #5) - -### Problem - -Label handling is permissive and can allow empty/duplicate/poorly normalized values, increasing push churn and metadata inconsistencies. - -### Plan - -- Introduce a single canonical label normalization utility used by validate and push paths. -- Enforce schema constraints in `internal/fs/frontmatter.go`: - - no empty labels after trim - - no whitespace-containing labels - - deterministic dedupe -- Apply normalized labels in metadata sync and rollback comparison logic in `internal/sync/push.go`. -- Improve validation error messages to identify offending labels clearly. -- Update docs (`README.md`, `docs/usage.md`) with exact label rules. - -### Validation - -- Add unit tests for normalization and invalid-label rejection. -- Add push metadata tests showing no-op behavior for equivalent label sets. - -## Workstream E: Versioned User-Agent And Run-Correlation Logging (Improvement #6) - -### Problem - -HTTP user agent defaults to `conf/dev`, and logs do not carry a stable per-run correlation key. - -### Plan - -- Build a versioned UA string from CLI version in `cmd/confluence_client.go` and pass through `ClientConfig.UserAgent`. -- Preserve token/header redaction guarantees (no auth leakage in debug logs). -- Generate a per-command run ID at command start (`pull`, `push`, `diff`, `validate`) and include it in structured logs. -- Ensure retry/rate-limit logs include run ID for easier incident tracing. - -### Validation - -- Add tests for UA propagation and non-leak logging behavior. -- Add command tests asserting run ID is attached to key lifecycle log lines. - -## Cross-Cutting Test/Quality Requirements - -- Add/adjust tests for every changed invariant (unit + integration where applicable). -- Run: - - `go test ./...` - - `go vet ./...` - - `go run ./tools/coveragecheck` - - `go run ./tools/gofmtcheck` -- Keep coverage gates passing for `./cmd`, `./internal/sync`, `./internal/git`. - -## Delivery Slices - -1. Archive verification and async task polling. -2. Push content compensation rollback. -3. Label normalization/validation hardening. -4. AST-based relink engine. -5. Versioned UA and run-correlation logging. - -Each slice should be mergeable independently with tests and docs updates. - -## Implementation Progress - -- [x] Slice 1: Archive verification and async task polling. - - Added long-task polling support in the Confluence client with success/failure/timeout handling. - - Updated push delete flow to block local state mutation until archive completion is confirmed. - - Added diagnostics for `ARCHIVE_TASK_TIMEOUT` and `ARCHIVE_TASK_FAILED`. - - Added unit coverage for polling behavior and integration coverage for delete blocking semantics. -- [x] Slice 2: Push content compensation rollback. - - Added existing-page content snapshots (title, parent, status, ADF) before mutation. - - Added rollback content restore for post-update failures with `ROLLBACK_PAGE_CONTENT_RESTORED` / `ROLLBACK_PAGE_CONTENT_FAILED` diagnostics. - - Ensured rollback is skipped in dry-run mode and added tests for both restore and dry-run behavior. -- [x] Slice 3: Label normalization/validation hardening. - - Added canonical label normalization in `internal/fs/frontmatter.go` (trim/lowercase/dedupe/sort). - - Strengthened schema validation for empty and whitespace-containing labels with clearer error messages. - - Applied normalized labels in push metadata sync + rollback comparisons and added no-op equivalence tests. - - Updated `README.md` and `docs/usage.md` with exact label rules. -- [x] Slice 4: AST-based relink engine. - - Replaced regex relink matching with Goldmark AST traversal to plan true link destination rewrites. - - Added a destination-span markdown rewriter that updates only link destination tokens while preserving surrounding text. - - Added coverage for code spans/fences, anchors + titles, escaped/nested labels, and no-op documents. -- [x] Slice 5: Versioned UA and run-correlation logging. - - Added versioned user-agent propagation (`conf/`) from CLI version into Confluence client config. - - Added per-command run IDs for `pull`, `push`, `diff`, and `validate` with lifecycle logs carrying `run_id` + `command`. - - Added tests for UA propagation, run ID stability across lifecycle logs, and existing non-leak logging guarantees. - -## Verification Criteria - -- Existing-page push failures after content update show explicit content rollback diagnostics. -- Delete/archival pushes do not finalize local state unless archive completion is confirmed. -- Relink rewrites only true markdown links and skips code text safely. -- Invalid/empty/duplicate labels are blocked by validation, and equivalent label sets do not churn on push. -- HTTP telemetry includes versioned UA and per-run correlation fields without leaking credentials. - -## Risks And Mitigations - -1. **Confluence long-task semantics vary by tenant** - Mitigation: fail safe on unknown states/timeouts, with actionable diagnostics. - -2. **Content rollback can fail under concurrent edits** - Mitigation: best-effort revert with clear failure diagnostics and recovery guidance. - -3. **AST relink implementation may change markdown formatting unexpectedly** - Mitigation: use targeted destination rewrites and preserve untouched source spans. - -4. **Stricter label rules may break existing content unexpectedly** - Mitigation: clear validate errors and documented migration behavior. - -5. **Additional logging fields may increase noise** - Mitigation: keep fields structured and concise; gate verbose details behind debug level. diff --git a/agents/plans/2026-02-25-production-readiness-comprehensive-v1.md b/agents/plans/2026-02-25-production-readiness-comprehensive-v1.md deleted file mode 100644 index f4a31fd..0000000 --- a/agents/plans/2026-02-25-production-readiness-comprehensive-v1.md +++ /dev/null @@ -1,67 +0,0 @@ -# Production Readiness Comprehensive Remediation Plan - -## Objective - -Deliver a production-grade reliability and operations baseline for conf by closing data-safety gaps in pull and push execution, hardening cancellation and retry behavior, improving release and observability standards, and raising quality gates so unattended operation is safe in real team environments. - -## Assumptions - -- The current command model remains stable and no command deprecations are required in this cycle. -- Backward compatibility for existing workspace layouts and frontmatter conventions is required. -- A temporary increase in test and CI runtime is acceptable to gain stronger correctness guarantees. -- Improvements are prioritized for safety and recoverability before throughput optimization. - -## Implementation Plan - -- [x] Remove placeholder fallback in strict reverse link handling so unresolved internal links fail consistently in validate and push (internal/sync/hooks.go:179, cmd/validate.go:326, internal/sync/push.go:468). -- [x] Split push into preflight planning and mutation phases so intent is resolved before remote writes begin (internal/sync/push.go:430, internal/sync/push.go:491, internal/sync/push.go:554). -- [x] Add rollback handlers for partial push failures, including created pages, uploaded assets, and metadata drift, and report rollback outcomes in diagnostics (internal/sync/push.go:317, internal/sync/push.go:1034). -- [x] Change pull discard-local semantics to drop stash only after successful pull completion, preventing local loss on unrelated failures (cmd/pull.go:163). -- [x] Replace broad pull cleanup with scoped restoration that minimizes destructive resets while preserving user-authored content (cmd/pull.go:423, cmd/pull.go:424). -- [x] Unify incremental pagination to use continuation semantics across estimate and execution paths and prevent offset drift (internal/sync/pull.go:793, internal/sync/pull.go:814, cmd/pull.go:776). -- [x] Persist SpaceKey as authoritative sync state and remove markdown-scan fallback inference during target resolution (internal/fs/state.go:18, internal/sync/pull.go:619, cmd/pull.go:319). -- [x] Propagate command context through diff and validate conversion paths so cancellation immediately stops expensive operations (cmd/conf/main.go:13, cmd/diff.go:58, cmd/validate.go:326). -- [x] Extend retry behavior to include transient network and timeout failures while preserving idempotent remote interactions (internal/confluence/client.go:763, internal/confluence/retry.go:18). -- [x] Make retry and rate-limit policies operator-configurable through flags and environment to handle tenant-specific quotas (internal/confluence/ratelimit.go:8, cmd/root.go:59). -- [x] Add explicit client close lifecycle for limiter resources so long-running automation does not leak background goroutines (internal/confluence/ratelimit.go:60, internal/confluence/client.go:120). -- [x] Raise quality gates and add tests for uncovered high-risk paths in push, relink, progress, and root command wiring (internal/sync/push.go:260, internal/sync/push.go:1034, cmd/relink.go:139, cmd/progress.go:17, cmd/root.go:40, tools/coveragecheck/main.go:22, .golangci.yml:7). -- [x] Rework e2e suites to require sandbox configuration and remove hardcoded live identifiers so tests align with safety policy (cmd/e2e_test.go:25, cmd/e2e_test.go:171, cmd/e2e_test.go:258, AGENTS.md:55). -- [x] Expand release workflow with checksums, signing, SBOM generation, vulnerability scanning, and publish steps for verifiable artifacts (.github/workflows/release.yml:34, .github/workflows/release.yml:83, .github/workflows/release.yml:132). -- [x] Replace shell-specific clean behavior and validate developer targets on Windows and Linux to improve portability (Makefile:6, Makefile:35, .github/workflows/ci.yml:37, tools/gofmtcheck/main.go:1). -- [x] Add licensing and support-governance documents so distribution posture matches installation and release expectations (LICENSE:1, SECURITY.md:1, SUPPORT.md:1, README.md:66). -- [x] Expand operator runbooks for conflict handling, rollback expectations, and dry-run behavior with test-backed guidance (docs/automation.md:41, docs/automation.md:52, docs/automation.md:71). -- [x] Add structured pull and push telemetry for timing, retries, conflict choices, and rollback events to improve incident diagnosis (cmd/pull.go:71, cmd/push.go:79, cmd/automation.go:52, internal/confluence/client.go:787, internal/sync/push.go:399, cmd/progress.go:36). - -## Verification Criteria - -- Strict validation rejects unresolved internal links and assets without placeholder substitutions in both validate and push pathways. -- Forced-failure pull scenarios preserve local changes and state metadata when discard-local is enabled and pull exits with error. -- Push failure simulations prove no orphaned remote artifacts remain after rollback handling completes. -- Incremental pull pagination tests demonstrate no skipped or duplicated pages across large change sets. -- Cancellation tests confirm diff and validate stop promptly when interrupt or terminate signals are received. -- Retry and rate-limit configuration is externally tunable and covered by unit and integration tests. -- Coverage and lint gates fail when critical orchestration modules regress below newly defined thresholds. -- Release workflow outputs include verifiable checksums, provenance metadata, and SBOM artifacts for each built binary. - -## Potential Risks and Mitigations - -1. **Risk: Transactional and rollback logic increases push orchestration complexity and regression potential.** - Mitigation: Deliver in small slices behind integration tests that simulate failures at each remote mutation boundary and require green reliability suites before merge. - -2. **Risk: Confluence API behavior differences across tenants can invalidate retry and rollback assumptions.** - Mitigation: Add contract-style tests with mocked API variants and keep fallback-safe defaults that stop writes when response semantics are ambiguous. - -3. **Risk: Higher quality gates can slow development throughput and increase CI cycle time.** - Mitigation: Phase gate increases over multiple iterations, parallelize test jobs, and prioritize high-risk package coverage before broad threshold expansion. - -4. **Risk: Cross-platform shell and tooling changes can introduce local workflow friction.** - Mitigation: Validate on both Windows and Linux CI runners and document migration notes in developer docs before enabling stricter checks. - -5. **Risk: E2E sandbox hardening may reduce convenience for ad-hoc testing.** - Mitigation: Provide a repeatable sandbox bootstrap script and explicit environment contract so safe testing remains easy while production spaces stay protected. - -## Alternative Approaches - -1. **Minimal Safety Patch Track:** Address only immediate data-loss and strict-validation blockers first, then defer CI, release, and observability work. This shortens time to partial hardening but leaves operational maturity gaps. -2. **Reliability-First Track:** Complete pull and push correctness, rollback, and cancellation hardening first, then raise quality and release controls in a second wave. This balances risk reduction and delivery speed. -3. **Platform Modernization Track:** Perform a larger architecture refactor that separates orchestration, remote mutation planning, and execution engines before fixes. This may yield cleaner long-term design but carries the highest short-term regression risk. diff --git a/agents/plans/2026-03-05-live-workflow-findings-remediation.md b/agents/plans/2026-03-05-live-workflow-findings-remediation.md deleted file mode 100644 index f66befb..0000000 --- a/agents/plans/2026-03-05-live-workflow-findings-remediation.md +++ /dev/null @@ -1,297 +0,0 @@ -# Live Workflow Findings Remediation Plan - -## Objective - -Close the reliability, workflow, and round-trip correctness gaps discovered during the live TD2 and SD2 verification run on March 5, 2026, so `conf` can graduate from limited beta use to a production-ready sync tool for real Confluence spaces. - -## Source Inputs - -- Live execution log: `C:\Users\rgone\AppData\Local\Temp\conf-live-20260305-211238\TEST_LOG.md` -- Spaces exercised with real API traffic: `TD2`, `SD2` -- Workflow exercised: `pull -> edit -> validate -> diff -> push -> pull`, plus direct Confluence API create/update/delete calls to simulate a second user - -## Production Readiness Bar - -The following items are blockers for a production-ready release: - -1. Cross-space link handling parity between `validate`, `push`, and `pull` -2. Safe handling of frontmatter `status` on new-page pushes -3. Correct attachment/media ADF generation and round-trip pull behavior -4. Stable hierarchy round-trip for parent pages with children - -The following items are not blockers for limited beta usage, but should be resolved before a `1.0.0` claim: - -1. Mermaid support contract -2. Cleanup lifecycle for failed sync branches -3. Workflow polish and diagnostics consistency - -## Assumptions - -- Command model remains unchanged: `init`, `pull`, `push`, `validate`, `diff`, `search`, `relink`, `status`, `doctor`, `clean` -- Existing workspace layouts and frontmatter formats must remain backward compatible -- Fixes should preserve current safety guarantees around isolated worktrees, snapshot refs, and no-write validation -- Direct Confluence API behavior can vary by tenant, so new logic must fail safe and emit actionable diagnostics - -## Workstream A: Cross-Space Link Resolution Parity - -### Problems Observed - -- `conf validate` succeeded for relative cross-space Markdown links, but `conf push` failed in its isolated worktree with unresolved link errors. -- The failure reproduced in both directions: `SD2 -> TD2` and `TD2 -> SD2`. -- Pull emitted unresolved warnings for valid absolute Confluence cross-space links that should have been preserved without diagnostics. - -### Plan - -- Trace and unify global page index construction across: - - `cmd/validate.go` - - `cmd/push_worktree.go` - - `internal/sync/hooks.go` - - `internal/sync/index.go` -- Make standalone `validate` and in-worktree `push` use the same repo root discovery, path normalization, and global index scope. -- Normalize cross-space path lookups so worktree-local paths resolve identically to active-workspace paths. -- Preserve fragment handling (`#anchor`) consistently for cross-space links in both directions. -- Adjust pull-side forward link handling so valid absolute Confluence cross-space links do not emit unresolved-reference warnings. -- Add diagnostics that distinguish: - - truly unresolved cross-space links - - intentionally preserved absolute cross-space links - -### Validation - -- Add integration coverage for: - - new `SD2` page linking relatively to existing `TD2` page - - new `TD2` page linking relatively to existing `SD2` page - - `validate` success implies `push` success for the same content -- Add pull coverage proving absolute cross-space page URLs survive conversion without unresolved warnings. - -## Workstream B: Frontmatter `status` Semantics and Rollback Safety - -### Problems Observed - -- New-page push with frontmatter `status: In progress` created the page body successfully, then failed during metadata sync with `Invalid status 'null'`. -- Rollback attempted `purge=true` deletion against a current page and failed, leaving orphaned content behind until manual API cleanup. - -### Plan - -- Separate page lifecycle state from content-status lozenge handling: - - `state` remains mapped to page lifecycle (`current` / `draft`) - - `status` remains mapped to content-status lozenge metadata only -- Audit new-page and existing-page metadata sync so content-status updates: - - are only attempted when `status` is present - - use the correct v1 content-status API payloads - - can be deleted cleanly when `status` is removed -- Rework rollback for newly created pages: - - if page creation succeeded but later metadata sync failed, use the correct delete path for current pages - - never rely on `purge=true` where Confluence rejects it -- Extend rollback diagnostics to explicitly report: - - created-page cleanup success - - created-page cleanup failure - - content-status rollback success/failure - -### Validation - -- Add push integration tests covering: - - new page with `status` - - update existing page with `status` - - remove existing `status` - - failure after page creation with successful cleanup -- Add direct client tests for content-status API calls and rollback error handling. - -## Workstream C: Attachment and Media Fidelity - -### Problems Observed - -- Push uploaded both attachments, but resulting ADF was wrong: - - the image attachment did not appear as a valid image/media node - - the file link degraded into `[Embedded content]` with `UNKNOWN_MEDIA_ID` -- Pull round-tripped the broken ADF into placeholder Markdown: - - `\[Embedded content\] [Media: UNKNOWN_MEDIA_ID]` -- Because the page body retained `UNKNOWN_MEDIA_ID`, pull skipped stale attachment pruning for safety. -- Later local attachment deletion did not remove the remote attachments. - -### Plan - -- Trace the full attachment pipeline across: - - markdown asset discovery - - asset path normalization/migration - - upload result mapping - - reverse conversion hook output - - final ADF patching before page update -- Fix image handling so pushed Markdown images become valid Confluence media/image nodes with real attachment identity. -- Fix file-link handling so pushed Markdown file links become valid file/media nodes with real attachment IDs. -- Ensure the final outgoing ADF references uploaded attachments, not placeholder or pending IDs. -- Fix forward conversion so pull can resolve those attachment nodes back to stable local asset paths. -- Ensure stale attachment pruning works after a full push/pull cycle, including remote attachment deletion. -- Improve push summary output to report attachment create/delete operations when they occur. - -### Validation - -- Add live-style integration coverage for: - - Markdown image attachment - - Markdown file attachment - - mixed image + file on the same page - - delete attachment locally -> push -> confirm remote deletion - - delete attachment remotely -> pull -> confirm local deletion -- Add direct ADF assertions in tests to verify: - - image attachment resolves to real media node - - file attachment resolves to real file/media node - - no `UNKNOWN_MEDIA_ID` remains in pushed payloads - -## Workstream D: Hierarchy Round-Trip Invariants - -### Problems Observed - -- A parent page created locally as `/.md` pushed successfully, but after pull it came back as: - - top-level `Live-Workflow-Test-2026-03-05.md` - - sibling directory `Live-Workflow-Test-2026-03-05/` -- That violates the documented invariant for pages with children. -- The resulting `folder_path_index` also showed inconsistent Windows-style separator normalization in state. - -### Plan - -- Revisit pull path planning for pages with children in: - - `internal/sync/pull_paths.go` - - `internal/sync/pull.go` - - `internal/sync/pull_pages.go` -- Ensure that pages with children always hydrate back to `DirectoryName/DirectoryName.md`. -- Normalize path separators consistently across: - - `page_path_index` - - `folder_path_index` - - in-memory planned path maps -- Verify that folder-backed children and page-backed children both preserve the same local hierarchy rules after round-trip. -- Add explicit regression guards for page-parent plus folder-child combinations, since the live run exercised both on the same tree. - -### Validation - -- Add E2E round-trip tests for: - - parent page with direct page children - - parent page with folder child - - nested folder child created remotely and then pulled locally -- Assert exact post-pull paths, not just page presence. - -## Workstream E: Mermaid Support Contract - -### Problems Observed - -- PlantUML survived push as a Confluence extension. -- Mermaid did not. It was stored as a plain `codeBlock` with language `mermaid`, which likely does not render as a diagram in the Confluence UI. - -### Plan - -- Adopt the portable support contract: - - PlantUML remains first-class via the existing `plantumlcloud` extension handler. - - Mermaid is preserved only as fenced code and is not treated as a rendered Confluence diagram macro. -- Document Mermaid behavior clearly in `README.md`, `AGENTS.md`, and usage docs so authored expectations match live Confluence behavior. -- Add validation warnings that fire before push when Mermaid fenced code blocks are present, making the downgrade explicit while preserving successful syncs. -- Keep reverse conversion behavior unchanged for Mermaid content: - - push emits Confluence ADF `codeBlock` nodes with language `mermaid` - - pull round-trips those nodes back to authored Mermaid fenced code - -### Validation - -- Add round-trip tests proving Mermaid fenced code stays Mermaid fenced code after reverse + forward conversion. -- Add command tests proving `validate` warns, but does not fail, when Mermaid code fences are present. -- Add one sandbox E2E test that checks pushed Mermaid content is stored remotely as a `codeBlock` node with language `mermaid`. - -## Workstream F: Cleanup and Recovery Artifact Lifecycle - -### Problems Observed - -- Failed pushes correctly retained snapshot refs and `sync/*` branches for recovery. -- `conf clean --yes --non-interactive` removed snapshot refs but did not prune the leftover `sync/*` branches. - -### Plan - -- Extend `clean` so it can safely remove stale `sync/*` branches when: - - current branch is not a `sync/*` branch - - no linked worktree remains - - corresponding recovery refs are already gone or explicitly targeted for cleanup -- Emit a cleanup summary that reports: - - removed worktrees - - removed snapshot refs - - removed sync branches - - skipped branches and why -- Add branch-retention rules for genuinely active recovery scenarios so cleanup does not become destructive. - -### Validation - -- Add integration coverage for: - - failed push leaves branch + snapshot ref - - `conf clean` removes both when safe - - `conf clean` preserves active branch when current HEAD is on a sync branch - -## Workstream G: Workflow Polish and Diagnostics Consistency - -### Problems Observed - -- `conf init` still prompted whenever `.env` was missing, even though credentials were already available through environment variables. -- `conf status` reported clean content state while sandbox Git still showed deleted attachment files. -- Push repeatedly warned on Confluence folder-list HTTP 500 responses, even though fallback behavior worked. -- Diff output was useful, but not always clear about metadata parity versus content parity. - -### Plan - -- Improve `conf init` so non-interactive environments can scaffold `.env` directly from already-set auth environment variables. -- Decide whether `status` should remain Markdown-only or grow an optional asset-drift view. If it stays Markdown-only, document that explicitly. -- Improve diagnostics around unresolved references and fallback behavior so users can distinguish: - - harmless preserved absolute links - - degraded but pullable content - - truly broken references -- Refine folder-list fallback logging so repeated tenant-side 500s remain visible but less noisy. -- Review `diff` metadata rendering for labels and other synced frontmatter so remote parity is easier to interpret. - -### Validation - -- Add command tests for non-interactive `init` with env-backed auth. -- Add status/diff documentation and tests for chosen asset-drift semantics. -- Add logging tests for folder-list fallback de-duplication or clearer warning messages. - -## Delivery Order - -1. Workstream B: `status` frontmatter safety and rollback correctness -2. Workstream A: cross-space link parity between `validate`, `push`, and `pull` -3. Workstream C: attachment/media correctness -4. Workstream D: hierarchy round-trip invariants -5. Workstream E: Mermaid support contract -6. Workstream F: cleanup branch lifecycle -7. Workstream G: workflow polish and diagnostics - -This order prioritizes data correctness and remote-write safety before workflow polish. - -## Verification Matrix - -The remediation is complete only when the live scenarios below pass without workarounds: - -- New page with `status` pushes successfully and cleans up safely on forced failure. -- Relative cross-space links succeed in both `validate` and `push`. -- Absolute cross-space page URLs pull without unresolved warnings. -- Image and file attachments round-trip through push and pull with valid ADF and stable Markdown. -- Local attachment deletion removes remote attachments. -- Parent page with children round-trips back to `/.md`. -- Mermaid behavior matches the documented support model. -- `conf clean` removes stale sync branches as well as snapshot refs. -- A final real `pull -> edit -> validate -> diff -> push -> pull` run over TD2 and SD2 completes with: - - no unresolved warnings - - clean `conf status` - - clean `conf doctor` - - no manual API cleanup required - -## Risks and Mitigations - -1. **Risk: Attachment fixes span several layers and can regress existing media behavior.** - Mitigation: add end-to-end assertions on final ADF plus round-trip Markdown, not just unit-level hook tests. - -2. **Risk: Hierarchy fixes can destabilize existing pulled workspaces.** - Mitigation: normalize paths carefully, add migration-safe handling for existing state files, and test both old and new layouts. - -3. **Risk: Cross-space link fixes can overreach into intentionally external URLs.** - Mitigation: keep strict separation between same-tenant Confluence URLs, known cross-space local targets, and true external links. - -4. **Risk: Cleanup improvements could accidentally remove recovery state users still need.** - Mitigation: preserve explicit safety checks and require clear eligibility before deleting sync branches. - -5. **Risk: Mermaid support may depend on tenant-specific Confluence macro behavior.** - Mitigation: decide and document the support contract first, then implement only the tested, portable behavior. - -## Release Recommendation - -Until Workstreams A through D are complete and verified in a fresh live run, `conf` should be treated as beta software for carefully supervised use, not a production-ready general release. diff --git a/agents/plans/2026-03-05-live-workflow-polish-followups.md b/agents/plans/2026-03-05-live-workflow-polish-followups.md deleted file mode 100644 index a08135b..0000000 --- a/agents/plans/2026-03-05-live-workflow-polish-followups.md +++ /dev/null @@ -1,631 +0,0 @@ -# Live Workflow Polish Follow-up Plan - -## Objective - -Capture the non-blocking but high-value workflow, diagnostics, and operator-experience improvements noticed during the March 5, 2026 live TD2/SD2 validation run. - -## Relationship To Other Plans - -- This plan complements `agents/plans/2026-03-05-live-workflow-findings-remediation.md`. -- The remediation plan covers production blockers. -- This plan covers workflow smoothness, clarity, and maintainability improvements that should follow once the blocking correctness issues are under control. - -## Implementation Progress - -- [x] Batch 1 completed: items 1, 2, 5, and 7 are closed with regression coverage on this branch. -- [x] Batch 2 completed: items 3, 6, 12, and 13 are closed with regression coverage on this branch. -- [x] Batch 3 completed: items 10, 11, and 18 are closed, and follow-up hardening landed for items 6, 12, and 13. -- [x] Batch 4 completed: items 4 and 9 are closed with warning-taxonomy regression coverage and explicit extension-support documentation updates. -- [x] Item 8 was re-verified as already complete on this branch. -- [x] Batch 5 completed: item 14 is closed with structured report regression coverage for success and failure paths. -- [x] Batch 6 completed: item 15 is closed with explicit path-move visibility in pull/diff/status plus rename-policy documentation and regression coverage. -- [ ] Remaining items: 16 and 17. - -## Improvements - -### 1. Non-Interactive `init` Should Respect Existing Environment Auth - -#### Problem - -`conf init` still prompts whenever `.env` is missing, even if `ATLASSIAN_DOMAIN`, `ATLASSIAN_EMAIL`, and `ATLASSIAN_API_TOKEN` are already present in the environment. - -#### Plan - -- Update `cmd/init.go` so `ensureDotEnv` first checks resolved config inputs from the environment. -- If all required auth values are already available, scaffold `.env` directly without prompting. -- Preserve prompting only when values are genuinely missing. -- Keep interactive behavior unchanged for human-driven onboarding. - -#### Validation - -- Add command tests for: - - env-backed non-interactive init with no `.env` - - partially populated env that still requires prompting - - existing `.env` unchanged path - -### 2. Clarify `status` Semantics Around Asset Drift - -#### Problem - -`conf status` can report a clean space while Git still shows deleted or modified asset files. That makes it look more complete than it really is. - -#### Plan - -- Decide whether `status` should: - - remain Markdown/page-focused only, or - - gain explicit asset drift reporting -- If status remains page-focused: - - document that clearly in command help and docs - - surface a short note when asset drift exists but is excluded -- If asset drift is added: - - report added/modified/deleted assets separately from Markdown pages - - avoid conflating local asset filesystem changes with remote attachment drift unless both are actually computed - -#### Validation - -- Add command tests covering spaces with: - - page-only changes - - asset-only changes - - mixed page and asset changes - -### 3. Improve `diff` Readability For Metadata Drift - -#### Problem - -`conf diff` is useful, but labels and other synced frontmatter differences are not always easy to interpret as metadata drift versus content drift. - -#### Plan - -- Review how frontmatter is rendered in diff output for: - - labels - - created/updated metadata - - status/state metadata -- Consider a lightweight metadata summary ahead of the textual diff for file mode and space mode. -- Ensure labels are rendered deterministically and comparisons are easy to scan. - -#### Validation - -- Add diff tests for: - - label-only changes - - metadata-only remote changes - - content-only changes - - combined metadata and content changes - -### 4. Tighten Warning Taxonomy And Signal Quality - -#### Problem - -Warnings currently blur together: - -- acceptable preserved absolute Confluence links -- degraded-but-tolerable pull output -- actually broken references that need user action - -#### Plan - -- Introduce clearer warning classes for: - - preserved external/cross-space links - - unresolved but safely degraded references - - broken strict-path references that block push -- Review pull and diff warnings so preserved absolute links do not look like failures. -- Make diagnostics more actionable by including whether a warning requires user intervention. - -#### Validation - -- Add tests proving: - - preserved absolute Confluence links do not emit misleading unresolved warnings - - real unresolved references still emit actionable diagnostics - -### 5. Improve Push Summaries For Attachment Activity - -#### Problem - -Push summaries center on page counts and diagnostics, but attachment creation, deletion, and preservation are not surfaced clearly enough for operators. - -#### Plan - -- Expand push summary output to report: - - attachments uploaded - - attachments deleted - - attachments preserved - - attachment operations skipped due to safety fallbacks -- Keep the summary concise, but ensure attachment-affecting pushes are visibly different from text-only pushes. - -#### Validation - -- Add summary coverage for: - - upload-only push - - delete-only push - - mixed page + attachment push - - keep-orphan-assets behavior - -### 6. Reduce Folder Fallback Warning Noise - -#### Problem - -Confluence returned repeated HTTP 500 responses for `GET /wiki/api/v2/folders`, but fallback behavior worked. The warnings are currently too noisy for an operator who only needs to know that fallback mode was engaged. - -#### Plan - -- De-duplicate repeated folder API fallback warnings within a single run. -- Include one concise explanation that fallback-to-pages mode is active. -- Preserve detailed response info in verbose or debug logs only. - -#### Validation - -- Add logging tests ensuring repeated folder-list failures produce one high-signal operator warning, not repeated noise. - -### 7. Extend `clean` To Handle Stale `sync/*` Branches - -#### Problem - -`conf clean` removes snapshot refs but does not remove stale `sync/*` branches from failed runs. - -#### Plan - -- Extend cleanup logic so safe stale `sync/*` branches are pruned when: - - current branch is not one of them - - no linked worktree remains - - recovery refs are gone or marked safe to remove -- Improve summary output so branch cleanup is explicit. - -#### Validation - -- Add tests for: - - stale sync branches cleaned successfully - - active sync branch preserved - - no-op cleanup on already-clean repo - -### 8. Normalize Paths More Aggressively On Windows - -#### Problem - -State and hierarchy indexes showed inconsistent separator styles during the live run, especially in `folder_path_index`. - -#### Plan - -- Audit path normalization in: - - state serialization - - path indexes - - hierarchy planners - - diagnostics -- Standardize on a single normalized slash style for persisted state files. -- Add regression coverage for mixed-separator inputs on Windows paths. - -#### Validation - -- Add tests for: - - `page_path_index` normalization - - `folder_path_index` normalization - - mixed slash inputs during pull/push/state save cycles - -### 9. Document Extension And Macro Support Explicitly - -#### Problem - -The user-facing support contract for extensions is not explicit enough. The live run showed real differences between PlantUML and Mermaid support. - -#### Plan - -- Add a support matrix to docs covering at least: - - PlantUML - - Mermaid - - raw ADF extension preservation - - unknown Confluence macros/extensions -- Clarify whether each item is: - - rendered round-trip support - - preserved-but-not-rendered - - unsupported - -#### Validation - -- Update `README.md`, `docs/usage.md`, and `AGENTS.md` where applicable. -- Ensure docs align with actual tested behavior. - -### 10. Add A Repeatable Live Sandbox Smoke-Test Workflow - -#### Problem - -The real validation workflow is possible, but it is still too manual and easy to execute inconsistently. - -#### Plan - -- Create a documented sandbox smoke-test workflow for explicit non-production spaces. -- Include: - - workspace bootstrap - - pull/edit/validate/diff/push/pull cycle - - conflict simulation steps - - cleanup expectations -- Consider a helper script or automation entrypoint that runs the safe parts end to end against sandbox-configured spaces only. - -#### Validation - -- Provide a reproducible operator runbook. -- Optionally add a gated manual automation target for sandbox-only live verification. - -### 11. Add Tenant Capability Detection And Adaptive Fallbacks - -#### Problem - -Some Confluence APIs behaved differently than expected during the live run, especially folder APIs and metadata-related paths. Today, `conf` discovers those gaps reactively during a push or pull instead of choosing a stable execution mode up front. - -#### Plan - -- Add lightweight tenant capability probing for features that materially affect sync behavior, such as: - - folders API reliability - - content-status metadata support - - any other known optional or flaky API paths -- Cache capability results for the duration of a run. -- Use those results to choose execution modes deliberately before mutation starts. -- Surface a concise operator summary when `conf` enters degraded or compatibility mode. - -#### Validation - -- Add tests that simulate unsupported or flaky endpoints and verify deterministic fallback mode selection. -- Ensure capability probing never causes remote writes. - -### 12. Strengthen `doctor` For Semantic Sync Corruption - -#### Problem - -`doctor` currently focuses on structural consistency, but it does not detect some meaningful semantic corruption cases that surfaced in the live run, such as unresolved media placeholders or hierarchy shape drift. - -#### Plan - -- Extend `doctor` to detect: - - `UNKNOWN_MEDIA_ID` placeholders - - unresolved embedded-content placeholders - - stale `sync/*` recovery branches - - hierarchy layout that violates documented parent-page conventions - - other known degraded round-trip states -- Classify findings by severity and whether `--repair` can safely fix them. -- Keep repairs conservative; diagnostics are better than destructive auto-fixes. - -#### Validation - -- Add `doctor` tests for each known degraded state found in the live run. -- Add command coverage for both report-only and `--repair` modes. - -### 13. Add A Guided Recovery Flow For Failed Pushes - -#### Problem - -Failed pushes leave retained refs and branches for safety, but there is no guided recovery workflow beyond partial cleanup. That makes recovery more manual than it should be. - -#### Plan - -- Introduce a `conf recover` flow, or expand `clean` substantially, so users can: - - inspect retained failed sync runs - - see why a run failed - - choose to retry, discard, or clean retained recovery artifacts -- Tie recovery output to existing snapshot refs, sync branches, and worktree metadata. -- Keep recovery non-destructive by default. - -#### Validation - -- Add integration tests for: - - failed push recovery inspection - - cleanup of abandoned recovery artifacts - - safe handling when the current branch is itself a recovery branch - -### 14. Add Structured Run Reports - -#### Problem - -Live verification is currently hard to automate consistently because command output is human-readable but not easy to compare mechanically across runs. - -#### Plan - -- Add an optional machine-readable run report output such as `--report-json` for: - - `pull` - - `push` - - `validate` - - `diff` -- Include: - - diagnostics - - mutated files/pages - - attachment operations - - fallback modes entered - - recovery artifacts created - - timing and run IDs -- Keep human-readable output unchanged by default. - -#### Validation - -- Add golden-style tests for report shape stability. -- Ensure JSON reports remain usable in both success and failure paths. - -### 15. Define A Clear Path Stability And Rename Policy - -#### Problem - -Path churn is still somewhat implicit. Renames, hierarchy changes, and sanitized title changes can move files in ways that are not always obvious to operators and can break local links or expectations. - -#### Plan - -- Define and document how `conf` handles: - - title changes - - page moves - - folder moves - - sanitization-driven path changes -- Consider whether alias tracking, rename diagnostics, or path-history metadata are needed. -- Ensure path changes are visible in `diff`, `pull`, and status/recovery diagnostics. - -#### Validation - -- Add tests for: - - title rename round-trips - - hierarchy move round-trips - - sanitized path changes on pull - -### 16. Strengthen Push Preflight And Release Messaging - -#### Problem - -Some failures are still surprising because capability mismatches or degraded behavior are only discovered during execution. Also, the current maturity level should be communicated more explicitly. - -#### Plan - -- Expand preflight so it can optionally report: - - remote capability concerns - - exact planned page and attachment mutations - - known degraded modes before write execution -- Review release docs, README language, and versioning guidance so the product is clearly labeled beta until blocker workstreams are done. -- Keep maturity messaging aligned with actual tested behavior. - -#### Validation - -- Add preflight coverage for degraded-mode reporting. -- Update release-facing docs to match the current maturity contract. - -### 17. Align Generated `AGENTS.md` With Actual Workflow And Documentation Strategy - -#### Problem - -The generated `AGENTS.md` scaffolding is no longer fully aligned with the current codebase, the live-tested behavior, or the desired documentation process: - -- it still splits usage into human-in-the-loop and autonomous modes, even though one general workflow is sufficient -- it still refers to `space` as a normal frontmatter key users must not edit, which is stale relative to the current frontmatter model -- technical templates overstate support for Mermaid and relative cross-space links -- it does not explain the intended direction that generated Specs/PRDs should become the working source of truth for feature behavior and product intent - -#### Plan - -- Update generated workspace and space-level `AGENTS.md` templates in: - - `cmd/init.go` - - `cmd/agents.go` -- Replace the split workflow sections with one general recommended workflow: - - `pull -> edit -> validate -> diff -> push` - - mention that humans may still review or approve specific steps, but do not model that as a separate mode -- Remove stale frontmatter guidance and align template language with the real model: - - `id` remains immutable - - `version` remains sync-managed - - `state`, `status`, and `labels` remain user-editable - - do not present `space` as a normal active frontmatter field -- Add a concise support-contract note or link to docs covering: - - same-space links - - cross-space links - - attachments - - PlantUML - - Mermaid - - hierarchy behavior -- Add explicit guidance that, based on the current codebase and existing plans, new Specs/PRDs should be generated and maintained as the intended source of truth, or at minimum the closest maintained product-definition artifact until the larger planning/doc system is consolidated. -- Ensure generated `AGENTS.md` points readers to the primary plan and any future Specs/PRDs when behavior or requirements are unclear. - -#### Validation - -- Add golden-style tests for generated `AGENTS.md` output. -- Verify generated templates do not mention the old split workflow model. -- Verify generated templates align with current frontmatter behavior and current documented support boundaries. - -### 18. Harden `conf search` Correctness, Backend Parity, And Documentation - -#### Problem - -The `search` feature is useful, but it still has several correctness and productization gaps: - -- the current source exposes `search`, but the checked-in `conf.exe` binary is stale and does not -- README and usage docs are not fully aligned with the implemented flags and behavior -- `--reindex` emits plain-text progress output even in JSON mode, which is unfriendly for automation -- post-`pull` search index updates currently hardcode SQLite instead of respecting configured backend choice -- the indexing model appears vulnerable to stale hits when Markdown files are deleted locally, because existing indexed paths are only purged when the file is still encountered during indexing - -#### Plan - -- Fix release/build hygiene so shipped binaries always match the current command set. -- Align `README.md`, `docs/usage.md`, and generated `AGENTS.md` with the current `search` feature set, including: - - command availability - - engine selection - - filter semantics - - `--result-detail` - - local-only / zero-API-call behavior -- Change search progress reporting so machine-readable output stays clean: - - send progress to stderr, or - - suppress it unless explicitly requested -- Make post-`pull` index updates honor the configured search backend instead of always writing SQLite state. -- Add indexed-path deletion reconciliation so removed Markdown files do not remain searchable after incremental updates or pull-triggered partial indexing. -- Review backend parity between SQLite and Bleve for: - - query results - - label/space facets - - date filters - - incremental update semantics -- Add stronger release coverage so command registration drift is caught before shipping. - -#### Validation - -- Add tests for: - - deleted Markdown file removed from search results after update - - JSON output remains valid when `--reindex` is used - - configured Bleve backend is respected by post-`pull` indexing - - README/help/docs stay aligned with command registration -- Add parity-oriented tests where SQLite and Bleve should return equivalent results for the same fixture set. - -## Suggested Order - -1. `init` env-aware bootstrap -2. warning taxonomy cleanup -3. push summary improvements -4. `clean` sync-branch cleanup -5. Windows path normalization -6. `status` asset semantics -7. `diff` metadata clarity -8. extension support matrix docs -9. live sandbox smoke-test workflow -10. folder fallback warning de-duplication -11. tenant capability detection -12. stronger `doctor` -13. guided recovery flow -14. structured run reports -15. path stability policy -16. preflight and release messaging -17. generated `AGENTS.md` alignment and Specs/PRDs guidance -18. `search` correctness, backend parity, and documentation - -## Success Criteria - -- Operators can bootstrap a workspace non-interactively when credentials are already present in env vars. -- `status`, `diff`, and warning output better reflect what is actually happening without misleading clean/dirty signals. -- Push summaries clearly report attachment activity. -- Cleanup removes all safe stale recovery artifacts, including stale sync branches. -- Persisted state uses stable normalized paths on Windows. -- Docs clearly explain extension support behavior and sandbox verification workflow. -- `doctor` can identify meaningful degraded states from real failed round-trips. -- Operators have a guided recovery path after failed pushes. -- Commands can emit structured run reports for live verification and automation. -- Preflight makes degraded modes and risky capabilities visible before remote writes start. -- Generated `AGENTS.md` scaffolding reflects one general workflow, current product constraints, and the intended Specs/PRDs documentation direction. -- `search` behaves consistently across backends, does not leave stale deleted-file hits behind, and remains automation-friendly in JSON mode. - -## P2 Backlog - -These items are worth doing, but should remain behind the blocker remediation plan and the main polish follow-ups. - -### 19. Add Release Gating With Live Sandbox Smoke Tests - -#### Problem - -Current quality signals are still too synthetic to fully protect releases from workflow regressions that only appear against live Confluence tenants. - -#### Plan - -- Require an explicit sandbox live smoke-test check before promoting release candidates. -- Keep it gated to sandbox-configured spaces only. -- Separate release-blocking live checks from ordinary developer CI to avoid accidental production-space execution. - -### 20. Add Upgrade And Migration Coverage For Older Workspaces - -#### Problem - -Fixes to state files, hierarchy layout, and metadata handling may unintentionally break existing user workspaces created by older versions. - -#### Plan - -- Add migration fixtures for older `.confluence-state.json` and markdown layouts. -- Verify pull/push/status/doctor behavior stays safe after upgrade. -- Document any migration semantics if automatic normalization changes persisted files. - -### 21. Make `--dry-run` Closer To Real Execution - -#### Problem - -`--dry-run` is useful, but it should validate more of the real execution path so operators can trust it as a genuine preflight. - -#### Plan - -- Validate final payload shape, attachment mutation plan, and cleanup plan in dry-run mode. -- Show the exact remote operations that would occur, including page/archive/attachment changes. -- Preserve the guarantee that no local or remote state is mutated. - -### 22. Add Read-Only Inspection For Recovery Artifacts - -#### Problem - -Even before a full `recover` workflow exists, operators need an easy way to inspect failed-run artifacts without dropping into Git internals. - -#### Plan - -- Add a read-only inspection command or submode to list: - - retained `sync/*` branches - - snapshot refs - - failed run timestamps - - associated failure reasons when available - -### 23. Improve No-Op Explainability - -#### Problem - -No-op runs succeed quietly, but they often do not explain why nothing changed, which makes troubleshooting harder. - -#### Plan - -- Improve no-op output for `pull`, `push`, and `clean` so it states why the command was a no-op. -- Distinguish cases such as: - - no local changes - - no remote changes - - changes existed but were intentionally skipped - -### 24. Add Performance And Scale Tests - -#### Problem - -Live validation covered correctness, but not scale. Large spaces, pagination stress, and attachment-heavy pages may still expose bottlenecks or edge-case failures. - -#### Plan - -- Add scale-oriented tests for: - - larger page counts - - attachment-heavy pages - - long pagination chains - - rate-limit and retry pressure - -### 25. Strengthen Destructive Operation Previews - -#### Problem - -Archive/delete pushes should make the exact destructive target set obvious before execution. - -#### Plan - -- Expand preflight and confirmation flows to show exact pages and attachments that will be archived or deleted. -- Keep summaries concise, but make destructive targets explicit. - -### 26. Add A Feature/Tenant Compatibility Matrix - -#### Problem - -Operators need a clearer understanding of what behavior is guaranteed, what is best-effort, and what depends on tenant capability. - -#### Plan - -- Publish a compatibility matrix covering: - - core sync features - - macro/extension support - - tenant capability dependencies - - degraded fallback modes - -### 27. Add Changelog Discipline For Sync Semantics - -#### Problem - -Behavior changes in sync semantics are especially important to operators, but they are easy to lose in generic release notes. - -#### Plan - -- Track user-visible sync behavior changes explicitly in changelog or release-note guidance. -- Highlight changes to: - - hierarchy rules - - attachment handling - - validation strictness - - cleanup/recovery semantics - -### 28. Add Sanitized Golden Live Fixtures - -#### Problem - -Synthetic fixtures are not catching enough real-world edge cases from Confluence content. - -#### Plan - -- Build a sanitized fixture corpus from real pulled pages. -- Use it for round-trip, pull, push, and diff regression tests. -- Keep private or tenant-specific details removed while preserving structure that triggered bugs in the live run. diff --git a/agents/plans/2026-03-07-remaining-polish-and-backlog.md b/agents/plans/2026-03-07-remaining-polish-and-backlog.md deleted file mode 100644 index 93882ab..0000000 --- a/agents/plans/2026-03-07-remaining-polish-and-backlog.md +++ /dev/null @@ -1,241 +0,0 @@ -# Remaining Polish & Backlog Plan - -## Objective - -Consolidate all unfinished work from the March 5 live-workflow polish follow-ups into a single actionable plan. Everything listed here is either a remaining P1 polish item or a P2 backlog item that was deferred during the remediation and polish passes. - -## Relationship To Completed Work - -- `2026-03-05-live-workflow-findings-remediation.md` — production blockers, fully landed on `codex/live-workflow-findings-remediation`. -- `2026-03-05-live-workflow-polish-followups.md` — 16 of 18 main items completed; items 16 and 17 remain. P2 backlog (items 19–28) was untouched. - -## Remaining P1 Items - -### 1. Strengthen Push Preflight And Release Messaging (was item 16) - -#### Problem - -Some failures are still surprising because capability mismatches or degraded behavior are only discovered during execution. Also, the current maturity level should be communicated more explicitly. - -#### Plan - -- Expand preflight so it can optionally report: - - remote capability concerns - - exact planned page and attachment mutations - - known degraded modes before write execution -- Review release docs, README language, and versioning guidance so the product is clearly labeled beta until blocker workstreams are done. -- Keep maturity messaging aligned with actual tested behavior. - -#### Validation - -- Add preflight coverage for degraded-mode reporting. -- Update release-facing docs to match the current maturity contract. - -### 2. Align Generated `AGENTS.md` With Actual Workflow And Documentation Strategy (was item 17) - -#### Problem - -The generated `AGENTS.md` scaffolding is no longer fully aligned with the current codebase, the live-tested behavior, or the desired documentation process: - -- it still splits usage into human-in-the-loop and autonomous modes, even though one general workflow is sufficient -- it still refers to `space` as a normal frontmatter key users must not edit, which is stale relative to the current frontmatter model -- technical templates overstate support for Mermaid and relative cross-space links -- it does not explain the intended direction that generated Specs/PRDs should become the working source of truth for feature behavior and product intent - -#### Plan - -- Update generated workspace and space-level `AGENTS.md` templates in: - - `cmd/init.go` - - `cmd/agents.go` -- Replace the split workflow sections with one general recommended workflow: - - `pull -> edit -> validate -> diff -> push` - - mention that humans may still review or approve specific steps, but do not model that as a separate mode -- Remove stale frontmatter guidance and align template language with the real model: - - `id` remains immutable - - `version` remains sync-managed - - `state`, `status`, and `labels` remain user-editable - - do not present `space` as a normal active frontmatter field -- Add a concise support-contract note or link to docs covering: - - same-space links - - cross-space links - - attachments - - PlantUML - - Mermaid - - hierarchy behavior -- Add explicit guidance that new Specs/PRDs should be generated and maintained as the intended source of truth. -- Ensure generated `AGENTS.md` points readers to the primary plan and any future Specs/PRDs when behavior or requirements are unclear. - -#### Validation - -- Add golden-style tests for generated `AGENTS.md` output. -- Verify generated templates do not mention the old split workflow model. -- Verify generated templates align with current frontmatter behavior and current documented support boundaries. - ---- - -## P2 Backlog - -### 3. Add Release Gating With Live Sandbox Smoke Tests (was item 19) - -#### Problem - -Current quality signals are still too synthetic to fully protect releases from workflow regressions that only appear against live Confluence tenants. - -#### Plan - -- Require an explicit sandbox live smoke-test check before promoting release candidates. -- Keep it gated to sandbox-configured spaces only. -- Separate release-blocking live checks from ordinary developer CI to avoid accidental production-space execution. - -### 4. Add Upgrade And Migration Coverage For Older Workspaces (was item 20) - -#### Problem - -Fixes to state files, hierarchy layout, and metadata handling may unintentionally break existing user workspaces created by older versions. - -#### Plan - -- Add migration fixtures for older `.confluence-state.json` and markdown layouts. -- Verify pull/push/status/doctor behavior stays safe after upgrade. -- Document any migration semantics if automatic normalization changes persisted files. - -### 5. Make `--dry-run` Closer To Real Execution (was item 21) - -#### Problem - -`--dry-run` is useful, but it should validate more of the real execution path so operators can trust it as a genuine preflight. - -#### Plan - -- Validate final payload shape, attachment mutation plan, and cleanup plan in dry-run mode. -- Show the exact remote operations that would occur, including page/archive/attachment changes. -- Preserve the guarantee that no local or remote state is mutated. - -### 6. Add Read-Only Inspection For Recovery Artifacts (was item 22) - -#### Problem - -Even before a full `recover` workflow exists, operators need an easy way to inspect failed-run artifacts without dropping into Git internals. - -#### Plan - -- Add a read-only inspection command or submode to list: - - retained `sync/*` branches - - snapshot refs - - failed run timestamps - - associated failure reasons when available - -### 7. Improve No-Op Explainability (was item 23) - -#### Problem - -No-op runs succeed quietly, but they often do not explain why nothing changed, which makes troubleshooting harder. - -#### Plan - -- Improve no-op output for `pull`, `push`, and `clean` so it states why the command was a no-op. -- Distinguish cases such as: - - no local changes - - no remote changes - - changes existed but were intentionally skipped - -### 8. Add Performance And Scale Tests (was item 24) - -#### Problem - -Live validation covered correctness, but not scale. Large spaces, pagination stress, and attachment-heavy pages may still expose bottlenecks or edge-case failures. - -#### Plan - -- Add scale-oriented tests for: - - larger page counts - - attachment-heavy pages - - long pagination chains - - rate-limit and retry pressure - -### 9. Strengthen Destructive Operation Previews (was item 25) - -#### Problem - -Archive/delete pushes should make the exact destructive target set obvious before execution. - -#### Plan - -- Expand preflight and confirmation flows to show exact pages and attachments that will be archived or deleted. -- Keep summaries concise, but make destructive targets explicit. - -### 10. Add A Feature/Tenant Compatibility Matrix (was item 26) - -#### Problem - -Operators need a clearer understanding of what behavior is guaranteed, what is best-effort, and what depends on tenant capability. - -#### Plan - -- Publish a compatibility matrix covering: - - core sync features - - macro/extension support - - tenant capability dependencies - - degraded fallback modes - -### 11. Add Changelog Discipline For Sync Semantics (was item 27) - -#### Problem - -Behavior changes in sync semantics are especially important to operators, but they are easy to lose in generic release notes. - -#### Plan - -- Track user-visible sync behavior changes explicitly in changelog or release-note guidance. -- Highlight changes to: - - hierarchy rules - - attachment handling - - validation strictness - - cleanup/recovery semantics - -### 12. Add Sanitized Golden Live Fixtures (was item 28) - -#### Problem - -Synthetic fixtures are not catching enough real-world edge cases from Confluence content. - -#### Plan - -- Build a sanitized fixture corpus from real pulled pages. -- Use it for round-trip, pull, push, and diff regression tests. -- Keep private or tenant-specific details removed while preserving structure that triggered bugs in the live run. - ---- - -## Suggested Order - -### Priority 1 — Complete remaining polish -1. Push preflight and release messaging (item 1) -2. Generated `AGENTS.md` alignment (item 2) - -### Priority 2 — Backlog, grouped by theme - -**Operator experience:** -3. No-op explainability (item 7) -4. Destructive operation previews (item 9) -5. Recovery artifact inspection (item 6) - -**Testing and quality gates:** -6. Dry-run fidelity (item 5) -7. Performance and scale tests (item 8) -8. Sanitized golden live fixtures (item 12) -9. Release gating with sandbox smoke tests (item 3) - -**Documentation and contracts:** -10. Feature/tenant compatibility matrix (item 10) -11. Changelog discipline (item 11) - -**Migration safety:** -12. Upgrade and migration coverage (item 4) - -## Success Criteria - -- Preflight makes degraded modes and risky capabilities visible before remote writes start. -- Release docs accurately reflect the current beta maturity contract. -- Generated `AGENTS.md` scaffolding reflects one general workflow, current product constraints, and the intended Specs/PRDs documentation direction. -- Backlog items are tracked and prioritized for post-beta execution. diff --git a/agents/plans/confluence_sync_cli.md b/agents/plans/confluence_sync_cli.md deleted file mode 100644 index 8aecc70..0000000 --- a/agents/plans/confluence_sync_cli.md +++ /dev/null @@ -1,361 +0,0 @@ -# Confluence Markdown Sync CLI Implementation Plan - -## 1. Overview -This document outlines the plan for building a CLI tool in Go that synchronizes Confluence pages with a local directory. It converts Confluence's Atlassian Document Format (ADF) JSON to Markdown for local editing and converts Markdown back to ADF for publishing updates to Confluence. - -This design uses Git as a local history engine only (no Git remote required). The CLI owns Git operations (branches, worktrees, tags, snapshots), so users should not need to run Git commands directly. - -**Binary Name:** `conf` - -**Key Libraries:** -- `github.com/rgonek/jira-adf-converter/converter`: Forward conversion (ADF JSON -> Markdown) via `converter.New(converter.Config)` and `ConvertWithContext(...)`, returning `converter.Result{Markdown, Warnings}`. -- `github.com/rgonek/jira-adf-converter/mdconverter`: Reverse conversion (Markdown -> ADF JSON) via `mdconverter.New(mdconverter.ReverseConfig)` and `ConvertWithContext(...)`, returning `mdconverter.Result{ADF, Warnings}`. -- `github.com/spf13/cobra`: For CLI command structure. -- `github.com/spf13/viper`: For configuration management (environment variables and .env support). -- `gopkg.in/yaml.v3`: For parsing Markdown frontmatter. - -## 2. Architecture & Design - -### 2.1 Authentication -Authentication will be handled via environment variables or a local `.env` file: -- `ATLASSIAN_DOMAIN`: The base URL of the Atlassian instance (e.g., `https://your-domain.atlassian.net`). -- `ATLASSIAN_EMAIL`: The user's email address. -- `ATLASSIAN_API_TOKEN`: The API token generated from Atlassian account settings. - -Compatibility and precedence: -- `conf` may accept legacy `CONFLUENCE_*` variables for backward compatibility. -- Resolution order: `CONFLUENCE_*` (if set) -> `ATLASSIAN_*` -> `.env` file -> error. - -### 2.2 Data Mapping & Storage -- **Directory Structure**: - - **Root (`XXX`)**: The directory where `conf init` is run. Contains `.git`. - - **Space Directory (`XXX/`)**: All pages for a space reside here. -- **File Format**: Markdown (`.md`) with Frontmatter. -- **Title Source of Truth**: Frontmatter `title` field. Fallback to first H1 header if missing. -- **Frontmatter (required fields)**: - - `id`: Stable page ID for update/delete operations. - - `space`: Confluence space key for validation. - - `version`: Last synced remote version. -- **Frontmatter (optional/mutable fields)**: - - `state`: Target page lifecycle state (`draft` or `current`). Defaults to `current` if omitted. When pulling, if the remote page is published, this key is omitted to keep frontmatter clean. - - `status`: Confluence "Content Status" visual lozenge (e.g., "Ready to review"). Syncs with the page state lozenge in the UI. - - `labels`: Confluence page labels (array of strings). -- **Frontmatter Mutability Rules**: - - Immutable: `id`, `space`. - - Mutable by sync only: `version`. - - Mutable by user: `state`, `status`, `labels`. - - Manual or AI edits to immutable keys fail validation. - - Changing `state` from `current` to `draft` on a page that is already published remotely fails validation (Confluence API limitation: cannot unpublish pages). -- **State**: - - **Per-Space State File**: `XXX//.confluence-state.json`. - - **State Keys**: - - `last_pull_high_watermark`: RFC3339 remote-modified timestamp high-watermark. - - `page_path_index`: Map of local path -> page ID (used for local delete detection). - - `attachment_index`: Map of local asset path -> attachment ID (used for rename/delete handling). - - **Git Ignore**: Added to `.gitignore` automatically. - -### 2.3 Workflow - -#### 2.3.0 Git Operating Model -- Git is required locally, but no Git remote is required. -- All Git operations are CLI-managed; users interact through `conf` commands only. -- `init` behaves as follows: - - If no git repo exists: `git init -b main`. - - If git repo exists: Use current branch. - - Prompts for environment variables if missing and creates `.env`. - - Generates `AGENTS.md` and `README.md`. -- **Safety Rule**: NEVER perform real `pull` or `push` operations against a live Confluence space within the repository root. This prevents synced content from being accidentally committed. Use a separate sandbox directory for live tests. -- Recovery and cleanup flows must be provided by CLI behavior/messages rather than manual Git instructions. - -#### 2.3.1 Context Detection -- **Root Context**: If CWD is `XXX`, commands accept `[TARGET]` (`SPACE_KEY` or `.md` file path). -- **Space Context**: If CWD is `XXX/`, commands infer space from the directory name. - -#### 2.3.2 Pull (Confluence -> Local) -**Workflow with CLI-Managed Git Stash:** -1. **Context Check**: Determine Space (Flag or Directory). -2. **Git Scope**: Operations restricted to `XXX/`. -3. **Pre-check**: Run `git status --porcelain -- `. - - If dirty: Run `git stash push --include-untracked -m "Auto-stash " -- ` and capture stash ref. -4. **Fetch and Plan Conversion**: - - Read `last_pull_high_watermark` from `.confluence-state.json`. - - Fetch pages/attachments modified since `(last_pull_high_watermark - overlap_window)` (or all if missing). - - Capture `pull_started_at` (server timestamp) before processing results. - - Track `max_remote_modified_at` from fetched entities. - - Build a deterministic pre-conversion page path map `page_path_by_id` (page ID -> planned Markdown path) before rendering any page content. - - For folder hierarchy, use each page's `parentType`/`parentId`; when `parentType=folder`, resolve ancestor folders via `GET /wiki/api/v2/folders/{id}` chain. - - Page paths must preserve Confluence hierarchy: folder and parent/child relationships map to nested local directories (ancestors as directory segments, page as `*.md` leaf). - - Pages with children are represented as `/.md` to avoid ambiguity with folder-only nodes. - - If parent pages are missing, deleted, or cyclic, fall back to top-level placement for affected pages and continue pull. - - Build planned attachment path map `attachment_path_by_id` (attachment ID -> planned local asset path). - - Convert page ADF to Markdown using `converter.ConvertWithContext(ctx, adfJSON, converter.ConvertOptions{SourcePath: })`. - - Use `converter.Config{ResolutionMode: converter.ResolutionBestEffort, LinkHook: ..., MediaHook: ...}` so unresolved refs degrade to fallback output with warnings instead of failing pull. - - Collect `converter.Result.Warnings` (especially `unresolved_reference`) and surface them as pull diagnostics. - - Pull link rewrite rule: same-space page ID with known target path => rewrite to relative Markdown path from the current file (preserve `#fragment` anchors). - - For external/cross-space links, keep absolute links. - - For unresolved same-space page links, keep original absolute link and emit diagnostics. - - Update/Create files in `XXX/` and download new/updated attachments to `assets//-`. -5. **Reconcile Deletes**: - - Compare remote page/attachment IDs vs local `page_path_index` and `attachment_index`. - - For remote deletions, hard-delete local files/assets (no trash folder) and update indexes. -6. **Persist State**: - - Write updated indexes and set `last_pull_high_watermark = max(max_remote_modified_at, pull_started_at)` only after successful file reconciliation. -7. **Commit (if changed)**: - - Run `git add `. - - If scoped staged changes exist, run `git commit -m "Sync from Confluence: [Space] (v[NewVersion])"`. - - If no scoped staged changes exist, treat as no-op pull (no commit, no tag). -8. **Restore**: - - If stashed: CLI runs `git stash apply --index ` and then `git stash drop `. - - If apply conflicts, stop, report conflicted files, and keep stash entry for CLI-driven recovery (no user Git commands required). -9. **Tag Sync Point**: - - Create annotated tag: `confluence-sync/pull//` only when the pull created a commit. - -#### 2.3.3 Push (Local -> Confluence) -**Change Detection (Git-based, includes uncommitted workspace, per-file commits grouped via sync branch):** -1. **Capture and Validate Workspace Snapshot**: - - **Stash**: Run `git stash push` on target files to clean workspace (Stash-Merge-Pop strategy). - - Capture current workspace state for target scope (`staged`, `unstaged`, `untracked`, and deletions). - - Capture out-of-scope workspace state so it can be restored exactly after merge. - - Run `conf validate [TARGET]` against that captured state. - - Abort `push` if validation fails (and restore stash). -2. **Identify Files**: Determine changed files from the workspace snapshot. - - If no in-scope files changed, exit success as a no-op (`push` creates no snapshot commit, no sync branch/worktree, and no tag). -3. **Create Internal Snapshot Commit**: - - Create an internal snapshot commit from in-scope workspace state and store it under hidden ref `refs/confluence-sync/snapshots//`. - - Snapshot commits are local-only internals and are not added to user-visible branch history. -4. **Create Ephemeral Sync Branch**: `sync//` from the snapshot ref. -5. **Run in Isolated Worktree**: - - Create temporary worktree for the sync branch (e.g., `.confluence-worktrees/-`). - - Execute sync operations in that worktree to avoid touching the user's active working tree. -6. **Process Loop**: For each changed file: - - **Conflict Check**: Compare Remote Version vs Local Frontmatter. - - **Convert Markdown -> ADF**: Run `mdconverter.ConvertWithContext(ctx, markdown, mdconverter.ConvertOptions{SourcePath: })` using `mdconverter.ReverseConfig{ResolutionMode: mdconverter.ResolutionStrict, LinkHook: ..., MediaHook: ...}`. Fail file processing on unresolved refs or invalid hook output. - - **Upload Assets**: If new images are referenced, upload to Confluence. - - **Update/Delete**: - - Modified/Added `.md`: PUT page update to Confluence. - - Deleted `.md`: Archive remote page by default using `page_path_index` (`--hard-delete` optional). - - **Post-Update**: - - Update local file Frontmatter (Version/Modified timestamp) and local state indexes. - - **Git Commit**: - - `git add assets/`. - - `git commit -m "Sync [Page] to Confluence"`. -7. **Finalize**: - - If all page operations succeed, merge sync branch back into the original branch. - - **Pop Stash**: Run `git stash pop` to restore user's uncommitted edits. If Frontmatter conflicts occur (due to version update), leave standard Git conflict markers for user to resolve. - - Create annotated tag: `confluence-sync/push//` only when a merge commit is created. - - If any page operation fails, do not merge and keep sync branch + snapshot ref for CLI-guided recovery. - - If out-of-scope state restoration conflicts, stop, report impacted paths, and keep recovery refs. -8. **Cleanup**: - - Remove temporary worktree after success or failure. - - On full success, delete sync branch and hidden snapshot ref. - - On failure, retain sync branch and hidden snapshot ref for recovery. -9. **Note**: `push` does NOT update `last_pull_high_watermark`. - -#### 2.3.4 Attachment & Link Handling -- **Converter Integration (concrete API)**: - - `pull`/`diff` use `converter.New(converter.Config)` + `ConvertWithContext(ctx, adfJSON, converter.ConvertOptions{SourcePath: ...})`. - - `validate`/`push` use `mdconverter.New(mdconverter.ReverseConfig)` + `ConvertWithContext(ctx, markdown, mdconverter.ConvertOptions{SourcePath: ...})`. - - Both directions surface structured warnings (`converter.Result.Warnings`, `mdconverter.Result.Warnings`) that must be translated into CLI diagnostics. -- **Hook Contract (exact signatures)**: - - Forward link hook: `func(context.Context, converter.LinkRenderInput) (converter.LinkRenderOutput, error)`. - - Forward media hook: `func(context.Context, converter.MediaRenderInput) (converter.MediaRenderOutput, error)`. - - Reverse link hook: `func(context.Context, mdconverter.LinkParseInput) (mdconverter.LinkParseOutput, error)`. - - Reverse media hook: `func(context.Context, mdconverter.MediaParseInput) (mdconverter.MediaParseOutput, error)`. - - Hook metadata includes typed fields (`PageID`, `SpaceKey`, `AttachmentID`, `Filename`, `Anchor`) plus raw attrs payloads for custom mapping logic. -- **Hook Output Validation (library-enforced)**: - - Forward handled link output requires non-empty `Href` unless `TextOnly=true`. - - Forward handled media output requires non-empty `Markdown`. - - Reverse handled link output requires non-empty `Destination`; `ForceLink` and `ForceCard` are mutually exclusive. - - Reverse handled media output requires `MediaType` in `{image,file}` and exactly one of `ID` or `URL`. -- **Invocation Notes (library behavior)**: - - ADF -> Markdown hooks run on link marks, inline cards, then media nodes. - - Markdown -> ADF detects `mention:` links before link hooks; remaining links then pass through hook/card heuristics. -- **Attachments**: - - **Pull Planning**: Build `attachment_path_by_id` before conversion. - - **Download**: CLI scans ADF for media and downloads files to `assets/`. - - **Storage Pattern**: Use `assets//-` to avoid name collisions. - - **Link Rewrite**: Pull media hook rewrites Markdown image/file references to local relative paths (e.g., `![Image](assets/12345/8899-diagram.png)`). - - **Push Mapping**: Push media hook resolves local asset paths to `MediaParseOutput{MediaType, ID|URL, Alt}`; sync uploads missing attachments, then writes resolved IDs/URLs back into the outgoing ADF payload. - - **ADF Mapping**: Push conversion emits ADF `mediaSingle`/`media` nodes with resolved attachment identity (`id` or `url`). -- **Page Links**: - - **Pull Planning**: Build `page_path_by_id` before converting page content. - - **Pull Rewrite**: Pull link hook resolves Confluence page links to relative Markdown links (e.g., `[Link](./ChildPage.md)`) using `page_path_by_id` and current page path. - - **Push Rewrite**: Build `page_id_by_path`, then reverse link hooks resolve local relative links to canonical Confluence destinations before ADF emission. - - **Anchor Handling**: Preserve in-document fragments while rewriting links in both directions. -- **Resolution Modes**: - - **Best-Effort** (`pull`, `diff`): use `ResolutionBestEffort`; `ErrUnresolved` falls back to default behavior and emits diagnostics. - - **Strict** (`validate`, `push`): use `ResolutionStrict`; `ErrUnresolved` fails conversion before any remote write. -- **Side-Effect Boundary**: - - Hooks return mapping decisions only; sync orchestration owns network/filesystem side effects (downloads/uploads/file writes/deletes). - -#### 2.3.4.1 Diagram Extension Contract -- **PlantUML**: - - Supported as a first-class Confluence extension through the existing `plantumlcloud` extension handler. - - Pull and push must preserve authored Markdown round-trip semantics for supported PlantUML blocks. -- **Mermaid**: - - Not treated as a first-class rendered Confluence extension. - - Markdown Mermaid fences push as ADF `codeBlock` nodes with language `mermaid`. - - Pull must preserve those `codeBlock` nodes as authored Mermaid fenced code. - - `validate` must emit a non-fatal warning before push so users know Mermaid will be preserved as code, not rendered as a Confluence diagram macro. - -#### 2.3.5 Git Integration Enhancements -- **Smart .gitignore**: `init` adds `.DS_Store`, `*.tmp`, `.confluence-state.json`, `.env`, `conf.exe`, etc. -- **Diff Command**: `conf diff [TARGET]` fetches remote, converts to MD, and runs `git diff --no-index` (`.md` suffix => file mode, otherwise space mode). -- **Rich Commits**: - - Subject: `Sync "[Page Title]" to Confluence (v[Version])` - - Body: `Page ID: [ID]\nURL: [URL]` - - Trailers: - - `Confluence-Page-ID: [ID]` - - `Confluence-Version: [VERSION]` - - `Confluence-Space-Key: [SPACE_KEY]` - - `Confluence-URL: [URL]` -- **Asset Tracking**: `push` automatically runs `git add` on referenced images in `assets/`. -- **Grouped Push Runs**: Use an ephemeral sync branch so per-file commits stay granular while the run is merged as one unit on success. -- **Isolated Sync Execution**: Use `git worktree` for push runs to avoid mutating the user's active working tree. -- **Workspace Snapshot Pushes**: Include uncommitted (`staged`, `unstaged`, `untracked`, deletions) local changes in push runs via an internal snapshot commit. -- **Internal Snapshot Refs**: Store snapshot commits under `refs/confluence-sync/snapshots/...`; clean them on full success and retain them on failure for recovery. -- **Out-of-Scope Preservation**: After successful push merge, restore all out-of-scope local workspace changes exactly as they were before push. -- **Local-Only Git**: No Git remote is required; the CLI does not depend on Git fetch/push operations. -- **No-Manual-Git UX**: All recovery and cleanup paths are surfaced through CLI flows/messages. -- **Sync Tags**: Create annotated tags only for successful non-no-op sync runs (runs that produce a pull commit or push merge commit). - -#### 2.3.6 Interactivity -- **Space Selection**: If `pull` is run without args in root, fetch spaces and show interactive list (using `charmbracelet/huh` or similar). -- **Conflict Resolution**: If `push` detects remote is ahead, prompt user: `[P]ull & Merge`, `[F]orce Overwrite`, `[C]ancel`. -- **Automation Flags**: - - `--yes`: Auto-approve confirmation prompts (for example, bulk-change or delete confirmations). Does not auto-resolve version conflicts. - - `--non-interactive`: Disable prompts; fail fast when a required decision is missing. - - `pull --force` (`-f`): Force full-space pull planning and conversion even when incremental change detection reports no changes. - - `push --on-conflict=pull-merge|force|cancel`: Non-interactive equivalent for remote-ahead conflict decisions. -- **Safety Confirmation**: If `pull` or `push` affects >10 files or performs remote/local deletes, prompt for confirmation `[y/N]`; `--yes` auto-approves, and `--non-interactive` without `--yes` fails. - -#### 2.3.7 Validation Gate -- **`validate` Command**: Checks frontmatter schema, immutable key integrity, link/asset resolution, and Markdown -> ADF conversion. -- **Converter Profile Match**: `validate` uses the same strict reverse-conversion profile as `push` (`mdconverter.ResolutionStrict` + same link/media hook adapters) so push behavior is predictable. -- **Pre-Push Requirement**: `push` always runs `validate` first. -- **Failure Output**: Returns machine-readable and human-readable diagnostics (file path, field/error, remediation hint). - -#### 2.3.8 Developer Tooling -- Provide a top-level `Makefile` for common local workflows (build, test, lint/format, and CLI smoke checks). -- Keep `Makefile` targets aligned with the implemented command set and CI expectations. - -## 3. CLI Commands - -| Command | Arguments | Description | -| :--- | :--- | :--- | -| `init` | none | Checks git installed. Initializes local repo on branch `main` if needed (or uses current branch), creates `.gitignore` (ignoring `.confluence-state.json`, `.env`), verifies config/prompts for env vars, creates `AGENTS.md` and `README.md`. | -| `pull` | `[TARGET]` | Pulls entire space or a single file. If `TARGET` ends with `.md`, treat as file path; otherwise treat as `SPACE_KEY`. Commits changes, updates state watermark, and manages dirty workspace restoration. Provides interactive conflict resolution when auto-merge fails. Supports `--discard-local` to overwrite local changes. | -| `push` | `[TARGET]` | Pushes all changes in space or one file. If `TARGET` ends with `.md`, treat as file path; otherwise treat as `SPACE_KEY`. Includes uncommitted local changes through an internal workspace snapshot. Automatically triggers `pull` on version conflicts if `--on-conflict=pull-merge` is used. | - -| `validate` | `[TARGET]` | Validates sync invariants before push: frontmatter schema, immutable key integrity, links/assets, and Markdown->ADF conversion. | -| `diff` | `[TARGET]` | Shows file- or space-scoped diff against Confluence. If `TARGET` ends with `.md`, treat as file path; otherwise treat as `SPACE_KEY`. | - -Automation support for `pull`/`push`: `--yes`, `--non-interactive`; `pull` additionally supports `--skip-missing-assets`, `--force`, and `--discard-local`; `push` additionally supports `--on-conflict=pull-merge|force|cancel`. - - -## 4. Delivery Plan (PR-by-PR) - -### 4.0 Merge Rules (apply to every PR) -- [ ] Keep invariants intact: `push` always gates on `validate`, immutable frontmatter keys remain enforced, and no unresolved strict conversion reaches remote writes. -- [ ] Keep docs aligned in the same PR (`README.md`, `AGENTS.md`, and this plan when behavior changes). -- [ ] Add or update tests for changed behavior before merge. -- [ ] Keep each PR independently reviewable and shippable. - -### PR-01 - CLI Bootstrap, Init, Config, and Tooling -Checklist: -- [ ] Initialize module, command tree (`cobra`), and shared target parser (`.md` => file mode, else space mode). -- [ ] Implement config loading (`ATLASSIAN_*`, compatibility `CONFLUENCE_*`, and `.env` support). -- [ ] Implement `init` command (git checks/bootstrap, `.env`, `.gitignore`, template docs). -- [ ] Wire shared automation flags (`--yes`, `--non-interactive`) and push conflict flag parsing (`--on-conflict`). -- [ ] Add top-level `Makefile` (`build`, `test`, `lint`/`fmt`). -Done criteria: -- [ ] `conf --help` shows all planned commands and flags. -- [ ] `init` can bootstrap a fresh repo on `main` and initialize config files. -- [ ] Unit tests cover target parsing and config precedence. - -### PR-02 - Confluence Client and Local Data Model Foundation -Checklist: -- [x] Create `confluence` package with page/space/change APIs and archive/delete endpoints. -- [x] Create filesystem/state layer: frontmatter read/write, path sanitization, `.confluence-state.json` IO. -- [x] Implement immutable frontmatter key checks and schema validation primitives. -Done criteria: -- [x] Unit tests cover frontmatter schema, immutable key protection, and state persistence. -- [x] Client interfaces are stable enough for `pull`/`push` orchestration. - -### PR-03 - Converter Adapter + Hook Profiles + `validate` -Checklist: -- [x] Integrate `converter` and `mdconverter` with internal adapter constructors. -- [x] Implement forward profile (`best_effort`) for `pull`/`diff` and reverse profile (`strict`) for `validate`/`push`. -- [x] Pass `ConvertOptions{SourcePath: ...}` through all conversion entrypoints. -- [x] Implement hook adapters (link/media both directions) and warning-to-diagnostic mapping. -- [x] Implement `validate [TARGET]` with strict reverse conversion + hook parity with planned `push` profile. -Done criteria: -- [x] `validate` fails on strict unresolved refs and immutable-key edits. -- [x] Unit tests cover unresolved behavior (`best_effort` vs `strict`) and hook-output validation constraints. - - -### PR-04 - `pull` End-to-End (Best-Effort Conversion) -Checklist: -- [x] Implement incremental fetch (watermark + overlap), deterministic path planning (`page_path_by_id`, `attachment_path_by_id`), and conversion flow. -- [x] Implement link/media rewrite behavior, anchor preservation, attachment download, and delete reconciliation. -- [x] Implement scoped stash/restore flow, no-op detection, scoped commit creation, and pull sync tagging. -Done criteria: -- [x] Integration tests verify pull rewrites, unresolved diagnostics fallback, delete reconciliation, and watermark updates. -- [x] Integration tests verify stash restore behavior and tag creation only on non-no-op pulls. - -### PR-05 - `push` v1 (Functional Sync Loop on Clean Workspace) -Checklist: -- [x] Implement in-scope change detection and mandatory pre-push `validate` gate. -- [x] Build `page_id_by_path` / `attachment_id_by_path` lookup maps and strict reverse conversion before writes. -- [x] Implement conflict policy handling (`pull-merge|force|cancel`), page update/archive flow, attachment upload/delete flow. -- [x] Implement per-file commits with structured trailers and no-op push short-circuit. -Done criteria: -- [x] Integration tests verify strict unresolved failures happen before remote writes. -- [x] Integration tests verify conflict-policy behavior and push commit trailer format. - -### PR-06 - `push` v2 (Isolated Worktree + Snapshot/Recovery Model) -Checklist: -- [x] Implement hidden snapshot refs (`refs/confluence-sync/snapshots/...`) for in-scope workspace capture. -- [x] Implement ephemeral sync branch + temporary worktree lifecycle. -- [x] Include staged/unstaged/untracked/deleted workspace changes in push snapshots. -- [x] Implement merge-on-success, cleanup-on-success, and retain-on-failure behavior for recovery refs. -- [x] Restore out-of-scope workspace state exactly after successful merge and create non-no-op push sync tags. -Done criteria: -- [x] Integration tests verify snapshot/worktree lifecycle, failure retention, and success cleanup. -- [x] Integration tests verify out-of-scope workspace preservation and no-op push (no refs/merge/tag). - -### PR-07 - `diff`, Hardening, and Final Test Matrix -Checklist: -- [x] Implement `diff [TARGET]` with best-effort remote conversion and scoped comparison. -- [x] Finalize non-interactive behavior (`--yes`, `--non-interactive`, `--on-conflict`) across pull/push. -- [x] Add/finish round-trip golden tests and end-to-end integration scenarios (including no-git-remote environment). -- [x] Refresh docs to final behavior (`README.md`, `AGENTS.md`, plan notes). -Done criteria: -- [x] `diff` works in both file and space modes. -- [x] Full CI test matrix passes with all invariants covered. -- [x] Docs describe the implemented behavior without plan drift. - -## 5. Directory Structure -``` -confluence-markdown-sync/ -├── cmd/ -│ ├── conf/ -│ │ └── main.go -│ ├── root.go -│ ├── pull.go -│ ├── push.go -│ ├── init.go -│ ├── validate.go -│ └── diff.go -├── internal/ -│ ├── config/ # Env var loading -│ ├── confluence/ # API client -│ ├── converter/ # Markdown <-> ADF logic -│ ├── fs/ # File system & Frontmatter handling -│ ├── git/ # Git command wrappers -│ └── sync/ # Core synchronization logic (Tree building) -├── Makefile -├── go.mod -├── go.sum - -``` diff --git a/agents/plans/full-text-search.md b/agents/plans/full-text-search.md deleted file mode 100644 index 866f6e2..0000000 --- a/agents/plans/full-text-search.md +++ /dev/null @@ -1,327 +0,0 @@ -# Plan: Full-Text Search with Bleve + SQLite FTS5 (Dual Backend) - -## Context - -AI agents using `conf` are token-expensive during reads because grep/ripgrep has no awareness of document structure. The Atlassian MCP wins on reads because it returns targeted, structured results. Adding `conf search` with Goldmark (markdown AST) and a pluggable search backend gives agents heading-anchored, faceted search — the "search-then-read" pattern that makes MCP efficient, but locally with zero API calls. - -We implement **two backends** (Bleve and SQLite FTS5) behind a shared interface to evaluate which works better in practice, then drop the loser. - -## Architecture - -``` -cmd/search.go -- CLI command, output formatting - | -internal/search/ - parser.go -- Goldmark AST → sections, code blocks - document.go -- Shared document types - store.go -- Store interface - indexer.go -- Orchestrates file walking + store calls - | - ├── blevestore/store.go -- Bleve scorch implementation - └── sqlitestore/store.go -- SQLite + FTS5 implementation -``` - -### Store Interface - -```go -type Store interface { - Index(docs []Document) error // Upsert documents for a file - DeleteByPath(relPath string) error // Remove all docs for a file - Search(opts SearchOptions) ([]SearchResult, error) - ListLabels() ([]string, error) // All unique labels with counts - ListSpaces() ([]string, error) // All unique space keys - UpdateMeta() error // Mark index timestamp - LastIndexedAt() (time.Time, error) // Read index timestamp - Close() error -} -``` - -Both backends implement this. The `Indexer` orchestrates file walking and calls `Store` methods — it never touches Bleve or SQLite directly. - -### Document Model (shared, 3 types) - -| Doc Type | ID Pattern | Purpose | -|----------|-----------|---------| -| `page` | `page:` | Full file: frontmatter facets + full body text | -| `section` | `section::` | Heading-anchored section: heading hierarchy + section content | -| `code` | `code::` | Fenced code block: language tag + content + heading context | - -All types carry denormalized frontmatter fields (`space_key`, `labels`, `title`, `page_id`) for zero-join filtering. - -```go -type Document struct { - ID string // Composite ID - Type string // "page", "section", "code" - Path string // Relative path (forward slashes) - PageID string - Title string - SpaceKey string - Labels []string - Content string // Body (page), section text, or code content - HeadingPath []string // Heading hierarchy for sections/code - HeadingText string // Innermost heading text - HeadingLevel int - Language string // Code block language - Line int // 1-based start line - ModTime time.Time -} -``` - -## File Layout - -``` -internal/search/ - document.go -- Document struct, SearchResult, Match types - store.go -- Store interface definition - parser.go -- ParseMarkdownStructure() Goldmark AST walker - parser_test.go - indexer.go -- Indexer: file walking, Store orchestration - indexer_test.go - search_testhelpers_test.go -- Shared test helpers - - blevestore/ - store.go -- Bleve Store implementation - store_test.go - mapping.go -- Bleve index mapping/schema - - sqlitestore/ - store.go -- SQLite+FTS5 Store implementation - store_test.go - schema.go -- DDL statements, migration - -cmd/ - search.go -- newSearchCmd(), runSearch(), formatters - search_test.go -``` - -## Implementation Phases - -### Phase 1: Shared Types + Goldmark Parser -**Files:** `internal/search/document.go`, `internal/search/store.go`, `internal/search/parser.go`, `internal/search/parser_test.go` - -Zero external dependencies beyond Goldmark (already in go.mod). - -**`ParseMarkdownStructure(source []byte) ParseResult`** walks the Goldmark AST: -1. Collect all `*ast.Heading` nodes (level, text, line) and `*ast.FencedCodeBlock` nodes -2. Build sections with heading stack: pop entries when same-or-higher level heading arrives -3. Map code blocks to enclosing heading context by line position - -**Reuse pattern from:** `internal/sync/assets.go:34` — `goldmark.New().Parser().Parse(text.NewReader(source))` + `ast.Walk` - -**Line numbers:** Convert Goldmark byte offsets (`node.Lines().At(0).Start`) to 1-based lines by counting `\n` in `source[:offset]`. - -### Phase 2a: Bleve Backend -**Files:** `internal/search/blevestore/mapping.go`, `internal/search/blevestore/store.go`, `internal/search/blevestore/store_test.go` - -**Dependency:** `go get github.com/blevesearch/bleve/v2` - -**Index location:** `/.confluence-search-index/bleve/` - -**Field mapping:** -- **keyword** (exact match): `type`, `path`, `page_id`, `space_key`, `labels`, `language` -- **text** (standard analyzer): `title`, `content`, `heading_text`, `heading_path` -- **numeric**: `heading_level`, `line` -- **date**: `mod_time` - -**Search query construction:** -- Text: `DisjunctionQuery` across `content` (boost 2.0), `heading_text` (1.5), `title` (1.0) -- Filters: `TermQuery` on keyword fields -- Combined: `ConjunctionQuery` -- Result aggregation: group hits by `path`, sort by top score - -### Phase 2b: SQLite FTS5 Backend -**Files:** `internal/search/sqlitestore/schema.go`, `internal/search/sqlitestore/store.go`, `internal/search/sqlitestore/store_test.go` - -**Dependency:** `go get modernc.org/sqlite` (pure Go, no CGo — works on Windows without gcc) - -**Index location:** `/.confluence-search-index/search.db` - -**Schema:** -```sql -CREATE TABLE IF NOT EXISTS documents ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL, -- 'page', 'section', 'code' - path TEXT NOT NULL, - page_id TEXT, - title TEXT, - space_key TEXT, - labels TEXT, -- JSON array: '["arch","security"]' - content TEXT, - heading_path TEXT, -- JSON array: '["## Foo","### Bar"]' - heading_text TEXT, - heading_level INTEGER, - language TEXT, - line INTEGER, - mod_time TEXT -); - -CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(path); -CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(type); -CREATE INDEX IF NOT EXISTS idx_documents_space ON documents(space_key); - --- FTS5 virtual table for full-text search -CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5( - title, - content, - heading_text, - content=documents, - content_rowid=rowid, - tokenize='porter unicode61' -); - --- Triggers to keep FTS in sync -CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN - INSERT INTO documents_fts(rowid, title, content, heading_text) - VALUES (new.rowid, new.title, new.content, new.heading_text); -END; --- (similar for UPDATE/DELETE triggers) - -CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, - value TEXT -); -``` - -**Search query:** -```sql -SELECT d.*, fts.rank -FROM documents_fts fts -JOIN documents d ON d.rowid = fts.rowid -WHERE documents_fts MATCH ? - AND d.type IN ('section', 'code') - AND (? = '' OR d.space_key = ?) - AND (? = '' OR EXISTS ( - SELECT 1 FROM json_each(d.labels) WHERE json_each.value = ? - )) -ORDER BY fts.rank -LIMIT ? -``` - -**Label listing:** `SELECT DISTINCT j.value FROM documents, json_each(documents.labels) j ORDER BY j.value` - -### Phase 3: Indexer (shared orchestration) -**Files:** `internal/search/indexer.go`, `internal/search/indexer_test.go` - -The `Indexer` operates on the `Store` interface — backend-agnostic. - -```go -type Indexer struct { - store Store - rootDir string -} - -func NewIndexer(store Store, rootDir string) *Indexer -func (ix *Indexer) Reindex() (int, error) // Full reindex -func (ix *Indexer) IndexSpace(spaceDir, spaceKey string) (int, error) -func (ix *Indexer) IncrementalUpdate() (int, error) // Mtime-based delta -func (ix *Indexer) Close() error -``` - -**Per-file indexing flow:** -1. `fs.ReadMarkdownDocument(absPath)` — get frontmatter + body -2. `ParseMarkdownStructure(body)` — get sections + code blocks -3. `store.DeleteByPath(relPath)` — remove old documents -4. Build `[]Document` (1 page + N sections + M code blocks) -5. `store.Index(docs)` — insert all - -**File walking:** Reuse the standard skip pattern (`assets/`, `.`-prefixed dirs, `.md` only) from `internal/sync/index.go`. Discover spaces via `fs.FindAllStateFiles()`. - -### Phase 4: CLI Command -**Files:** `cmd/search.go`, `cmd/search_test.go` - -**Command:** `conf search QUERY [flags]` - -**Flags:** -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--space` | string | "" | Filter to specific space key | -| `--label` | []string | nil | Filter by label (repeatable) | -| `--heading` | string | "" | Filter to sections under matching headings | -| `--format` | string | auto | `text` or `json` (auto: TTY→text, pipe→json) | -| `--limit` | int | 20 | Max results | -| `--reindex` | bool | false | Force full reindex before searching | -| `--engine` | string | "sqlite" | Backend: `bleve` or `sqlite` (for A/B testing) | -| `--list-labels` | bool | false | List all indexed labels and exit | -| `--list-spaces` | bool | false | List all indexed spaces and exit | - -**`runSearch` flow:** -1. `gitRepoRoot()` -2. Open store based on `--engine` flag -3. Create `Indexer` with store -4. If `--reindex`: full reindex, else: incremental update -5. If `--list-labels`: `store.ListLabels()` → format → exit -6. If `--list-spaces`: `store.ListSpaces()` → format → exit -7. `store.Search(opts)` → format output - -**Text output:** -``` -DEV/security/overview.md - Security Overview [architecture, security] - ## OAuth2 Flow > ### Token Refresh (line 87) - ...refresh tokens are rotated every 15 minutes using PKCE... -``` - -**JSON output:** `[]SearchResult` with `json.Encoder.SetIndent("", " ")` - -**Registration:** Add `newSearchCmd()` to `rootCmd.AddCommand(...)` in `cmd/root.go:98` - -### Phase 5: Integration Hooks -**Modified files:** `cmd/pull.go`, `cmd/clean.go`, `cmd/init.go` - -**5a. Post-pull indexing** (`cmd/pull.go` after line 336): -```go -if err := updateSearchIndexForSpace(repoRoot, pullCtx.spaceDir, pullCtx.spaceKey, out); err != nil { - _, _ = fmt.Fprintf(out, "warning: search index update failed: %v\n", err) -} -``` -Non-fatal — a failed index update never fails a pull. - -**5b. Clean command** (`cmd/clean.go` after line 124): -Remove search index directories as part of clean artifacts. - -**5c. Gitignore** (`cmd/init.go`): -- Add `.confluence-search-index/` to `gitignoreContent` template (line 15) -- Add `.confluence-search-index/` to `ensureGitignore()` entries (line 221) - -**5d. Repo `.gitignore`** — add `.confluence-search-index/` - -### Phase 6: Documentation -- Update `AGENTS.md` with `conf search` command reference -- Update `docs/usage.md` with search docs - -## Implementation Order - -1. **Phase 1** — Shared types + Goldmark parser (testable immediately, no new deps) -2. **Phase 2b** — SQLite backend first (simpler, pure Go, faster to get working) -3. **Phase 3** — Indexer (uses SQLite backend for initial testing) -4. **Phase 4** — CLI command (end-to-end working with SQLite) -5. **Phase 5** — Integration hooks -6. **Phase 2a** — Bleve backend (add second backend, compare) -7. **Phase 6** — Documentation + decide which backend to keep - -## Critical Files Reference - -| File | Action | -|------|--------| -| `internal/fs/frontmatter.go` | Reuse `ReadMarkdownDocument()`, `ReadFrontmatter()`, `NormalizeLabels()` | -| `internal/fs/state.go` | Reuse `FindAllStateFiles()`, `LoadState()` | -| `internal/sync/assets.go:34` | Reference Goldmark AST walk pattern | -| `internal/sync/index.go` | Replicate WalkDir skip logic | -| `cmd/root.go:98` | Add `newSearchCmd()` | -| `cmd/pull.go:336` | Insert post-pull indexing hook | -| `cmd/clean.go:124` | Insert search index cleanup | -| `cmd/init.go:15,221` | Add `.confluence-search-index/` to gitignore | - -## Verification - -1. **Unit tests:** `make test` — all new tests pass -2. **Smoke test both backends:** - - `conf search "term" --engine sqlite` vs `conf search "term" --engine bleve` - - Compare: speed, result quality, index size -3. **Facet discovery:** - - `conf search --list-labels --format json` → verify all labels returned - - `conf search --list-spaces --format json` → verify all spaces returned -4. **Incremental:** Edit a file → `conf search "term"` → verify only changed file reindexed -5. **Post-pull:** `conf pull SPACE` → verify "Updated search index" message -6. **Clean:** `conf clean` → verify index removed -7. **Pipe:** `conf search "term" | head` → verify auto-JSON format -8. **Init:** `conf init` → verify `.gitignore` includes `.confluence-search-index/` diff --git a/agents/plans/refactor-large-files.md b/agents/plans/refactor-large-files.md deleted file mode 100644 index d8c3a40..0000000 --- a/agents/plans/refactor-large-files.md +++ /dev/null @@ -1,226 +0,0 @@ -# Refactor Plan: Files with 800+ Lines of Code - -**Date:** 2026-02-27 -**Goal:** Break up all Go source files exceeding 800 lines into smaller, focused units with clear single responsibilities. No behaviour changes. - ---- - -## Affected Files (sorted by size) - -| File | Lines | Priority | -|------|-------|----------| -| `internal/sync/push.go` | 2,587 | High | -| `internal/confluence/client.go` | 1,772 | High | -| `internal/sync/pull.go` | 1,642 | High | -| `cmd/push_test.go` | 1,632 | Medium | -| `cmd/pull.go` | 1,470 | High | -| `cmd/push.go` | 1,371 | High | -| `cmd/pull_test.go` | 1,303 | Medium | -| `internal/confluence/client_test.go` | 1,151 | Medium | -| `internal/sync/pull_test.go` | 969 | Low | - ---- - -## Guiding Principles - -1. **No behaviour changes.** Refactoring only — all existing tests must stay green. -2. **One responsibility per file.** Each extracted file should have a clear, single focus. -3. **Keep package membership.** All extracted files stay in the same Go package as the source. -4. **Test files mirror source files.** When a source file is split, split its test file to match. -5. **Run `make test` after every split** to verify no regressions. - ---- - -## Phase 1 — `internal/sync/push.go` (2,587 lines) - -This is the most critical and largest file. Split into 6 focused files: - -### Extracted files - -| New file | Responsibility | Approx lines | -|----------|---------------|--------------| -| `internal/sync/push.go` | Orchestration entry point: `Push(...)`, `PushOptions`, `PushResult`, `PushRemote` interface | ~300 | -| `internal/sync/push_types.go` | All types and enums: `PushChangeType`, `PushFileChange`, `PushCommitPlan`, `PushConflictPolicy`, `pushRollbackTracker` | ~150 | -| `internal/sync/push_page.go` | Page upsert/delete pipeline: `pushUpsertPage`, `pushDeletePage`, `syncPageMetadata` | ~400 | -| `internal/sync/push_hierarchy.go` | Folder hierarchy: `ensureFolderHierarchy`, `precreatePendingPushPages` | ~250 | -| `internal/sync/push_assets.go` | Asset/attachment pipeline: `BuildStrictAttachmentIndex`, `CollectReferencedAssetPaths`, `PrepareMarkdownForAttachmentConversion`, `migrateReferencedAssetsToPageHierarchy` | ~400 | -| `internal/sync/push_adf.go` | ADF post-processing: `ensureADFMediaCollection`, `walkAndFixMediaNodes` | ~200 | - -### Steps - -1. Create `push_types.go` — move all struct/enum type declarations out of `push.go`. -2. Create `push_assets.go` — move asset/attachment resolution functions. -3. Create `push_adf.go` — move ADF media node fixup functions. -4. Create `push_hierarchy.go` — move folder hierarchy and pre-create functions. -5. Create `push_page.go` — move individual page upsert/delete and metadata sync. -6. Trim `push.go` down to the orchestration entry point. -7. Run `make test`. - ---- - -## Phase 2 — `internal/confluence/client.go` (1,772 lines) - -Split into 5 focused files: - -### Extracted files - -| New file | Responsibility | Approx lines | -|----------|---------------|--------------| -| `internal/confluence/client.go` | `Client` struct, `ClientConfig`, constructor, `newRequest`, `do`, auth, rate-limit, retry core | ~350 | -| `internal/confluence/client_errors.go` | `APIError`, `decodeAPIErrorMessage`, `mapConfluenceErrorCode`, `confluenceStatusHint`, sentinel errors | ~150 | -| `internal/confluence/client_spaces.go` | `ListSpaces`, `GetSpace` | ~100 | -| `internal/confluence/client_pages.go` | `ListPages`, `GetPage`, `GetFolder`, `CreatePage`, `UpdatePage`, `DeletePage`, `CreateFolder`, `MovePage`, `ArchivePages`, `WaitForArchiveTask`, `getArchiveTaskStatus` | ~600 | -| `internal/confluence/client_attachments.go` | `ListAttachments`, `DownloadAttachment`, `UploadAttachment`, `DeleteAttachment`, attachment ID resolution helpers | ~400 | - -### Steps - -1. Create `client_errors.go` — move error types and helpers. -2. Create `client_spaces.go` — move space methods. -3. Create `client_attachments.go` — move attachment methods. -4. Create `client_pages.go` — move page/folder/archive methods. -5. Trim `client.go` to core HTTP machinery. -6. Run `make test`. - -### Test file split (`client_test.go`, 1,151 lines) - -Mirror the source split: - -| New file | Tests for | -|----------|-----------| -| `client_test.go` | Core client, auth, user-agent, `GetUser` | -| `client_spaces_test.go` | `ListSpaces`, `GetSpace` | -| `client_pages_test.go` | Page/folder/archive methods | -| `client_attachments_test.go` | Attachment methods | -| `client_errors_test.go` | Error decoding, token-leak security test | - ---- - -## Phase 3 — `internal/sync/pull.go` (1,642 lines) - -Split into 5 focused files: - -### Extracted files - -| New file | Responsibility | Approx lines | -|----------|---------------|--------------| -| `internal/sync/pull.go` | Entry point: `Pull(...)`, `PullOptions`, `PullResult`, `PullRemote` interface, `Progress` interface | ~250 | -| `internal/sync/pull_types.go` | Internal structs used during pull orchestration | ~100 | -| `internal/sync/pull_pages.go` | Page listing, change feed, folder hierarchy: `listAllPages`, `listAllChanges`, `ResolveFolderPathIndex`, `resolveFolderHierarchyFromPages` | ~350 | -| `internal/sync/pull_paths.go` | Path planning: `PlanPagePaths`, `deletedPageIDs`, `movedPageIDs`, `recoverMissingPages` | ~300 | -| `internal/sync/pull_assets.go` | Attachment handling: `collectAttachmentRefs`, `resolveUnknownAttachmentRefsByFilename`, `removeAttachmentsForPage` | ~350 | - -### Steps - -1. Create `pull_types.go` — move internal type declarations. -2. Create `pull_assets.go` — move attachment/media resolution. -3. Create `pull_paths.go` — move path planning and deletion helpers. -4. Create `pull_pages.go` — move listing and hierarchy resolution. -5. Trim `pull.go` to the entry point. -6. Run `make test`. - -### Test file split (`pull_test.go`, 969 lines) - -| New file | Tests for | -|----------|-----------| -| `pull_test.go` | Core orchestration, incremental, force-full, draft recovery | -| `pull_paths_test.go` | `PlanPagePaths` variants, folder hierarchy fallback | -| `pull_assets_test.go` | Asset resolution, unknown media ID, skip-missing-assets | - ---- - -## Phase 4 — `cmd/pull.go` (1,470 lines) - -Split into 4 focused files: - -### Extracted files - -| New file | Responsibility | Approx lines | -|----------|---------------|--------------| -| `cmd/pull.go` | Command definition, flags, `runPull` entry point | ~200 | -| `cmd/pull_state.go` | State loading and healing: `loadPullStateWithHealing`, `rebuildStateFromConfluenceAndLocal` | ~250 | -| `cmd/pull_stash.go` | Git stash lifecycle and conflict resolution: `stashScopeIfDirty`, `applyAndDropStash`, `handlePullConflict`, `applyPullConflictChoice`, `fixPulledVersionsAfterStashRestore` | ~400 | -| `cmd/pull_context.go` | Target resolution and impact estimation: `resolveInitialPullContext`, `estimatePullImpactWithSpace`, `cleanupFailedPullScope` | ~300 | - -### Steps - -1. Create `pull_context.go`. -2. Create `pull_state.go`. -3. Create `pull_stash.go`. -4. Trim `cmd/pull.go` to the command entry point. -5. Run `make test`. - -### Test file split (`cmd/pull_test.go`, 1,303 lines) - -| New file | Tests for | -|----------|-----------| -| `cmd/pull_test.go` | Core run-pull lifecycle, tag creation, no-op | -| `cmd/pull_stash_test.go` | Stash restore, Keep Both conflict, stash-with-discard-local | -| `cmd/pull_state_test.go` | State healing, corrupt state recovery | -| `cmd/pull_context_test.go` | Force flag, safety confirmations, non-interactive gating | - ---- - -## Phase 5 — `cmd/push.go` (1,371 lines) - -Split into 4 focused files: - -### Extracted files - -| New file | Responsibility | Approx lines | -|----------|---------------|--------------| -| `cmd/push.go` | Command definition, flags, `runPush` entry point | ~200 | -| `cmd/push_worktree.go` | Worktree and snapshot lifecycle: `runPushInWorktree`, merge, tag, snapshot ref management | ~350 | -| `cmd/push_stash.go` | Stash management: `restorePushStash`, `restoreTrackedPathsFromStash`, `restoreUntrackedPathsFromStashParent` | ~250 | -| `cmd/push_changes.go` | Change collection and dry-run: `collectSyncPushChanges`, `collectPushChangesForTarget`, `collectGitChangesWithUntracked`, `gitPushBaselineRef`, `prepareDryRunSpaceDir`, `copyDirTree`, `toSyncPushChanges`, `toSyncConflictPolicy`, `runPushDryRun`, `runPushPreflight` | ~450 | - -### Steps - -1. Create `push_stash.go`. -2. Create `push_changes.go`. -3. Create `push_worktree.go`. -4. Trim `cmd/push.go` to the entry point. -5. Run `make test`. - -### Test file split (`cmd/push_test.go`, 1,632 lines) - -| New file | Tests for | -|----------|-----------| -| `cmd/push_test.go` | Core lifecycle, trailers, state file tracking, no-op | -| `cmd/push_conflict_test.go` | Conflict policies, pull-merge stash restore | -| `cmd/push_dryrun_test.go` | Dry-run and preflight mode | -| `cmd/push_stash_test.go` | Stash restore, out-of-scope preservation, failure retention | - ---- - -## Phase 6 — Verification and Cleanup - -1. Run `make test` — all tests must pass. -2. Run `make lint` — no new lint warnings. -3. Run `make build` — binary builds cleanly. -4. Confirm no file in the repository exceeds 800 lines (with a short script or manual count). -5. Update any import paths or cross-references if needed (should not be required since all splits stay in the same package). - ---- - -## Execution Order - -Execute phases sequentially. Each phase must be independently verified with `make test` before starting the next. - -``` -Phase 1: internal/sync/push.go (highest risk — largest, most coupled) -Phase 2: internal/confluence/client.go (medium risk — clear method boundaries) -Phase 3: internal/sync/pull.go (medium risk — mirrors push structure) -Phase 4: cmd/pull.go (lower risk — mostly orchestration glue) -Phase 5: cmd/push.go (lower risk — mostly orchestration glue) -Phase 6: Verification and cleanup -``` - -Test files are split in the same phase as their corresponding source file. - ---- - -## Risk Notes - -- **Phase 1** is the highest-risk split because `push.go` has many inter-function dependencies (the rollback tracker is passed through several layers). Carefully audit function signatures when moving to `push_page.go` and `push_hierarchy.go`. -- **Circular imports** cannot occur since all splits stay within the same package. -- **Unexported helpers** shared across multiple new files remain accessible since they are in the same package — no visibility changes are needed. diff --git a/agents/plans/self_healing_sync_and_deterministic_hierarchy.md b/agents/plans/self_healing_sync_and_deterministic_hierarchy.md deleted file mode 100644 index 5cbdf15..0000000 --- a/agents/plans/self_healing_sync_and_deterministic_hierarchy.md +++ /dev/null @@ -1,150 +0,0 @@ -# Self-Healing Sync and Deterministic Hierarchy - -## Status -**Proposed Date**: 2026-02-26 -**Target Version**: 0.2.x -**Owner**: Agentic Core -**Execution Status**: Complete (6/6 sections implemented) - -## Implementation Progress -- [x] 1. State File Self-Healing -- [x] 2. Deterministic Hierarchy (Page vs. Folder) -- [x] 3. Complete Removal of Space Key from Frontmatter -- [x] 4. Deletion Warnings for Dirty Worktrees -- [x] 5. Sync Inspection (`conf status`) -- [x] 6. Workspace Recovery (`conf clean`) - -## Change Log -- 2026-02-26 (Step 1): Removed `space` from frontmatter write-path and schema requirements. Push/validate now ignore `space` frontmatter mismatches, and file-target space resolution now uses state/directory context instead of requiring `space:`. -- 2026-02-26 (Step 2): Added conflict-marker detection in state loading and automatic pull-time state healing (remote page + local ID rebuild). Added explicit dirty-worktree deletion warnings when pull detects remote deletions that overlap local markdown edits. -- 2026-02-26 (Step 3): Made hierarchy parenting deterministic by preferring nearest index pages (`X/X.md`) over folders, updated folder precreation to use index-parent awareness, and added index-driven folder collapse/reparent behavior diagnostics during push. -- 2026-02-26 (Step 4): Added `conf status` (alias: `conf status`) to inspect local not-pushed markdown changes, remote not-pulled page drift, pending additions/deletions, and max tracked version drift without mutating workspace or remote. -- 2026-02-26 (Step 5): Added `conf clean` (alias: `conf clean`) to recover interrupted sync sessions by switching off `sync/*` branches, removing stale `.confluence-worktrees/*`, pruning snapshot refs, and normalizing readable state files. - ---- - -## 1. State File Self-Healing - -### 1.1 Problem: Corruption via Git Conflicts -When `conf` performs sync operations, it uses ephemeral branches and internal Git merges. If two users change the space structure or metadata simultaneously, `.confluence-state.json` may experience a Git merge conflict. Git inserts conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`), which cause the Go JSON parser to fail with a cryptic error on any subsequent command. - -### 1.2 Solution: Detection and Auto-Rebuild -- **Conflict Detection**: Modify the state loader to check for Git conflict markers (`<<<<<<<`) when a JSON parsing error occurs. -- **Warning & Auto-Rebuild**: Display a warning: "Git conflict detected in `.confluence-state.json`. Rebuilding state from Confluence and local IDs..." -- **Reconstruction Logic**: - 1. Fetch all pages from the remote space. - 2. Scan the local space directory for all `.md` files. - 3. Extract `id` from the frontmatter of each file. - 4. Match remote IDs to local paths to reconstruct the `page_path_index`. - 5. Rebuild the `folder_path_index` from the remote hierarchy. - 6. Overwrite the corrupted `.confluence-state.json` with the healed version. - -### 1.3 Reproduction Steps -1. `conf pull TD` (v1) -2. Manually add conflict markers to `.confluence-state.json`: - ```json - <<<<<<< HEAD - "page_path_index": { "foo.md": "123" } - ======= - "page_path_index": { "bar.md": "456" } - >>>>>>> sync/branch - ``` -3. Run `conf pull TD`. -4. **Verification**: Tool should detect the markers, warn, rebuild the state, and finish the pull successfully. - ---- - -## 2. Deterministic Hierarchy (Page vs. Folder) - -### 2.1 Problem: Non-Deterministic Mapping -The tool supports using a markdown file (e.g., `Parent/Parent.md`) as the parent for other files in its directory. However, if the directory is processed *before* the index file is mapped or pushed, the tool may create a "Folder" object in Confluence instead of using the Page. Once a Folder is created, it persists even if an Index file is later provided. - -### 2.2 Solution: Hierarchy Planning and Normalization -- **Hierarchy Planning**: Before creating any folders or pages in `push`, scan the entire set of changes to identify "Index Files" (`X/X.md`). -- **Hierarchy Mapping**: If a file is in folder `X/`, and `X/X.md` is present (either in the current change set or already tracked), prioritize its ID as the parent. -- **Folder Cleanup**: If a Folder object exists for `X` but an Index Page `X/X.md` is now being pushed, the tool should offer to "collapse" the folder and move child pages to the new Index Page. - -### 2.3 Reproduction Steps -1. Create `Parent/Child.md` (no `Parent/Parent.md`). -2. `conf push TD`. (Verified: Folder "Parent" created). -3. Create `Parent/Parent.md`. -4. `conf push TD`. -5. **Verification**: Tool should identify the new Index File and potentially reparent `Child.md` under the new `Parent` page in Confluence. - ---- - -## 3. Complete Removal of Space Key from Frontmatter - -### 3.1 Problem: Redundant and Brittle Metadata -The `space:` key is currently required in every markdown file. This is redundant (it exists in `.confluence-state.json` and in the command-line arguments) and causes validation errors when moving files between spaces. - -### 3.2 Solution: Stop Reading and Writing Space Metadata -- **Remove from Writing**: Modify the markdown writer (used in `pull` and `push` ID write-backs) to omit the `space` field entirely. -- **Ignore during Reading**: Modify the validator and push logic to ignore the `space` field if it exists in the frontmatter. -- **Source of Truth**: Always determine the space key from the CLI target space and the tracked `.confluence-state.json` for that directory. - -### 3.3 Reproduction Steps -1. Take an existing markdown file with `space: TD`. -2. Run `conf pull TD`. -3. **Verification**: The `space: TD` line should be removed from the local markdown file by the tool. -4. Add `space: WRONG_SPACE` manually to a file. -5. Run `conf push TD`. -6. **Verification**: The push should succeed, ignoring the incorrect key. - ---- - -## 4. Deletion Warnings for Dirty Worktrees - -### 4.1 Problem: "Ghost" Files after Moves/Deletions -When a page is renamed or deleted remotely, the local `pull` command tries to delete the old file. If the file has uncommitted changes, Git's merge logic skips the deletion for safety. Currently, this happens silently, leaving the user with an "orphan" file. - -### 4.2 Solution: Explicit Warnings -- **Log Warning**: If a remote deletion is detected during `pull` but the file has uncommitted local changes, log: - `WARNING: Skipped local deletion of 'path/to/page.md' because it contains uncommitted edits. Please resolve manually or run with --discard-local.` - -### 4.3 Reproduction Steps -1. Sync a page remotely. -2. Modify the file locally without committing. -3. Trash the page remotely via Confluence API/UI. -4. Run `conf pull`. -5. **Verification**: Tool should not delete the file and should print the warning message. - ---- - -## 5. Sync Inspection (`conf status`) - -### 5.1 Problem: Lack of Visibility -Users cannot quickly see a high-level summary of the sync state (local vs. remote) without running a full `diff` or `push --dry-run`. - -### 5.2 Solution: New Inspection Command -- **Command**: `conf status [TARGET]` -- **Output**: - - List of locally modified files (not yet pushed). - - List of remotely modified files (not yet pulled). - - Version drift (e.g., "Local state is 3 versions behind remote space"). - - Summary of pending additions/deletions. - -### 5.3 Reproduction Steps -1. Modify one file locally and one page remotely. -2. Run `conf status`. -3. **Verification**: Tool should accurately list both the local change and the remote change. - ---- - -## 6. Workspace Recovery (`conf clean`) - -### 6.1 Problem: Hanging Sync State -If a `push` or `pull` crashes or is force-interrupted, the user might be left on an ephemeral `sync/` branch with a "dirty" working directory or hidden snapshot refs. - -### 6.2 Solution: Recovery Utility -- **Command**: `conf clean` -- **Actions**: - 1. Identify the current branch. If it's a `sync/` branch, offer to return to the original branch (e.g., `master`). - 2. Clean up any stale worktrees in `.confluence-worktrees/`. - 3. Identify and offer to delete stale `refs/confluence-sync/snapshots/` refs. - 4. Ensure the `.confluence-state.json` is in a consistent state. - -### 6.3 Reproduction Steps -1. Start a `conf push` and terminate the process abruptly mid-execution. -2. Run `conf clean`. -3. **Verification**: Tool should detect the hanging sync branch and offer to restore the workspace to a clean state. diff --git a/agents/plans/test_coverage_extension.md b/agents/plans/test_coverage_extension.md deleted file mode 100644 index 58e936c..0000000 --- a/agents/plans/test_coverage_extension.md +++ /dev/null @@ -1,48 +0,0 @@ -# Test Coverage Extension Plan - -## Goal -Increase test coverage for `internal/git` (currently 55.5%) and `cmd` (currently 65.2%) to exceed CI gates with a safer margin and ensure functional reliability. - -## 1. `internal/git` Coverage Extension (Target: >70%) -Focus on unimplemented or low-coverage foundational Git operations. - -- **`branch.go`**: - - `CurrentBranch`: Test in a new repo (should return `main` or `master`) and after switching branches. - - `Merge`: Test merging a feature branch into main with and without conflicts. - - `DeleteBranch`: Test deleting an unmerged branch (should fail unless forced) and a merged branch. -- **`commit.go`**: - - `AddForce`: Test adding a file that is explicitly ignored by `.gitignore`. - - `Tag`: Test creating a lightweight tag and an annotated tag, then verifying they exist. -- **`git.go`**: - - `NewClient`: Test initialization and validation of `RootDir`. - - `RunGit`: Test execution of invalid git commands to exercise error reporting. -- **`stash.go`**: - - `StashApply`: Test applying a specific stash ref. - - `StashDrop`: Test dropping a specific stash ref. - - `StashPop`: Test popping with and without conflicts. -- **`worktree.go`**: - - `PruneWorktrees`: Test pruning stale worktree metadata. - - `RemoveWorktree`: Test removing a worktree that is dirty (should require force or fail). - -## 2. `cmd` Coverage Extension (Target: >75%) -Focus on end-to-end command execution and complex logic blocks. - -- **`status` command (`status.go`, `status_run_test.go`)**: - - Implement `TestRunStatus_Integration`: Setup a real Git repo, create local changes (staged, unstaged, untracked), mock `confluence.Client` to return matching/differing pages, and verify the output of `runStatus`. -- **`clean` command (`clean.go`, `clean_test.go`)**: - - Implement `TestRunClean_Integration`: Create stale sync branches, worktrees, and snapshot refs. Run `clean` and verify they are removed. -- **`prune` command (`prune.go`)**: - - Implement `TestRunPrune_Integration`: Create local markdown files that do not exist in a mocked Confluence space. Run `prune` and verify they are deleted. -- **`agents` command (`agents.go`, `agents_test.go`)**: - - Implement `TestRunAgentsInit`: Run the command and verify that the expected `.md` files (Tech, HR, PM, etc.) are created with the correct templates. -- **Dry Run Simulator (`dry_run_remote.go`)**: - - Exercise the simulator by running `push --dry-run` and `pull --dry-run` in integration tests, ensuring all simulator methods (like `ArchivePages`, `MovePage`) are touched. -- **Automation Helpers (`automation.go`)**: - - Test `resolvePushConflictPolicy` with all policy variants. - - Test `requireSafetyConfirmation` with different impact thresholds. - -## 3. Implementation Strategy -1. **Infrastructure**: Enhance `cmd/helpers_test.go` and `internal/git/helpers_test.go` to provide a reusable `TestContext` that includes a real Git repo and a configurable mock Confluence client. -2. **Phase 1: Git Foundation**: Implement the `internal/git` tests first, as they are used by most `cmd` tests. -3. **Phase 2: CLI Commands**: Implement integration-style tests for `status`, `clean`, `prune`, and `agents`. -4. **Verification**: Run `go run ./tools/coveragecheck` after each phase to track progress. diff --git a/cmd/agents_test.go b/cmd/agents_test.go index aa8291f..83c32fa 100644 --- a/cmd/agents_test.go +++ b/cmd/agents_test.go @@ -137,15 +137,20 @@ func TestAgentsMDTemplateAlignment(t *testing.T) { if !strings.Contains(content, "`version`") { t.Error("workspace AGENTS.md missing version frontmatter reference") } + if !strings.Contains(content, "remove `id` and `version` from the copy before pushing") { + t.Error("workspace AGENTS.md missing copy-page safety guidance for id/version") + } // Must contain Content Support Contract section. if !strings.Contains(content, "Content Support Contract") { t.Error("workspace AGENTS.md missing Content Support Contract section") } - // Must contain Documentation Strategy section. - if !strings.Contains(content, "Documentation Strategy") { - t.Error("workspace AGENTS.md missing Documentation Strategy section") + // Root workspace AGENTS.md should stay operational and self-contained. + for _, banned := range []string{"Documentation Strategy", "Specs and PRDs", "Spec/PRD document"} { + if strings.Contains(content, banned) { + t.Errorf("workspace AGENTS.md should not reference repo docs/specs via %q", banned) + } } // Must mention Mermaid preservation behavior. diff --git a/cmd/init.go b/cmd/init.go index 69b8315..336e009 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -56,6 +56,7 @@ Use ` + "`conf search`" + ` to find content without reading entire files. - **Frontmatter**: - ` + "`id`" + `: Immutable — do not edit. - ` + "`version`" + `: Managed by ` + "`conf`" + ` — do not edit. + - When copying an existing page to create a new one, remove ` + "`id`" + ` and ` + "`version`" + ` from the copy before pushing. - ` + "`state`" + `: Lifecycle state (` + "`draft`" + ` or ` + "`current`" + `). Omitted means ` + "`current`" + `. Cannot revert to ` + "`draft`" + ` once published. - ` + "`status`" + `: Confluence visual lozenge (e.g., "Ready to review"). - ` + "`labels`" + `: Confluence page labels (array of strings). @@ -88,9 +89,6 @@ Space/ - **Mermaid**: Preserved as fenced code blocks; pushed as ADF ` + "`codeBlock`" + ` (not rendered as a Confluence diagram). ` + "`validate`" + ` warns with ` + "`MERMAID_PRESERVED_AS_CODEBLOCK`" + `. - **Hierarchy**: Pages with children use the ` + "`ParentPage/ParentPage.md`" + ` convention; moves are surfaced as ` + "`PAGE_PATH_MOVED`" + ` diagnostics. -## Documentation Strategy -Specs and PRDs generated in this workspace should be maintained as the working source of truth for feature behavior and product intent. When behavior or requirements are unclear, refer to the primary plan (if one exists) or to the relevant Spec/PRD document. - ## Space-Specific Rules Each space directory (e.g., ` + "`Technical documentation (TD)/`" + `) may contain its own ` + "`AGENTS.md`" + ` with space-specific content rules (e.g., required templates, PII guidelines). Check those if they exist. ` diff --git a/openspec/project.md b/openspec/project.md index 668f33f..c3b7096 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -16,8 +16,6 @@ The canonical behavior contract lives in `openspec/specs/*/spec.md`. Narrative docs such as `README.md`, `docs/usage.md`, `docs/automation.md`, `docs/compatibility.md`, and `docs/specs/*` are secondary summaries and operator guides. They must stay aligned with the OpenSpec files. -Implementation plans under `agents/plans/*.md` are historical delivery plans and backlog notes, not the live product contract. - ## Domain Terms - Workspace root: the local Git repository root used by `conf` From 33493cad3c3cc9c67aa6f540734e181d989d7b62 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 8 Mar 2026 21:07:05 +0100 Subject: [PATCH 31/31] Fix golangci-lint errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gosec: add //nolint:gosec to three os.ReadFile calls (test temp paths and recovery metadata path) and the uintptr→int conversion in resolveSearchFormat. unused: delete nine dead functions that had no callers — printNoIndexDiff, prepareDryRunSpaceDir, copyDirTree, appendPushResultToReport, pushOperationByPath, normalizeReportPushPath, commandRunReport.absorb, movedPageIDs, pushChangesNeedMetadataSync. Co-Authored-By: Claude Sonnet 4.6 --- cmd/diff_git.go | 5 --- cmd/init_test.go | 6 +-- cmd/push_changes.go | 46 ----------------------- cmd/recover.go | 2 +- cmd/report.go | 56 ---------------------------- cmd/search.go | 2 +- internal/sync/pull_paths.go | 8 ---- internal/sync/tenant_capabilities.go | 9 ----- 8 files changed, 5 insertions(+), 129 deletions(-) diff --git a/cmd/diff_git.go b/cmd/diff_git.go index 80ca45a..46fe906 100644 --- a/cmd/diff_git.go +++ b/cmd/diff_git.go @@ -12,11 +12,6 @@ import ( "strings" ) -func printNoIndexDiff(out io.Writer, leftPath, rightPath string) error { - _, err := renderNoIndexDiff(out, leftPath, rightPath) - return err -} - func renderNoIndexDiff(out io.Writer, leftPath, rightPath string) (bool, error) { workingDir, leftArg, rightArg := diffCommandPaths(leftPath, rightPath) diff --git a/cmd/init_test.go b/cmd/init_test.go index 46b7936..646aeed 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -99,7 +99,7 @@ func TestRunInit_ScaffoldsDotEnvFromExistingEnvironmentWithoutPrompt(t *testing. t.Fatalf("runInit() error: %v", err) } - dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) + dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) //nolint:gosec // path is test temp dir + fixed filename if err != nil { t.Fatalf("read .env: %v", err) } @@ -149,7 +149,7 @@ func TestRunInit_PartialEnvironmentStillPromptsForCredentials(t *testing.T) { t.Fatalf("runInit() error: %v", err) } - dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) + dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) //nolint:gosec // path is test temp dir + fixed filename if err != nil { t.Fatalf("read .env: %v", err) } @@ -201,7 +201,7 @@ func TestRunInit_ExistingDotEnvRemainsUnchanged(t *testing.T) { t.Fatalf("runInit() error: %v", err) } - dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) + dotEnvRaw, err := os.ReadFile(filepath.Join(repo, ".env")) //nolint:gosec // path is test temp dir + fixed filename if err != nil { t.Fatalf("read .env: %v", err) } diff --git a/cmd/push_changes.go b/cmd/push_changes.go index bbc7edf..c0d809c 100644 --- a/cmd/push_changes.go +++ b/cmd/push_changes.go @@ -472,52 +472,6 @@ func collectGitChangesWithUntracked(client *git.Client, baselineRef, scopePath s return changes, nil } -func prepareDryRunSpaceDir(spaceDir string) (string, func(), error) { - tmpRoot, err := os.MkdirTemp("", "conf-dry-run-*") - if err != nil { - return "", nil, fmt.Errorf("create dry-run temp dir: %w", err) - } - - cleanup := func() { - _ = os.RemoveAll(tmpRoot) - } - - dryRunSpaceDir := filepath.Join(tmpRoot, filepath.Base(spaceDir)) - if err := copyDirTree(spaceDir, dryRunSpaceDir); err != nil { - cleanup() - return "", nil, fmt.Errorf("prepare dry-run space copy: %w", err) - } - - return dryRunSpaceDir, cleanup, nil -} - -func copyDirTree(src, dst string) error { - return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - - targetPath := filepath.Join(dst, relPath) - if d.IsDir() { - return os.MkdirAll(targetPath, 0o750) - } - - raw, err := os.ReadFile(path) //nolint:gosec // path comes from filepath.WalkDir under trusted source dir - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(targetPath), 0o750); err != nil { - return err - } - return os.WriteFile(targetPath, raw, 0o600) - }) -} - func toSyncPushChanges(changes []git.FileStatus, spaceScopePath string) ([]syncflow.PushFileChange, error) { normalizedScope := filepath.ToSlash(filepath.Clean(spaceScopePath)) if normalizedScope == "." { diff --git a/cmd/recover.go b/cmd/recover.go index 1fb4393..5a409b5 100644 --- a/cmd/recover.go +++ b/cmd/recover.go @@ -389,7 +389,7 @@ func listRecoveryMetadata(repoRoot string) (map[string]recoveryMetadata, []strin continue } metadataPath := filepath.Join(spaceDir, file.Name()) - raw, err := os.ReadFile(metadataPath) + raw, err := os.ReadFile(metadataPath) //nolint:gosec // path is space dir + recovery metadata filename if err != nil { warnings = append(warnings, fmt.Sprintf("skipping unreadable recovery metadata %s: %v", file.Name(), err)) continue diff --git a/cmd/report.go b/cmd/report.go index a3d50b9..65610bc 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -325,53 +325,6 @@ func reportAttachmentOpsFromPush(result syncflow.PushResult, spaceDir string) [] return out } -func appendPushResultToReport(report *commandRunReport, result syncflow.PushResult, changes []syncflow.PushFileChange, spaceDir string) { - if report == nil { - return - } - report.Diagnostics = append(report.Diagnostics, reportDiagnosticsFromPush(result.Diagnostics, spaceDir)...) - report.AttachmentOperations = append(report.AttachmentOperations, reportAttachmentOpsFromPush(result, spaceDir)...) - report.FallbackModes = append(report.FallbackModes, fallbackModesFromPushDiagnostics(result.Diagnostics)...) - - operationsByPath := pushOperationByPath(changes) - for _, commit := range result.Commits { - relPath := reportRelativePath(spaceDir, commit.Path) - report.MutatedFiles = append(report.MutatedFiles, relPath) - report.MutatedPages = append(report.MutatedPages, commandRunReportPage{ - Operation: operationsByPath[normalizeReportPushPath(commit.Path)], - Path: relPath, - PageID: strings.TrimSpace(commit.PageID), - Title: strings.TrimSpace(commit.PageTitle), - Version: commit.Version, - Deleted: commit.Deleted, - }) - } -} - -func pushOperationByPath(changes []syncflow.PushFileChange) map[string]string { - operations := make(map[string]string, len(changes)) - for _, change := range changes { - switch change.Type { - case syncflow.PushChangeAdd: - operations[normalizeReportPushPath(change.Path)] = "create" - case syncflow.PushChangeDelete: - operations[normalizeReportPushPath(change.Path)] = "delete" - case syncflow.PushChangeModify: - operations[normalizeReportPushPath(change.Path)] = "update" - } - } - return operations -} - -func normalizeReportPushPath(path string) string { - path = filepath.ToSlash(filepath.Clean(strings.TrimSpace(path))) - path = strings.TrimPrefix(path, "./") - if path == "." { - return "" - } - return path -} - func sortedUniqueStrings(values []string) []string { if len(values) == 0 { return []string{} @@ -392,15 +345,6 @@ func sortedUniqueStrings(values []string) []string { return out } -func (r *commandRunReport) absorb(other commandRunReport) { - r.Diagnostics = append(r.Diagnostics, other.Diagnostics...) - r.MutatedFiles = append(r.MutatedFiles, other.MutatedFiles...) - r.MutatedPages = append(r.MutatedPages, other.MutatedPages...) - r.AttachmentOperations = append(r.AttachmentOperations, other.AttachmentOperations...) - r.FallbackModes = append(r.FallbackModes, other.FallbackModes...) - r.RecoveryArtifacts = append(r.RecoveryArtifacts, other.RecoveryArtifacts...) -} - func (r *commandRunReport) setRecoveryArtifactStatus(artifactType, name, status string) { artifactType = strings.TrimSpace(artifactType) name = strings.TrimSpace(name) diff --git a/cmd/search.go b/cmd/search.go index be457b4..2e8deb4 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -271,7 +271,7 @@ func resolveSearchFormat(format string, out io.Writer) string { return format } // If out is not os.Stdout fall back to json (pipe-like context). - if out == os.Stdout && term.IsTerminal(int(os.Stdout.Fd())) { + if out == os.Stdout && term.IsTerminal(int(os.Stdout.Fd())) { //nolint:gosec // Fd is small and fits in int return "text" } return "json" diff --git a/internal/sync/pull_paths.go b/internal/sync/pull_paths.go index 59a2543..9af9bd9 100644 --- a/internal/sync/pull_paths.go +++ b/internal/sync/pull_paths.go @@ -222,14 +222,6 @@ func deletedPageIDs(previousPageIndex map[string]string, remotePages map[string] return sortedStringKeys(set) } -func movedPageIDs(previousPageIndex map[string]string, nextPathByID map[string]string) []string { - set := map[string]struct{}{} - for _, move := range PlannedPagePathMoves(previousPageIndex, nextPathByID) { - set[move.PageID] = struct{}{} - } - return sortedStringKeys(set) -} - // PlannedPagePathMove describes a tracked page whose planned markdown path changed. type PlannedPagePathMove struct { PageID string diff --git a/internal/sync/tenant_capabilities.go b/internal/sync/tenant_capabilities.go index b851a0b..52e9a95 100644 --- a/internal/sync/tenant_capabilities.go +++ b/internal/sync/tenant_capabilities.go @@ -229,15 +229,6 @@ func pushChangesNeedFolderHierarchy(changes []PushFileChange) bool { return false } -func pushChangesNeedMetadataSync(changes []PushFileChange) bool { - for _, change := range changes { - if change.Type == PushChangeAdd || change.Type == PushChangeModify { - return true - } - } - return false -} - func filepathDirFromRel(relPath string) string { return filepath.ToSlash(filepath.Dir(filepath.FromSlash(strings.TrimSpace(relPath)))) }