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

Compare changes

Choose any two refs to compare.

+39 -187
appview/db/issues.go
···
}
}
type CommentListItem struct {
Self *IssueComment
Replies []*IssueComment
···
return &comment, nil
}
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
// ensure sequence exists
_, err := tx.Exec(`
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
return err
}
-
// check if issue already exists
-
var existingRowId, existingIssueId sql.NullInt64
-
err = tx.QueryRow(`
-
select rowid, issue_id from issues
-
where did = ? and rkey = ?
-
`, issue.Did, issue.Rkey).Scan(&existingRowId, &existingIssueId)
-
switch {
-
case err == sql.ErrNoRows:
-
return createNewIssue(tx, issue)
-
case err != nil:
return err
-
default:
-
// Case 3: Issue exists - update it
-
return updateIssue(tx, issue, existingRowId.Int64, int(existingIssueId.Int64))
}
}
···
return row.Scan(&issue.Id, &issue.IssueId)
}
-
func updateIssue(tx *sql.Tx, issue *Issue, existingRowId int64, existingIssueId int) error {
// update existing issue
_, err := tx.Exec(`
-
update issues
-
set title = ?, body = ?
where did = ? and rkey = ?
-
`, issue.Title, issue.Body, issue.Did, issue.Rkey)
-
if err != nil {
-
return err
-
}
-
-
// set the values from existing record
-
issue.Id = existingRowId
-
issue.IssueId = existingIssueId
-
return nil
}
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
···
repoMap[string(repos[i].RepoAt())] = &repos[i]
}
-
for issueAt := range issueMap {
-
i := issueMap[issueAt]
-
r := repoMap[string(i.RepoAt)]
-
i.Repo = r
}
// collect comments
···
return issues, nil
}
-
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
-
issues := make([]Issue, 0, limit)
-
-
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 ")
-
}
-
limitClause := ""
-
if limit != 0 {
-
limitClause = fmt.Sprintf(" limit %d ", limit)
-
}
-
-
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
-
%s
-
order by
-
i.created desc
-
%s`,
-
whereClause, limitClause)
-
-
rows, err := e.Query(query, args...)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var issue Issue
-
var issueCreatedAt string
-
err := rows.Scan(
-
&issue.Id,
-
&issue.Did,
-
&issue.RepoAt,
-
&issue.IssueId,
-
&issueCreatedAt,
-
&issue.Title,
-
&issue.Body,
-
&issue.Open,
-
)
-
if err != nil {
-
return nil, err
-
}
-
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
-
if err != nil {
-
return nil, err
-
}
-
issue.Created = issueCreatedTime
-
-
issues = append(issues, issue)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return issues, nil
-
}
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
return GetIssuesPaginated(e, pagination.FirstPage(), 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)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var issue Issue
-
var issueCreatedAt, repoCreatedAt string
-
var repo Repo
-
err := rows.Scan(
-
&issue.Id,
-
&issue.Did,
-
&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
-
}
-
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
-
if err != nil {
-
return nil, err
-
}
-
issue.Created = issueCreatedTime
-
-
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
-
if err != nil {
-
return nil, err
-
}
-
repo.Created = repoCreatedTime
-
-
issues = append(issues, issue)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return issues, nil
}
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
···
}
}
+
func (i *Issue) State() string {
+
if i.Open {
+
return "open"
+
}
+
return "closed"
+
}
+
type CommentListItem struct {
Self *IssueComment
Replies []*IssueComment
···
return &comment, nil
}
+
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)
···
return err
}
+
issues, err := GetIssues(
+
tx,
+
FilterEq("did", issue.Did),
+
FilterEq("rkey", issue.Rkey),
+
)
switch {
case err != nil:
return err
+
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
+
}
+
+
issue.Id = existingIssue.Id
+
issue.IssueId = existingIssue.IssueId
+
return updateIssue(tx, issue)
}
}
···
return row.Scan(&issue.Id, &issue.IssueId)
}
+
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 GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
···
repoMap[string(repos[i].RepoAt())] = &repos[i]
}
+
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)
+
}
}
// collect comments
···
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) {
+7 -3
appview/db/profile.go
···
*items = append(*items, &pull)
}
-
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
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`
args = append(args, did)
case VanityStatClosedIssueCount:
-
query = `select count(id) from issues where owner_did = ? and open = 0`
args = append(args, did)
case VanityStatRepositoryCount:
query = `select count(id) from repos where did = ?`
···
*items = append(*items, &pull)
}
+
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 did = ? and open = 1`
args = append(args, did)
case VanityStatClosedIssueCount:
+
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 = ?`
+7 -12
appview/ingester.go
···
"encoding/json"
"fmt"
"log/slog"
-
"strings"
"time"
···
"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/idresolver"
"tangled.sh/tangled.sh/core/rbac"
)
···
IdResolver *idresolver.Resolver
Config *config.Config
Logger *slog.Logger
}
type processFunc func(ctx context.Context, e *models.Event) error
···
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")
}
tx, err := ddb.BeginTx(ctx, nil)
···
}
defer tx.Rollback()
-
err = db.NewIssue(tx, &issue)
if err != nil {
l.Error("failed to create issue", "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")
}
_, err = db.AddIssueComment(ddb, *comment)
···
"encoding/json"
"fmt"
"log/slog"
"time"
···
"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/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
···
issue := db.IssueFromRecord(did, rkey, record)
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
+
return fmt.Errorf("failed to validate issue: %w", err)
}
tx, err := ddb.BeginTx(ctx, nil)
···
}
defer tx.Rollback()
+
err = db.PutIssue(tx, &issue)
if err != nil {
l.Error("failed to create issue", "err", err)
return err
···
return fmt.Errorf("failed to parse comment from record: %w", err)
}
+
if err := i.Validator.ValidateIssueComment(comment); err != nil {
+
return fmt.Errorf("failed to validate comment: %w", err)
}
_, err = db.AddIssueComment(ddb, *comment)
+256 -107
appview/issues/issues.go
···
"log/slog"
"net/http"
"slices"
-
"strings"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
"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"
···
db *db.DB,
config *config.Config,
notifier notify.Notifier,
) *Issues {
return &Issues{
oauth: oauth,
···
Reactions: reactionCountMap,
UserReacted: userReactions,
})
}
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
···
replyToUri := r.FormValue("reply-to")
var replyTo *string
if replyToUri != "" {
-
uri, err := syntax.ParseATURI(replyToUri)
-
if err != nil {
-
l.Error("failed to get parse replyTo", "err", err, "replyTo", replyToUri)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
-
if uri.Collection() != tangled.RepoIssueCommentNSID {
-
l.Error("invalid replyTo collection", "collection", uri.Collection())
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
-
u := uri.String()
-
replyTo = &u
}
comment := db.IssueComment{
···
}
// rkey is optional, it was introduced later
-
if comment.Rkey != "" {
// update the record on pds
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
if err != nil {
-
// failed to get record
-
log.Println(err, 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,
SwapRecord: ex.Cid,
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueComment{
-
Repo: &repoAt,
-
Issue: issueAt,
-
Owner: &comment.OwnerDid,
-
Body: newBody,
-
CreatedAt: createdAt,
-
},
},
})
if err != nil {
-
log.Println(err)
}
}
-
// optimistic update for htmx
-
comment.Body = newBody
-
comment.Edited = &edited
-
// return new comment body with htmx
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
})
-
return
-
}
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 {
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, "issues", "Failed to load issue. Try again later.")
return
}
-
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
return
}
-
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 {
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)
if err != nil {
-
log.Println("failed to delete comment")
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,
Repo: user.Did,
Rkey: comment.Rkey,
})
···
comment.Deleted = &deleted
// htmx fragment of comment after deletion
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
})
}
···
return
}
-
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
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) {
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
return
}
···
RepoInfo: f.RepoInfo(user),
})
case http.MethodPost:
-
title := r.FormValue("title")
-
body := r.FormValue("body")
-
if title == "" || body == "" {
-
rp.pages.Notice(w, "issues", "Title and body are required")
return
}
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
-
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
return
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
-
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
return
}
tx, err := rp.db.BeginTx(r.Context(), nil)
if err != nil {
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
return
}
-
issue := &db.Issue{
-
RepoAt: f.RepoAt(),
-
Rkey: tid.TID(),
-
Title: title,
-
Body: body,
-
OwnerDid: user.Did,
}
-
err = db.NewIssue(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 {
log.Println("failed to create issue", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
rp.notifier.NewIssue(r.Context(), issue)
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
}
}
···
"log/slog"
"net/http"
"slices"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
"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/pagination"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/appview/validator"
···
db *db.DB,
config *config.Config,
notifier notify.Notifier,
+
validator *validator.Validator,
) *Issues {
return &Issues{
oauth: oauth,
···
Reactions: reactionCountMap,
UserReacted: userReactions,
})
+
}
+
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 {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
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.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")
+
+
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
+
}
+
+
newRecord := newIssue.AsRecord()
+
+
// edit 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, 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.RepoIssueNSID,
+
Repo: user.Did,
+
Rkey: newIssue.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
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 {
+
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
+
}
+
+
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) {
···
replyToUri := r.FormValue("reply-to")
var replyTo *string
if replyToUri != "" {
+
replyTo = &replyToUri
}
comment := db.IssueComment{
···
}
// rkey is optional, it was introduced later
+
if newComment.Rkey != "" {
// update the record on pds
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
if err != nil {
+
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
}
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
+
Rkey: newComment.Rkey,
SwapRecord: ex.Cid,
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
},
})
if err != nil {
+
l.Error("failed to update record on PDS", "err", err)
}
}
// return new comment body with htmx
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
+
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]
+
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.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
if err != nil {
+
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.RepoIssueCommentNSID,
Repo: user.Did,
Rkey: comment.Rkey,
})
···
comment.Deleted = &deleted
// htmx fragment of comment after deletion
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
+
Comment: &comment,
})
}
···
return
}
+
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 {
+
l.Error("failed to get repo and knot", "err", err)
return
}
···
RepoInfo: f.RepoInfo(user),
})
case http.MethodPost:
+
issue := &db.Issue{
+
RepoAt: f.RepoAt(),
+
Rkey: tid.TID(),
+
Title: r.FormValue("title"),
+
Body: r.FormValue("body"),
+
Did: user.Did,
+
Created: time.Now(),
+
}
+
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
}
+
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
}
+
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)
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
if err := errors.Join(err1, err2); err != nil {
+
l.Error("failed to rollback txn", "err", err)
+
}
}
+
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
}
+
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
+
}
+3
appview/issues/router.go
···
r.Get("/reply", i.ReplyIssueComment)
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
})
r.Post("/close", i.CloseIssue)
r.Post("/reopen", i.ReopenIssue)
})
···
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)
})
+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)
parsedURL := &url.URL{
Scheme: scheme,
···
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
+
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
IssueOwnerHandle string
OrderedReactionKinds []db.ReactionKind
Reactions map[db.ReactionKind]int
UserReacted map[db.ReactionKind]bool
-
State string
}
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
Active string
}
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
params.Active = "issues"
return p.executeRepo("repo/issues/new", w, params)
}
···
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
-
Comment *db.Comment
}
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
}
-
type SingleIssueCommentParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
-
Comment *db.Comment
}
-
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
-
return p.executePlain("repo/issues/fragments/issueComment", w, params)
}
type RepoNewPullParams struct {
···
return fragmentPaths, 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
+
CommentList []db.CommentListItem
IssueOwnerHandle string
OrderedReactionKinds []db.ReactionKind
Reactions map[db.ReactionKind]int
UserReacted map[db.ReactionKind]bool
+
}
+
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)
}
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.IssueComment
}
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
}
+
type ReplyIssueCommentPlaceholderParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
+
Comment *db.IssueComment
}
+
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/">
Click to read the upgrade guide</a>.
</div>
</details>
···
<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.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">
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
-
-
<span class="flex items-center gap-1 text-sm">
{{ i "book" "w-3 h-3" }}
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
-
docs
-
</a>
</span>
</div>
···
{{ define "title" }}knots{{ end }}
{{ define "content" }}
+
<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">
{{ i "book" "w-3 h-3" }}
+
<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">
{{ block "topbarLayout" . }}
-
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" 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">
{{ 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">
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
···
<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-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-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-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-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>
</div>
<div id="right-items" class="flex items-center gap-2">
···
<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="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"
>
<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"
>
{{ block "repoContent" . }}{{ end }}
</section>
···
</section>
<section
+
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 dark:text-white"
>
{{ block "repoContent" . }}{{ end }}
</section>
+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 }}
+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 }}
+6 -6
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
{{ template "timestamp" . }}
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
{{ if and $isCommentOwner (not .Comment.Deleted) }}
-
{{ template "edit" . }}
-
{{ template "delete" . }}
{{ end }}
</div>
{{ end }}
···
</a>
{{ end }}
-
{{ define "edit" }}
<a
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
-
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.Id }}/comment/{{ .Comment.Id }}/edit"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}">
{{ i "pencil" "size-3" }}
</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.Id }}/comment/{{ .Comment.Id }}/"
hx-confirm="Are you sure you want to delete your comment?"
hx-swap="outerHTML"
hx-target="#comment-body-{{.Comment.Id}}"
···
{{ template "timestamp" . }}
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
{{ if and $isCommentOwner (not .Comment.Deleted) }}
+
{{ template "editIssueComment" . }}
+
{{ template "deleteIssueComment" . }}
{{ end }}
</div>
{{ 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}}"
+2 -2
appview/pages/templates/repo/issues/fragments/newComment.html
···
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
<button
id="close-button"
type="button"
···
}
});
</script>
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
<button
type="button"
class="btn flex items-center gap-2"
···
{{ $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"
···
}
});
</script>
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
<button
type="button"
class="btn flex items-center gap-2"
+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>
-
{{ $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 }}
-
<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>
-
{{ if .Issue.Body }}
-
<article id="body" class="mt-8 prose dark:prose-invert">
-
{{ .Issue.Body | markdown }}
-
</article>
-
{{ 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>
{{ 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>
-
{{ block "newComment" . }} {{ end }}
{{ 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', '');
-
}
-
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 }}
···
{{ end }}
{{ define "repoContent" }}
+
<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 }}
+
{{ 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 }}
+
{{ 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 and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
+
{{ template "issueActions" . }}
+
{{ end }}
+
</div>
+
<div id="issue-actions-error" class="error"></div>
+
{{ end }}
+
{{ define "issueActions" }}
+
{{ template "editIssue" . }}
+
{{ template "deleteIssue" . }}
{{ end }}
+
{{ 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 }}
+
{{ 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 "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)
+
}}
+
{{ template "repo/issues/fragments/newComment" . }}
+
<div>
+
{{ 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 }}
-
<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="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>
</div>
-
{{ end }}
-
</div>
-
-
{{ block "pagination" . }} {{ end }}
-
{{ end }}
{{ define "pagination" }}
···
{{ 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 }}
+
<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" .Did }}
+
</span>
+
<span class="before:content-['ยท']">
+
{{ template "repo/fragments/time" .Created }}
+
</span>
+
<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>
+
{{ 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>
{{ end }}
···
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
+
{{ template "repo/issues/fragments/putIssue" . }}
{{ 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">
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
-
-
-
<span class="flex items-center gap-1 text-sm">
{{ i "book" "w-3 h-3" }}
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
-
docs
-
</a>
</span>
</div>
···
{{ define "title" }}spindles{{ end }}
{{ define "content" }}
+
<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">
{{ i "book" "w-3 h-3" }}
+
<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" />
</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.
···
<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" />
</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-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" />
</a>
</div>
</div>
{{ end }}
{{ define "features" }}
-
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4">
{{ template "feature" (list
(dict
"title" "lightweight git repo hosting"
···
{{ define "feature" }}
{{ $info := index . 0 }}
{{ $bullets := index . 1 }}
+
<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 shadow-sm" />
</a>
</div>
</div>
{{ end }}
{{ define "features" }}
+
<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>
<h2 class="text-center text-xl italic dark:text-white">
tightly-knit social coding.
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
+
<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>
<h2 class="text-center text-xl italic dark:text-white">
tightly-knit social coding.
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
+
<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 }}
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
···
</summary>
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
{{ range $items }}
+
{{ $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>
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
<form
class="mt-4 max-w-sm mx-auto"
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
+
<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"
+6 -1
appview/repo/feed.go
···
"time"
"tangled.sh/tangled.sh/core/appview/db"
"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()))
if err != nil {
return nil, err
}
···
"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.GetIssuesPaginated(
+
rp.db,
+
pagination.Page{Limit: feedLimitPerType},
+
db.FilterEq("repo_at", f.RepoAt()),
+
)
if err != nil {
return nil, err
}
+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)
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))
···
"log/slog"
"net/http"
"net/url"
"path/filepath"
"slices"
"strconv"
···
}
// fetch the raw binary content using sh.tangled.repo.blob xrpc
+
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))
+10 -9
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)
follows, err := fetchFollows(s.db, profile.UserDid)
if err != nil {
l.Error("failed to fetch follows", "err", err)
-
return nil, err
}
if len(follows) == 0 {
-
return nil, 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
}
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
}
loggedInUserFollowing = make(map[string]struct{}, len(following))
for _, follow := range following {
···
}
}
-
return &FollowsPageParams{
-
Follows: followCards,
-
Card: profile,
-
}, nil
}
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
···
"github.com/gorilla/feeds"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
"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 &params, err
}
if len(follows) == 0 {
+
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 &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 &params, err
}
loggedInUserFollowing = make(map[string]struct{}, len(following))
for _, follow := range following {
···
}
}
+
params.Follows = followCards
+
+
return &params, nil
}
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
+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)
return issues.Router(mw)
}
···
}
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, 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"
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
}
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)
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
if err != nil {
···
IdResolver: res,
Config: config,
Logger: tlog.New("ingester"),
}
err = jc.StartJetstream(ctx, ingester.Ingest())
if err != nil {
···
knotstream,
spindlestream,
slog.Default(),
}
return state, nil
···
"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;
}
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;
···
}
label {
+
@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)
case strings.HasPrefix(mimeType, "text/"):
w.Header().Set("Cache-Control", "public, no-cache")
···
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
-
}
-
}
gr, err := git.PlainOpen(repoPath)
if err != nil {
···
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 := 500
gr, err := git.PlainOpen(repoPath)
if err != nil {
+11 -1
knotserver/xrpc/repo_log.go
···
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
}
if path != "" {
···
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: total,
}
if path != "" {
+8 -2
nix/gomod2nix.toml
···
[mod."github.com/whyrusleeping/cbor-gen"]
version = "v0.3.1"
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
[mod."github.com/yuin/goldmark"]
-
version = "v1.4.15"
-
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
[mod."github.com/yuin/goldmark-highlighting/v2"]
version = "v2.0.0-20230729083705-37449abec8cc"
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
···
[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.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";
in
-
buildGoApplication {
-
pname = "knot";
-
version = "1.8.1";
-
inherit src modules;
-
doCheck = false;
-
subPackages = ["cmd/knot"];
-
tags = ["libsqlite3"];
-
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;
-
}
···
modules,
sqlite-lib,
src,
+
}: let
+
version = "1.9.0-alpha";
in
+
buildGoApplication {
+
pname = "knot";
+
inherit src version modules;
+
doCheck = false;
+
subPackages = ["cmd/knot"];
+
tags = ["libsqlite3"];
+
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;
+
}