Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions internal/db/models_issue_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand All @@ -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"`
Expand Down
111 changes: 111 additions & 0 deletions internal/rest/handlers_issue_cross_reference_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions internal/rest/handlers_issue_timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions internal/service/issue_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
issueReferenceSourceIssueBody = "issue_body"
issueReferenceSourcePullRequestBody = "pull_request_body"
issueReferenceSourceIssueComment = "issue_comment"
issueReferenceSourceWikiPage = "wiki_page"
)

type issueReferenceSource struct {
Expand All @@ -26,6 +27,7 @@ type issueReferenceSource struct {
SourceIssueNumber *int
SourcePRNumber *int
SourceCommentID *uint
SourceWikiSlug *string
Body string
CreatedAt time.Time
}
Expand Down Expand Up @@ -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{}).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = ?)",
Expand Down Expand Up @@ -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
})
}
Expand Down
46 changes: 41 additions & 5 deletions internal/service/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
14 changes: 14 additions & 0 deletions internal/service/wiki_gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,21 @@ 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:
// On rename, the old slug's search document must be
// 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)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
Loading