Monorepo for Tangled tangled.org

appview/db: rework issue and comment CRUD ops

- all Create ops are upserts by default, this means the ingester simply
has to create a new item during ingestion, the db handler will decide
if it is an edit or a create operation
- all ops have been updated to use db.Filter

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 32dbd785 1fe818b5

verified
Changed files
+270 -238
appview
+267 -235
appview/db/issues.go
··· 240 return nil 241 } 242 243 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 244 - var issueAt string 245 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 246 - return issueAt, err 247 - } 248 249 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 250 - var ownerDid string 251 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 252 - return ownerDid, err 253 - } 254 255 - func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 256 - var issues []Issue 257 - openValue := 0 258 - if isOpen { 259 - openValue = 1 260 } 261 262 - rows, err := e.Query( 263 ` 264 - with numbered_issue as ( 265 select 266 - i.id, 267 - i.owner_did, 268 - i.rkey, 269 - i.issue_id, 270 - i.created, 271 - i.title, 272 - i.body, 273 - i.open, 274 - count(c.id) as comment_count, 275 - row_number() over (order by i.created desc) as row_num 276 from 277 - issues i 278 - left join 279 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 280 - where 281 - i.repo_at = ? and i.open = ? 282 - group by 283 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 284 - ) 285 - select 286 - id, 287 - owner_did, 288 - rkey, 289 - issue_id, 290 - created, 291 - title, 292 - body, 293 - open, 294 - comment_count 295 - from 296 - numbered_issue 297 - where 298 - row_num between ? and ?`, 299 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 300 if err != nil { 301 - return nil, err 302 } 303 defer rows.Close() 304 305 for rows.Next() { 306 var issue Issue 307 var createdAt string 308 - var metadata IssueMetadata 309 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 310 if err != nil { 311 - return nil, err 312 } 313 314 - createdTime, err := time.Parse(time.RFC3339, createdAt) 315 - if err != nil { 316 - return nil, err 317 } 318 - issue.Created = createdTime 319 - issue.Metadata = &metadata 320 321 - issues = append(issues, issue) 322 } 323 324 - if err := rows.Err(); err != nil { 325 - return nil, err 326 } 327 328 return issues, nil 329 } ··· 375 var issue Issue 376 var issueCreatedAt string 377 err := rows.Scan( 378 - &issue.ID, 379 - &issue.OwnerDid, 380 &issue.RepoAt, 381 &issue.IssueId, 382 &issueCreatedAt, ··· 405 } 406 407 func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 408 - return GetIssuesWithLimit(e, 0, filters...) 409 } 410 411 // timeframe here is directly passed into the sql query filter, and any ··· 448 var issueCreatedAt, repoCreatedAt string 449 var repo Repo 450 err := rows.Scan( 451 - &issue.ID, 452 - &issue.OwnerDid, 453 &issue.Rkey, 454 &issue.RepoAt, 455 &issue.IssueId, ··· 479 } 480 repo.Created = repoCreatedTime 481 482 - issue.Metadata = &IssueMetadata{ 483 - Repo: &repo, 484 - } 485 - 486 issues = append(issues, issue) 487 } 488 ··· 499 500 var issue Issue 501 var createdAt string 502 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 503 if err != nil { 504 return nil, err 505 } ··· 513 return &issue, nil 514 } 515 516 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 517 - query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 518 - row := e.QueryRow(query, repoAt, issueId) 519 - 520 - var issue Issue 521 - var createdAt string 522 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 523 if err != nil { 524 - return nil, nil, err 525 } 526 527 - createdTime, err := time.Parse(time.RFC3339, createdAt) 528 if err != nil { 529 - return nil, nil, err 530 } 531 - issue.Created = createdTime 532 533 - comments, err := GetComments(e, repoAt, issueId) 534 - if err != nil { 535 - return nil, nil, err 536 } 537 538 - return &issue, comments, nil 539 - } 540 541 - func NewIssueComment(e Execer, comment *Comment) error { 542 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 543 - _, err := e.Exec( 544 - query, 545 - comment.OwnerDid, 546 - comment.RepoAt, 547 - comment.Rkey, 548 - comment.Issue, 549 - comment.CommentId, 550 - comment.Body, 551 - ) 552 return err 553 } 554 555 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 556 - var comments []Comment 557 558 - rows, err := e.Query(` 559 select 560 - owner_did, 561 - issue_id, 562 - comment_id, 563 rkey, 564 body, 565 created, 566 edited, 567 deleted 568 from 569 - comments 570 - where 571 - repo_at = ? and issue_id = ? 572 - order by 573 - created asc`, 574 - repoAt, 575 - issueId, 576 - ) 577 - if err == sql.ErrNoRows { 578 - return []Comment{}, nil 579 - } 580 if err != nil { 581 return nil, err 582 } 583 - defer rows.Close() 584 585 for rows.Next() { 586 - var comment Comment 587 - var createdAt string 588 - var deletedAt, editedAt, rkey sql.NullString 589 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 590 if err != nil { 591 return nil, err 592 } 593 594 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 595 - if err != nil { 596 - return nil, err 597 } 598 - comment.Created = &createdAtTime 599 600 - if deletedAt.Valid { 601 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 602 - if err != nil { 603 - return nil, err 604 } 605 - comment.Deleted = &deletedTime 606 } 607 608 - if editedAt.Valid { 609 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 610 - if err != nil { 611 - return nil, err 612 } 613 - comment.Edited = &editedTime 614 } 615 616 - if rkey.Valid { 617 - comment.Rkey = rkey.String 618 } 619 620 comments = append(comments, comment) 621 } 622 623 - if err := rows.Err(); err != nil { 624 return nil, err 625 } 626 627 return comments, nil 628 } 629 630 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 631 - query := ` 632 - select 633 - owner_did, body, rkey, created, deleted, edited 634 - from 635 - comments where repo_at = ? and issue_id = ? and comment_id = ? 636 - ` 637 - row := e.QueryRow(query, repoAt, issueId, commentId) 638 - 639 - var comment Comment 640 - var createdAt string 641 - var deletedAt, editedAt, rkey sql.NullString 642 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 643 - if err != nil { 644 - return nil, err 645 } 646 647 - createdTime, err := time.Parse(time.RFC3339, createdAt) 648 - if err != nil { 649 - return nil, err 650 } 651 - comment.Created = &createdTime 652 653 - if deletedAt.Valid { 654 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 655 - if err != nil { 656 - return nil, err 657 - } 658 - comment.Deleted = &deletedTime 659 - } 660 661 - if editedAt.Valid { 662 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 663 - if err != nil { 664 - return nil, err 665 - } 666 - comment.Edited = &editedTime 667 } 668 669 - if rkey.Valid { 670 - comment.Rkey = rkey.String 671 } 672 673 - comment.RepoAt = repoAt 674 - comment.Issue = issueId 675 - comment.CommentId = commentId 676 - 677 - return &comment, nil 678 - } 679 - 680 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 681 - _, err := e.Exec( 682 - ` 683 - update comments 684 - set body = ?, 685 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 686 - where repo_at = ? and issue_id = ? and comment_id = ? 687 - `, newBody, repoAt, issueId, commentId) 688 return err 689 } 690 691 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 692 - _, err := e.Exec( 693 - ` 694 - update comments 695 - set body = "", 696 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 697 - where repo_at = ? and issue_id = ? and comment_id = ? 698 - `, repoAt, issueId, commentId) 699 - return err 700 - } 701 - 702 - func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 703 - _, err := e.Exec( 704 - ` 705 - update comments 706 - set body = ?, 707 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 708 - where owner_did = ? and rkey = ? 709 - `, newBody, ownerDid, rkey) 710 - return err 711 - } 712 - 713 - func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 714 - _, err := e.Exec( 715 - ` 716 - update comments 717 - set body = "", 718 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 719 - where owner_did = ? and rkey = ? 720 - `, ownerDid, rkey) 721 - return err 722 - } 723 724 - func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 725 - _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 726 - return err 727 - } 728 729 - func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 730 - _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 731 - return err 732 - } 733 - 734 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 735 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 736 - return err 737 - } 738 - 739 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 740 - _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 741 return err 742 } 743
··· 240 return nil 241 } 242 243 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 244 + issueMap := make(map[string]*Issue) // at-uri -> issue 245 246 + var conditions []string 247 + var args []any 248 249 + for _, filter := range filters { 250 + conditions = append(conditions, filter.Condition()) 251 + args = append(args, filter.Arg()...) 252 } 253 254 + whereClause := "" 255 + if conditions != nil { 256 + whereClause = " where " + strings.Join(conditions, " and ") 257 + } 258 + 259 + pLower := FilterGte("row_num", page.Offset+1) 260 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 261 + 262 + args = append(args, pLower.Arg()...) 263 + args = append(args, pUpper.Arg()...) 264 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 265 + 266 + query := fmt.Sprintf( 267 ` 268 + select * from ( 269 select 270 + id, 271 + did, 272 + rkey, 273 + repo_at, 274 + issue_id, 275 + title, 276 + body, 277 + open, 278 + created, 279 + edited, 280 + deleted, 281 + row_number() over (order by created desc) as row_num 282 from 283 + issues 284 + %s 285 + ) ranked_issues 286 + %s 287 + `, 288 + whereClause, 289 + pagination, 290 + ) 291 + 292 + rows, err := e.Query(query, args...) 293 if err != nil { 294 + return nil, fmt.Errorf("failed to query issues table: %w", err) 295 } 296 defer rows.Close() 297 298 for rows.Next() { 299 var issue Issue 300 var createdAt string 301 + var editedAt, deletedAt sql.Null[string] 302 + var rowNum int64 303 + err := rows.Scan( 304 + &issue.Id, 305 + &issue.Did, 306 + &issue.Rkey, 307 + &issue.RepoAt, 308 + &issue.IssueId, 309 + &issue.Title, 310 + &issue.Body, 311 + &issue.Open, 312 + &createdAt, 313 + &editedAt, 314 + &deletedAt, 315 + &rowNum, 316 + ) 317 if err != nil { 318 + return nil, fmt.Errorf("failed to scan issue: %w", err) 319 + } 320 + 321 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 322 + issue.Created = t 323 + } 324 + 325 + if editedAt.Valid { 326 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 327 + issue.Edited = &t 328 + } 329 } 330 331 + if deletedAt.Valid { 332 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 333 + issue.Deleted = &t 334 + } 335 } 336 + 337 + atUri := issue.AtUri().String() 338 + issueMap[atUri] = &issue 339 + } 340 + 341 + // collect reverse repos 342 + repoAts := make([]string, 0, len(issueMap)) // or just []string{} 343 + for _, issue := range issueMap { 344 + repoAts = append(repoAts, string(issue.RepoAt)) 345 + } 346 + 347 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 348 + if err != nil { 349 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 350 + } 351 + 352 + repoMap := make(map[string]*Repo) 353 + for i := range repos { 354 + repoMap[string(repos[i].RepoAt())] = &repos[i] 355 + } 356 + 357 + for issueAt := range issueMap { 358 + i := issueMap[issueAt] 359 + r := repoMap[string(i.RepoAt)] 360 + i.Repo = r 361 + } 362 363 + // collect comments 364 + issueAts := slices.Collect(maps.Keys(issueMap)) 365 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 366 + if err != nil { 367 + return nil, fmt.Errorf("failed to query comments: %w", err) 368 } 369 370 + for i := range comments { 371 + issueAt := comments[i].IssueAt 372 + if issue, ok := issueMap[issueAt]; ok { 373 + issue.Comments = append(issue.Comments, comments[i]) 374 + } 375 + } 376 + 377 + var issues []Issue 378 + for _, i := range issueMap { 379 + issues = append(issues, *i) 380 } 381 + 382 + sort.Slice(issues, func(i, j int) bool { 383 + return issues[i].Created.After(issues[j].Created) 384 + }) 385 386 return issues, nil 387 } ··· 433 var issue Issue 434 var issueCreatedAt string 435 err := rows.Scan( 436 + &issue.Id, 437 + &issue.Did, 438 &issue.RepoAt, 439 &issue.IssueId, 440 &issueCreatedAt, ··· 463 } 464 465 func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 466 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 467 } 468 469 // timeframe here is directly passed into the sql query filter, and any ··· 506 var issueCreatedAt, repoCreatedAt string 507 var repo Repo 508 err := rows.Scan( 509 + &issue.Id, 510 + &issue.Did, 511 &issue.Rkey, 512 &issue.RepoAt, 513 &issue.IssueId, ··· 537 } 538 repo.Created = repoCreatedTime 539 540 issues = append(issues, issue) 541 } 542 ··· 553 554 var issue Issue 555 var createdAt string 556 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 557 if err != nil { 558 return nil, err 559 } ··· 567 return &issue, nil 568 } 569 570 + func AddIssueComment(e Execer, c IssueComment) (int64, error) { 571 + result, err := e.Exec( 572 + `insert into issue_comments ( 573 + did, 574 + rkey, 575 + issue_at, 576 + body, 577 + reply_to, 578 + created, 579 + edited 580 + ) 581 + values (?, ?, ?, ?, ?, ?, null) 582 + on conflict(did, rkey) do update set 583 + issue_at = excluded.issue_at, 584 + body = excluded.body, 585 + edited = case 586 + when 587 + issue_comments.issue_at != excluded.issue_at 588 + or issue_comments.body != excluded.body 589 + or issue_comments.reply_to != excluded.reply_to 590 + then ? 591 + else issue_comments.edited 592 + end`, 593 + c.Did, 594 + c.Rkey, 595 + c.IssueAt, 596 + c.Body, 597 + c.ReplyTo, 598 + c.Created.Format(time.RFC3339), 599 + time.Now().Format(time.RFC3339), 600 + ) 601 if err != nil { 602 + return 0, err 603 } 604 605 + id, err := result.LastInsertId() 606 if err != nil { 607 + return 0, err 608 } 609 + 610 + return id, nil 611 + } 612 613 + func DeleteIssueComments(e Execer, filters ...filter) error { 614 + var conditions []string 615 + var args []any 616 + for _, filter := range filters { 617 + conditions = append(conditions, filter.Condition()) 618 + args = append(args, filter.Arg()...) 619 } 620 621 + whereClause := "" 622 + if conditions != nil { 623 + whereClause = " where " + strings.Join(conditions, " and ") 624 + } 625 626 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 627 + 628 + _, err := e.Exec(query, args...) 629 return err 630 } 631 632 + func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 633 + var comments []IssueComment 634 + 635 + var conditions []string 636 + var args []any 637 + for _, filter := range filters { 638 + conditions = append(conditions, filter.Condition()) 639 + args = append(args, filter.Arg()...) 640 + } 641 642 + whereClause := "" 643 + if conditions != nil { 644 + whereClause = " where " + strings.Join(conditions, " and ") 645 + } 646 + 647 + query := fmt.Sprintf(` 648 select 649 + id, 650 + did, 651 rkey, 652 + issue_at, 653 + reply_to, 654 body, 655 created, 656 edited, 657 deleted 658 from 659 + issue_comments 660 + %s 661 + `, whereClause) 662 + 663 + rows, err := e.Query(query, args...) 664 if err != nil { 665 return nil, err 666 } 667 668 for rows.Next() { 669 + var comment IssueComment 670 + var created string 671 + var rkey, edited, deleted, replyTo sql.Null[string] 672 + err := rows.Scan( 673 + &comment.Id, 674 + &comment.Did, 675 + &rkey, 676 + &comment.IssueAt, 677 + &replyTo, 678 + &comment.Body, 679 + &created, 680 + &edited, 681 + &deleted, 682 + ) 683 if err != nil { 684 return nil, err 685 } 686 687 + // this is a remnant from old times, newer comments always have rkey 688 + if rkey.Valid { 689 + comment.Rkey = rkey.V 690 } 691 + 692 + if t, err := time.Parse(time.RFC3339, created); err == nil { 693 + comment.Created = t 694 + } 695 696 + if edited.Valid { 697 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 698 + comment.Edited = &t 699 } 700 } 701 702 + if deleted.Valid { 703 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 704 + comment.Deleted = &t 705 } 706 } 707 708 + if replyTo.Valid { 709 + comment.ReplyTo = &replyTo.V 710 } 711 712 comments = append(comments, comment) 713 } 714 715 + if err = rows.Err(); err != nil { 716 return nil, err 717 } 718 719 return comments, nil 720 } 721 722 + func DeleteIssues(e Execer, filters ...filter) error { 723 + var conditions []string 724 + var args []any 725 + for _, filter := range filters { 726 + conditions = append(conditions, filter.Condition()) 727 + args = append(args, filter.Arg()...) 728 } 729 730 + whereClause := "" 731 + if conditions != nil { 732 + whereClause = " where " + strings.Join(conditions, " and ") 733 } 734 735 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 736 + _, err := e.Exec(query, args...) 737 + return err 738 + } 739 740 + func CloseIssues(e Execer, filters ...filter) error { 741 + var conditions []string 742 + var args []any 743 + for _, filter := range filters { 744 + conditions = append(conditions, filter.Condition()) 745 + args = append(args, filter.Arg()...) 746 } 747 748 + whereClause := "" 749 + if conditions != nil { 750 + whereClause = " where " + strings.Join(conditions, " and ") 751 } 752 753 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 754 + _, err := e.Exec(query, args...) 755 return err 756 } 757 758 + func ReopenIssues(e Execer, filters ...filter) error { 759 + var conditions []string 760 + var args []any 761 + for _, filter := range filters { 762 + conditions = append(conditions, filter.Condition()) 763 + args = append(args, filter.Arg()...) 764 + } 765 766 + whereClause := "" 767 + if conditions != nil { 768 + whereClause = " where " + strings.Join(conditions, " and ") 769 + } 770 771 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 772 + _, err := e.Exec(query, args...) 773 return err 774 } 775
+3 -3
appview/state/profile.go
··· 467 468 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 469 for _, issue := range issues { 470 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 471 if err != nil { 472 return err 473 } ··· 499 500 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 501 return &feeds.Item{ 502 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 503 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 504 Created: issue.Created, 505 Author: author, 506 }
··· 467 468 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 469 for _, issue := range issues { 470 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 471 if err != nil { 472 return err 473 } ··· 499 500 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 501 return &feeds.Item{ 502 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 503 + 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"}, 504 Created: issue.Created, 505 Author: author, 506 }