diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -803,7 +803,7 @@ return err } - issue := db.IssueFromRecord(did, rkey, record) + issue := models.IssueFromRecord(did, rkey, record) if err := i.Validator.ValidateIssue(&issue); err != nil { return fmt.Errorf("failed to validate issue: %w", err) @@ -869,7 +869,7 @@ return fmt.Errorf("invalid record: %w", err) } - comment, err := db.IssueCommentFromRecord(did, rkey, record) + comment, err := models.IssueCommentFromRecord(did, rkey, record) if err != nil { return fmt.Errorf("failed to parse comment from record: %w", err) } diff --git a/appview/db/issues.go b/appview/db/issues.go --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -10,196 +10,11 @@ "time" "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/api/tangled" "tangled.org/core/appview/models" "tangled.org/core/appview/pagination" ) -type Issue struct { - Id int64 - Did string - Rkey string - RepoAt syntax.ATURI - IssueId int - Created time.Time - Edited *time.Time - Deleted *time.Time - Title string - Body string - Open bool - - // optionally, populate this when querying for reverse mappings - // like comment counts, parent repo etc. - Comments []IssueComment - Labels models.LabelState - Repo *models.Repo -} - -func (i *Issue) AtUri() syntax.ATURI { - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) -} - -func (i *Issue) AsRecord() tangled.RepoIssue { - return tangled.RepoIssue{ - Repo: i.RepoAt.String(), - Title: i.Title, - Body: &i.Body, - CreatedAt: i.Created.Format(time.RFC3339), - } -} - -func (i *Issue) State() string { - if i.Open { - return "open" - } - return "closed" -} - -type CommentListItem struct { - Self *IssueComment - Replies []*IssueComment -} - -func (i *Issue) CommentList() []CommentListItem { - // Create a map to quickly find comments by their aturi - toplevel := make(map[string]*CommentListItem) - var replies []*IssueComment - - // collect top level comments into the map - for _, comment := range i.Comments { - if comment.IsTopLevel() { - toplevel[comment.AtUri().String()] = &CommentListItem{ - Self: &comment, - } - } else { - replies = append(replies, &comment) - } - } - - for _, r := range replies { - parentAt := *r.ReplyTo - if parent, exists := toplevel[parentAt]; exists { - parent.Replies = append(parent.Replies, r) - } - } - - var listing []CommentListItem - for _, v := range toplevel { - listing = append(listing, *v) - } - - // sort everything - sortFunc := func(a, b *IssueComment) bool { - return a.Created.Before(b.Created) - } - sort.Slice(listing, func(i, j int) bool { - return sortFunc(listing[i].Self, listing[j].Self) - }) - for _, r := range listing { - sort.Slice(r.Replies, func(i, j int) bool { - return sortFunc(r.Replies[i], r.Replies[j]) - }) - } - - return listing -} - -func (i *Issue) Participants() []string { - participantSet := make(map[string]struct{}) - participants := []string{} - - addParticipant := func(did string) { - if _, exists := participantSet[did]; !exists { - participantSet[did] = struct{}{} - participants = append(participants, did) - } - } - - addParticipant(i.Did) - - for _, c := range i.Comments { - addParticipant(c.Did) - } - - return participants -} - -func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { - created, err := time.Parse(time.RFC3339, record.CreatedAt) - if err != nil { - created = time.Now() - } - - body := "" - if record.Body != nil { - body = *record.Body - } - - return Issue{ - RepoAt: syntax.ATURI(record.Repo), - Did: did, - Rkey: rkey, - Created: created, - Title: record.Title, - Body: body, - Open: true, // new issues are open by default - } -} - -type IssueComment struct { - Id int64 - Did string - Rkey string - IssueAt string - ReplyTo *string - Body string - Created time.Time - Edited *time.Time - Deleted *time.Time -} - -func (i *IssueComment) AtUri() syntax.ATURI { - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) -} - -func (i *IssueComment) AsRecord() tangled.RepoIssueComment { - return tangled.RepoIssueComment{ - Body: i.Body, - Issue: i.IssueAt, - CreatedAt: i.Created.Format(time.RFC3339), - ReplyTo: i.ReplyTo, - } -} - -func (i *IssueComment) IsTopLevel() bool { - return i.ReplyTo == nil -} - -func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { - created, err := time.Parse(time.RFC3339, record.CreatedAt) - if err != nil { - created = time.Now() - } - - ownerDid := did - - if _, err = syntax.ParseATURI(record.Issue); err != nil { - return nil, err - } - - comment := IssueComment{ - Did: ownerDid, - Rkey: rkey, - Body: record.Body, - IssueAt: record.Issue, - ReplyTo: record.ReplyTo, - Created: created, - } - - return &comment, nil -} - -func PutIssue(tx *sql.Tx, issue *Issue) error { +func PutIssue(tx *sql.Tx, issue *models.Issue) error { // ensure sequence exists _, err := tx.Exec(` insert or ignore into repo_issue_seqs (repo_at, next_issue_id) @@ -234,7 +49,7 @@ } } -func createNewIssue(tx *sql.Tx, issue *Issue) error { +func createNewIssue(tx *sql.Tx, issue *models.Issue) error { // get next issue_id var newIssueId int err := tx.QueryRow(` @@ -257,7 +72,7 @@ return row.Scan(&issue.Id, &issue.IssueId) } -func updateIssue(tx *sql.Tx, issue *Issue) error { +func updateIssue(tx *sql.Tx, issue *models.Issue) error { // update existing issue _, err := tx.Exec(` update issues @@ -267,8 +82,8 @@ return err } -func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { - issueMap := make(map[string]*Issue) // at-uri -> issue +func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { + issueMap := make(map[string]*models.Issue) // at-uri -> issue var conditions []string var args []any @@ -323,7 +138,7 @@ defer rows.Close() for rows.Next() { - var issue Issue + var issue models.Issue var createdAt string var editedAt, deletedAt sql.Null[string] var rowNum int64 @@ -416,7 +231,7 @@ } } - var issues []Issue + var issues []models.Issue for _, i := range issueMap { issues = append(issues, *i) } @@ -428,15 +243,15 @@ return issues, nil } -func GetIssues(e Execer, filters ...filter) ([]Issue, error) { +func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { return GetIssuesPaginated(e, pagination.FirstPage(), filters...) } -func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { +func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` row := e.QueryRow(query, repoAt, issueId) - var issue Issue + var issue models.Issue var createdAt string err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) if err != nil { @@ -452,7 +267,7 @@ return &issue, nil } -func AddIssueComment(e Execer, c IssueComment) (int64, error) { +func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { result, err := e.Exec( `insert into issue_comments ( did, @@ -514,8 +329,8 @@ return err } -func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { - var comments []IssueComment +func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { + var comments []models.IssueComment var conditions []string var args []any @@ -551,7 +366,7 @@ } for rows.Next() { - var comment IssueComment + var comment models.IssueComment var created string var rkey, edited, deleted, replyTo sql.Null[string] err := rows.Scan( @@ -670,7 +485,7 @@ var count models.IssueCount if err := row.Scan(&count.Open, &count.Closed); err != nil { - return models.IssueCount{0, 0}, err + return models.IssueCount{}, err } return count, nil diff --git a/appview/db/profile.go b/appview/db/profile.go --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -50,7 +50,7 @@ } type IssueEvents struct { - Items []*Issue + Items []*models.Issue } type IssueEventStats struct { diff --git a/appview/issues/issues.go b/appview/issues/issues.go --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -76,7 +76,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -130,7 +130,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -226,7 +226,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Notice(w, noticeId, "Failed to delete issue.") @@ -273,7 +273,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -319,7 +319,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -363,7 +363,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -382,7 +382,7 @@ replyTo = &replyToUri } - comment := db.IssueComment{ + comment := models.IssueComment{ Did: user.Did, Rkey: tid.TID(), IssueAt: issue.AtUri().String(), @@ -446,7 +446,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -487,7 +487,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -591,7 +591,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -632,7 +632,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -673,7 +673,7 @@ return } - issue, ok := r.Context().Value("issue").(*db.Issue) + issue, ok := r.Context().Value("issue").(*models.Issue) if !ok { l.Error("failed to get issue") rp.pages.Error404(w) @@ -829,7 +829,7 @@ RepoInfo: f.RepoInfo(user), }) case http.MethodPost: - issue := &db.Issue{ + issue := &models.Issue{ RepoAt: f.RepoAt(), Rkey: tid.TID(), Title: r.FormValue("title"), diff --git a/appview/models/issue.go b/appview/models/issue.go new file mode 100644 --- /dev/null +++ b/appview/models/issue.go @@ -0,0 +1,194 @@ +package models + +import ( + "fmt" + "sort" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" +) + +type Issue struct { + Id int64 + Did string + Rkey string + RepoAt syntax.ATURI + IssueId int + Created time.Time + Edited *time.Time + Deleted *time.Time + Title string + Body string + Open bool + + // optionally, populate this when querying for reverse mappings + // like comment counts, parent repo etc. + Comments []IssueComment + Labels LabelState + Repo *Repo +} + +func (i *Issue) AtUri() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) +} + +func (i *Issue) AsRecord() tangled.RepoIssue { + return tangled.RepoIssue{ + Repo: i.RepoAt.String(), + Title: i.Title, + Body: &i.Body, + CreatedAt: i.Created.Format(time.RFC3339), + } +} + +func (i *Issue) State() string { + if i.Open { + return "open" + } + return "closed" +} + +type CommentListItem struct { + Self *IssueComment + Replies []*IssueComment +} + +func (i *Issue) CommentList() []CommentListItem { + // Create a map to quickly find comments by their aturi + toplevel := make(map[string]*CommentListItem) + var replies []*IssueComment + + // collect top level comments into the map + for _, comment := range i.Comments { + if comment.IsTopLevel() { + toplevel[comment.AtUri().String()] = &CommentListItem{ + Self: &comment, + } + } else { + replies = append(replies, &comment) + } + } + + for _, r := range replies { + parentAt := *r.ReplyTo + if parent, exists := toplevel[parentAt]; exists { + parent.Replies = append(parent.Replies, r) + } + } + + var listing []CommentListItem + for _, v := range toplevel { + listing = append(listing, *v) + } + + // sort everything + sortFunc := func(a, b *IssueComment) bool { + return a.Created.Before(b.Created) + } + sort.Slice(listing, func(i, j int) bool { + return sortFunc(listing[i].Self, listing[j].Self) + }) + for _, r := range listing { + sort.Slice(r.Replies, func(i, j int) bool { + return sortFunc(r.Replies[i], r.Replies[j]) + }) + } + + return listing +} + +func (i *Issue) Participants() []string { + participantSet := make(map[string]struct{}) + participants := []string{} + + addParticipant := func(did string) { + if _, exists := participantSet[did]; !exists { + participantSet[did] = struct{}{} + participants = append(participants, did) + } + } + + addParticipant(i.Did) + + for _, c := range i.Comments { + addParticipant(c.Did) + } + + return participants +} + +func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { + created, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + created = time.Now() + } + + body := "" + if record.Body != nil { + body = *record.Body + } + + return Issue{ + RepoAt: syntax.ATURI(record.Repo), + Did: did, + Rkey: rkey, + Created: created, + Title: record.Title, + Body: body, + Open: true, // new issues are open by default + } +} + +type IssueComment struct { + Id int64 + Did string + Rkey string + IssueAt string + ReplyTo *string + Body string + Created time.Time + Edited *time.Time + Deleted *time.Time +} + +func (i *IssueComment) AtUri() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) +} + +func (i *IssueComment) AsRecord() tangled.RepoIssueComment { + return tangled.RepoIssueComment{ + Body: i.Body, + Issue: i.IssueAt, + CreatedAt: i.Created.Format(time.RFC3339), + ReplyTo: i.ReplyTo, + } +} + +func (i *IssueComment) IsTopLevel() bool { + return i.ReplyTo == nil +} + +func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { + created, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + created = time.Now() + } + + ownerDid := did + + if _, err = syntax.ParseATURI(record.Issue); err != nil { + return nil, err + } + + comment := IssueComment{ + Did: ownerDid, + Rkey: rkey, + Body: record.Body, + IssueAt: record.Issue, + ReplyTo: record.ReplyTo, + Created: created, + } + + return &comment, nil +} diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -34,7 +34,7 @@ } } -func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { +func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { for _, notifier := range m.notifiers { notifier.NewIssue(ctx, issue) } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -13,7 +13,7 @@ NewStar(ctx context.Context, star *db.Star) DeleteStar(ctx context.Context, star *db.Star) - NewIssue(ctx context.Context, issue *db.Issue) + NewIssue(ctx context.Context, issue *models.Issue) NewFollow(ctx context.Context, follow *models.Follow) DeleteFollow(ctx context.Context, follow *models.Follow) @@ -38,7 +38,7 @@ func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} -func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} +func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -889,7 +889,7 @@ LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo Active string - Issues []db.Issue + Issues []models.Issue LabelDefs map[string]*models.LabelDefinition Page pagination.Page FilteringByOpen bool @@ -904,8 +904,8 @@ LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo Active string - Issue *db.Issue - CommentList []db.CommentListItem + Issue *models.Issue + CommentList []models.CommentListItem LabelDefs map[string]*models.LabelDefinition OrderedReactionKinds []db.ReactionKind @@ -921,7 +921,7 @@ type EditIssueParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Issue *db.Issue + Issue *models.Issue Action string } @@ -944,7 +944,7 @@ type RepoNewIssueParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Issue *db.Issue // existing issue if any -- passed when editing + Issue *models.Issue // existing issue if any -- passed when editing Active string Action string } @@ -958,8 +958,8 @@ type EditIssueCommentParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Issue *db.Issue - Comment *db.IssueComment + Issue *models.Issue + Comment *models.IssueComment } func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { @@ -969,8 +969,8 @@ type ReplyIssueCommentPlaceholderParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Issue *db.Issue - Comment *db.IssueComment + Issue *models.Issue + Comment *models.IssueComment } func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { @@ -980,8 +980,8 @@ type ReplyIssueCommentParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Issue *db.Issue - Comment *db.IssueComment + Issue *models.Issue + Comment *models.IssueComment } func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { @@ -991,8 +991,8 @@ type IssueCommentBodyParams struct { LoggedInUser *oauth.User RepoInfo repoinfo.RepoInfo - Issue *db.Issue - Comment *db.IssueComment + Issue *models.Issue + Comment *models.IssueComment } func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { diff --git a/appview/posthog/notifier.go b/appview/posthog/notifier.go --- a/appview/posthog/notifier.go +++ b/appview/posthog/notifier.go @@ -57,7 +57,7 @@ } } -func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { +func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { err := n.client.Enqueue(posthog.Capture{ DistinctId: issue.Did, Event: "new_issue", diff --git a/appview/repo/feed.go b/appview/repo/feed.go --- a/appview/repo/feed.go +++ b/appview/repo/feed.go @@ -9,6 +9,7 @@ "time" "tangled.org/core/appview/db" + "tangled.org/core/appview/models" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" @@ -108,7 +109,7 @@ return items, nil } -func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { +func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) if err != nil { return nil, err diff --git a/appview/state/profile.go b/appview/state/profile.go --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -467,7 +467,7 @@ return nil } -func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { +func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error { for _, issue := range issues { owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) if err != nil { @@ -499,7 +499,7 @@ } } -func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { +func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { return &feeds.Item{ Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, diff --git a/appview/validator/issue.go b/appview/validator/issue.go --- a/appview/validator/issue.go +++ b/appview/validator/issue.go @@ -5,9 +5,10 @@ "strings" "tangled.org/core/appview/db" + "tangled.org/core/appview/models" ) -func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { +func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { // if comments have parents, only ingest ones that are 1 level deep if comment.ReplyTo != nil { parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) @@ -32,7 +33,7 @@ return nil } -func (v *Validator) ValidateIssue(issue *db.Issue) error { +func (v *Validator) ValidateIssue(issue *models.Issue) error { if issue.Title == "" { return fmt.Errorf("issue title is empty") }