forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+16 -73
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 6
-
-
if t.Owner == nil {
-
fieldCount--
-
}
+
fieldCount := 5
-
if t.Repo == nil {
+
if t.ReplyTo == nil {
fieldCount--
···
return err
-
// t.Repo (string) (string)
-
if t.Repo != nil {
-
-
if len("repo") > 1000000 {
-
return xerrors.Errorf("Value in field \"repo\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("repo")); err != nil {
-
return err
-
}
-
-
if t.Repo == nil {
-
if _, err := cw.Write(cbg.CborNull); err != nil {
-
return err
-
}
-
} else {
-
if len(*t.Repo) > 1000000 {
-
return xerrors.Errorf("Value in field t.Repo was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
-
return err
-
}
-
}
-
}
-
// t.LexiconTypeID (string) (string)
if len("$type") > 1000000 {
return xerrors.Errorf("Value in field \"$type\" was too long")
···
return err
-
// t.Owner (string) (string)
-
if t.Owner != nil {
+
// t.ReplyTo (string) (string)
+
if t.ReplyTo != nil {
-
if len("owner") > 1000000 {
-
return xerrors.Errorf("Value in field \"owner\" was too long")
+
if len("replyTo") > 1000000 {
+
return xerrors.Errorf("Value in field \"replyTo\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil {
return err
-
if _, err := cw.WriteString(string("owner")); err != nil {
+
if _, err := cw.WriteString(string("replyTo")); err != nil {
return err
-
if t.Owner == nil {
+
if t.ReplyTo == nil {
if _, err := cw.Write(cbg.CborNull); err != nil {
return err
} else {
-
if len(*t.Owner) > 1000000 {
-
return xerrors.Errorf("Value in field t.Owner was too long")
+
if len(*t.ReplyTo) > 1000000 {
+
return xerrors.Errorf("Value in field t.ReplyTo was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil {
return err
-
if _, err := cw.WriteString(string(*t.Owner)); err != nil {
+
if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil {
return err
···
t.Body = string(sval)
-
// t.Repo (string) (string)
-
case "repo":
-
-
{
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Repo = (*string)(&sval)
-
}
-
}
// t.LexiconTypeID (string) (string)
case "$type":
···
t.Issue = string(sval)
-
// t.Owner (string) (string)
-
case "owner":
+
// t.ReplyTo (string) (string)
+
case "replyTo":
b, err := cr.ReadByte()
···
return err
-
t.Owner = (*string)(&sval)
+
t.ReplyTo = (*string)(&sval)
// t.CreatedAt (string) (string)
+1 -2
api/tangled/issuecomment.go
···
Body string `json:"body" cborgen:"body"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
Issue string `json:"issue" cborgen:"issue"`
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
+
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
}
+134
appview/db/db.go
···
return err
})
+
// remove issue_at from issues and replace with generated column
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// couple other changes:
+
// - columns renamed to be more consistent
+
// - adds edited and deleted fields
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists issues_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text not null,
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored,
+
+
-- at identifiers
+
repo_at text not null,
+
+
-- content
+
issue_id integer not null,
+
title text not null,
+
body text not null,
+
open integer not null default 1,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
edited text, -- timestamp
+
deleted text, -- timestamp
+
+
unique(did, rkey),
+
unique(repo_at, issue_id),
+
unique(at_uri),
+
foreign key (repo_at) references repos(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data
+
_, err = tx.Exec(`
+
insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created)
+
select
+
i.id,
+
i.owner_did,
+
i.rkey,
+
i.repo_at,
+
i.issue_id,
+
i.title,
+
i.body,
+
i.open,
+
i.created
+
from issues i;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table issues`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table issues_new rename to issues`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
+
// - renames the comments table to 'issue_comments'
+
// - rework issue comments to update constraints:
+
// * unique(did, rkey)
+
// * remove comment-id and just use the global ID
+
// * foreign key (repo_at, issue_id)
+
// - new columns
+
// * column "reply_to" which can be any other comment
+
// * column "at-uri" which is a generated column
+
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists issue_comments (
+
-- identifiers
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text,
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored,
+
+
-- at identifiers
+
issue_at text not null,
+
reply_to text, -- at_uri of parent comment
+
+
-- content
+
body text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
edited text,
+
deleted text,
+
+
-- constraints
+
unique(did, rkey),
+
unique(at_uri),
+
foreign key (issue_at) references issues(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data
+
_, err = tx.Exec(`
+
insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted)
+
select
+
c.id,
+
c.owner_did,
+
c.rkey,
+
i.at_uri, -- get at_uri from issues table
+
c.body,
+
c.created,
+
c.edited,
+
c.deleted
+
from comments c
+
join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table comments`)
+
return err
+
})
+
return &DB{db}, nil
}
+410 -453
appview/db/issues.go
···
import (
"database/sql"
"fmt"
-
mathrand "math/rand/v2"
+
"maps"
+
"slices"
+
"sort"
"strings"
"time"
···
)
type Issue struct {
-
ID int64
-
RepoAt syntax.ATURI
-
OwnerDid string
-
IssueId int
-
Rkey string
-
Created time.Time
-
Title string
-
Body string
-
Open bool
+
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.
-
Metadata *IssueMetadata
+
Comments []IssueComment
+
Repo *Repo
}
-
type IssueMetadata struct {
-
CommentCount int
-
Repo *Repo
-
// labels, assignee etc.
+
func (i *Issue) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
}
-
type Comment struct {
-
OwnerDid string
-
RepoAt syntax.ATURI
-
Rkey string
-
Issue int
-
CommentId int
-
Body string
-
Created *time.Time
-
Deleted *time.Time
-
Edited *time.Time
+
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) AtUri() syntax.ATURI {
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
+
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 IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
···
}
return Issue{
-
RepoAt: syntax.ATURI(record.Repo),
-
OwnerDid: did,
-
Rkey: rkey,
-
Created: created,
-
Title: record.Title,
-
Body: body,
-
Open: true, // new issues are open by default
+
RepoAt: syntax.ATURI(record.Repo),
+
Did: did,
+
Rkey: rkey,
+
Created: created,
+
Title: record.Title,
+
Body: body,
+
Open: true, // new issues are open by default
}
}
-
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
-
ownerDid := issueUri.Authority().String()
-
issueRkey := issueUri.RecordKey().String()
+
type IssueComment struct {
+
Id int64
+
Did string
+
Rkey string
+
IssueAt string
+
ReplyTo *string
+
Body string
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
}
-
var repoAt string
-
var issueId int
+
func (i *IssueComment) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
+
}
-
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
-
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
-
if err != nil {
-
return "", 0, err
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
+
return tangled.RepoIssueComment{
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
}
+
}
-
return syntax.ATURI(repoAt), issueId, nil
+
func (i *IssueComment) IsTopLevel() bool {
+
return i.ReplyTo == nil
}
-
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
+
func IssueCommentFromRecord(e Execer, 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 record.Owner != nil {
-
ownerDid = *record.Owner
-
}
-
issueUri, err := syntax.ParseATURI(record.Issue)
-
if err != nil {
-
return Comment{}, err
-
}
-
-
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
-
if err != nil {
-
return Comment{}, err
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
+
return nil, err
}
-
comment := Comment{
-
OwnerDid: ownerDid,
-
RepoAt: repoAt,
-
Rkey: rkey,
-
Body: record.Body,
-
Issue: issueId,
-
CommentId: mathrand.IntN(1000000),
-
Created: &created,
+
comment := IssueComment{
+
Did: ownerDid,
+
Rkey: rkey,
+
Body: record.Body,
+
IssueAt: record.Issue,
+
ReplyTo: record.ReplyTo,
+
Created: created,
}
-
return comment, nil
+
return &comment, nil
}
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
-
defer tx.Rollback()
-
+
func PutIssue(tx *sql.Tx, issue *Issue) error {
+
// ensure sequence exists
_, err := tx.Exec(`
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
values (?, 1)
-
`, issue.RepoAt)
+
`, issue.RepoAt)
if err != nil {
return err
}
-
var nextId int
-
err = tx.QueryRow(`
-
update repo_issue_seqs
-
set next_issue_id = next_issue_id + 1
-
where repo_at = ?
-
returning next_issue_id - 1
-
`, issue.RepoAt).Scan(&nextId)
-
if err != nil {
+
issues, err := GetIssues(
+
tx,
+
FilterEq("did", issue.Did),
+
FilterEq("rkey", issue.Rkey),
+
)
+
switch {
+
case err != nil:
return err
-
}
-
-
issue.IssueId = nextId
+
case len(issues) == 0:
+
return createNewIssue(tx, issue)
+
case len(issues) != 1: // should be unreachable
+
return fmt.Errorf("invalid number of issues returned: %d", len(issues))
+
default:
+
// if content is identical, do not edit
+
existingIssue := issues[0]
+
if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
+
return nil
+
}
-
res, err := tx.Exec(`
-
insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
-
values (?, ?, ?, ?, ?, ?, ?)
-
`, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
-
if err != nil {
-
return err
+
issue.Id = existingIssue.Id
+
issue.IssueId = existingIssue.IssueId
+
return updateIssue(tx, issue)
}
+
}
-
lastID, err := res.LastInsertId()
+
func createNewIssue(tx *sql.Tx, issue *Issue) error {
+
// get next issue_id
+
var newIssueId int
+
err := tx.QueryRow(`
+
update repo_issue_seqs
+
set next_issue_id = next_issue_id + 1
+
where repo_at = ?
+
returning next_issue_id - 1
+
`, issue.RepoAt).Scan(&newIssueId)
if err != nil {
return err
}
-
issue.ID = lastID
-
if err := tx.Commit(); err != nil {
-
return err
-
}
+
// insert new issue
+
row := tx.QueryRow(`
+
insert into issues (repo_at, did, rkey, issue_id, title, body)
+
values (?, ?, ?, ?, ?, ?)
+
returning rowid, issue_id
+
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
-
return nil
+
return row.Scan(&issue.Id, &issue.IssueId)
}
-
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
-
var issueAt string
-
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
-
return issueAt, err
-
}
-
-
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
-
var ownerDid string
-
err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
-
return ownerDid, err
-
}
-
-
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
-
var issues []Issue
-
openValue := 0
-
if isOpen {
-
openValue = 1
-
}
-
-
rows, err := e.Query(
-
`
-
with numbered_issue as (
-
select
-
i.id,
-
i.owner_did,
-
i.rkey,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open,
-
count(c.id) as comment_count,
-
row_number() over (order by i.created desc) as row_num
-
from
-
issues i
-
left join
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
-
where
-
i.repo_at = ? and i.open = ?
-
group by
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
-
)
-
select
-
id,
-
owner_did,
-
rkey,
-
issue_id,
-
created,
-
title,
-
body,
-
open,
-
comment_count
-
from
-
numbered_issue
-
where
-
row_num between ? and ?`,
-
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var issue Issue
-
var createdAt string
-
var metadata IssueMetadata
-
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
-
if err != nil {
-
return nil, err
-
}
-
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
-
}
-
issue.Created = createdTime
-
issue.Metadata = &metadata
-
-
issues = append(issues, issue)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return issues, nil
+
func updateIssue(tx *sql.Tx, issue *Issue) error {
+
// update existing issue
+
_, err := tx.Exec(`
+
update issues
+
set title = ?, body = ?, edited = ?
+
where did = ? and rkey = ?
+
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
+
return err
}
-
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
-
issues := make([]Issue, 0, limit)
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
+
issueMap := make(map[string]*Issue) // at-uri -> issue
var conditions []string
var args []any
+
for _, filter := range filters {
conditions = append(conditions, filter.Condition())
args = append(args, filter.Arg()...)
···
if conditions != nil {
whereClause = " where " + strings.Join(conditions, " and ")
}
-
limitClause := ""
-
if limit != 0 {
-
limitClause = fmt.Sprintf(" limit %d ", limit)
-
}
+
+
pLower := FilterGte("row_num", page.Offset+1)
+
pUpper := FilterLte("row_num", page.Offset+page.Limit)
+
+
args = append(args, pLower.Arg()...)
+
args = append(args, pUpper.Arg()...)
+
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
query := fmt.Sprintf(
-
`select
-
i.id,
-
i.owner_did,
-
i.repo_at,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open
-
from
-
issues i
+
`
+
select * from (
+
select
+
id,
+
did,
+
rkey,
+
repo_at,
+
issue_id,
+
title,
+
body,
+
open,
+
created,
+
edited,
+
deleted,
+
row_number() over (order by created desc) as row_num
+
from
+
issues
+
%s
+
) ranked_issues
%s
-
order by
-
i.created desc
-
%s`,
-
whereClause, limitClause)
+
`,
+
whereClause,
+
pagination,
+
)
rows, err := e.Query(query, args...)
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("failed to query issues table: %w", err)
}
defer rows.Close()
for rows.Next() {
var issue Issue
-
var issueCreatedAt string
+
var createdAt string
+
var editedAt, deletedAt sql.Null[string]
+
var rowNum int64
err := rows.Scan(
-
&issue.ID,
-
&issue.OwnerDid,
+
&issue.Id,
+
&issue.Did,
+
&issue.Rkey,
&issue.RepoAt,
&issue.IssueId,
-
&issueCreatedAt,
&issue.Title,
&issue.Body,
&issue.Open,
+
&createdAt,
+
&editedAt,
+
&deletedAt,
+
&rowNum,
)
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("failed to scan issue: %w", err)
+
}
+
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
issue.Created = t
+
}
+
+
if editedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
+
issue.Edited = &t
+
}
}
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
-
if err != nil {
-
return nil, err
+
if deletedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
+
issue.Deleted = &t
+
}
}
-
issue.Created = issueCreatedTime
-
issues = append(issues, issue)
+
atUri := issue.AtUri().String()
+
issueMap[atUri] = &issue
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
// collect reverse repos
+
repoAts := make([]string, 0, len(issueMap)) // or just []string{}
+
for _, issue := range issueMap {
+
repoAts = append(repoAts, string(issue.RepoAt))
}
-
return issues, nil
-
}
-
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
-
return GetIssuesWithLimit(e, 0, filters...)
-
}
-
-
// timeframe here is directly passed into the sql query filter, and any
-
// timeframe in the past should be negative; e.g.: "-3 months"
-
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
-
var issues []Issue
-
-
rows, err := e.Query(
-
`select
-
i.id,
-
i.owner_did,
-
i.rkey,
-
i.repo_at,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open,
-
r.did,
-
r.name,
-
r.knot,
-
r.rkey,
-
r.created
-
from
-
issues i
-
join
-
repos r on i.repo_at = r.at_uri
-
where
-
i.owner_did = ? and i.created >= date ('now', ?)
-
order by
-
i.created desc`,
-
ownerDid, timeframe)
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
}
-
defer rows.Close()
-
for rows.Next() {
-
var issue Issue
-
var issueCreatedAt, repoCreatedAt string
-
var repo Repo
-
err := rows.Scan(
-
&issue.ID,
-
&issue.OwnerDid,
-
&issue.Rkey,
-
&issue.RepoAt,
-
&issue.IssueId,
-
&issueCreatedAt,
-
&issue.Title,
-
&issue.Body,
-
&issue.Open,
-
&repo.Did,
-
&repo.Name,
-
&repo.Knot,
-
&repo.Rkey,
-
&repoCreatedAt,
-
)
-
if err != nil {
-
return nil, err
-
}
+
repoMap := make(map[string]*Repo)
+
for i := range repos {
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
+
}
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
-
if err != nil {
-
return nil, err
+
for issueAt, i := range issueMap {
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
+
i.Repo = r
+
} else {
+
// do not show up the issue if the repo is deleted
+
// TODO: foreign key where?
+
delete(issueMap, issueAt)
}
-
issue.Created = issueCreatedTime
+
}
-
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
-
if err != nil {
-
return nil, err
-
}
-
repo.Created = repoCreatedTime
+
// collect comments
+
issueAts := slices.Collect(maps.Keys(issueMap))
+
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query comments: %w", err)
+
}
-
issue.Metadata = &IssueMetadata{
-
Repo: &repo,
+
for i := range comments {
+
issueAt := comments[i].IssueAt
+
if issue, ok := issueMap[issueAt]; ok {
+
issue.Comments = append(issue.Comments, comments[i])
}
-
-
issues = append(issues, issue)
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
var issues []Issue
+
for _, i := range issueMap {
+
issues = append(issues, *i)
}
+
sort.Slice(issues, func(i, j int) bool {
+
return issues[i].Created.After(issues[j].Created)
+
})
+
return issues, nil
}
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
+
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
+
}
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*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 createdAt string
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
if err != nil {
return nil, err
}
···
return &issue, nil
}
-
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
-
query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
-
row := e.QueryRow(query, repoAt, issueId)
-
-
var issue Issue
-
var createdAt string
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
+
result, err := e.Exec(
+
`insert into issue_comments (
+
did,
+
rkey,
+
issue_at,
+
body,
+
reply_to,
+
created,
+
edited
+
)
+
values (?, ?, ?, ?, ?, ?, null)
+
on conflict(did, rkey) do update set
+
issue_at = excluded.issue_at,
+
body = excluded.body,
+
edited = case
+
when
+
issue_comments.issue_at != excluded.issue_at
+
or issue_comments.body != excluded.body
+
or issue_comments.reply_to != excluded.reply_to
+
then ?
+
else issue_comments.edited
+
end`,
+
c.Did,
+
c.Rkey,
+
c.IssueAt,
+
c.Body,
+
c.ReplyTo,
+
c.Created.Format(time.RFC3339),
+
time.Now().Format(time.RFC3339),
+
)
if err != nil {
-
return nil, nil, err
+
return 0, err
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
id, err := result.LastInsertId()
if err != nil {
-
return nil, nil, err
+
return 0, err
}
-
issue.Created = createdTime
+
+
return id, nil
+
}
-
comments, err := GetComments(e, repoAt, issueId)
-
if err != nil {
-
return nil, nil, err
+
func DeleteIssueComments(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
return &issue, comments, nil
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
func NewIssueComment(e Execer, comment *Comment) error {
-
query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
-
_, err := e.Exec(
-
query,
-
comment.OwnerDid,
-
comment.RepoAt,
-
comment.Rkey,
-
comment.Issue,
-
comment.CommentId,
-
comment.Body,
-
)
+
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
return err
}
-
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
-
var comments []Comment
+
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
+
var comments []IssueComment
+
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
rows, err := e.Query(`
+
query := fmt.Sprintf(`
select
-
owner_did,
-
issue_id,
-
comment_id,
+
id,
+
did,
rkey,
+
issue_at,
+
reply_to,
body,
created,
edited,
deleted
from
-
comments
-
where
-
repo_at = ? and issue_id = ?
-
order by
-
created asc`,
-
repoAt,
-
issueId,
-
)
-
if err == sql.ErrNoRows {
-
return []Comment{}, nil
-
}
+
issue_comments
+
%s
+
`, whereClause)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
-
defer rows.Close()
for rows.Next() {
-
var comment Comment
-
var createdAt string
-
var deletedAt, editedAt, rkey sql.NullString
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
+
var comment IssueComment
+
var created string
+
var rkey, edited, deleted, replyTo sql.Null[string]
+
err := rows.Scan(
+
&comment.Id,
+
&comment.Did,
+
&rkey,
+
&comment.IssueAt,
+
&replyTo,
+
&comment.Body,
+
&created,
+
&edited,
+
&deleted,
+
)
if err != nil {
return nil, err
}
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
// this is a remnant from old times, newer comments always have rkey
+
if rkey.Valid {
+
comment.Rkey = rkey.V
}
-
comment.Created = &createdAtTime
-
if deletedAt.Valid {
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
-
if err != nil {
-
return nil, err
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
+
comment.Created = t
+
}
+
+
if edited.Valid {
+
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
+
comment.Edited = &t
}
-
comment.Deleted = &deletedTime
}
-
if editedAt.Valid {
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
-
if err != nil {
-
return nil, err
+
if deleted.Valid {
+
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
+
comment.Deleted = &t
}
-
comment.Edited = &editedTime
}
-
if rkey.Valid {
-
comment.Rkey = rkey.String
+
if replyTo.Valid {
+
comment.ReplyTo = &replyTo.V
}
comments = append(comments, comment)
}
-
if err := rows.Err(); err != nil {
+
if err = rows.Err(); err != nil {
return nil, err
}
return comments, nil
}
-
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
-
query := `
-
select
-
owner_did, body, rkey, created, deleted, edited
-
from
-
comments where repo_at = ? and issue_id = ? and comment_id = ?
-
`
-
row := e.QueryRow(query, repoAt, issueId, commentId)
-
-
var comment Comment
-
var createdAt string
-
var deletedAt, editedAt, rkey sql.NullString
-
err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
-
if err != nil {
-
return nil, err
+
func DeleteIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
comment.Created = &createdTime
-
if deletedAt.Valid {
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
-
if err != nil {
-
return nil, err
-
}
-
comment.Deleted = &deletedTime
-
}
+
query := fmt.Sprintf(`delete from issues %s`, whereClause)
+
_, err := e.Exec(query, args...)
+
return err
+
}
-
if editedAt.Valid {
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
-
if err != nil {
-
return nil, err
-
}
-
comment.Edited = &editedTime
+
func CloseIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
if rkey.Valid {
-
comment.Rkey = rkey.String
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
comment.RepoAt = repoAt
-
comment.Issue = issueId
-
comment.CommentId = commentId
-
-
return &comment, nil
-
}
-
-
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = ?,
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where repo_at = ? and issue_id = ? and comment_id = ?
-
`, newBody, repoAt, issueId, commentId)
+
query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
+
_, err := e.Exec(query, args...)
return err
}
-
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = "",
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where repo_at = ? and issue_id = ? and comment_id = ?
-
`, repoAt, issueId, commentId)
-
return err
-
}
+
func ReopenIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
-
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = ?,
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where owner_did = ? and rkey = ?
-
`, newBody, ownerDid, rkey)
-
return err
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = "",
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where owner_did = ? and rkey = ?
-
`, ownerDid, rkey)
-
return err
-
}
-
-
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
-
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
-
return err
-
}
-
-
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
-
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
-
return err
-
}
-
-
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
-
_, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
-
return err
-
}
-
-
func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
-
_, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
+
query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
+
_, err := e.Exec(query, args...)
return err
}
+7 -3
appview/db/profile.go
···
*items = append(*items, &pull)
}
-
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
+
issues, err := GetIssues(
+
e,
+
FilterEq("did", forDid),
+
FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
+
)
if err != nil {
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
}
···
query = `select count(id) from pulls where owner_did = ? and state = ?`
args = append(args, did, PullOpen)
case VanityStatOpenIssueCount:
-
query = `select count(id) from issues where owner_did = ? and open = 1`
+
query = `select count(id) from issues where did = ? and open = 1`
args = append(args, did)
case VanityStatClosedIssueCount:
-
query = `select count(id) from issues where owner_did = ? and open = 0`
+
query = `select count(id) from issues where did = ? and open = 0`
args = append(args, did)
case VanityStatRepositoryCount:
query = `select count(id) from repos where did = ?`
+29 -74
appview/ingester.go
···
"encoding/json"
"fmt"
"log/slog"
-
"strings"
+
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/serververify"
+
"tangled.sh/tangled.sh/core/appview/validator"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/rbac"
)
···
IdResolver *idresolver.Resolver
Config *config.Config
Logger *slog.Logger
+
Validator *validator.Validator
}
type processFunc func(ctx context.Context, e *models.Event) error
···
}
switch e.Commit.Operation {
-
case models.CommitOperationCreate:
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
raw := json.RawMessage(e.Commit.Record)
record := tangled.RepoIssue{}
err = json.Unmarshal(raw, &record)
···
issue := db.IssueFromRecord(did, rkey, record)
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
-
return fmt.Errorf("title is empty after HTML sanitization")
-
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
+
return fmt.Errorf("failed to validate issue: %w", err)
}
tx, err := ddb.BeginTx(ctx, nil)
···
l.Error("failed to begin transaction", "err", err)
return err
}
+
defer tx.Rollback()
-
err = db.NewIssue(tx, &issue)
+
err = db.PutIssue(tx, &issue)
if err != nil {
l.Error("failed to create issue", "err", err)
return err
}
-
return nil
-
-
case models.CommitOperationUpdate:
-
raw := json.RawMessage(e.Commit.Record)
-
record := tangled.RepoIssue{}
-
err = json.Unmarshal(raw, &record)
+
err = tx.Commit()
if err != nil {
-
l.Error("invalid record", "err", err)
-
return err
-
}
-
-
body := ""
-
if record.Body != nil {
-
body = *record.Body
-
}
-
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
-
return fmt.Errorf("title is empty after HTML sanitization")
-
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
-
}
-
-
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
-
if err != nil {
-
l.Error("failed to update issue", "err", err)
+
l.Error("failed to commit txn", "err", err)
return err
}
return nil
case models.CommitOperationDelete:
-
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
+
if err := db.DeleteIssues(
+
ddb,
+
db.FilterEq("did", did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
l.Error("failed to delete", "err", err)
return fmt.Errorf("failed to delete issue record: %w", err)
}
···
return nil
}
-
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
+
return nil
}
func (i *Ingester) ingestIssueComment(e *models.Event) error {
···
}
switch e.Commit.Operation {
-
case models.CommitOperationCreate:
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
raw := json.RawMessage(e.Commit.Record)
record := tangled.RepoIssueComment{}
err = json.Unmarshal(raw, &record)
if err != nil {
-
l.Error("invalid record", "err", err)
-
return err
+
return fmt.Errorf("invalid record: %w", err)
}
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
if err != nil {
-
l.Error("failed to parse comment from record", "err", err)
-
return err
+
return fmt.Errorf("failed to parse comment from record: %w", err)
}
-
sanitizer := markup.NewSanitizer()
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
+
if err := i.Validator.ValidateIssueComment(comment); err != nil {
+
return fmt.Errorf("failed to validate comment: %w", err)
}
-
err = db.NewIssueComment(ddb, &comment)
+
_, err = db.AddIssueComment(ddb, *comment)
if err != nil {
-
l.Error("failed to create issue comment", "err", err)
-
return err
-
}
-
-
return nil
-
-
case models.CommitOperationUpdate:
-
raw := json.RawMessage(e.Commit.Record)
-
record := tangled.RepoIssueComment{}
-
err = json.Unmarshal(raw, &record)
-
if err != nil {
-
l.Error("invalid record", "err", err)
-
return err
-
}
-
-
sanitizer := markup.NewSanitizer()
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
-
}
-
-
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
-
if err != nil {
-
l.Error("failed to update issue comment", "err", err)
-
return err
+
return fmt.Errorf("failed to create issue comment: %w", err)
}
return nil
case models.CommitOperationDelete:
-
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
-
l.Error("failed to delete", "err", err)
+
if err := db.DeleteIssueComments(
+
ddb,
+
db.FilterEq("did", did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
return fmt.Errorf("failed to delete issue comment record: %w", err)
}
return nil
}
-
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
+
return nil
}
+477 -280
appview/issues/issues.go
···
package issues
import (
+
"context"
+
"database/sql"
+
"errors"
"fmt"
"log"
-
mathrand "math/rand/v2"
+
"log/slog"
"net/http"
"slices"
-
"strconv"
-
"strings"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/data"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/go-chi/chi/v5"
···
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/appview/reporesolver"
+
"tangled.sh/tangled.sh/core/appview/validator"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/idresolver"
+
tlog "tangled.sh/tangled.sh/core/log"
"tangled.sh/tangled.sh/core/tid"
)
···
db *db.DB
config *config.Config
notifier notify.Notifier
+
logger *slog.Logger
+
validator *validator.Validator
}
func New(
···
db *db.DB,
config *config.Config,
notifier notify.Notifier,
+
validator *validator.Validator,
) *Issues {
return &Issues{
oauth: oauth,
···
db: db,
config: config,
notifier: notifier,
+
logger: tlog.New("issues"),
+
validator: validator,
}
}
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoSingleIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
-
return
-
}
-
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue and comments", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
if err != nil {
-
log.Println("failed to get issue reactions")
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
l.Error("failed to get issue reactions", "err", err)
}
userReactions := map[db.ReactionKind]bool{}
···
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
}
-
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
-
if err != nil {
-
log.Println("failed to resolve issue owner", err)
-
}
-
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Issue: issue,
-
Comments: comments,
-
-
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
-
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
CommentList: issue.CommentList(),
OrderedReactionKinds: db.OrderedReactionKinds,
Reactions: reactionCountMap,
UserReacted: userReactions,
})
-
}
-
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
+
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "EditIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
-
return
-
}
+
switch r.Method {
+
case http.MethodGet:
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
})
+
case http.MethodPost:
+
noticeId := "issues"
+
newIssue := issue
+
newIssue.Title = r.FormValue("title")
+
newIssue.Body = r.FormValue("body")
-
collaborators, err := f.Collaborators(r.Context())
-
if err != nil {
-
log.Println("failed to fetch repo collaborators: %w", err)
-
}
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
-
return user.Did == collab.Did
-
})
-
isIssueOwner := user.Did == issue.OwnerDid
-
-
// TODO: make this more granular
-
if isIssueOwner || isCollaborator {
+
if err := rp.validator.ValidateIssue(newIssue); err != nil {
+
l.Error("validation error", "err", err)
+
rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
+
return
+
}
-
closed := tangled.RepoIssueStateClosed
+
newRecord := newIssue.AsRecord()
+
// edit an atproto record
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
-
log.Println("failed to get authorized client", err)
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
return
}
+
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
+
if err != nil {
+
l.Error("failed to get record", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
+
return
+
}
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueStateNSID,
+
Collection: tangled.RepoIssueNSID,
Repo: user.Did,
-
Rkey: tid.TID(),
+
Rkey: newIssue.Rkey,
+
SwapRecord: ex.Cid,
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueState{
-
Issue: issue.AtUri().String(),
-
State: closed,
-
},
+
Val: &newRecord,
},
})
+
if err != nil {
+
l.Error("failed to edit record on PDS", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
+
return
+
}
+
// modify on DB -- TODO: transact this cleverly
+
tx, err := rp.db.Begin()
if err != nil {
-
log.Println("failed to update issue state", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
l.Error("failed to edit issue on DB", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.PutIssue(tx, newIssue)
+
if err != nil {
+
log.Println("failed to edit issue", err)
+
rp.pages.Notice(w, "issues", "Failed to edit issue.")
+
return
+
}
+
+
if err = tx.Commit(); err != nil {
+
l.Error("failed to edit issue", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to cedit issue.")
return
}
-
err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
+
rp.pages.HxRefresh(w)
+
}
+
}
+
+
func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "DeleteIssue")
+
noticeId := "issue-actions-error"
+
+
user := rp.oauth.GetUser(r)
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
+
return
+
}
+
l = l.With("did", issue.Did, "rkey", issue.Rkey)
+
+
// delete from PDS
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
log.Println("failed to get authorized client", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
+
return
+
}
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.RepoIssueNSID,
+
Repo: issue.Did,
+
Rkey: issue.Rkey,
+
})
+
if err != nil {
+
// TODO: transact this better
+
l.Error("failed to delete issue from PDS", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
+
return
+
}
+
+
// delete from db
+
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
+
l.Error("failed to delete issue", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
+
return
+
}
+
+
// return to all issues page
+
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
+
}
+
+
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "CloseIssue")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
+
}
+
+
collaborators, err := f.Collaborators(r.Context())
+
if err != nil {
+
log.Println("failed to fetch repo collaborators: %w", err)
+
}
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
+
return user.Did == collab.Did
+
})
+
isIssueOwner := user.Did == issue.Did
+
+
// TODO: make this more granular
+
if isIssueOwner || isCollaborator {
+
err = db.CloseIssues(
+
rp.db,
+
db.FilterEq("id", issue.Id),
+
)
if err != nil {
log.Println("failed to close issue", err)
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
return
}
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
} else {
log.Println("user is not permitted to close issue")
···
}
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "ReopenIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
-
return
-
}
-
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
···
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
return user.Did == collab.Did
})
-
isIssueOwner := user.Did == issue.OwnerDid
+
isIssueOwner := user.Did == issue.Did
if isCollaborator || isIssueOwner {
-
err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
+
err := db.ReopenIssues(
+
rp.db,
+
db.FilterEq("id", issue.Id),
+
)
if err != nil {
log.Println("failed to reopen issue", err)
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
return
}
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
} else {
log.Println("user is not the owner of the repo")
···
}
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "NewIssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
switch r.Method {
-
case http.MethodPost:
-
body := r.FormValue("body")
-
if body == "" {
-
rp.pages.Notice(w, "issue", "Body is required")
-
return
-
}
+
body := r.FormValue("body")
+
if body == "" {
+
rp.pages.Notice(w, "issue", "Body is required")
+
return
+
}
-
commentId := mathrand.IntN(1000000)
-
rkey := tid.TID()
+
replyToUri := r.FormValue("reply-to")
+
var replyTo *string
+
if replyToUri != "" {
+
replyTo = &replyToUri
+
}
-
err := db.NewIssueComment(rp.db, &db.Comment{
-
OwnerDid: user.Did,
-
RepoAt: f.RepoAt(),
-
Issue: issueIdInt,
-
CommentId: commentId,
-
Body: body,
-
Rkey: rkey,
-
})
-
if err != nil {
-
log.Println("failed to create comment", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
+
comment := db.IssueComment{
+
Did: user.Did,
+
Rkey: tid.TID(),
+
IssueAt: issue.AtUri().String(),
+
ReplyTo: replyTo,
+
Body: body,
+
Created: time.Now(),
+
}
+
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
+
l.Error("failed to validate comment", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
return
+
}
+
record := comment.AsRecord()
-
createdAt := time.Now().Format(time.RFC3339)
-
ownerDid := user.Did
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue at", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
return
+
}
-
atUri := f.RepoAt().String()
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
+
// create a record first
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueCommentNSID,
+
Repo: comment.Did,
+
Rkey: comment.Rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
l.Error("failed to create comment", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
return
+
}
+
atUri := resp.Uri
+
defer func() {
+
if err := rollbackRecord(context.Background(), atUri, client); err != nil {
+
l.Error("rollback failed", "err", err)
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueCommentNSID,
-
Repo: user.Did,
-
Rkey: rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueComment{
-
Repo: &atUri,
-
Issue: issueAt,
-
Owner: &ownerDid,
-
Body: body,
-
CreatedAt: createdAt,
-
},
-
},
-
})
-
if err != nil {
-
log.Println("failed to create comment", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
+
}()
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
+
commentId, err := db.AddIssueComment(rp.db, comment)
+
if err != nil {
+
l.Error("failed to create comment", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
+
+
// reset atUri to make rollback a no-op
+
atUri = ""
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
}
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "IssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
-
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
return
}
+
comment := comments[0]
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
return
-
}
-
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
}
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "EditIssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
-
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
return
}
+
comment := comments[0]
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
return
-
}
-
-
if comment.OwnerDid != user.Did {
+
if comment.Did != user.Did {
+
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
return
}
···
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
case http.MethodPost:
// extract form value
···
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
-
rkey := comment.Rkey
-
// optimistic update
-
edited := time.Now()
-
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
+
now := time.Now()
+
newComment := comment
+
newComment.Body = newBody
+
newComment.Edited = &now
+
record := newComment.AsRecord()
+
+
_, err = db.AddIssueComment(rp.db, newComment)
if err != nil {
log.Println("failed to perferom update-description query", err)
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
}
// rkey is optional, it was introduced later
-
if comment.Rkey != "" {
+
if newComment.Rkey != "" {
// update the record on pds
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
if err != nil {
-
// failed to get record
-
log.Println(err, rkey)
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
return
}
-
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
-
record, _ := data.UnmarshalJSON(value)
-
-
repoAt := record["repo"].(string)
-
issueAt := record["issue"].(string)
-
createdAt := record["createdAt"].(string)
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
-
Rkey: rkey,
+
Rkey: newComment.Rkey,
SwapRecord: ex.Cid,
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueComment{
-
Repo: &repoAt,
-
Issue: issueAt,
-
Owner: &comment.OwnerDid,
-
Body: newBody,
-
CreatedAt: createdAt,
-
},
+
Val: &record,
},
})
if err != nil {
-
log.Println(err)
+
l.Error("failed to update record on PDS", "err", err)
}
}
-
// optimistic update for htmx
-
comment.Body = newBody
-
comment.Edited = &edited
-
// return new comment body with htmx
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &newComment,
})
+
}
+
}
+
+
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
return
+
}
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
}
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
+
if err != nil {
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
+
return
+
}
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
+
return
+
}
+
comment := comments[0]
+
+
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
Comment: &comment,
+
})
}
-
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
+
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "ReplyIssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
+
return
+
}
+
comment := comments[0]
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
+
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
Comment: &comment,
+
})
+
}
+
+
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "DeleteIssueComment")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
+
}
+
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
+
return
+
}
+
comment := comments[0]
-
if comment.OwnerDid != user.Did {
+
if comment.Did != user.Did {
+
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
return
}
···
// optimistic deletion
deleted := time.Now()
-
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
+
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
if err != nil {
-
log.Println("failed to delete comment")
+
l.Error("failed to delete comment", "err", err)
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
return
}
···
return
}
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
-
Collection: tangled.GraphFollowNSID,
+
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
Rkey: comment.Rkey,
})
···
comment.Deleted = &deleted
// htmx fragment of comment after deletion
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
}
···
return
}
-
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
+
openVal := 0
+
if isOpen {
+
openVal = 1
+
}
+
issues, err := db.GetIssuesPaginated(
+
rp.db,
+
page,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("open", openVal),
+
)
if err != nil {
log.Println("failed to get issues", err)
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
}
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "NewIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
···
RepoInfo: f.RepoInfo(user),
})
case http.MethodPost:
-
title := r.FormValue("title")
-
body := r.FormValue("body")
+
issue := &db.Issue{
+
RepoAt: f.RepoAt(),
+
Rkey: tid.TID(),
+
Title: r.FormValue("title"),
+
Body: r.FormValue("body"),
+
Did: user.Did,
+
Created: time.Now(),
+
}
-
if title == "" || body == "" {
-
rp.pages.Notice(w, "issues", "Title and body are required")
+
if err := rp.validator.ValidateIssue(issue); err != nil {
+
l.Error("validation error", "err", err)
+
rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
return
}
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
-
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
+
record := issue.AsRecord()
+
+
// create an atproto record
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
-
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueNSID,
+
Repo: user.Did,
+
Rkey: issue.Rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
l.Error("failed to create issue", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
+
atUri := resp.Uri
tx, err := rp.db.BeginTx(r.Context(), nil)
if err != nil {
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
return
}
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rollbackRecord(context.Background(), atUri, client)
-
issue := &db.Issue{
-
RepoAt: f.RepoAt(),
-
Rkey: tid.TID(),
-
Title: title,
-
Body: body,
-
OwnerDid: user.Did,
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
if err := errors.Join(err1, err2); err != nil {
+
l.Error("failed to rollback txn", "err", err)
+
}
}
-
err = db.NewIssue(tx, issue)
+
defer rollback()
+
+
err = db.PutIssue(tx, issue)
if err != nil {
log.Println("failed to create issue", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
-
return
-
}
-
atUri := f.RepoAt().String()
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueNSID,
-
Repo: user.Did,
-
Rkey: issue.Rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssue{
-
Repo: atUri,
-
Title: title,
-
Body: &body,
-
},
-
},
-
})
-
if err != nil {
+
if err = tx.Commit(); err != nil {
log.Println("failed to create issue", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
+
// everything is successful, do not rollback the atproto record
+
atUri = ""
rp.notifier.NewIssue(r.Context(), issue)
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
}
}
+
+
// this is used to rollback changes made to the PDS
+
//
+
// it is a no-op if the provided ATURI is empty
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
+
if aturi == "" {
+
return nil
+
}
+
+
parsed := syntax.ATURI(aturi)
+
+
collection := parsed.Collection().String()
+
repo := parsed.Authority().String()
+
rkey := parsed.RecordKey().String()
+
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
+
Collection: collection,
+
Repo: repo,
+
Rkey: rkey,
+
})
+
return err
+
}
+24 -10
appview/issues/router.go
···
r.Route("/", func(r chi.Router) {
r.With(middleware.Paginate).Get("/", i.RepoIssues)
-
r.Get("/{issue}", i.RepoSingleIssue)
+
+
r.Route("/{issue}", func(r chi.Router) {
+
r.Use(mw.ResolveIssue())
+
r.Get("/", i.RepoSingleIssue)
+
+
// authenticated routes
+
r.Group(func(r chi.Router) {
+
r.Use(middleware.AuthMiddleware(i.oauth))
+
r.Post("/comment", i.NewIssueComment)
+
r.Route("/comment/{commentId}/", func(r chi.Router) {
+
r.Get("/", i.IssueComment)
+
r.Delete("/", i.DeleteIssueComment)
+
r.Get("/edit", i.EditIssueComment)
+
r.Post("/edit", i.EditIssueComment)
+
r.Get("/reply", i.ReplyIssueComment)
+
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
+
})
+
r.Get("/edit", i.EditIssue)
+
r.Post("/edit", i.EditIssue)
+
r.Delete("/", i.DeleteIssue)
+
r.Post("/close", i.CloseIssue)
+
r.Post("/reopen", i.ReopenIssue)
+
})
+
})
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(i.oauth))
r.Get("/new", i.NewIssue)
r.Post("/new", i.NewIssue)
-
r.Post("/{issue}/comment", i.NewIssueComment)
-
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
-
r.Get("/", i.IssueComment)
-
r.Delete("/", i.DeleteIssueComment)
-
r.Get("/edit", i.EditIssueComment)
-
r.Post("/edit", i.EditIssueComment)
-
})
-
r.Post("/{issue}/close", i.CloseIssue)
-
r.Post("/{issue}/reopen", i.ReopenIssue)
})
})
+40
appview/middleware/middleware.go
···
}
}
+
// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
+
func (mw Middleware) ResolveIssue() middlewareFunc {
+
return func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
f, err := mw.repoResolver.Resolve(r)
+
if err != nil {
+
log.Println("failed to fully resolve repo", err)
+
mw.pages.ErrorKnot404(w)
+
return
+
}
+
+
issueIdStr := chi.URLParam(r, "issue")
+
issueId, err := strconv.Atoi(issueIdStr)
+
if err != nil {
+
log.Println("failed to fully resolve issue ID", err)
+
mw.pages.ErrorKnot404(w)
+
return
+
}
+
+
issues, err := db.GetIssues(
+
mw.db,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("issue_id", issueId),
+
)
+
if err != nil {
+
log.Println("failed to get issues", "err", err)
+
return
+
}
+
if len(issues) != 1 {
+
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
+
return
+
}
+
issue := issues[0]
+
+
ctx := context.WithValue(r.Context(), "issue", &issue)
+
next.ServeHTTP(w, r.WithContext(ctx))
+
})
+
}
+
}
+
// this should serve the go-import meta tag even if the path is technically
// a 404 like tangled.sh/oppi.li/go-git/v5
func (mw Middleware) GoImport() middlewareFunc {
+3
appview/pages/funcmap.go
···
"split": func(s string) []string {
return strings.Split(s, "\n")
},
+
"contains": func(s string, target string) bool {
+
return strings.Contains(s, target)
+
},
"resolve": func(s string) string {
identity, err := p.resolver.ResolveIdent(context.Background(), s)
+1 -1
appview/pages/markup/markdown.go
···
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
-
repoName, url.PathEscape(rctx.RepoInfo.Ref), actualPath)
+
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
parsedURL := &url.URL{
Scheme: scheme,
+47 -46
appview/pages/pages.go
···
return fragmentPaths, nil
}
-
func (p *Pages) fragments() (*template.Template, error) {
-
fragmentPaths, err := p.fragmentPaths()
-
if err != nil {
-
return nil, err
-
}
-
-
funcs := p.funcMap()
-
-
// parse all fragments together
-
allFragments := template.New("").Funcs(funcs)
-
for _, f := range fragmentPaths {
-
name := p.pathToName(f)
-
-
pf, err := template.New(name).
-
Funcs(funcs).
-
ParseFS(p.embedFS, f)
-
if err != nil {
-
return nil, err
-
}
-
-
allFragments, err = allFragments.AddParseTree(name, pf.Tree)
-
if err != nil {
-
return nil, err
-
}
-
}
-
-
return allFragments, nil
-
}
-
// parse without memoization
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
paths, err := p.fragmentPaths()
···
RepoInfo repoinfo.RepoInfo
Active string
Issue *db.Issue
-
Comments []db.Comment
+
CommentList []db.CommentListItem
IssueOwnerHandle string
OrderedReactionKinds []db.ReactionKind
Reactions map[db.ReactionKind]int
UserReacted map[db.ReactionKind]bool
+
}
-
State string
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
+
params.Active = "issues"
+
return p.executeRepo("repo/issues/issue", w, params)
+
}
+
+
type EditIssueParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Action string
+
}
+
+
func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
+
params.Action = "edit"
+
return p.executePlain("repo/issues/fragments/putIssue", w, params)
}
type ThreadReactionFragmentParams struct {
···
return p.executePlain("repo/fragments/reaction", w, params)
}
-
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
-
params.Active = "issues"
-
if params.Issue.Open {
-
params.State = "open"
-
} else {
-
params.State = "closed"
-
}
-
return p.executeRepo("repo/issues/issue", w, params)
-
}
-
type RepoNewIssueParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue // existing issue if any -- passed when editing
Active string
+
Action string
}
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
params.Active = "issues"
+
params.Action = "create"
return p.executeRepo("repo/issues/new", w, params)
}
···
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
-
Comment *db.Comment
+
Comment *db.IssueComment
}
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
}
-
type SingleIssueCommentParams struct {
+
type ReplyIssueCommentPlaceholderParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
-
Comment *db.Comment
+
Comment *db.IssueComment
}
-
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
-
return p.executePlain("repo/issues/fragments/issueComment", w, params)
+
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
+
return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params)
+
}
+
+
type ReplyIssueCommentParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Comment *db.IssueComment
+
}
+
+
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
+
return p.executePlain("repo/issues/fragments/replyComment", w, params)
+
}
+
+
type IssueCommentBodyParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Comment *db.IssueComment
+
}
+
+
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+
return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
}
type RepoNewPullParams struct {
+1 -1
appview/pages/templates/banner.html
···
<div class="mx-6">
These services may not be fully accessible until upgraded.
<a class="underline text-red-800 dark:text-red-200"
-
href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations/">
+
href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md">
Click to read the upgrade guide</a>.
</div>
</details>
+8
appview/pages/templates/fragments/logotype.html
···
+
{{ define "fragments/logotype" }}
+
<span class="flex items-center gap-2">
+
<span class="font-bold italic">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
+
alpha
+
</span>
+
<span>
+
{{ end }}
+3 -6
appview/pages/templates/knots/index.html
···
{{ define "title" }}knots{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom">
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
-
-
<span class="flex items-center gap-1 text-sm">
+
<span class="flex items-center gap-1">
{{ i "book" "w-3 h-3" }}
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
-
docs
-
</a>
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a>
</span>
</div>
+4 -4
appview/pages/templates/layouts/base.html
···
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
{{ block "extrameta" . }}{{ end }}
</head>
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
+
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
{{ block "topbarLayout" . }}
-
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
+
<header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;">
{{ if .LoggedInUser }}
<div id="upgrade-banner"
···
{{ end }}
{{ block "mainLayout" . }}
-
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
+
<div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4">
{{ block "contentLayout" . }}
<main class="col-span-1 md:col-span-8">
{{ block "content" . }}{{ end }}
···
{{ end }}
{{ block "footerLayout" . }}
-
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
+
<footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12">
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
+1 -3
appview/pages/templates/layouts/fragments/topbar.html
···
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
-
<a href="/" hx-boost="true" class="flex gap-2 font-bold italic">
-
tangled<sub>alpha</sub>
-
</a>
+
<a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a>
</div>
<div id="right-items" class="flex items-center gap-2">
+2 -2
appview/pages/templates/layouts/repobase.html
···
</section>
<section
-
class="w-full flex flex-col drop-shadow-sm"
+
class="w-full flex flex-col"
>
<nav class="w-full pl-4 overflow-auto">
<div class="flex z-60">
···
</div>
</nav>
<section
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
>
{{ block "repoContent" . }}{{ end }}
</section>
-1
appview/pages/templates/repo/index.html
···
</details>
{{ end }}
-
{{ define "branchSelector" }}
<div class="flex gap-2 items-center justify-between w-full">
<div class="flex gap-2 items-center">
+58
appview/pages/templates/repo/issues/fragments/commentList.html
···
+
{{ define "repo/issues/fragments/commentList" }}
+
<div class="flex flex-col gap-8">
+
{{ range $item := .CommentList }}
+
{{ template "commentListing" (list $ .) }}
+
{{ end }}
+
<div>
+
{{ end }}
+
+
{{ define "commentListing" }}
+
{{ $root := index . 0 }}
+
{{ $comment := index . 1 }}
+
{{ $params :=
+
(dict
+
"RepoInfo" $root.RepoInfo
+
"LoggedInUser" $root.LoggedInUser
+
"Issue" $root.Issue
+
"Comment" $comment.Self) }}
+
+
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
+
{{ template "topLevelComment" $params }}
+
+
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
+
{{ range $index, $reply := $comment.Replies }}
+
<div class="relative ">
+
<!-- Horizontal connector -->
+
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
+
+
<div class="pl-2">
+
{{
+
template "replyComment"
+
(dict
+
"RepoInfo" $root.RepoInfo
+
"LoggedInUser" $root.LoggedInUser
+
"Issue" $root.Issue
+
"Comment" $reply)
+
}}
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
+
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
+
</div>
+
{{ end }}
+
+
{{ define "topLevelComment" }}
+
<div class="rounded px-6 py-4 bg-white dark:bg-gray-800">
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
+
</div>
+
{{ end }}
+
+
{{ define "replyComment" }}
+
<div class="p-4 w-full mx-auto overflow-hidden">
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
+
</div>
+
{{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
{{ define "repo/issues/fragments/editIssueComment" }}
-
{{ with .Comment }}
-
<div id="comment-container-{{.CommentId}}">
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
+
<div id="comment-body-{{.Comment.Id}}" class="pt-2">
+
<textarea
+
id="edit-textarea-{{ .Comment.Id }}"
+
name="body"
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
+
rows="5"
+
autofocus>{{ .Comment.Body }}</textarea>
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<span class="before:content-['ยท']"></span>
-
author
-
{{ end }}
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
-
id="{{ .CommentId }}">
-
{{ template "repo/fragments/time" .Created }}
-
</a>
-
-
<button
-
class="btn px-2 py-1 flex items-center gap-2 text-sm group"
-
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
-
hx-include="#edit-textarea-{{ .CommentId }}"
-
hx-target="#comment-container-{{ .CommentId }}"
-
hx-swap="outerHTML">
-
{{ i "check" "w-4 h-4" }}
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
<button
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
-
hx-target="#comment-container-{{ .CommentId }}"
-
hx-swap="outerHTML">
-
{{ i "x" "w-4 h-4" }}
-
</button>
-
<span id="comment-{{.CommentId}}-status"></span>
-
</div>
+
{{ template "editActions" $ }}
+
</div>
+
{{ end }}
-
<div>
-
<textarea
-
id="edit-textarea-{{ .CommentId }}"
-
name="body"
-
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
-
</div>
+
{{ define "editActions" }}
+
<div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2">
+
{{ template "cancel" . }}
+
{{ template "save" . }}
</div>
-
{{ end }}
+
{{ end }}
+
+
{{ define "save" }}
+
<button
+
class="btn-create py-0 flex gap-1 items-center group text-sm"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
+
hx-include="#edit-textarea-{{ .Comment.Id }}"
+
hx-target="#comment-body-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "check" "size-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
{{ end }}
+
{{ define "cancel" }}
+
<button
+
class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
+
hx-target="#comment-body-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "x" "size-4" }}
+
cancel
+
</button>
+
{{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
···
-
{{ define "repo/issues/fragments/issueComment" }}
-
{{ with .Comment }}
-
<div id="comment-container-{{.CommentId}}">
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
-
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<span class="before:content-['ยท']"></span>
-
author
-
{{ end }}
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
-
id="{{ .CommentId }}">
-
{{ if .Deleted }}
-
deleted {{ template "repo/fragments/time" .Deleted }}
-
{{ else if .Edited }}
-
edited {{ template "repo/fragments/time" .Edited }}
-
{{ else }}
-
{{ template "repo/fragments/time" .Created }}
-
{{ end }}
-
</a>
-
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
-
{{ if and $isCommentOwner (not .Deleted) }}
-
<button
-
class="btn px-2 py-1 text-sm"
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
-
hx-swap="outerHTML"
-
hx-target="#comment-container-{{.CommentId}}"
-
>
-
{{ i "pencil" "w-4 h-4" }}
-
</button>
-
<button
-
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
-
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
-
hx-confirm="Are you sure you want to delete your comment?"
-
hx-swap="outerHTML"
-
hx-target="#comment-container-{{.CommentId}}"
-
>
-
{{ i "trash-2" "w-4 h-4" }}
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
-
</div>
-
{{ if not .Deleted }}
-
<div class="prose dark:prose-invert">
-
{{ .Body | markdown }}
-
</div>
-
{{ end }}
-
</div>
-
{{ end }}
-
{{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
+
{{ define "repo/issues/fragments/issueCommentActions" }}
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
+
<div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2">
+
{{ template "edit" . }}
+
{{ template "delete" . }}
+
</div>
+
{{ end }}
+
{{ end }}
+
+
{{ define "edit" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}">
+
{{ i "pencil" "size-3" }}
+
edit
+
</a>
+
{{ end }}
+
+
{{ define "delete" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
+
hx-confirm="Are you sure you want to delete your comment?"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}"
+
>
+
{{ i "trash-2" "size-3" }}
+
delete
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
···
+
{{ define "repo/issues/fragments/issueCommentBody" }}
+
<div id="comment-body-{{.Comment.Id}}">
+
{{ if not .Comment.Deleted }}
+
<div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div>
+
{{ else }}
+
<div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div>
+
{{ end }}
+
</div>
+
{{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
+
{{ define "repo/issues/fragments/issueCommentHeader" }}
+
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 ">
+
{{ template "user/fragments/picHandleLink" .Comment.Did }}
+
{{ template "hats" $ }}
+
{{ template "timestamp" . }}
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
+
{{ template "editIssueComment" . }}
+
{{ template "deleteIssueComment" . }}
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "hats" }}
+
{{ $isIssueAuthor := eq .Comment.Did .Issue.Did }}
+
{{ if $isIssueAuthor }}
+
(author)
+
{{ end }}
+
{{ end }}
+
+
{{ define "timestamp" }}
+
<a href="#{{ .Comment.Id }}"
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
+
id="{{ .Comment.Id }}">
+
{{ if .Comment.Deleted }}
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
+
{{ else if .Comment.Edited }}
+
edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }}
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Created }}
+
{{ end }}
+
</a>
+
{{ end }}
+
+
{{ define "editIssueComment" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
+
+
{{ define "deleteIssueComment" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
+
hx-confirm="Are you sure you want to delete your comment?"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}"
+
>
+
{{ i "trash-2" "size-3" }}
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
···
+
{{ define "repo/issues/fragments/newComment" }}
+
{{ if .LoggedInUser }}
+
<form
+
id="comment-form"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
>
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full">
+
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
+
</div>
+
<textarea
+
id="comment-textarea"
+
name="body"
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
+
placeholder="Add to the discussion. Markdown is supported."
+
onkeyup="updateCommentForm()"
+
rows="5"
+
></textarea>
+
<div id="issue-comment"></div>
+
<div id="issue-action" class="error"></div>
+
</div>
+
+
<div class="flex gap-2 mt-2">
+
<button
+
id="comment-button"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
type="submit"
+
hx-disabled-elt="#comment-button"
+
class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group"
+
disabled
+
>
+
{{ i "message-square-plus" "w-4 h-4" }}
+
comment
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
+
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
+
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
+
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }}
+
<button
+
id="close-button"
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-indicator="#close-spinner"
+
hx-trigger="click"
+
>
+
{{ i "ban" "w-4 h-4" }}
+
close
+
<span id="close-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
<div
+
id="close-with-comment"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
hx-trigger="click from:#close-button"
+
hx-disabled-elt="#close-with-comment"
+
hx-target="#issue-comment"
+
hx-indicator="#close-spinner"
+
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
+
hx-swap="none"
+
>
+
</div>
+
<div
+
id="close-issue"
+
hx-disabled-elt="#close-issue"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
+
hx-trigger="click from:#close-button"
+
hx-target="#issue-action"
+
hx-indicator="#close-spinner"
+
hx-swap="none"
+
>
+
</div>
+
<script>
+
document.addEventListener('htmx:configRequest', function(evt) {
+
if (evt.target.id === 'close-with-comment') {
+
const commentText = document.getElementById('comment-textarea').value.trim();
+
if (commentText === '') {
+
evt.detail.parameters = {};
+
evt.preventDefault();
+
}
+
}
+
});
+
</script>
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
+
<button
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
+
hx-indicator="#reopen-spinner"
+
hx-swap="none"
+
>
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
+
reopen
+
<span id="reopen-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
{{ end }}
+
+
<script>
+
function updateCommentForm() {
+
const textarea = document.getElementById('comment-textarea');
+
const commentButton = document.getElementById('comment-button');
+
const closeButton = document.getElementById('close-button');
+
+
if (textarea.value.trim() !== '') {
+
commentButton.removeAttribute('disabled');
+
} else {
+
commentButton.setAttribute('disabled', '');
+
}
+
+
if (closeButton) {
+
if (textarea.value.trim() !== '') {
+
closeButton.innerHTML = `
+
{{ i "ban" "w-4 h-4" }}
+
<span>close with comment</span>
+
<span id="close-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>`;
+
} else {
+
closeButton.innerHTML = `
+
{{ i "ban" "w-4 h-4" }}
+
<span>close</span>
+
<span id="close-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>`;
+
}
+
}
+
}
+
+
document.addEventListener('DOMContentLoaded', function() {
+
updateCommentForm();
+
});
+
</script>
+
</div>
+
</form>
+
{{ else }}
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
+
<a href="/login" class="underline">login</a> to join the discussion
+
</div>
+
{{ end }}
+
{{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
+
{{ define "repo/issues/fragments/putIssue" }}
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
+
<form
+
{{ if eq .Action "edit" }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
+
{{ else }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
+
{{ end }}
+
hx-swap="none"
+
hx-indicator="#spinner">
+
<div class="flex flex-col gap-2">
+
<div>
+
<label for="title">title</label>
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
+
</div>
+
<div>
+
<label for="body">body</label>
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
class="w-full resize-y"
+
placeholder="Describe your issue. Markdown is supported."
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+
</div>
+
<div class="flex justify-between">
+
<div id="issues" class="error"></div>
+
<div class="flex gap-2 items-center">
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
+
type="button"
+
{{ if .Issue }}
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
+
{{ else }}
+
href="/{{ .RepoInfo.FullName }}/issues"
+
{{ end }}
+
>
+
{{ i "x" "w-4 h-4" }}
+
cancel
+
</a>
+
<button type="submit" class="btn-create flex items-center gap-2">
+
{{ if eq .Action "edit" }}
+
{{ i "pencil" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ else }}
+
{{ i "circle-plus" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ end }}
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
</div>
+
</form>
+
{{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
···
+
{{ define "repo/issues/fragments/replyComment" }}
+
<form
+
class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2"
+
id="reply-form-{{ .Comment.Id }}"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
hx-disabled-elt="#reply-{{ .Comment.Id }}"
+
>
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
+
<textarea
+
id="reply-{{.Comment.Id}}-textarea"
+
name="body"
+
class="w-full p-2"
+
placeholder="Leave a reply..."
+
autofocus
+
rows="3"
+
hx-trigger="keydown[ctrlKey&&key=='Enter']"
+
hx-target="#reply-form-{{ .Comment.Id }}"
+
hx-get="#"
+
hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea>
+
+
<input
+
type="text"
+
id="reply-to"
+
name="reply-to"
+
required
+
value="{{ .Comment.AtUri }}"
+
class="hidden"
+
/>
+
{{ template "replyActions" . }}
+
</form>
+
{{ end }}
+
+
{{ define "replyActions" }}
+
<div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm">
+
{{ template "cancel" . }}
+
{{ template "reply" . }}
+
</div>
+
{{ end }}
+
+
{{ define "cancel" }}
+
<button
+
class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder"
+
hx-target="#reply-form-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "x" "size-4" }}
+
cancel
+
</button>
+
{{ end }}
+
+
{{ define "reply" }}
+
<button
+
id="reply-{{ .Comment.Id }}"
+
type="submit"
+
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
+
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
reply
+
</button>
+
{{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
···
+
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
+
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
+
{{ if .LoggedInUser }}
+
<img
+
src="{{ tinyAvatar .LoggedInUser.Did }}"
+
alt=""
+
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
+
/>
+
{{ end }}
+
<input
+
class="w-full py-2 border-none focus:outline-none"
+
placeholder="Leave a reply..."
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply"
+
hx-trigger="focus"
+
hx-target="closest div"
+
hx-swap="outerHTML"
+
>
+
</input>
+
</div>
+
{{ end }}
+95 -202
appview/pages/templates/repo/issues/issue.html
···
{{ end }}
{{ define "repoContent" }}
-
<header class="pb-4">
-
<h1 class="text-2xl">
-
{{ .Issue.Title | description }}
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
-
</h1>
-
</header>
+
<section id="issue-{{ .Issue.IssueId }}">
+
{{ template "issueHeader" .Issue }}
+
{{ template "issueInfo" . }}
+
{{ if .Issue.Body }}
+
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
+
{{ end }}
+
{{ template "issueReactions" . }}
+
</section>
+
{{ end }}
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
-
{{ $icon := "ban" }}
-
{{ if eq .State "open" }}
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
-
{{ $icon = "circle-dot" }}
-
{{ end }}
+
{{ define "issueHeader" }}
+
<header class="pb-2">
+
<h1 class="text-2xl">
+
{{ .Title | description }}
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
+
</h1>
+
</header>
+
{{ end }}
-
<section class="mt-2">
-
<div class="inline-flex items-center gap-2">
-
<div id="state"
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
-
<span class="text-white">{{ .State }}</span>
-
</div>
-
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
-
opened by
-
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
-
{{ template "user/fragments/picHandleLink" $owner }}
-
<span class="select-none before:content-['\00B7']"></span>
-
{{ template "repo/fragments/time" .Issue.Created }}
-
</span>
-
</div>
+
{{ define "issueInfo" }}
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
{{ if eq .Issue.State "open" }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "circle-dot" }}
+
{{ end }}
+
<div class="inline-flex items-center gap-2">
+
<div id="state"
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
+
<span class="text-white">{{ .Issue.State }}</span>
+
</div>
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
+
opened by
+
{{ template "user/fragments/picHandleLink" .Issue.Did }}
+
<span class="select-none before:content-['\00B7']"></span>
+
{{ if .Issue.Edited }}
+
edited {{ template "repo/fragments/time" .Issue.Edited }}
+
{{ else }}
+
{{ template "repo/fragments/time" .Issue.Created }}
+
{{ end }}
+
</span>
-
{{ if .Issue.Body }}
-
<article id="body" class="mt-8 prose dark:prose-invert">
-
{{ .Issue.Body | markdown }}
-
</article>
-
{{ end }}
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
+
{{ template "issueActions" . }}
+
{{ end }}
+
</div>
+
<div id="issue-actions-error" class="error"></div>
+
{{ end }}
-
<div class="flex items-center gap-2 mt-2">
-
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
-
{{ range $kind := .OrderedReactionKinds }}
-
{{
-
template "repo/fragments/reaction"
-
(dict
-
"Kind" $kind
-
"Count" (index $.Reactions $kind)
-
"IsReacted" (index $.UserReacted $kind)
-
"ThreadAt" $.Issue.AtUri)
-
}}
-
{{ end }}
-
</div>
-
</section>
+
{{ define "issueActions" }}
+
{{ template "editIssue" . }}
+
{{ template "deleteIssue" . }}
{{ end }}
-
{{ define "repoAfter" }}
-
<section id="comments" class="my-2 mt-2 space-y-2 relative">
-
{{ range $index, $comment := .Comments }}
-
<div
-
id="comment-{{ .CommentId }}"
-
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
-
{{ if gt $index 0 }}
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
-
{{ end }}
-
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}}
-
</div>
-
{{ end }}
-
</section>
+
{{ define "editIssue" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
+
hx-swap="innerHTML"
+
hx-target="#issue-{{.Issue.IssueId}}">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
-
{{ block "newComment" . }} {{ end }}
+
{{ define "deleteIssue" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
+
hx-confirm="Are you sure you want to delete your issue?"
+
hx-swap="none">
+
{{ i "trash-2" "size-3" }}
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+
{{ define "issueReactions" }}
+
<div class="flex items-center gap-2 mt-2">
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
+
{{ range $kind := .OrderedReactionKinds }}
+
{{
+
template "repo/fragments/reaction"
+
(dict
+
"Kind" $kind
+
"Count" (index $.Reactions $kind)
+
"IsReacted" (index $.UserReacted $kind)
+
"ThreadAt" $.Issue.AtUri)
+
}}
+
{{ end }}
+
</div>
{{ end }}
-
{{ define "newComment" }}
-
{{ if .LoggedInUser }}
-
<form
-
id="comment-form"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
-
hx-on::after-request="if(event.detail.successful) this.reset()"
-
>
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
-
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
-
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
-
</div>
-
<textarea
-
id="comment-textarea"
-
name="body"
-
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
-
placeholder="Add to the discussion. Markdown is supported."
-
onkeyup="updateCommentForm()"
-
></textarea>
-
<div id="issue-comment"></div>
-
<div id="issue-action" class="error"></div>
-
</div>
-
-
<div class="flex gap-2 mt-2">
-
<button
-
id="comment-button"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
-
type="submit"
-
hx-disabled-elt="#comment-button"
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"
-
disabled
-
>
-
{{ i "message-square-plus" "w-4 h-4" }}
-
comment
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
-
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
-
<button
-
id="close-button"
-
type="button"
-
class="btn flex items-center gap-2"
-
hx-indicator="#close-spinner"
-
hx-trigger="click"
-
>
-
{{ i "ban" "w-4 h-4" }}
-
close
-
<span id="close-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<div
-
id="close-with-comment"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
-
hx-trigger="click from:#close-button"
-
hx-disabled-elt="#close-with-comment"
-
hx-target="#issue-comment"
-
hx-indicator="#close-spinner"
-
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
-
hx-swap="none"
-
>
-
</div>
-
<div
-
id="close-issue"
-
hx-disabled-elt="#close-issue"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
-
hx-trigger="click from:#close-button"
-
hx-target="#issue-action"
-
hx-indicator="#close-spinner"
-
hx-swap="none"
-
>
-
</div>
-
<script>
-
document.addEventListener('htmx:configRequest', function(evt) {
-
if (evt.target.id === 'close-with-comment') {
-
const commentText = document.getElementById('comment-textarea').value.trim();
-
if (commentText === '') {
-
evt.detail.parameters = {};
-
evt.preventDefault();
-
}
-
}
-
});
-
</script>
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
-
<button
-
type="button"
-
class="btn flex items-center gap-2"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
-
hx-indicator="#reopen-spinner"
-
hx-swap="none"
-
>
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
-
reopen
-
<span id="reopen-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
{{ end }}
-
-
<script>
-
function updateCommentForm() {
-
const textarea = document.getElementById('comment-textarea');
-
const commentButton = document.getElementById('comment-button');
-
const closeButton = document.getElementById('close-button');
-
-
if (textarea.value.trim() !== '') {
-
commentButton.removeAttribute('disabled');
-
} else {
-
commentButton.setAttribute('disabled', '');
-
}
+
{{ define "repoAfter" }}
+
<div class="flex flex-col gap-4 mt-4">
+
{{
+
template "repo/issues/fragments/commentList"
+
(dict
+
"RepoInfo" $.RepoInfo
+
"LoggedInUser" $.LoggedInUser
+
"Issue" $.Issue
+
"CommentList" $.Issue.CommentList)
+
}}
-
if (closeButton) {
-
if (textarea.value.trim() !== '') {
-
closeButton.innerHTML = `
-
{{ i "ban" "w-4 h-4" }}
-
<span>close with comment</span>
-
<span id="close-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>`;
-
} else {
-
closeButton.innerHTML = `
-
{{ i "ban" "w-4 h-4" }}
-
<span>close</span>
-
<span id="close-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>`;
-
}
-
}
-
}
+
{{ template "repo/issues/fragments/newComment" . }}
+
<div>
+
{{ end }}
-
document.addEventListener('DOMContentLoaded', function() {
-
updateCommentForm();
-
});
-
</script>
-
</div>
-
</form>
-
{{ else }}
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
-
<a href="/login" class="underline">login</a> to join the discussion
-
</div>
-
{{ end }}
-
{{ end }}
+42 -44
appview/pages/templates/repo/issues/issues.html
···
{{ end }}
{{ define "repoAfter" }}
-
<div class="flex flex-col gap-2 mt-2">
-
{{ range .Issues }}
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
-
<div class="pb-2">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
-
class="no-underline hover:underline"
-
>
-
{{ .Title | description }}
-
<span class="text-gray-500">#{{ .IssueId }}</span>
-
</a>
-
</div>
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
-
{{ $icon := "ban" }}
-
{{ $state := "closed" }}
-
{{ if .Open }}
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
-
{{ $icon = "circle-dot" }}
-
{{ $state = "open" }}
-
{{ end }}
+
<div class="flex flex-col gap-2 mt-2">
+
{{ range .Issues }}
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
+
<div class="pb-2">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
+
class="no-underline hover:underline"
+
>
+
{{ .Title | description }}
+
<span class="text-gray-500">#{{ .IssueId }}</span>
+
</a>
+
</div>
+
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
{{ $state := "closed" }}
+
{{ if .Open }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "circle-dot" }}
+
{{ $state = "open" }}
+
{{ end }}
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
-
<span class="text-white dark:text-white">{{ $state }}</span>
-
</span>
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
+
<span class="text-white dark:text-white">{{ $state }}</span>
+
</span>
-
<span class="ml-1">
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
-
</span>
+
<span class="ml-1">
+
{{ template "user/fragments/picHandleLink" .Did }}
+
</span>
-
<span class="before:content-['ยท']">
-
{{ template "repo/fragments/time" .Created }}
-
</span>
+
<span class="before:content-['ยท']">
+
{{ template "repo/fragments/time" .Created }}
+
</span>
-
<span class="before:content-['ยท']">
-
{{ $s := "s" }}
-
{{ if eq .Metadata.CommentCount 1 }}
-
{{ $s = "" }}
-
{{ end }}
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a>
-
</span>
-
</p>
+
<span class="before:content-['ยท']">
+
{{ $s := "s" }}
+
{{ if eq (len .Comments) 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
+
</span>
+
</p>
+
</div>
+
{{ end }}
</div>
-
{{ end }}
-
</div>
-
-
{{ block "pagination" . }} {{ end }}
-
+
{{ block "pagination" . }} {{ end }}
{{ end }}
{{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
···
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
-
class="mt-6 space-y-6"
-
hx-swap="none"
-
hx-indicator="#spinner"
-
>
-
<div class="flex flex-col gap-4">
-
<div>
-
<label for="title">title</label>
-
<input type="text" name="title" id="title" class="w-full" />
-
</div>
-
<div>
-
<label for="body">body</label>
-
<textarea
-
name="body"
-
id="body"
-
rows="6"
-
class="w-full resize-y"
-
placeholder="Describe your issue. Markdown is supported."
-
></textarea>
-
</div>
-
<div>
-
<button type="submit" class="btn-create flex items-center gap-2">
-
{{ i "circle-plus" "w-4 h-4" }}
-
create issue
-
<span id="create-pull-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</div>
-
</div>
-
<div id="issues" class="error"></div>
-
</form>
+
{{ template "repo/issues/fragments/putIssue" . }}
{{ end }}
+48 -12
appview/pages/templates/repo/needsUpgrade.html
···
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
-
{{ define "extrameta" }}
{{ template "repo/fragments/meta" . }}
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
{{ end }}
-
{{ define "repoContent" }}
<main>
-
<div class="w-full h-full flex place-content-center {{ if .LoggedInUser }} bg-yellow-100 dark:bg-yellow-900 {{ end }}">
-
<div class="py-6 w-fit flex flex-col gap-4 text-center">
-
{{ if .LoggedInUser }}
-
<p class=" text-yellow-800 dark:text-yellow-200 text-center">
-
Your knot needs an upgrade. This repository is currently unavailable to users.
-
</p>
+
<div class="relative w-full h-96 flex items-center justify-center">
+
<div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600">
+
<!-- mimic the repo view here, placeholders are LLM generated -->
+
<div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left">
+
{{ $files :=
+
(list
+
"src"
+
"docs"
+
"config"
+
"lib"
+
"index.html"
+
"log.html"
+
"needsUpgrade.html"
+
"new.html"
+
"tags.html"
+
"tree.html")
+
}}
+
{{ range $files }}
+
<span>
+
{{ if (contains . ".") }}
+
{{ i "file" "size-4 inline-flex" }}
{{ else }}
-
<p class="text-gray-400 dark:text-gray-500 py-6 text-center">
-
The knot hosting this repository needs an upgrade. This repository is currently unavailable.
-
</p>
+
{{ i "folder" "size-4 inline-flex fill-current" }}
{{ end }}
-
</div>
+
+
{{ . }}
+
</span>
+
{{ end }}
+
</div>
+
<div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left">
+
{{ $commits :=
+
(list
+
"Fix authentication bug in login flow"
+
"Add new dashboard widgets for metrics"
+
"Implement real-time notifications system")
+
}}
+
{{ range $commits }}
+
<div class="flex flex-col">
+
<span>{{ . }}</span>
+
<span class="text-xs">{{ . }}</span>
+
</div>
+
{{ end }}
+
</div>
</div>
+
<div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur">
+
<div class="text-center">
+
{{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }}
+
The knot hosting this repository needs an upgrade. This repository is currently unavailable.
+
</div>
+
</div>
+
</div>
</main>
{{ end }}
+3 -7
appview/pages/templates/spindles/index.html
···
{{ define "title" }}spindles{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom">
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
-
-
-
<span class="flex items-center gap-1 text-sm">
+
<span class="flex items-center gap-1">
{{ i "book" "w-3 h-3" }}
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
-
docs
-
</a>
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
</span>
</div>
+1 -1
appview/pages/templates/timeline/fragments/hero.html
···
<figure class="w-full hidden md:block md:w-auto">
<a href="https://tangled.sh/@tangled.sh/core" class="block">
-
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded hover:shadow-md transition-shadow" />
+
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" />
</a>
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
Monorepo for Tangled, built in the open with the community.
+3 -3
appview/pages/templates/timeline/home.html
···
{{ define "feature" }}
{{ $info := index . 0 }}
{{ $bullets := index . 1 }}
-
<div class="flex flex-col items-top gap-6 md:flex-row md:gap-12">
+
<div class="flex flex-col items-center gap-6 md:flex-row md:items-top">
<div class="flex-1">
<h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2>
<ul class="leading-normal">
···
</div>
<div class="flex-shrink-0 w-96 md:w-1/3">
<a href="{{ $info.image }}">
-
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded" />
+
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" />
</a>
</div>
</div>
{{ end }}
{{ define "features" }}
-
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4">
+
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm">
{{ template "feature" (list
(dict
"title" "lightweight git repo hosting"
+2 -4
appview/pages/templates/user/completeSignup.html
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
-
<h1
-
class="text-center text-2xl font-semibold italic dark:text-white"
-
>
-
tangled
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
+
{{ template "fragments/logotype" }}
</h1>
<h2 class="text-center text-xl italic dark:text-white">
tightly-knit social coding.
+2 -2
appview/pages/templates/user/login.html
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
-
tangled
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
+
{{ template "fragments/logotype" }}
</h1>
<h2 class="text-center text-xl italic dark:text-white">
tightly-knit social coding.
+2 -2
appview/pages/templates/user/overview.html
···
</summary>
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
{{ range $items }}
-
{{ $repoOwner := resolve .Metadata.Repo.Did }}
-
{{ $repoName := .Metadata.Repo.Name }}
+
{{ $repoOwner := resolve .Repo.Did }}
+
{{ $repoName := .Repo.Name }}
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
+3 -1
appview/pages/templates/user/signup.html
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
+
{{ template "fragments/logotype" }}
+
</h1>
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
<form
class="mt-4 max-w-sm mx-auto"
+1 -1
appview/posthog/notifier.go
···
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: issue.OwnerDid,
+
DistinctId: issue.Did,
Event: "new_issue",
Properties: posthog.Properties{
"repo_at": issue.RepoAt.String(),
+7 -2
appview/repo/feed.go
···
"time"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"github.com/bluesky-social/indigo/atproto/syntax"
···
return nil, err
}
-
issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
+
issues, err := db.GetIssuesPaginated(
+
rp.db,
+
pagination.Page{Limit: feedLimitPerType},
+
db.FilterEq("repo_at", f.RepoAt()),
+
)
if err != nil {
return nil, err
}
···
}
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
-
owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid)
+
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
if err != nil {
return nil, err
}
+22 -50
appview/repo/index.go
···
Host: host,
}
-
var needsKnotUpgrade bool
+
user := rp.oauth.GetUser(r)
+
repoInfo := f.RepoInfo(user)
+
// Build index response from multiple XRPC calls
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
-
if err != nil {
-
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
log.Println("failed to call XRPC repo.index", err)
-
needsKnotUpgrade = true
+
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
+
LoggedInUser: user,
+
NeedsKnotUpgrade: true,
+
RepoInfo: repoInfo,
+
})
+
return
+
} else {
+
rp.pages.Error503(w)
+
log.Println("failed to build index response", err)
return
}
-
-
rp.pages.Error503(w)
-
log.Println("failed to build index response", err)
-
return
}
tagMap := make(map[string][]string)
···
log.Println(err)
}
-
user := rp.oauth.GetUser(r)
-
repoInfo := f.RepoInfo(user)
-
// TODO: a bit dirty
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
if err != nil {
···
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
LoggedInUser: user,
-
NeedsKnotUpgrade: needsKnotUpgrade,
RepoInfo: repoInfo,
TagMap: tagMap,
RepoIndexResponse: *result,
···
// first get branches to determine the ref if not specified
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
if err != nil {
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
-
return nil, xrpcerr
-
}
return nil, err
}
···
// now run the remaining queries in parallel
var wg sync.WaitGroup
-
var mu sync.Mutex
-
var errs []error
+
var errs error
var (
tagsResp types.RepoTagsResponse
···
defer wg.Done()
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
if err != nil {
-
mu.Lock()
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
-
errs = append(errs, xrpcerr)
-
} else {
-
errs = append(errs, err)
-
}
-
mu.Unlock()
+
errs = errors.Join(errs, err)
return
}
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
-
mu.Lock()
-
errs = append(errs, err)
-
mu.Unlock()
+
errs = errors.Join(errs, err)
}
}()
···
defer wg.Done()
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
if err != nil {
-
mu.Lock()
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.tree", xrpcerr)
-
errs = append(errs, xrpcerr)
-
} else {
-
errs = append(errs, err)
-
}
-
mu.Unlock()
+
errs = errors.Join(errs, err)
return
}
treeResp = resp
···
defer wg.Done()
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
if err != nil {
-
mu.Lock()
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
log.Println("failed to call XRPC repo.log", xrpcerr)
-
errs = append(errs, xrpcerr)
-
} else {
-
errs = append(errs, err)
-
}
-
mu.Unlock()
+
errs = errors.Join(errs, err)
return
}
if err := json.Unmarshal(logBytes, &logResp); err != nil {
-
mu.Lock()
-
errs = append(errs, err)
-
mu.Unlock()
+
errs = errors.Join(errs, err)
}
}()
···
wg.Wait()
-
if len(errs) > 0 {
-
return nil, errs[0] // return first error
+
if errs != nil {
+
return nil, errs
}
var files []types.NiceTree
+1 -2
appview/repo/repo.go
···
"log/slog"
"net/http"
"net/url"
-
"path"
"path/filepath"
"slices"
"strconv"
···
}
// fetch the raw binary content using sh.tangled.repo.blob xrpc
-
repoName := path.Join("%s/%s", f.OwnerDid(), f.Name)
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath))
+13 -12
appview/state/profile.go
···
"github.com/gorilla/feeds"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
-
// "tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
loggedInUser := s.oauth.GetUser(r)
+
params := FollowsPageParams{
+
Card: profile,
+
}
follows, err := fetchFollows(s.db, profile.UserDid)
if err != nil {
l.Error("failed to fetch follows", "err", err)
-
return nil, err
+
return &params, err
}
if len(follows) == 0 {
-
return nil, nil
+
return &params, nil
}
followDids := make([]string, 0, len(follows))
···
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
if err != nil {
l.Error("failed to get profiles", "followDids", followDids, "err", err)
-
return nil, err
+
return &params, err
}
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
···
following, err := db.GetFollowing(s.db, loggedInUser.Did)
if err != nil {
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
-
return nil, err
+
return &params, err
}
loggedInUserFollowing = make(map[string]struct{}, len(following))
for _, follow := range following {
···
}
}
-
return &FollowsPageParams{
-
Follows: followCards,
-
Card: profile,
-
}, nil
+
params.Follows = followCards
+
+
return &params, nil
}
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
···
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
for _, issue := range issues {
-
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
if err != nil {
return err
}
···
func (s *State) createIssueItem(issue *db.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.Metadata.Repo.Name),
-
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"},
+
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"},
Created: issue.Created,
Author: author,
}
+1 -1
appview/state/router.go
···
}
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
return issues.Router(mw)
}
+5 -2
appview/state/state.go
···
"tangled.sh/tangled.sh/core/appview/pages"
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
"tangled.sh/tangled.sh/core/appview/reporesolver"
+
"tangled.sh/tangled.sh/core/appview/validator"
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
···
knotstream *eventconsumer.Consumer
spindlestream *eventconsumer.Consumer
logger *slog.Logger
+
validator *validator.Validator
}
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
}
pgs := pages.NewPages(config, res)
-
cache := cache.New(config.Redis.Addr)
sess := session.New(cache)
-
oauth := oauth.NewOAuth(config, sess)
+
validator := validator.New(d)
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
if err != nil {
···
IdResolver: res,
Config: config,
Logger: tlog.New("ingester"),
+
Validator: validator,
}
err = jc.StartJetstream(ctx, ingester.Ingest())
if err != nil {
···
knotstream,
spindlestream,
slog.Default(),
+
validator,
}
return state, nil
+53
appview/validator/issue.go
···
+
package validator
+
+
import (
+
"fmt"
+
"strings"
+
+
"tangled.sh/tangled.sh/core/appview/db"
+
)
+
+
func (v *Validator) ValidateIssueComment(comment *db.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))
+
if err != nil {
+
return fmt.Errorf("failed to fetch parent comment: %w", err)
+
}
+
if len(parents) != 1 {
+
return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents))
+
}
+
+
// depth check
+
parent := parents[0]
+
if parent.ReplyTo != nil {
+
return fmt.Errorf("incorrect depth, this comment is replying at depth >1")
+
}
+
}
+
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
+
return fmt.Errorf("body is empty after HTML sanitization")
+
}
+
+
return nil
+
}
+
+
func (v *Validator) ValidateIssue(issue *db.Issue) error {
+
if issue.Title == "" {
+
return fmt.Errorf("issue title is empty")
+
}
+
+
if issue.Body == "" {
+
return fmt.Errorf("issue body is empty")
+
}
+
+
if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" {
+
return fmt.Errorf("title is empty after HTML sanitization")
+
}
+
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" {
+
return fmt.Errorf("body is empty after HTML sanitization")
+
}
+
+
return nil
+
}
+18
appview/validator/validator.go
···
+
package validator
+
+
import (
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
)
+
+
type Validator struct {
+
db *db.DB
+
sanitizer markup.Sanitizer
+
}
+
+
func New(db *db.DB) *Validator {
+
return &Validator{
+
db: db,
+
sanitizer: markup.NewSanitizer(),
+
}
+
}
-35
docs/migrations/knot-1.7.0.md
···
-
# Upgrading from v1.7.0
-
-
After v1.7.0, knot secrets have been deprecated. You no
-
longer need a secret from the appview to run a knot. All
-
authorized commands to knots are managed via [Inter-Service
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
-
Knots will be read-only until upgraded.
-
-
Upgrading is quite easy, in essence:
-
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
-
environment variable entirely
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
-
your DID. You can find your DID in the
-
[settings](https://tangled.sh/settings) page.
-
- Restart your knot once you have replaced the environment
-
variable
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
-
hit the "retry" button to verify your knot. This simply
-
writes a `sh.tangled.knot` record to your PDS.
-
-
## Nix
-
-
If you use the nix module, simply bump the flake to the
-
latest revision, and change your config block like so:
-
-
```diff
-
services.tangled-knot = {
-
enable = true;
-
server = {
-
- secretFile = /path/to/secret;
-
+ owner = "did:plc:foo";
-
};
-
};
-
```
+60
docs/migrations.md
···
+
# Migrations
+
+
This document is laid out in reverse-chronological order.
+
Newer migration guides are listed first, and older guides
+
are further down the page.
+
+
## Upgrading from v1.8.x
+
+
After v1.8.2, the HTTP API for knot and spindles have been
+
deprecated and replaced with XRPC. Repositories on outdated
+
knots will not be viewable from the appview. Upgrading is
+
straightforward however.
+
+
For knots:
+
+
- Upgrade to latest tag (v1.9.0 or above)
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
+
hit the "retry" button to verify your knot
+
+
For spindles:
+
+
- Upgrade to latest tag (v1.9.0 or above)
+
- Head to the [spindle
+
dashboard](https://tangled.sh/spindles) and hit the
+
"retry" button to verify your spindle
+
+
## Upgrading from v1.7.x
+
+
After v1.7.0, knot secrets have been deprecated. You no
+
longer need a secret from the appview to run a knot. All
+
authorized commands to knots are managed via [Inter-Service
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
+
Knots will be read-only until upgraded.
+
+
Upgrading is quite easy, in essence:
+
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
+
environment variable entirely
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
+
your DID. You can find your DID in the
+
[settings](https://tangled.sh/settings) page.
+
- Restart your knot once you have replaced the environment
+
variable
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
+
hit the "retry" button to verify your knot. This simply
+
writes a `sh.tangled.knot` record to your PDS.
+
+
If you use the nix module, simply bump the flake to the
+
latest revision, and change your config block like so:
+
+
```diff
+
services.tangled-knot = {
+
enable = true;
+
server = {
+
- secretFile = /path/to/secret;
+
+ owner = "did:plc:foo";
+
};
+
};
+
```
+
+1 -1
input.css
···
}
label {
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
+
@apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
}
input {
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
+1
knotserver/xrpc/repo_blob.go
···
return
}
w.Header().Set("ETag", eTag)
+
w.Header().Set("Content-Type", mimeType)
case strings.HasPrefix(mimeType, "text/"):
w.Header().Set("Cache-Control", "public, no-cache")
+8 -6
knotserver/xrpc/repo_branches.go
···
cursor := r.URL.Query().Get("cursor")
-
limit := 50 // default
-
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
-
limit = l
-
}
-
}
+
// limit := 50 // default
+
// if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
// if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
+
// limit = l
+
// }
+
// }
+
+
limit := 500
gr, err := git.PlainOpen(repoPath)
if err != nil {
+11 -1
knotserver/xrpc/repo_log.go
···
return
}
+
total, err := gr.TotalCommits()
+
if err != nil {
+
x.Logger.Error("fetching total commits", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to fetch total commits"),
+
), http.StatusNotFound)
+
return
+
}
+
// Create response using existing types.RepoLogResponse
response := types.RepoLogResponse{
Commits: commits,
Ref: ref,
Page: (offset / limit) + 1,
PerPage: limit,
-
Total: len(commits), // This is not accurate for pagination, but matches existing behavior
+
Total: total,
}
if path != "" {
+9 -9
lexicons/issue/comment.json
···
"key": "tid",
"record": {
"type": "object",
-
"required": ["issue", "body", "createdAt"],
+
"required": [
+
"issue",
+
"body",
+
"createdAt"
+
],
"properties": {
"issue": {
"type": "string",
"format": "at-uri"
},
-
"repo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
"body": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "datetime"
+
},
+
"replyTo": {
+
"type": "string",
+
"format": "at-uri"
}
}
}
+8 -2
nix/gomod2nix.toml
···
[mod."github.com/whyrusleeping/cbor-gen"]
version = "v0.3.1"
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
+
[mod."github.com/wyatt915/goldmark-treeblood"]
+
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
+
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
+
[mod."github.com/wyatt915/treeblood"]
+
version = "v0.1.15"
+
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
[mod."github.com/yuin/goldmark"]
-
version = "v1.4.15"
-
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
+
version = "v1.7.12"
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
[mod."github.com/yuin/goldmark-highlighting/v2"]
version = "v2.0.0-20230729083705-37449abec8cc"
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+15 -17
nix/pkgs/knot-unwrapped.nix
···
modules,
sqlite-lib,
src,
-
}:
-
let
-
version = "1.8.1-alpha";
+
}: let
+
version = "1.9.0-alpha";
in
-
buildGoApplication {
-
pname = "knot";
-
version = "1.8.1";
-
inherit src modules;
+
buildGoApplication {
+
pname = "knot";
+
inherit src version modules;
-
doCheck = false;
+
doCheck = false;
-
subPackages = ["cmd/knot"];
-
tags = ["libsqlite3"];
+
subPackages = ["cmd/knot"];
+
tags = ["libsqlite3"];
-
ldflags = [
-
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
-
];
+
ldflags = [
+
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
+
];
-
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
-
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
-
CGO_ENABLED = 1;
-
}
+
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
+
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
+
CGO_ENABLED = 1;
+
}