From 78b09f677ca2661a2aafb0a238f89ec1c7838c60 Mon Sep 17 00:00:00 2001 From: zhangyangyu Date: Sat, 30 May 2026 22:31:48 +0800 Subject: [PATCH] feat: add wiki issue cross references --- internal/db/models_issue_reference.go | 7 +- .../handlers_issue_cross_reference_test.go | 111 ++++++++++++++++++ internal/rest/handlers_issue_timeline.go | 3 + internal/service/issue_reference.go | 55 +++++++++ internal/service/timeline.go | 46 +++++++- internal/service/wiki_gc.go | 14 +++ 6 files changed, 228 insertions(+), 8 deletions(-) diff --git a/internal/db/models_issue_reference.go b/internal/db/models_issue_reference.go index 51ed6ae..7f88c84 100644 --- a/internal/db/models_issue_reference.go +++ b/internal/db/models_issue_reference.go @@ -3,7 +3,7 @@ package db import "time" // IssueReference records a GitHub-style cross-reference edge from an issue, -// pull request, or issue comment body to a target issue/PR number. +// pull request, issue comment, or wiki page body to a target issue/PR number. type IssueReference struct { ID uint `gorm:"primaryKey;autoIncrement"` @@ -14,10 +14,11 @@ type IssueReference struct { SourcePRNumber *int `gorm:"uniqueIndex:idx_issue_reference_edge,priority:4;index:idx_issue_reference_source,priority:4"` SourceCommentID *uint `gorm:"uniqueIndex:idx_issue_reference_edge,priority:5;index:idx_issue_reference_source,priority:5"` SourceComment *IssueComment + SourceWikiSlug *string `gorm:"type:varbinary(1024);uniqueIndex:idx_issue_reference_edge,priority:6;index:idx_issue_reference_source,priority:6"` - TargetRepositoryID uint `gorm:"not null;uniqueIndex:idx_issue_reference_edge,priority:6;index:idx_issue_reference_target,priority:1"` + TargetRepositoryID uint `gorm:"not null;uniqueIndex:idx_issue_reference_edge,priority:7;index:idx_issue_reference_target,priority:1"` TargetRepository Repository `gorm:"foreignKey:TargetRepositoryID"` - TargetNumber int `gorm:"not null;uniqueIndex:idx_issue_reference_edge,priority:7;index:idx_issue_reference_target,priority:2"` + TargetNumber int `gorm:"not null;uniqueIndex:idx_issue_reference_edge,priority:8;index:idx_issue_reference_target,priority:2"` RawReference string `gorm:"type:text"` CreatedAt time.Time `gorm:"index"` diff --git a/internal/rest/handlers_issue_cross_reference_test.go b/internal/rest/handlers_issue_cross_reference_test.go index fa99c3e..3666081 100644 --- a/internal/rest/handlers_issue_cross_reference_test.go +++ b/internal/rest/handlers_issue_cross_reference_test.go @@ -69,6 +69,53 @@ func TestGetIssueTimeline_CrossReferencedIssueCommentDedupes(t *testing.T) { } } +func TestGetIssueTimeline_CrossReferencedWikiPageLifecycle(t *testing.T) { + h := testharness.New(t) + compatSeedRepo(t, h, "xref-wiki") + full := "testuser/xref-wiki" + + w := h.DoRESTJSON(t, http.MethodPost, "/api/v3/repos/"+full+"/issues", map[string]any{ + "title": "target", + }) + assertStatusCode(t, w, http.StatusCreated) + + w = h.DoRESTJSON(t, http.MethodPut, wikiPagePath(full, "home"), map[string]any{ + "body": "# Home\n\nRelated to #1 and #1.\n", + }) + assertStatusCode(t, w, http.StatusOK) + + events := getTimelineEvents(t, h, "/api/v3/repos/"+full+"/issues/1/timeline") + if !hasCrossReferenceFromWikiPage(t, events, "home", full) { + t.Fatalf("expected cross-referenced event from wiki page home, got %#v", events) + } + + w = h.DoRESTJSON(t, http.MethodPut, wikiPagePath(full, "home"), map[string]any{ + "body": "# Home\n\nReference removed.\n", + }) + assertStatusCode(t, w, http.StatusOK) + + events = getTimelineEvents(t, h, "/api/v3/repos/"+full+"/issues/1/timeline") + if hasCrossReferenceFromWikiPage(t, events, "home", full) { + t.Fatalf("expected wiki cross-reference to be removed after update, got %#v", events) + } + + w = h.DoRESTJSON(t, http.MethodPut, wikiPagePath(full, "home"), map[string]any{ + "body": "# Home\n\nRelated again to #1.\n", + }) + assertStatusCode(t, w, http.StatusOK) + events = getTimelineEvents(t, h, "/api/v3/repos/"+full+"/issues/1/timeline") + if !hasCrossReferenceFromWikiPage(t, events, "home", full) { + t.Fatalf("expected wiki cross-reference to return after update, got %#v", events) + } + + w = h.DoREST(t, http.MethodDelete, wikiPagePath(full, "home"), nil) + assertStatusCode(t, w, http.StatusNoContent) + events = getTimelineEvents(t, h, "/api/v3/repos/"+full+"/issues/1/timeline") + if hasCrossReferenceFromWikiPage(t, events, "home", full) { + t.Fatalf("expected wiki cross-reference to be removed after delete, got %#v", events) + } +} + func TestGetIssueTimeline_CrossRepoReferencesRespectSourcePermissions(t *testing.T) { h := testharness.New(t) ctx := context.Background() @@ -115,6 +162,47 @@ func TestGetIssueTimeline_CrossRepoReferencesRespectSourcePermissions(t *testing } } +func TestGetIssueTimeline_CrossRepoWikiReferencesRespectSourcePermissions(t *testing.T) { + h := testharness.New(t) + ctx := context.Background() + compatSeedRepo(t, h, "xref-wiki-target") + + w := h.DoRESTJSON(t, http.MethodPost, "/api/v3/repos/testuser/xref-wiki-target/issues", map[string]any{ + "title": "target", + }) + assertStatusCode(t, w, http.StatusCreated) + + sourceOwner, sourceOwnerToken := seedHarnessUser(t, h, "xref-wiki-source-owner", false) + sourceCtx := service.ContextWithUser(ctx, sourceOwner) + sourceRepo, err := h.Svc.CreateRepo(sourceCtx, service.CreateRepoInput{ + OwnerLogin: sourceOwner.Login, + Name: "private-wiki-xref-source", + Private: true, + AutoInit: true, + }) + if err != nil { + t.Fatalf("create private source repo: %v", err) + } + if _, err := h.Svc.PutWikiPage(sourceCtx, sourceRepo.FullName, "home", "# Home\n\nreferences testuser/xref-wiki-target#1\n", "create wiki source", ""); err != nil { + t.Fatalf("create source wiki page: %v", err) + } + + w = h.DoRESTWithToken(t, http.MethodGet, "/api/v3/repos/testuser/xref-wiki-target/issues/1/timeline", sourceOwnerToken) + assertStatusCode(t, w, http.StatusOK) + events := testharness.DecodeJSONArray(t, w) + if !hasCrossReferenceFromWikiPage(t, events, "home", sourceRepo.FullName) { + t.Fatalf("expected source owner to see private wiki cross-reference, got %#v", events) + } + + _, outsiderToken := seedHarnessUser(t, h, "xref-wiki-outsider", false) + w = h.DoRESTWithToken(t, http.MethodGet, "/api/v3/repos/testuser/xref-wiki-target/issues/1/timeline", outsiderToken) + assertStatusCode(t, w, http.StatusOK) + events = testharness.DecodeJSONArray(t, w) + if hasCrossReferenceFromWikiPage(t, events, "home", sourceRepo.FullName) { + t.Fatalf("outsider must not see private wiki cross-reference, got %#v", events) + } +} + func getTimelineEvents(t *testing.T, h *testharness.Harness, path string) []map[string]any { t.Helper() w := h.DoREST(t, http.MethodGet, path, nil) @@ -176,3 +264,26 @@ func crossReferenceSourceIssueMatches(t *testing.T, event map[string]any, number repoURL, _ := issue["repository_url"].(string) return strings.HasSuffix(repoURL, "/api/v3/repos/"+repoFullName) } + +func hasCrossReferenceFromWikiPage(t *testing.T, events []map[string]any, slug, repoFullName string) bool { + t.Helper() + for _, event := range crossReferenceEvents(events) { + source, ok := event["source"].(map[string]any) + if !ok || source["type"] != "wiki_page" { + continue + } + page, ok := source["wiki_page"].(map[string]any) + if !ok { + continue + } + gotSlug, _ := page["slug"].(string) + if gotSlug != slug { + continue + } + pageURL, _ := page["url"].(string) + if strings.Contains(pageURL, "/api/v3/repos/"+repoFullName+"/wiki/pages/") { + return true + } + } + return false +} diff --git a/internal/rest/handlers_issue_timeline.go b/internal/rest/handlers_issue_timeline.go index 0bc2835..ec96229 100644 --- a/internal/rest/handlers_issue_timeline.go +++ b/internal/rest/handlers_issue_timeline.go @@ -164,6 +164,9 @@ func applyCrossReferencedSource(ctx context.Context, d *Deps, out map[string]any assoc := d.authorAssociationChecks(ctx, e.CrossRef.PullRequest.Repository) source["type"] = "pull_request" source["pull_request"] = transform.PR(*e.CrossRef.PullRequest, resolver, assoc, nil) + } else if e.CrossRef.WikiPage != nil { + source["type"] = "wiki_page" + source["wiki_page"] = transform.WikiPage(e.CrossRef.SourceRepositoryFullName, *e.CrossRef.WikiPage) } if e.CrossRef.Comment != nil { assoc := d.authorAssociationChecks(ctx, e.CrossRef.Comment.Repository) diff --git a/internal/service/issue_reference.go b/internal/service/issue_reference.go index 48d73a5..7bc8618 100644 --- a/internal/service/issue_reference.go +++ b/internal/service/issue_reference.go @@ -17,6 +17,7 @@ const ( issueReferenceSourceIssueBody = "issue_body" issueReferenceSourcePullRequestBody = "pull_request_body" issueReferenceSourceIssueComment = "issue_comment" + issueReferenceSourceWikiPage = "wiki_page" ) type issueReferenceSource struct { @@ -26,6 +27,7 @@ type issueReferenceSource struct { SourceIssueNumber *int SourcePRNumber *int SourceCommentID *uint + SourceWikiSlug *string Body string CreatedAt time.Time } @@ -104,6 +106,25 @@ func (s *Service) syncIssueCommentReferences(ctx context.Context, comment db.Iss return s.syncIssueReferencesForSource(ctx, source) } +func (s *Service) syncWikiPageReferences(ctx context.Context, repo db.Repository, slug, body string, createdAt time.Time) error { + if repo.ID == 0 || repo.FullName == "" || slug == "" { + return nil + } + sourceSlug := slug + err := s.syncIssueReferencesForSource(ctx, issueReferenceSource{ + SourceType: issueReferenceSourceWikiPage, + SourceRepositoryID: repo.ID, + SourceRepositoryFullName: repo.FullName, + SourceWikiSlug: &sourceSlug, + Body: body, + CreatedAt: createdAt, + }) + if isMissingTableErr(err) { + return nil + } + return err +} + func (s *Service) issueReferenceOwnerKind(ctx context.Context, repoID uint, number int) (string, error) { var count int64 if err := s.DBForCtx(ctx).Model(&db.Issue{}). @@ -187,6 +208,7 @@ func (s *Service) resolveIssueReferenceMatches(ctx context.Context, source issue SourceIssueNumber: source.SourceIssueNumber, SourcePRNumber: source.SourcePRNumber, SourceCommentID: source.SourceCommentID, + SourceWikiSlug: source.SourceWikiSlug, TargetRepositoryID: targetRepo.ID, TargetNumber: match.Number, RawReference: match.RawReference, @@ -238,9 +260,26 @@ func (s *Service) deleteIssueReferencesForSource(ctx context.Context, source iss } else { q = q.Where("source_comment_id IS NULL") } + if source.SourceWikiSlug != nil { + q = q.Where("source_wiki_slug = ?", *source.SourceWikiSlug) + } else { + q = q.Where("source_wiki_slug IS NULL") + } return q.Delete(&db.IssueReference{}) } +func (s *Service) deleteIssueReferencesForWikiPage(ctx context.Context, repoID uint, slug string) error { + err := s.deleteIssueReferencesForSource(ctx, issueReferenceSource{ + SourceType: issueReferenceSourceWikiPage, + SourceRepositoryID: repoID, + SourceWikiSlug: &slug, + }).Error + if isMissingTableErr(err) { + return nil + } + return err +} + func (s *Service) deleteIssueReferencesForIssueNumber(ctx context.Context, repoID uint, number int) error { return s.DBForCtx(ctx).Where( "(source_repository_id = ? AND (source_issue_number = ? OR source_pr_number = ?)) OR (target_repository_id = ? AND target_number = ?)", @@ -286,6 +325,22 @@ func (s *Service) BackfillIssueReferences(ctx context.Context) error { return err } } + + var pages []db.WikiPage + if err := tx.Preload("Repository"). + Where("deleted_at IS NULL"). + Find(&pages).Error; err != nil { + return err + } + for _, page := range pages { + body, err := s.wikiPageBody(txCtx, page) + if err != nil { + return err + } + if err := s.syncWikiPageReferences(txCtx, page.Repository, page.Slug, string(body), issueReferenceEventTime(page.CreatedAt, page.UpdatedAt)); err != nil { + return err + } + } return nil }) } diff --git a/internal/service/timeline.go b/internal/service/timeline.go index 3237fe3..635ca9d 100644 --- a/internal/service/timeline.go +++ b/internal/service/timeline.go @@ -24,12 +24,14 @@ type TimelineEvent struct { } // CrossReferencedSource is the source object for a cross-referenced timeline -// event. Exactly one of Issue or PullRequest should be populated. +// event. Exactly one of Issue, PullRequest, or WikiPage should be populated. type CrossReferencedSource struct { - Reference db.IssueReference - Issue *db.Issue - PullRequest *db.PullRequest - Comment *db.IssueComment + Reference db.IssueReference + SourceRepositoryFullName string + Issue *db.Issue + PullRequest *db.PullRequest + Comment *db.IssueComment + WikiPage *WikiPage } // GetIssueTimeline synthesizes a timeline of events for an issue or pull request. @@ -175,6 +177,17 @@ func (s *Service) listCrossReferencedTimelineEvents(ctx context.Context, targetR func (s *Service) loadCrossReferencedSource(ctx context.Context, ref db.IssueReference) (*CrossReferencedSource, string, error) { source := &CrossReferencedSource{Reference: ref} actor := "" + var sourceRepo db.Repository + err := s.DBForCtx(ctx). + Select("id", "full_name"). + First(&sourceRepo, "id = ?", ref.SourceRepositoryID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", nil + } + return nil, "", err + } + source.SourceRepositoryFullName = sourceRepo.FullName if ref.SourceCommentID != nil { var comment db.IssueComment err := preloadIssueComment(s.DBForCtx(ctx)).First(&comment, *ref.SourceCommentID).Error @@ -219,5 +232,28 @@ func (s *Service) loadCrossReferencedSource(ctx context.Context, ref db.IssueRef } return source, actor, nil } + if ref.SourceWikiSlug != nil { + page, err := s.loadLiveWikiPage(ctx, ref.SourceRepositoryID, *ref.SourceWikiSlug) + if err != nil { + if errors.Is(err, ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", nil + } + return nil, "", err + } + body, err := s.wikiPageBody(ctx, page) + if err != nil { + return nil, "", err + } + labelsBySlug, err := s.wikiLabelsForSlugs(ctx, ref.SourceRepositoryID, []string{page.Slug}) + if err != nil { + return nil, "", err + } + wikiPage := s.wikiPageFromCatalog(page, body, labelsBySlug[page.Slug]) + source.WikiPage = &wikiPage + if page.LastAuthor != nil { + actor = page.LastAuthor.Login + } + return source, actor, nil + } return nil, "", nil } diff --git a/internal/service/wiki_gc.go b/internal/service/wiki_gc.go index 90c2185..7e12297 100644 --- a/internal/service/wiki_gc.go +++ b/internal/service/wiki_gc.go @@ -57,6 +57,11 @@ func (s *Service) WikiCatalogPostCommit(ctx context.Context, repoID uint, result First(&repo, "id = ?", repoID).Error; err != nil { return fmt.Errorf("wiki post-commit: lookup repo %d: %w", repoID, err) } + var changeset db.WikiChangeset + if err := s.DBForCtx(ctx).Select("changeset_id", "committed_at"). + First(&changeset, "changeset_id = ?", result.ChangesetID).Error; err != nil { + return fmt.Errorf("wiki post-commit: lookup changeset %d: %w", result.ChangesetID, err) + } for _, ch := range result.Changes { switch ch.Op { case wikicatalog.OpUpsert, wikicatalog.OpRename: @@ -64,6 +69,9 @@ func (s *Service) WikiCatalogPostCommit(ctx context.Context, repoID uint, result // removed before indexing the new one or `wiki/search` // will surface both. if ch.Op == wikicatalog.OpRename && ch.PrevSlug != "" && ch.PrevSlug != ch.Slug { + if err := s.deleteIssueReferencesForWikiPage(ctx, repo.ID, ch.PrevSlug); err != nil { + return fmt.Errorf("wiki post-commit: delete prev issue refs for %s: %w", ch.PrevSlug, err) + } if result.Source == wikicatalog.SourceMigration { if err := s.deleteWikiSearchDocument(ctx, repo.FullName, ch.PrevSlug); err != nil { return fmt.Errorf("wiki post-commit: delete prev search doc for %s: %w", ch.PrevSlug, err) @@ -84,6 +92,9 @@ func (s *Service) WikiCatalogPostCommit(ctx context.Context, repoID uint, result body, ok := s.wikiBodyForReindex(ctx, ch.PageID, ch.BlobSHA) if ok { page.Body = body + if err := s.syncWikiPageReferences(ctx, repo, ch.Slug, body, changeset.CommittedAt); err != nil { + return fmt.Errorf("wiki post-commit: sync issue refs for %s: %w", ch.Slug, err) + } } if result.Source == wikicatalog.SourceMigration { if err := s.upsertWikiSearchDocument(ctx, repo.FullName, page); err != nil { @@ -93,6 +104,9 @@ func (s *Service) WikiCatalogPostCommit(ctx context.Context, repoID uint, result } s.queueWikiSearchUpsert(ctx, repo.FullName, page) case wikicatalog.OpDelete: + if err := s.deleteIssueReferencesForWikiPage(ctx, repo.ID, ch.Slug); err != nil { + return fmt.Errorf("wiki post-commit: delete issue refs for %s: %w", ch.Slug, err) + } if result.Source == wikicatalog.SourceMigration { if err := s.deleteWikiSearchDocument(ctx, repo.FullName, ch.Slug); err != nil { return fmt.Errorf("wiki post-commit: delete search doc for %s: %w", ch.Slug, err)