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

Compare changes

Choose any two refs to compare.

Changed files
+9477 -2787
.air
api
appview
cmd
combinediff
interdiff
jstest
knotserver
docker
rootfs
etc
s6-overlay
s6-rc.d
create-sshd-host-keys
knotserver
dependencies.d
run
sshd
user
contents.d
scripts
ssh
sshd_config.d
docs
jetstream
knotserver
lexicons
patchutil
rbac
types
+1 -1
.air/knotserver.toml
···
[build]
-
cmd = "go build -o .bin/knot ./cmd/knotserver/main.go"
+
cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knotserver/main.go'
bin = ".bin/knot"
root = "."
+246 -45
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 6
+
fieldCount := 7
if t.AddedAt == nil {
fieldCount--
if t.Description == nil {
+
fieldCount--
+
}
+
+
if t.Source == nil {
fieldCount--
···
return err
+
// t.Source (string) (string)
+
if t.Source != nil {
+
+
if len("source") > 1000000 {
+
return xerrors.Errorf("Value in field \"source\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("source")); err != nil {
+
return err
+
}
+
+
if t.Source == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Source) > 1000000 {
+
return xerrors.Errorf("Value in field t.Source was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Source))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Source)); err != nil {
+
return err
+
}
+
}
+
}
+
// t.AddedAt (string) (string)
if t.AddedAt != nil {
···
t.Owner = string(sval)
+
// t.Source (string) (string)
+
case "source":
+
+
{
+
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.Source = (*string)(&sval)
+
}
+
}
// t.AddedAt (string) (string)
case "addedAt":
···
fieldCount--
-
if t.SourceRepo == nil {
+
if t.Source == nil {
fieldCount--
···
-
// t.CreatedAt (string) (string)
-
if t.CreatedAt != nil {
+
// t.Source (tangled.RepoPull_Source) (struct)
+
if t.Source != nil {
-
if len("createdAt") > 1000000 {
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
+
if len("source") > 1000000 {
+
return xerrors.Errorf("Value in field \"source\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil {
return err
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
+
if _, err := cw.WriteString(string("source")); err != nil {
return err
-
if t.CreatedAt == nil {
-
if _, err := cw.Write(cbg.CborNull); err != nil {
-
return err
-
}
-
} else {
-
if len(*t.CreatedAt) > 1000000 {
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
-
return err
-
}
+
if err := t.Source.MarshalCBOR(cw); err != nil {
+
return err
-
// t.SourceRepo (string) (string)
-
if t.SourceRepo != nil {
+
// t.CreatedAt (string) (string)
+
if t.CreatedAt != nil {
-
if len("sourceRepo") > 1000000 {
-
return xerrors.Errorf("Value in field \"sourceRepo\" was too long")
+
if len("createdAt") > 1000000 {
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
return err
-
if _, err := cw.WriteString(string("sourceRepo")); err != nil {
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
return err
-
if t.SourceRepo == nil {
+
if t.CreatedAt == nil {
if _, err := cw.Write(cbg.CborNull); err != nil {
return err
} else {
-
if len(*t.SourceRepo) > 1000000 {
-
return xerrors.Errorf("Value in field t.SourceRepo was too long")
+
if len(*t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
return err
-
if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil {
+
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
return err
···
t.PullId = int64(extraI)
-
// t.CreatedAt (string) (string)
-
case "createdAt":
+
// t.Source (tangled.RepoPull_Source) (struct)
+
case "source":
+
b, err := cr.ReadByte()
if err != nil {
return err
···
if err := cr.UnreadByte(); err != nil {
return err
-
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
+
t.Source = new(RepoPull_Source)
+
if err := t.Source.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.Source pointer: %w", err)
-
-
t.CreatedAt = (*string)(&sval)
+
-
// t.SourceRepo (string) (string)
-
case "sourceRepo":
+
// t.CreatedAt (string) (string)
+
case "createdAt":
b, err := cr.ReadByte()
···
return err
-
t.SourceRepo = (*string)(&sval)
+
t.CreatedAt = (*string)(&sval)
// t.TargetRepo (string) (string)
···
t.TargetBranch = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
+
func (t *RepoPull_Source) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
fieldCount := 2
+
+
if t.Repo == nil {
+
fieldCount--
+
}
+
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
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.Branch (string) (string)
+
if len("branch") > 1000000 {
+
return xerrors.Errorf("Value in field \"branch\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("branch")); err != nil {
+
return err
+
}
+
+
if len(t.Branch) > 1000000 {
+
return xerrors.Errorf("Value in field t.Branch was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Branch)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *RepoPull_Source) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = RepoPull_Source{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("RepoPull_Source: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 6)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// 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.Branch (string) (string)
+
case "branch":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Branch = string(sval)
default:
+15 -9
api/tangled/repopull.go
···
} //
// RECORDTYPE: RepoPull
type RepoPull struct {
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
-
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
-
Patch string `json:"patch" cborgen:"patch"`
-
PullId int64 `json:"pullId" cborgen:"pullId"`
-
SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"`
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
-
Title string `json:"title" cborgen:"title"`
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
+
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
+
Patch string `json:"patch" cborgen:"patch"`
+
PullId int64 `json:"pullId" cborgen:"pullId"`
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
+
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
+
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
+
Title string `json:"title" cborgen:"title"`
+
}
+
+
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+
type RepoPull_Source struct {
+
Branch string `json:"branch" cborgen:"branch"`
+
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
}
+2
api/tangled/tangledrepo.go
···
// name: name of the repo
Name string `json:"name" cborgen:"name"`
Owner string `json:"owner" cborgen:"owner"`
+
// source: source of the repo
+
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
}
+7 -1
appview/auth/auth.go
···
}
func (a *Auth) ClearSession(r *http.Request, w http.ResponseWriter) error {
-
clientSession, _ := a.Store.Get(r, appview.SessionName)
+
clientSession, err := a.Store.Get(r, appview.SessionName)
+
if err != nil {
+
return fmt.Errorf("invalid session", err)
+
}
+
if clientSession.IsNew {
+
return fmt.Errorf("invalid session")
+
}
clientSession.Options.MaxAge = -1
return clientSession.Save(r, w)
}
+32
appview/db/db.go
···
return nil
})
+
runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table comments drop column comment_at;
+
alter table comments add column rkey text;
+
`)
+
return err
+
})
+
+
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table comments add column deleted text; -- timestamp
+
alter table comments add column edited text; -- timestamp
+
`)
+
return err
+
})
+
+
runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table pulls add column source_branch text;
+
alter table pulls add column source_repo_at text;
+
alter table pull_submissions add column source_rev text;
+
`)
+
return err
+
})
+
+
runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table repos add column source text;
+
`)
+
return err
+
})
+
return &DB{db}, nil
}
+237 -24
appview/db/issues.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/appview/pagination"
)
type Issue struct {
···
OwnerDid string
IssueId int
IssueAt string
-
Created *time.Time
+
Created time.Time
Title string
Body string
Open bool
+
+
// optionally, populate this when querying for reverse mappings
+
// like comment counts, parent repo etc.
Metadata *IssueMetadata
}
type IssueMetadata struct {
CommentCount int
+
Repo *Repo
// labels, assignee etc.
}
type Comment struct {
OwnerDid string
RepoAt syntax.ATURI
-
CommentAt string
+
Rkey string
Issue int
CommentId int
Body string
Created *time.Time
+
Deleted *time.Time
+
Edited *time.Time
}
func NewIssue(tx *sql.Tx, issue *Issue) error {
···
return ownerDid, err
}
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) {
+
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
var issues []Issue
openValue := 0
if isOpen {
···
}
rows, err := e.Query(
+
`
+
with numbered_issue as (
+
select
+
i.owner_did,
+
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
+
owner_did,
+
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.OwnerDid, &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
+
}
+
+
// 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.owner_did,
+
i.repo_at,
i.issue_id,
i.created,
i.title,
i.body,
i.open,
-
count(c.id)
+
r.did,
+
r.name,
+
r.knot,
+
r.rkey,
+
r.created
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
+
join
+
repos r on i.repo_at = r.at_uri
+
where
+
i.owner_did = ? and i.created >= date ('now', ?)
order by
i.created desc`,
-
repoAt, openValue)
+
ownerDid, timeframe)
if err != nil {
return nil, err
}
···
for rows.Next() {
var issue Issue
-
var createdAt string
-
var metadata IssueMetadata
-
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
+
var issueCreatedAt, repoCreatedAt string
+
var repo Repo
+
err := rows.Scan(
+
&issue.OwnerDid,
+
&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
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
if err != nil {
return nil, err
}
-
issue.Created = &createdTime
-
issue.Metadata = &metadata
+
issue.Created = issueCreatedTime
+
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
+
if err != nil {
+
return nil, err
+
}
+
repo.Created = repoCreatedTime
+
+
issue.Metadata = &IssueMetadata{
+
Repo: &repo,
+
}
issues = append(issues, issue)
}
···
if err != nil {
return nil, err
}
-
issue.Created = &createdTime
+
issue.Created = createdTime
return &issue, nil
}
···
if err != nil {
return nil, nil, err
}
-
issue.Created = &createdTime
+
issue.Created = createdTime
comments, err := GetComments(e, repoAt, issueId)
if err != nil {
···
return &issue, comments, nil
}
-
func NewComment(e Execer, comment *Comment) error {
-
query := `insert into comments (owner_did, repo_at, comment_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
+
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.CommentAt,
+
comment.Rkey,
comment.Issue,
comment.CommentId,
comment.Body,
···
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
var comments []Comment
-
rows, err := e.Query(`select owner_did, issue_id, comment_id, comment_at, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId)
+
rows, err := e.Query(`
+
select
+
owner_did,
+
issue_id,
+
comment_id,
+
rkey,
+
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
}
···
for rows.Next() {
var comment Comment
var createdAt string
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt)
+
var deletedAt, editedAt, rkey sql.NullString
+
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
if err != nil {
return nil, err
}
···
}
comment.Created = &createdAtTime
+
if deletedAt.Valid {
+
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
+
if err != nil {
+
return nil, err
+
}
+
comment.Deleted = &deletedTime
+
}
+
+
if editedAt.Valid {
+
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
+
if err != nil {
+
return nil, err
+
}
+
comment.Edited = &editedTime
+
}
+
+
if rkey.Valid {
+
comment.Rkey = rkey.String
+
}
+
comments = append(comments, comment)
}
···
}
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
+
}
+
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, err
+
}
+
comment.Created = &createdTime
+
+
if deletedAt.Valid {
+
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
+
if err != nil {
+
return nil, err
+
}
+
comment.Deleted = &deletedTime
+
}
+
+
if editedAt.Valid {
+
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
+
if err != nil {
+
return nil, err
+
}
+
comment.Edited = &editedTime
+
}
+
+
if rkey.Valid {
+
comment.Rkey = rkey.String
+
}
+
+
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)
+
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 CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
+6 -10
appview/db/jetstream.go
···
}
func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error {
-
_, err := db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs)
+
_, err := db.Exec(`
+
insert into _jetstream (id, last_time_us)
+
values (1, ?)
+
on conflict(id) do update set last_time_us = excluded.last_time_us
+
`, lastTimeUs)
return err
}
-
func (db DbWrapper) UpdateLastTimeUs(lastTimeUs int64) error {
-
_, err := db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs)
-
if err != nil {
-
return err
-
}
-
return nil
-
}
-
func (db DbWrapper) GetLastTimeUs() (int64, error) {
var lastTimeUs int64
-
row := db.QueryRow(`select last_time_us from _jetstream`)
+
row := db.QueryRow(`select last_time_us from _jetstream where id = 1;`)
err := row.Scan(&lastTimeUs)
return lastTimeUs, err
}
+164
appview/db/profile.go
···
+
package db
+
+
import (
+
"fmt"
+
"time"
+
)
+
+
type RepoEvent struct {
+
Repo *Repo
+
Source *Repo
+
}
+
+
type ProfileTimeline struct {
+
ByMonth []ByMonth
+
}
+
+
type ByMonth struct {
+
RepoEvents []RepoEvent
+
IssueEvents IssueEvents
+
PullEvents PullEvents
+
}
+
+
func (b ByMonth) IsEmpty() bool {
+
return len(b.RepoEvents) == 0 &&
+
len(b.IssueEvents.Items) == 0 &&
+
len(b.PullEvents.Items) == 0
+
}
+
+
type IssueEvents struct {
+
Items []*Issue
+
}
+
+
type IssueEventStats struct {
+
Open int
+
Closed int
+
}
+
+
func (i IssueEvents) Stats() IssueEventStats {
+
var open, closed int
+
for _, issue := range i.Items {
+
if issue.Open {
+
open += 1
+
} else {
+
closed += 1
+
}
+
}
+
+
return IssueEventStats{
+
Open: open,
+
Closed: closed,
+
}
+
}
+
+
type PullEvents struct {
+
Items []*Pull
+
}
+
+
func (p PullEvents) Stats() PullEventStats {
+
var open, merged, closed int
+
for _, pull := range p.Items {
+
switch pull.State {
+
case PullOpen:
+
open += 1
+
case PullMerged:
+
merged += 1
+
case PullClosed:
+
closed += 1
+
}
+
}
+
+
return PullEventStats{
+
Open: open,
+
Merged: merged,
+
Closed: closed,
+
}
+
}
+
+
type PullEventStats struct {
+
Closed int
+
Open int
+
Merged int
+
}
+
+
const TimeframeMonths = 3
+
+
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
+
timeline := ProfileTimeline{
+
ByMonth: make([]ByMonth, TimeframeMonths),
+
}
+
currentMonth := time.Now().Month()
+
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
+
+
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
+
if err != nil {
+
return nil, fmt.Errorf("error getting pulls by owner did: %w", err)
+
}
+
+
// group pulls by month
+
for _, pull := range pulls {
+
pullMonth := pull.Created.Month()
+
+
if currentMonth-pullMonth > TimeframeMonths {
+
// shouldn't happen; but times are weird
+
continue
+
}
+
+
idx := currentMonth - pullMonth
+
items := &timeline.ByMonth[idx].PullEvents.Items
+
+
*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)
+
}
+
+
for _, issue := range issues {
+
issueMonth := issue.Created.Month()
+
+
if currentMonth-issueMonth > TimeframeMonths {
+
// shouldn't happen; but times are weird
+
continue
+
}
+
+
idx := currentMonth - issueMonth
+
items := &timeline.ByMonth[idx].IssueEvents.Items
+
+
*items = append(*items, &issue)
+
}
+
+
repos, err := GetAllReposByDid(e, forDid)
+
if err != nil {
+
return nil, fmt.Errorf("error getting all repos by did: %w", err)
+
}
+
+
for _, repo := range repos {
+
// TODO: get this in the original query; requires COALESCE because nullable
+
var sourceRepo *Repo
+
if repo.Source != "" {
+
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
+
if err != nil {
+
return nil, err
+
}
+
}
+
+
repoMonth := repo.Created.Month()
+
+
if currentMonth-repoMonth > TimeframeMonths {
+
// shouldn't happen; but times are weird
+
continue
+
}
+
+
idx := currentMonth - repoMonth
+
+
items := &timeline.ByMonth[idx].RepoEvents
+
*items = append(*items, RepoEvent{
+
Repo: &repo,
+
Source: sourceRepo,
+
})
+
}
+
+
return &timeline, nil
+
}
+341 -24
appview/db/pulls.go
···
"database/sql"
"fmt"
"log"
+
"sort"
"strings"
"time"
"github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
)
···
Submissions []*PullSubmission
// meta
-
Created time.Time
+
Created time.Time
+
PullSource *PullSource
+
+
// optionally, populate this when querying for reverse mappings
+
Repo *Repo
+
}
+
+
type PullSource struct {
+
Branch string
+
RepoAt *syntax.ATURI
+
+
// optionally populate this for reverse mappings
+
Repo *Repo
}
type PullSubmission struct {
···
RoundNumber int
Patch string
Comments []PullComment
+
SourceRev string // include the rev that was used to create this submission: only for branch PRs
// meta
Created time.Time
···
return len(p.Submissions) - 1
}
-
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
+
func (p *Pull) IsPatchBased() bool {
+
return p.PullSource == nil
+
}
+
+
func (p *Pull) IsBranchBased() bool {
+
if p.PullSource != nil {
+
if p.PullSource.RepoAt != nil {
+
return p.PullSource.RepoAt == &p.RepoAt
+
} else {
+
// no repo specified
+
return true
+
}
+
}
+
return false
+
}
+
+
func (p *Pull) IsForkBased() bool {
+
if p.PullSource != nil {
+
if p.PullSource.RepoAt != nil {
+
// make sure repos are different
+
return p.PullSource.RepoAt != &p.RepoAt
+
}
+
}
+
return false
+
}
+
+
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
patch := s.Patch
-
diffs, _, err := gitdiff.Parse(strings.NewReader(patch))
+
// if format-patch; then extract each patch
+
var diffs []*gitdiff.File
+
if patchutil.IsFormatPatch(patch) {
+
patches, err := patchutil.ExtractPatches(patch)
+
if err != nil {
+
return nil, err
+
}
+
var ps [][]*gitdiff.File
+
for _, p := range patches {
+
ps = append(ps, p.Files)
+
}
+
+
diffs = patchutil.CombineDiff(ps...)
+
} else {
+
d, _, err := gitdiff.Parse(strings.NewReader(patch))
+
if err != nil {
+
return nil, err
+
}
+
diffs = d
+
}
+
+
return diffs, nil
+
}
+
+
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
+
diffs, err := s.AsDiff(targetBranch)
if err != nil {
log.Println(err)
}
···
return nd
}
+
func (s PullSubmission) IsFormatPatch() bool {
+
return patchutil.IsFormatPatch(s.Patch)
+
}
+
+
func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch {
+
patches, err := patchutil.ExtractPatches(s.Patch)
+
if err != nil {
+
log.Println("error extracting patches from submission:", err)
+
return []patchutil.FormatPatch{}
+
}
+
+
return patches
+
}
+
func NewPull(tx *sql.Tx, pull *Pull) error {
defer tx.Rollback()
···
pull.PullId = nextId
pull.State = PullOpen
-
_, err = tx.Exec(`
-
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state)
-
values (?, ?, ?, ?, ?, ?, ?, ?)
-
`, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.TargetBranch, pull.Body, pull.Rkey, pull.State)
+
var sourceBranch, sourceRepoAt *string
+
if pull.PullSource != nil {
+
sourceBranch = &pull.PullSource.Branch
+
if pull.PullSource.RepoAt != nil {
+
x := pull.PullSource.RepoAt.String()
+
sourceRepoAt = &x
+
}
+
}
+
+
_, err = tx.Exec(
+
`
+
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at)
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+
pull.RepoAt,
+
pull.OwnerDid,
+
pull.PullId,
+
pull.Title,
+
pull.TargetBranch,
+
pull.Body,
+
pull.Rkey,
+
pull.State,
+
sourceBranch,
+
sourceRepoAt,
+
)
if err != nil {
return err
}
_, err = tx.Exec(`
-
insert into pull_submissions (pull_id, repo_at, round_number, patch)
-
values (?, ?, ?, ?)
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch)
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
+
values (?, ?, ?, ?, ?)
+
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
if err != nil {
return err
}
···
return pullId - 1, err
}
-
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]Pull, error) {
-
var pulls []Pull
+
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) {
+
pulls := make(map[int]*Pull)
rows, err := e.Query(`
select
···
target_branch,
pull_at,
body,
-
rkey
+
rkey,
+
source_branch,
+
source_repo_at
from
pulls
where
-
repo_at = ? and state = ?
-
order by
-
created desc`, repoAt, state)
+
repo_at = ? and state = ?`, repoAt, state)
if err != nil {
return nil, err
}
···
for rows.Next() {
var pull Pull
var createdAt string
+
var sourceBranch, sourceRepoAt sql.NullString
err := rows.Scan(
&pull.OwnerDid,
&pull.PullId,
···
&pull.PullAt,
&pull.Body,
&pull.Rkey,
+
&sourceBranch,
+
&sourceRepoAt,
)
if err != nil {
return nil, err
···
}
pull.Created = createdTime
-
pulls = append(pulls, pull)
+
if sourceBranch.Valid {
+
pull.PullSource = &PullSource{
+
Branch: sourceBranch.String,
+
}
+
if sourceRepoAt.Valid {
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
+
if err != nil {
+
return nil, err
+
}
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
+
}
+
}
+
+
pulls[pull.PullId] = &pull
+
}
+
+
// get latest round no. for each pull
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
+
submissionsQuery := fmt.Sprintf(`
+
select
+
id, pull_id, round_number
+
from
+
pull_submissions
+
where
+
repo_at = ? and pull_id in (%s)
+
`, inClause)
+
+
args := make([]any, len(pulls)+1)
+
args[0] = repoAt.String()
+
idx := 1
+
for _, p := range pulls {
+
args[idx] = p.PullId
+
idx += 1
+
}
+
submissionsRows, err := e.Query(submissionsQuery, args...)
+
if err != nil {
+
return nil, err
+
}
+
defer submissionsRows.Close()
+
+
for submissionsRows.Next() {
+
var s PullSubmission
+
err := submissionsRows.Scan(
+
&s.ID,
+
&s.PullId,
+
&s.RoundNumber,
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
if p, ok := pulls[s.PullId]; ok {
+
p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
+
p.Submissions[s.RoundNumber] = &s
+
}
+
}
+
if err := rows.Err(); err != nil {
+
return nil, err
}
+
// get comment count on latest submission on each pull
+
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
+
commentsQuery := fmt.Sprintf(`
+
select
+
count(id), pull_id
+
from
+
pull_comments
+
where
+
submission_id in (%s)
+
group by
+
submission_id
+
`, inClause)
+
+
args = []any{}
+
for _, p := range pulls {
+
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
+
}
+
commentsRows, err := e.Query(commentsQuery, args...)
+
if err != nil {
+
return nil, err
+
}
+
defer commentsRows.Close()
+
+
for commentsRows.Next() {
+
var commentCount, pullId int
+
err := commentsRows.Scan(
+
&commentCount,
+
&pullId,
+
)
+
if err != nil {
+
return nil, err
+
}
+
if p, ok := pulls[pullId]; ok {
+
p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
+
}
+
}
if err := rows.Err(); err != nil {
return nil, err
}
-
return pulls, nil
+
orderedByDate := []*Pull{}
+
for _, p := range pulls {
+
orderedByDate = append(orderedByDate, p)
+
}
+
sort.Slice(orderedByDate, func(i, j int) bool {
+
return orderedByDate[i].Created.After(orderedByDate[j].Created)
+
})
+
+
return orderedByDate, nil
}
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
···
pull_at,
repo_at,
body,
-
rkey
+
rkey,
+
source_branch,
+
source_repo_at
from
pulls
where
···
var pull Pull
var createdAt string
+
var sourceBranch, sourceRepoAt sql.NullString
err := row.Scan(
&pull.OwnerDid,
&pull.PullId,
···
&pull.RepoAt,
&pull.Body,
&pull.Rkey,
+
&sourceBranch,
+
&sourceRepoAt,
)
if err != nil {
return nil, err
···
}
pull.Created = createdTime
+
// populate source
+
if sourceBranch.Valid {
+
pull.PullSource = &PullSource{
+
Branch: sourceBranch.String,
+
}
+
if sourceRepoAt.Valid {
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
+
if err != nil {
+
return nil, err
+
}
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
+
}
+
}
+
submissionsQuery := `
select
-
id, pull_id, repo_at, round_number, patch, created
+
id, pull_id, repo_at, round_number, patch, created, source_rev
from
pull_submissions
where
···
for submissionsRows.Next() {
var submission PullSubmission
var submissionCreatedStr string
+
var submissionSourceRev sql.NullString
err := submissionsRows.Scan(
&submission.ID,
&submission.PullId,
···
&submission.RoundNumber,
&submission.Patch,
&submissionCreatedStr,
+
&submissionSourceRev,
)
if err != nil {
return nil, err
···
return nil, err
}
submission.Created = submissionCreatedTime
+
+
if submissionSourceRev.Valid {
+
submission.SourceRev = submissionSourceRev.String
+
}
submissionsMap[submission.ID] = &submission
}
···
return nil, err
}
+
var pullSourceRepo *Repo
+
if pull.PullSource != nil {
+
if pull.PullSource.RepoAt != nil {
+
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
+
if err != nil {
+
log.Printf("failed to get repo by at uri: %v", err)
+
} else {
+
pull.PullSource.Repo = pullSourceRepo
+
}
+
}
+
}
+
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
for _, submission := range submissionsMap {
pull.Submissions[submission.RoundNumber] = submission
···
return &pull, nil
}
+
// timeframe here is directly passed into the sql query filter, and any
+
// timeframe in the past should be negative; e.g.: "-3 months"
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
+
var pulls []Pull
+
+
rows, err := e.Query(`
+
select
+
p.owner_did,
+
p.repo_at,
+
p.pull_id,
+
p.created,
+
p.title,
+
p.state,
+
r.did,
+
r.name,
+
r.knot,
+
r.rkey,
+
r.created
+
from
+
pulls p
+
join
+
repos r on p.repo_at = r.at_uri
+
where
+
p.owner_did = ? and p.created >= date ('now', ?)
+
order by
+
p.created desc`, did, timeframe)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var pull Pull
+
var repo Repo
+
var pullCreatedAt, repoCreatedAt string
+
err := rows.Scan(
+
&pull.OwnerDid,
+
&pull.RepoAt,
+
&pull.PullId,
+
&pullCreatedAt,
+
&pull.Title,
+
&pull.State,
+
&repo.Did,
+
&repo.Name,
+
&repo.Knot,
+
&repo.Rkey,
+
&repoCreatedAt,
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt)
+
if err != nil {
+
return nil, err
+
}
+
pull.Created = pullCreatedTime
+
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
+
if err != nil {
+
return nil, err
+
}
+
repo.Created = repoCreatedTime
+
+
pull.Repo = &repo
+
+
pulls = append(pulls, pull)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return pulls, nil
+
}
+
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
res, err := e.Exec(
···
return err
}
-
func ResubmitPull(e Execer, pull *Pull, newPatch string) error {
+
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
newRoundNumber := len(pull.Submissions)
_, err := e.Exec(`
-
insert into pull_submissions (pull_id, repo_at, round_number, patch)
-
values (?, ?, ?, ?)
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch)
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
+
values (?, ?, ?, ?, ?)
+
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
return err
}
+129 -15
appview/db/repos.go
···
import (
"database/sql"
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
)
type Repo struct {
···
// optionally, populate this when querying for reverse mappings
RepoStats *RepoStats
+
+
// optional
+
Source string
}
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
var repos []Repo
rows, err := e.Query(
-
`select did, name, knot, rkey, description, created
+
`select did, name, knot, rkey, description, created, source
from repos
order by created desc
limit ?
···
for rows.Next() {
var repo Repo
err := scanRepo(
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created,
+
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
)
if err != nil {
return nil, err
···
r.rkey,
r.description,
r.created,
-
count(s.id) as star_count
+
count(s.id) as star_count,
+
r.source
from
repos r
left join
···
where
r.did = ?
group by
-
r.at_uri`, did)
+
r.at_uri
+
order by r.created desc`,
+
did)
if err != nil {
return nil, err
}
···
var repoStats RepoStats
var createdAt string
var nullableDescription sql.NullString
+
var nullableSource sql.NullString
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount)
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource)
if err != nil {
return nil, err
}
if nullableDescription.Valid {
repo.Description = nullableDescription.String
-
} else {
-
repo.Description = ""
+
}
+
+
if nullableSource.Valid {
+
repo.Source = nullableSource.String
}
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
···
func AddRepo(e Execer, repo *Repo) error {
_, err := e.Exec(
-
`insert into repos
-
(did, name, knot, rkey, at_uri, description)
-
values (?, ?, ?, ?, ?, ?)`,
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description,
+
`insert into repos
+
(did, name, knot, rkey, at_uri, description, source)
+
values (?, ?, ?, ?, ?, ?, ?)`,
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source,
)
return err
}
-
func RemoveRepo(e Execer, did, name, rkey string) error {
-
_, err := e.Exec(`delete from repos where did = ? and name = ? and rkey = ?`, did, name, rkey)
+
func RemoveRepo(e Execer, did, name string) error {
+
_, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name)
return err
}
+
func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) {
+
var nullableSource sql.NullString
+
err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&nullableSource)
+
if err != nil {
+
return "", err
+
}
+
return nullableSource.String, nil
+
}
+
+
func GetForksByDid(e Execer, did string) ([]Repo, error) {
+
var repos []Repo
+
+
rows, err := e.Query(
+
`select did, name, knot, rkey, description, created, at_uri, source
+
from repos
+
where did = ? and source is not null and source != ''
+
order by created desc`,
+
did,
+
)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var repo Repo
+
var createdAt string
+
var nullableDescription sql.NullString
+
var nullableSource sql.NullString
+
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
+
if err != nil {
+
return nil, err
+
}
+
+
if nullableDescription.Valid {
+
repo.Description = nullableDescription.String
+
}
+
+
if nullableSource.Valid {
+
repo.Source = nullableSource.String
+
}
+
+
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
repo.Created = time.Now()
+
} else {
+
repo.Created = createdAtTime
+
}
+
+
repos = append(repos, repo)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return repos, nil
+
}
+
+
func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
+
var repo Repo
+
var createdAt string
+
var nullableDescription sql.NullString
+
var nullableSource sql.NullString
+
+
row := e.QueryRow(
+
`select did, name, knot, rkey, description, created, at_uri, source
+
from repos
+
where did = ? and name = ? and source is not null and source != ''`,
+
did, name,
+
)
+
+
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
+
if err != nil {
+
return nil, err
+
}
+
+
if nullableDescription.Valid {
+
repo.Description = nullableDescription.String
+
}
+
+
if nullableSource.Valid {
+
repo.Source = nullableSource.String
+
}
+
+
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
repo.Created = time.Now()
+
} else {
+
repo.Created = createdAtTime
+
}
+
+
return &repo, nil
+
}
+
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
_, err := e.Exec(
`insert into collaborators (did, repo)
···
PullCount PullCount
}
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error {
+
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
var createdAt string
var nullableDescription sql.NullString
-
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil {
+
var nullableSource sql.NullString
+
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
return err
}
···
*created = time.Now()
} else {
*created = createdAtTime
+
}
+
+
if nullableSource.Valid {
+
*source = nullableSource.String
+
} else {
+
*source = ""
}
return nil
+13
appview/db/timeline.go
···
*Repo
*Follow
*Star
+
EventAt time.Time
+
+
// optional: populate only if Repo is a fork
+
Source *Repo
}
// TODO: this gathers heterogenous events from different sources and aggregates
···
}
for _, repo := range repos {
+
var sourceRepo *Repo
+
if repo.Source != "" {
+
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
+
if err != nil {
+
return nil, err
+
}
+
}
+
events = append(events, TimelineEvent{
Repo: &repo,
EventAt: repo.Created,
+
Source: sourceRepo,
})
}
+62
appview/filetree/filetree.go
···
+
package filetree
+
+
import (
+
"path/filepath"
+
"sort"
+
"strings"
+
)
+
+
type FileTreeNode struct {
+
Name string
+
Path string
+
IsDirectory bool
+
Children map[string]*FileTreeNode
+
}
+
+
// NewNode creates a new node
+
func newNode(name, path string, isDir bool) *FileTreeNode {
+
return &FileTreeNode{
+
Name: name,
+
Path: path,
+
IsDirectory: isDir,
+
Children: make(map[string]*FileTreeNode),
+
}
+
}
+
+
func FileTree(files []string) *FileTreeNode {
+
rootNode := newNode("", "", true)
+
+
sort.Strings(files)
+
+
for _, file := range files {
+
if file == "" {
+
continue
+
}
+
+
parts := strings.Split(filepath.Clean(file), "/")
+
if len(parts) == 0 {
+
continue
+
}
+
+
currentNode := rootNode
+
currentPath := ""
+
+
for i, part := range parts {
+
if currentPath == "" {
+
currentPath = part
+
} else {
+
currentPath = filepath.Join(currentPath, part)
+
}
+
+
isDir := i < len(parts)-1
+
+
if _, exists := currentNode.Children[part]; !exists {
+
currentNode.Children[part] = newNode(part, currentPath, isDir)
+
}
+
+
currentNode = currentNode.Children[part]
+
}
+
}
+
+
return rootNode
+
}
+126
appview/middleware/middleware.go
···
+
package middleware
+
+
import (
+
"context"
+
"log"
+
"net/http"
+
"strconv"
+
"time"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/xrpc"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/pagination"
+
)
+
+
type Middleware func(http.Handler) http.Handler
+
+
func AuthMiddleware(a *auth.Auth) Middleware {
+
return func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
+
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
+
}
+
if r.Header.Get("HX-Request") == "true" {
+
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
+
w.Header().Set("HX-Redirect", "/login")
+
w.WriteHeader(http.StatusOK)
+
}
+
}
+
+
session, err := a.GetSession(r)
+
if session.IsNew || err != nil {
+
log.Printf("not logged in, redirecting")
+
redirectFunc(w, r)
+
return
+
}
+
+
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
+
if !ok || !authorized {
+
log.Printf("not logged in, redirecting")
+
redirectFunc(w, r)
+
return
+
}
+
+
// refresh if nearing expiry
+
// TODO: dedup with /login
+
expiryStr := session.Values[appview.SessionExpiry].(string)
+
expiry, err := time.Parse(time.RFC3339, expiryStr)
+
if err != nil {
+
log.Println("invalid expiry time", err)
+
redirectFunc(w, r)
+
return
+
}
+
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
+
did, ok2 := session.Values[appview.SessionDid].(string)
+
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
+
+
if !ok1 || !ok2 || !ok3 {
+
log.Println("invalid expiry time", err)
+
redirectFunc(w, r)
+
return
+
}
+
+
if time.Now().After(expiry) {
+
log.Println("token expired, refreshing ...")
+
+
client := xrpc.Client{
+
Host: pdsUrl,
+
Auth: &xrpc.AuthInfo{
+
Did: did,
+
AccessJwt: refreshJwt,
+
RefreshJwt: refreshJwt,
+
},
+
}
+
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
+
if err != nil {
+
log.Println("failed to refresh session", err)
+
redirectFunc(w, r)
+
return
+
}
+
+
sessionish := auth.RefreshSessionWrapper{atSession}
+
+
err = a.StoreSession(r, w, &sessionish, pdsUrl)
+
if err != nil {
+
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
+
return
+
}
+
+
log.Println("successfully refreshed token")
+
}
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
}
+
+
func Paginate(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
page := pagination.FirstPage()
+
+
offsetVal := r.URL.Query().Get("offset")
+
if offsetVal != "" {
+
offset, err := strconv.Atoi(offsetVal)
+
if err != nil {
+
log.Println("invalid offset")
+
} else {
+
page.Offset = offset
+
}
+
}
+
+
limitVal := r.URL.Query().Get("limit")
+
if limitVal != "" {
+
limit, err := strconv.Atoi(limitVal)
+
if err != nil {
+
log.Println("invalid limit")
+
} else {
+
page.Limit = limit
+
}
+
}
+
+
ctx := context.WithValue(r.Context(), "page", page)
+
next.ServeHTTP(w, r.WithContext(ctx))
+
})
+
}
+12 -1
appview/pages/funcmap.go
···
"time"
"github.com/dustin/go-humanize"
+
"tangled.sh/tangled.sh/core/appview/filetree"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
)
func funcMap() template.FuncMap {
···
return strings.Split(s, sep)
},
"add": func(a, b int) int {
+
return a + b
+
},
+
// the absolute state of go templates
+
"add64": func(a, b int64) int64 {
return a + b
},
"sub": func(a, b int) int {
···
return s
},
"timeFmt": humanize.Time,
+
"longTimeFmt": func(t time.Time) string {
+
return t.Format("2006-01-02 * 3:04 PM")
+
},
"shortTimeFmt": func(t time.Time) string {
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
{time.Second, "now", time.Second},
···
return v.Slice(start, end).Interface()
},
"markdown": func(text string) template.HTML {
-
return template.HTML(renderMarkdown(text))
+
return template.HTML(markup.RenderMarkdown(text))
},
"isNil": func(t any) bool {
// returns false for other "zero" values
···
}
return template.HTML(data)
},
+
"cssContentHash": CssContentHash,
+
"fileTree": filetree.FileTree,
}
}
-23
appview/pages/markdown.go
···
-
package pages
-
-
import (
-
"bytes"
-
-
"github.com/yuin/goldmark"
-
"github.com/yuin/goldmark/extension"
-
"github.com/yuin/goldmark/parser"
-
)
-
-
func renderMarkdown(source string) string {
-
md := goldmark.New(
-
goldmark.WithExtensions(extension.GFM),
-
goldmark.WithParserOptions(
-
parser.WithAutoHeadingID(),
-
),
-
)
-
var buf bytes.Buffer
-
if err := md.Convert([]byte(source), &buf); err != nil {
-
return source
-
}
-
return buf.String()
-
}
+24
appview/pages/markup/markdown.go
···
+
// Package markup is an umbrella package for all markups and their renderers.
+
package markup
+
+
import (
+
"bytes"
+
+
"github.com/yuin/goldmark"
+
"github.com/yuin/goldmark/extension"
+
"github.com/yuin/goldmark/parser"
+
)
+
+
func RenderMarkdown(source string) string {
+
md := goldmark.New(
+
goldmark.WithExtensions(extension.GFM),
+
goldmark.WithParserOptions(
+
parser.WithAutoHeadingID(),
+
),
+
)
+
var buf bytes.Buffer
+
if err := md.Convert([]byte(source), &buf); err != nil {
+
return source
+
}
+
return buf.String()
+
}
+26
appview/pages/markup/readme.go
···
+
package markup
+
+
import "strings"
+
+
type Format string
+
+
const (
+
FormatMarkdown Format = "markdown"
+
FormatText Format = "text"
+
)
+
+
var FileTypes map[Format][]string = map[Format][]string{
+
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
+
}
+
+
func GetFormat(filename string) Format {
+
for format, extensions := range FileTypes {
+
for _, extension := range extensions {
+
if strings.HasSuffix(filename, extension) {
+
return format
+
}
+
}
+
}
+
// default format
+
return FormatText
+
}
+383 -102
appview/pages/pages.go
···
import (
"bytes"
+
"crypto/sha256"
"embed"
+
"encoding/hex"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
+
"os"
"path"
"path/filepath"
"slices"
"strings"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
"tangled.sh/tangled.sh/core/appview/pagination"
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
+
"tangled.sh/tangled.sh/core/patchutil"
+
"tangled.sh/tangled.sh/core/types"
+
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/microcosm-cc/bluemonday"
-
"tangled.sh/tangled.sh/core/appview/auth"
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
-
"tangled.sh/tangled.sh/core/types"
)
//go:embed templates/* static
var Files embed.FS
type Pages struct {
-
t map[string]*template.Template
+
t map[string]*template.Template
+
dev bool
+
embedFS embed.FS
+
templateDir string // Path to templates on disk for dev mode
+
}
+
+
func NewPages(dev bool) *Pages {
+
p := &Pages{
+
t: make(map[string]*template.Template),
+
dev: dev,
+
embedFS: Files,
+
templateDir: "appview/pages",
+
}
+
+
// Initial load of all templates
+
p.loadAllTemplates()
+
+
return p
}
-
func NewPages() *Pages {
+
func (p *Pages) loadAllTemplates() {
templates := make(map[string]*template.Template)
+
var fragmentPaths []string
-
// Walk through embedded templates directory and parse all .html files
-
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
+
// Use embedded FS for initial loading
+
// First, collect all fragment paths
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
+
if d.IsDir() {
+
return nil
+
}
+
if !strings.HasSuffix(path, ".html") {
+
return nil
+
}
+
if !strings.Contains(path, "fragments/") {
+
return nil
+
}
+
name := strings.TrimPrefix(path, "templates/")
+
name = strings.TrimSuffix(name, ".html")
+
tmpl, err := template.New(name).
+
Funcs(funcMap()).
+
ParseFS(p.embedFS, path)
+
if err != nil {
+
log.Fatalf("setting up fragment: %v", err)
+
}
+
templates[name] = tmpl
+
fragmentPaths = append(fragmentPaths, path)
+
log.Printf("loaded fragment: %s", name)
+
return nil
+
})
+
if err != nil {
+
log.Fatalf("walking template dir for fragments: %v", err)
+
}
-
if !d.IsDir() && strings.HasSuffix(path, ".html") {
-
name := strings.TrimPrefix(path, "templates/")
-
name = strings.TrimSuffix(name, ".html")
+
// Then walk through and setup the rest of the templates
+
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
+
if err != nil {
+
return err
+
}
+
if d.IsDir() {
+
return nil
+
}
+
if !strings.HasSuffix(path, "html") {
+
return nil
+
}
+
// Skip fragments as they've already been loaded
+
if strings.Contains(path, "fragments/") {
+
return nil
+
}
+
// Skip layouts
+
if strings.Contains(path, "layouts/") {
+
return nil
+
}
+
name := strings.TrimPrefix(path, "templates/")
+
name = strings.TrimSuffix(name, ".html")
+
// Add the page template on top of the base
+
allPaths := []string{}
+
allPaths = append(allPaths, "templates/layouts/*.html")
+
allPaths = append(allPaths, fragmentPaths...)
+
allPaths = append(allPaths, path)
+
tmpl, err := template.New(name).
+
Funcs(funcMap()).
+
ParseFS(p.embedFS, allPaths...)
+
if err != nil {
+
return fmt.Errorf("setting up template: %w", err)
+
}
+
templates[name] = tmpl
+
log.Printf("loaded template: %s", name)
+
return nil
+
})
+
if err != nil {
+
log.Fatalf("walking template dir: %v", err)
+
}
-
// add fragments as templates
-
if strings.HasPrefix(path, "templates/fragments/") {
-
tmpl, err := template.New(name).
-
Funcs(funcMap()).
-
ParseFS(Files, path)
-
if err != nil {
-
return fmt.Errorf("setting up fragment: %w", err)
-
}
+
log.Printf("total templates loaded: %d", len(templates))
+
p.t = templates
+
}
-
templates[name] = tmpl
-
log.Printf("loaded fragment: %s", name)
-
}
+
// loadTemplateFromDisk loads a template from the filesystem in dev mode
+
func (p *Pages) loadTemplateFromDisk(name string) error {
+
if !p.dev {
+
return nil
+
}
-
// layouts and fragments are applied first
-
if !strings.HasPrefix(path, "templates/layouts/") &&
-
!strings.HasPrefix(path, "templates/fragments/") {
-
// Add the page template on top of the base
-
tmpl, err := template.New(name).
-
Funcs(funcMap()).
-
ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path)
-
if err != nil {
-
return fmt.Errorf("setting up template: %w", err)
-
}
+
log.Printf("reloading template from disk: %s", name)
-
templates[name] = tmpl
-
log.Printf("loaded template: %s", name)
-
}
-
+
// Find all fragments first
+
var fragmentPaths []string
+
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
+
if err != nil {
+
return err
+
}
+
if d.IsDir() {
+
return nil
+
}
+
if !strings.HasSuffix(path, ".html") {
+
return nil
+
}
+
if !strings.Contains(path, "fragments/") {
return nil
}
+
fragmentPaths = append(fragmentPaths, path)
return nil
})
if err != nil {
-
log.Fatalf("walking template dir: %v", err)
+
return fmt.Errorf("walking disk template dir for fragments: %w", err)
+
}
+
+
// Find the template path on disk
+
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
+
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
+
return fmt.Errorf("template not found on disk: %s", name)
}
-
log.Printf("total templates loaded: %d", len(templates))
+
// Create a new template
+
tmpl := template.New(name).Funcs(funcMap())
-
return &Pages{
-
t: templates,
+
// Parse layouts
+
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
+
layouts, err := filepath.Glob(layoutGlob)
+
if err != nil {
+
return fmt.Errorf("finding layout templates: %w", err)
+
}
+
+
// Create paths for parsing
+
allFiles := append(layouts, fragmentPaths...)
+
allFiles = append(allFiles, templatePath)
+
+
// Parse all templates
+
tmpl, err = tmpl.ParseFiles(allFiles...)
+
if err != nil {
+
return fmt.Errorf("parsing template files: %w", err)
}
+
+
// Update the template in the map
+
p.t[name] = tmpl
+
log.Printf("template reloaded from disk: %s", name)
+
return nil
}
-
type LoginParams struct {
+
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
+
// In dev mode, reload the template from disk before executing
+
if p.dev {
+
if err := p.loadTemplateFromDisk(templateName); err != nil {
+
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
+
// Continue with the existing template
+
}
+
}
+
+
tmpl, exists := p.t[templateName]
+
if !exists {
+
return fmt.Errorf("template not found: %s", templateName)
+
}
+
+
if base == "" {
+
return tmpl.Execute(w, params)
+
} else {
+
return tmpl.ExecuteTemplate(w, base, params)
+
}
}
func (p *Pages) execute(name string, w io.Writer, params any) error {
-
return p.t[name].ExecuteTemplate(w, "layouts/base", params)
+
return p.executeOrReload(name, w, "layouts/base", params)
}
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
-
return p.t[name].Execute(w, params)
+
return p.executeOrReload(name, w, "", params)
}
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
-
return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
+
return p.executeOrReload(name, w, "layouts/repobase", params)
+
}
+
+
type LoginParams struct {
}
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
type KnotParams struct {
LoggedInUser *auth.User
+
DidHandleMap map[string]string
Registration *db.Registration
Members []string
IsOwner bool
···
return p.execute("repo/new", w, params)
}
+
type ForkRepoParams struct {
+
LoggedInUser *auth.User
+
Knots []string
+
RepoInfo RepoInfo
+
}
+
+
func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
+
return p.execute("repo/fork", w, params)
+
}
+
type ProfilePageParams struct {
LoggedInUser *auth.User
UserDid string
···
CollaboratingRepos []db.Repo
ProfileStats ProfileStats
FollowStatus db.FollowStatus
-
DidHandleMap map[string]string
AvatarUri string
+
ProfileTimeline *db.ProfileTimeline
+
+
DidHandleMap map[string]string
}
type ProfileStats struct {
···
}
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
-
return p.executePlain("fragments/follow", w, params)
+
return p.executePlain("user/fragments/follow", w, params)
}
-
type StarFragmentParams struct {
+
type RepoActionsFragmentParams struct {
IsStarred bool
RepoAt syntax.ATURI
Stats db.RepoStats
}
-
func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error {
-
return p.executePlain("fragments/star", w, params)
+
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
+
return p.executePlain("repo/fragments/repoActions", w, params)
}
type RepoDescriptionParams struct {
···
}
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
-
return p.executePlain("fragments/editRepoDescription", w, params)
+
return p.executePlain("repo/fragments/editRepoDescription", w, params)
}
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
-
return p.executePlain("fragments/repoDescription", w, params)
+
return p.executePlain("repo/fragments/repoDescription", w, params)
}
type RepoInfo struct {
-
Name string
-
OwnerDid string
-
OwnerHandle string
-
Description string
-
Knot string
-
RepoAt syntax.ATURI
-
IsStarred bool
-
Stats db.RepoStats
-
Roles RolesInRepo
+
Name string
+
OwnerDid string
+
OwnerHandle string
+
Description string
+
Knot string
+
RepoAt syntax.ATURI
+
IsStarred bool
+
Stats db.RepoStats
+
Roles RolesInRepo
+
Source *db.Repo
+
SourceHandle string
+
DisableFork bool
}
type RolesInRepo struct {
···
return slices.Contains(r.Roles, "repo:settings")
}
+
func (r RolesInRepo) CollaboratorInviteAllowed() bool {
+
return slices.Contains(r.Roles, "repo:invite")
+
}
+
+
func (r RolesInRepo) RepoDeleteAllowed() bool {
+
return slices.Contains(r.Roles, "repo:delete")
+
}
+
func (r RolesInRepo) IsOwner() bool {
return slices.Contains(r.Roles, "repo:owner")
}
···
func (r RepoInfo) GetTabs() [][]string {
tabs := [][]string{
-
{"overview", "/"},
-
{"issues", "/issues"},
-
{"pulls", "/pulls"},
+
{"overview", "/", "square-chart-gantt"},
+
{"issues", "/issues", "circle-dot"},
+
{"pulls", "/pulls", "git-pull-request"},
}
if r.Roles.SettingsAllowed() {
-
tabs = append(tabs, []string{"settings", "/settings"})
+
tabs = append(tabs, []string{"settings", "/settings", "cog"})
}
return tabs
···
ext := filepath.Ext(params.ReadmeFileName)
switch ext {
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
-
htmlString = renderMarkdown(params.Readme)
+
htmlString = markup.RenderMarkdown(params.Readme)
params.Raw = false
params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
default:
···
}
type RepoCommitParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
types.RepoCommitResponse
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
EmailToDidOrHandle map[string]string
+
+
types.RepoCommitResponse
}
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
}
type RepoBlobParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
BreadCrumbs [][]string
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
BreadCrumbs [][]string
+
ShowRendered bool
+
RenderToggle bool
+
RenderedContents template.HTML
types.RepoBlobResponse
}
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
-
style := styles.Get("bw")
-
b := style.Builder()
-
b.Add(chroma.LiteralString, "noitalic")
-
style, _ = b.Build()
+
var style *chroma.Style = styles.Get("catpuccin-latte")
+
+
if params.ShowRendered {
+
switch markup.GetFormat(params.Path) {
+
case markup.FormatMarkdown:
+
params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents))
+
}
+
}
if params.Lines < 5000 {
c := params.Contents
formatter := chromahtml.New(
-
chromahtml.InlineCode(true),
+
chromahtml.InlineCode(false),
chromahtml.WithLineNumbers(true),
chromahtml.WithLinkableLineNumbers(true, "L"),
chromahtml.Standalone(false),
+
chromahtml.WithClasses(true),
)
lexer := lexers.Get(filepath.Base(params.Path))
···
RepoInfo RepoInfo
Collaborators []Collaborator
Active string
+
Branches []string
+
DefaultBranch string
// TODO: use repoinfo.roles
IsCollaboratorInviteAllowed bool
}
···
}
type RepoIssuesParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
Issues []db.Issue
-
DidHandleMap map[string]string
-
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
Issues []db.Issue
+
DidHandleMap map[string]string
+
Page pagination.Page
FilteringByOpen bool
}
···
return p.executeRepo("repo/issues/new", w, params)
}
+
type EditIssueCommentParams struct {
+
LoggedInUser *auth.User
+
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 *auth.User
+
DidHandleMap map[string]string
+
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 {
LoggedInUser *auth.User
RepoInfo RepoInfo
···
type RepoPullsParams struct {
LoggedInUser *auth.User
RepoInfo RepoInfo
-
Pulls []db.Pull
+
Pulls []*db.Pull
Active string
DidHandleMap map[string]string
FilteringBy db.PullState
···
return p.executeRepo("repo/pulls/pulls", w, params)
}
-
type RepoSinglePullParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
DidHandleMap map[string]string
+
type ResubmitResult uint64
-
Pull db.Pull
-
MergeCheck types.MergeCheckResponse
+
const (
+
ShouldResubmit ResubmitResult = iota
+
ShouldNotResubmit
+
Unknown
+
)
+
+
func (r ResubmitResult) Yes() bool {
+
return r == ShouldResubmit
+
}
+
func (r ResubmitResult) No() bool {
+
return r == ShouldNotResubmit
+
}
+
func (r ResubmitResult) Unknown() bool {
+
return r == Unknown
+
}
+
+
type RepoSinglePullParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
DidHandleMap map[string]string
+
Pull *db.Pull
+
MergeCheck types.MergeCheckResponse
+
ResubmitCheck ResubmitResult
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
DidHandleMap map[string]string
RepoInfo RepoInfo
Pull *db.Pull
-
Diff types.NiceDiff
+
Diff *types.NiceDiff
Round int
Submission *db.PullSubmission
}
···
return p.execute("repo/pulls/patch", w, params)
}
+
type RepoPullInterdiffParams struct {
+
LoggedInUser *auth.User
+
DidHandleMap map[string]string
+
RepoInfo RepoInfo
+
Pull *db.Pull
+
Round int
+
Interdiff *patchutil.InterdiffResult
+
}
+
+
// this name is a mouthful
+
func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
+
return p.execute("repo/pulls/interdiff", w, params)
+
}
+
+
type PullPatchUploadParams struct {
+
RepoInfo RepoInfo
+
}
+
+
func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
+
return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
+
}
+
+
type PullCompareBranchesParams struct {
+
RepoInfo RepoInfo
+
Branches []types.Branch
+
}
+
+
func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
+
return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
+
}
+
+
type PullCompareForkParams struct {
+
RepoInfo RepoInfo
+
Forks []db.Repo
+
}
+
+
func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
+
return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
+
}
+
+
type PullCompareForkBranchesParams struct {
+
RepoInfo RepoInfo
+
SourceBranches []types.Branch
+
TargetBranches []types.Branch
+
}
+
+
func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
+
return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
+
}
+
type PullResubmitParams struct {
LoggedInUser *auth.User
RepoInfo RepoInfo
···
}
func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
-
return p.executePlain("fragments/pullResubmit", w, params)
+
return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
}
type PullActionsParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Pull *db.Pull
-
RoundNumber int
-
MergeCheck types.MergeCheckResponse
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Pull *db.Pull
+
RoundNumber int
+
MergeCheck types.MergeCheckResponse
+
ResubmitCheck ResubmitResult
}
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
-
return p.executePlain("fragments/pullActions", w, params)
+
return p.executePlain("repo/pulls/fragments/pullActions", w, params)
}
type PullNewCommentParams struct {
···
}
func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
-
return p.executePlain("fragments/pullNewComment", w, params)
+
return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
}
func (p *Pages) Static() http.Handler {
+
if p.dev {
+
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
+
}
+
sub, err := fs.Sub(Files, "static")
if err != nil {
log.Fatalf("no static dir found? that's crazy: %v", err)
···
func Cache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
if strings.HasSuffix(r.URL.Path, ".css") {
+
path := strings.Split(r.URL.Path, "?")[0]
+
+
if strings.HasSuffix(path, ".css") {
// on day for css files
w.Header().Set("Cache-Control", "public, max-age=86400")
} else {
···
}
h.ServeHTTP(w, r)
})
+
}
+
+
func CssContentHash() string {
+
cssFile, err := Files.Open("static/tw.css")
+
if err != nil {
+
log.Printf("Error opening CSS file: %v", err)
+
return ""
+
}
+
defer cssFile.Close()
+
+
hasher := sha256.New()
+
if _, err := io.Copy(hasher, cssFile); err != nil {
+
log.Printf("Error hashing CSS file: %v", err)
+
return ""
+
}
+
+
return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
}
func (p *Pages) Error500(w io.Writer) error {
-112
appview/pages/templates/fragments/diff.html
···
-
{{ define "fragments/diff" }}
-
{{ $repo := index . 0 }}
-
{{ $diff := index . 1 }}
-
{{ $commit := $diff.Commit }}
-
{{ $stat := $diff.Stat }}
-
{{ $diff := $diff.Diff }}
-
-
{{ $this := $commit.This }}
-
{{ $parent := $commit.Parent }}
-
-
{{ $last := sub (len $diff) 1 }}
-
{{ range $idx, $hunk := $diff }}
-
{{ with $hunk }}
-
<section class="mt-6 border border-gray-200 w-full mx-auto rounded bg-white drop-shadow-sm">
-
<div id="file-{{ .Name.New }}">
-
<div id="diff-file">
-
<details open>
-
<summary class="list-none cursor-pointer sticky top-0">
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white flex justify-between">
-
<div id="left-side-items" class="p-2 flex gap-2 items-center">
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
-
-
{{ if .IsNew }}
-
<span class="bg-green-100 text-green-700 {{ $markerstyle }}">ADDED</span>
-
{{ else if .IsDelete }}
-
<span class="bg-red-100 text-red-700 {{ $markerstyle }}">DELETED</span>
-
{{ else if .IsCopy }}
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">COPIED</span>
-
{{ else if .IsRename }}
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">RENAMED</span>
-
{{ else }}
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">MODIFIED</span>
-
{{ end }}
-
-
{{ if .IsDelete }}
-
<a {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
-
{{ .Name.Old }}
-
</a>
-
{{ else if (or .IsCopy .IsRename) }}
-
<a {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
-
{{ .Name.Old }}
-
</a>
-
{{ i "arrow-right" "w-4 h-4" }}
-
<a {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
-
{{ .Name.New }}
-
</a>
-
{{ else }}
-
<a {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
-
{{ .Name.New }}
-
</a>
-
{{ end }}
-
</div>
-
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 rounded" }}
-
<div id="right-side-items" class="p-2 flex items-center">
-
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
-
{{ if gt $idx 0 }}
-
{{ $prev := index $diff (sub $idx 1) }}
-
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
-
{{ end }}
-
-
{{ if lt $idx $last }}
-
{{ $next := index $diff (add $idx 1) }}
-
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
-
{{ end }}
-
</div>
-
-
</div>
-
</summary>
-
-
<div class="transition-all duration-700 ease-in-out">
-
{{ if .IsDelete }}
-
<p class="text-center text-gray-400 p-4">
-
This file has been deleted in this commit.
-
</p>
-
{{ else }}
-
{{ if .IsBinary }}
-
<p class="text-center text-gray-400 p-4">
-
This is a binary file and will not be displayed.
-
</p>
-
{{ else }}
-
<pre class="overflow-auto">
-
{{- range .TextFragments -}}
-
<div class="bg-gray-100 text-gray-500 select-none">{{ .Header }}</div>
-
{{- range .Lines -}}
-
{{- if eq .Op.String "+" -}}
-
<div class="bg-green-100 text-green-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
-
{{- end -}}
-
-
{{- if eq .Op.String "-" -}}
-
<div class="bg-red-100 text-red-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
-
{{- end -}}
-
-
{{- if eq .Op.String " " -}}
-
<div class="bg-white text-gray-500 px"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
-
{{- end -}}
-
-
{{- end -}}
-
{{- end -}}
-
</pre>
-
{{- end -}}
-
{{ end }}
-
</div>
-
-
</details>
-
-
</div>
-
</div>
-
</section>
-
{{ end }}
-
{{ end }}
-
{{ end }}
-11
appview/pages/templates/fragments/editRepoDescription.html
···
-
{{ define "fragments/editRepoDescription" }}
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
-
<button type="submit" class="btn p-2 flex items-center gap-2 no-underline text-sm">
-
{{ i "check" "w-3 h-3" }} save
-
</button>
-
<button type="button" class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
-
{{ i "x" "w-3 h-3" }} cancel
-
</button>
-
</form>
-
{{ end }}
-17
appview/pages/templates/fragments/follow.html
···
-
{{ define "fragments/follow" }}
-
<button id="followBtn"
-
class="btn mt-2 w-full"
-
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}
-
hx-post="/follow?subject={{.UserDid}}"
-
{{ else }}
-
hx-delete="/follow?subject={{.UserDid}}"
-
{{ end }}
-
-
hx-trigger="click"
-
hx-target="#followBtn"
-
hx-swap="outerHTML"
-
>
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
-
</button>
-
{{ end }}
-72
appview/pages/templates/fragments/pullActions.html
···
-
{{ define "fragments/pullActions" }}
-
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
-
{{ $roundNumber := .RoundNumber }}
-
-
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
-
{{ $isMerged := .Pull.State.IsMerged }}
-
{{ $isClosed := .Pull.State.IsClosed }}
-
{{ $isOpen := .Pull.State.IsOpen }}
-
{{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }}
-
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
-
{{ $isLastRound := eq $roundNumber $lastIdx }}
-
<div class="relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
-
<button
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
-
hx-target="#actions-{{$roundNumber}}"
-
hx-swap="outerHtml"
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
-
{{ i "message-square-plus" "w-4 h-4" }}
-
<span>comment</span>
-
</button>
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
-
{{ $disabled := "" }}
-
{{ if $isConflicted }}
-
{{ $disabled = "disabled" }}
-
{{ end }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
-
hx-swap="none"
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
-
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
-
{{ i "git-merge" "w-4 h-4" }}
-
<span>merge</span>
-
</button>
-
{{ end }}
-
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
-
<button
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
hx-target="#actions-{{$roundNumber}}"
-
hx-swap="outerHtml"
-
class="btn p-2 flex items-center gap-2">
-
{{ i "rotate-ccw" "w-4 h-4" }}
-
<span>resubmit</span>
-
</button>
-
{{ end }}
-
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2">
-
{{ i "ban" "w-4 h-4" }}
-
<span>close</span>
-
</button>
-
{{ end }}
-
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2">
-
{{ i "circle-dot" "w-4 h-4" }}
-
<span>reopen</span>
-
</button>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
-
-32
appview/pages/templates/fragments/pullNewComment.html
···
-
{{ define "fragments/pullNewComment" }}
-
<div
-
id="pull-comment-card-{{ .RoundNumber }}"
-
class="bg-white rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
-
<div class="text-sm text-gray-500">
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
-
</div>
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
-
hx-swap="none"
-
class="w-full flex flex-wrap gap-2">
-
<textarea
-
name="body"
-
class="w-full p-2 rounded border border-gray-200"
-
placeholder="Add to the discussion..."></textarea>
-
<button type="submit" class="btn flex items-center gap-2">
-
{{ i "message-square" "w-4 h-4" }} comment
-
</button>
-
<button
-
type="button"
-
class="btn flex items-center gap-2"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
-
hx-swap="outerHTML"
-
hx-target="#pull-comment-card-{{ .RoundNumber }}">
-
{{ i "x" "w-4 h-4" }}
-
<span>cancel</span>
-
</button>
-
<div id="pull-comment"></div>
-
</form>
-
</div>
-
{{ end }}
-
-52
appview/pages/templates/fragments/pullResubmit.html
···
-
{{ define "fragments/pullResubmit" }}
-
<div
-
id="resubmit-pull-card"
-
class="rounded relative border bg-amber-50 border-amber-200 px-6 py-2">
-
-
<div class="flex items-center gap-2 text-amber-500">
-
{{ i "pencil" "w-4 h-4" }}
-
<span class="font-medium">resubmit your patch</span>
-
</div>
-
-
<div class="mt-2 text-sm text-gray-700">
-
You can update this patch to address any reviews.
-
This will begin a new round of reviews,
-
but you'll still be able to view your previous submissions and feedback.
-
</div>
-
-
<div class="mt-4 flex flex-col">
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
hx-swap="none"
-
class="w-full flex flex-wrap gap-2">
-
<textarea
-
name="patch"
-
class="w-full p-2 mb-2 rounded border border-gray-200"
-
placeholder="Paste your updated patch here."
-
rows="15"
-
>{{.Pull.LatestPatch}}</textarea>
-
<button
-
type="submit"
-
class="btn flex items-center gap-2"
-
{{ if or .Pull.State.IsClosed }}
-
disabled
-
{{ end }}>
-
{{ i "rotate-ccw" "w-4 h-4" }}
-
<span>resubmit</span>
-
</button>
-
<button
-
type="button"
-
class="btn flex items-center gap-2"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
-
hx-swap="outerHTML"
-
hx-target="#resubmit-pull-card">
-
{{ i "x" "w-4 h-4" }}
-
<span>cancel</span>
-
</button>
-
</form>
-
-
<div id="resubmit-error" class="error"></div>
-
<div id="resubmit-success" class="success"></div>
-
</div>
-
</div>
-
{{ end }}
-15
appview/pages/templates/fragments/repoDescription.html
···
-
{{ define "fragments/repoDescription" }}
-
<span id="repo-description" class="flex flex-wrap items-center gap-2" hx-target="this" hx-swap="outerHTML">
-
{{ if .RepoInfo.Description }}
-
{{ .RepoInfo.Description }}
-
{{ else }}
-
<span class="italic">this repo has no description</span>
-
{{ end }}
-
-
{{ if .RepoInfo.Roles.IsOwner }}
-
<button class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
-
{{ i "pencil" "w-3 h-3" }} edit
-
</button>
-
{{ end }}
-
</span>
-
{{ end }}
-28
appview/pages/templates/fragments/star.html
···
-
{{ define "fragments/star" }}
-
<button id="starBtn"
-
class="text-sm disabled:opacity-50 disabled:cursor-not-allowed"
-
-
{{ if .IsStarred }}
-
hx-delete="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}"
-
{{ else }}
-
hx-post="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}"
-
{{ end }}
-
-
hx-trigger="click"
-
hx-target="#starBtn"
-
hx-swap="outerHTML"
-
hx-disabled-elt="#starBtn"
-
>
-
<div class="flex gap-2 items-center">
-
{{ if .IsStarred }}
-
{{ i "star" "w-3 h-3 fill-current" }}
-
{{ else }}
-
{{ i "star" "w-3 h-3" }}
-
{{ end }}
-
<span>
-
{{ .Stats.StarCount }}
-
</span>
-
</div>
-
</button>
-
{{ end }}
-
+92 -34
appview/pages/templates/knot.html
···
-
{{define "title"}}{{ .Registration.Domain }}{{end}}
+
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
-
{{define "content"}}
-
<h1>{{.Registration.Domain}}</h1>
-
<p>
-
<code>
-
opened by: {{.Registration.ByDid}}
-
{{ if eq $.LoggedInUser.Did $.Registration.ByDid }}
-
(you)
-
{{ end }}
-
</code><br>
-
<code>on: {{.Registration.Created}}</code><br>
-
{{ if .Registration.Registered }}
-
<code>registered on: {{.Registration.Registered}}</code>
-
{{ else }}
-
<code>pending registration</code>
-
<button class="btn my-2" hx-post="/knots/{{.Domain}}/init" hx-swap="none">initialize</button>
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</p>
+
</div>
+
+
<div class="flex flex-col">
+
{{ block "registration-info" . }} {{ end }}
+
{{ block "members" . }} {{ end }}
+
{{ block "add-member" . }} {{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "registration-info" }}
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
+
<dt class="font-bold">opened by</dt>
+
<dd>
+
<span>
+
{{ index $.DidHandleMap .Registration.ByDid }} <span class="text-gray-500 dark:text-gray-400 font-mono">{{ .Registration.ByDid }}</span>
+
</span>
+
{{ if eq $.LoggedInUser.Did $.Registration.ByDid }}
+
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded ml-2">you</span>
{{ end }}
-
</p>
-
+
</dd>
+
+
<dt class="font-bold">opened</dt>
+
<dd>{{ .Registration.Created | timeFmt }}</dd>
+
{{ if .Registration.Registered }}
-
<h3> members </h3>
-
<ol>
-
{{ range $.Members }}
-
<li><a href="/{{.}}">{{.}}</a></li>
+
<dt class="font-bold">registered</dt>
+
<dd>{{ .Registration.Registered | timeFmt }}</dd>
{{ else }}
-
<p>no members</p>
+
<dt class="font-bold">status</dt>
+
<dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block">
+
Pending Registration
+
</dd>
{{ end }}
-
{{ end }}
-
</ol>
+
</dl>
+
+
{{ if not .Registration.Registered }}
+
<div class="mt-4">
+
<button
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
+
hx-post="/knots/{{.Domain}}/init"
+
hx-swap="none">
+
Initialize Registration
+
</button>
+
</div>
+
{{ end }}
+
</section>
+
{{ end }}
+
+
{{ define "members" }}
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">members</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
{{ if .Registration.Registered }}
+
<div id="member-list" class="flex flex-col gap-4">
+
{{ range $.Members }}
+
<div class="inline-flex items-center gap-4">
+
{{ i "user" "w-4 h-4 dark:text-gray-300" }}
+
<a href="/{{index $.DidHandleMap .}}" class="text-gray-900 dark:text-white">{{index $.DidHandleMap .}}
+
<span class="text-gray-500 dark:text-gray-400 font-mono">{{.}}</span>
+
</a>
+
</div>
+
{{ else }}
+
<p class="text-gray-500 dark:text-gray-400">No members have been added yet.</p>
+
{{ end }}
+
</div>
+
{{ else }}
+
<p class="text-gray-500 dark:text-gray-400">Members can be added after registration is complete.</p>
+
{{ end }}
+
</section>
+
{{ end }}
-
{{ if $.IsOwner }}
-
<h3>add member</h3>
-
<form hx-put="/knots/{{.Registration.Domain}}/member">
-
<label for="member">did or handle:</label>
-
<input type="text" id="member" name="member" required>
-
<button class="btn my-2" type="text">add member</button>
-
</form>
-
{{ end }}
-
{{end}}
+
{{ define "add-member" }}
+
{{ if $.IsOwner }}
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">add member</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<form
+
hx-put="/knots/{{.Registration.Domain}}/member"
+
class="max-w-2xl space-y-4">
+
<input
+
type="text"
+
id="member"
+
name="member"
+
placeholder="did or handle"
+
required
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
+
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add member</button>
+
+
<div id="add-member-error" class="error dark:text-red-400"></div>
+
</form>
+
</section>
+
{{ end }}
+
{{ end }}
+79 -84
appview/pages/templates/knots.html
···
{{ define "title" }}knots{{ end }}
-
{{ define "content" }}
-
<h1>knots</h1>
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Knots</p>
+
</div>
+
<div class="flex flex-col">
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p>
+
<form
+
hx-post="/knots/key"
+
class="max-w-2xl mb-8 space-y-4"
+
>
+
<input
+
type="text"
+
id="domain"
+
name="domain"
+
placeholder="knot.example.com"
+
required
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
+
/>
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">
+
generate key
+
</button>
+
<div id="settings-knots-error" class="error dark:text-red-400"></div>
+
</form>
+
</section>
-
<section class="mb-12">
-
<h2 class="text-2xl mb-4">register a knot</h2>
-
<form hx-post="/knots/key" class="flex gap-4 items-end">
-
<div>
-
<label for="domain"
-
>Generate a key to start your knot with.</label
-
>
-
<input
-
type="text"
-
id="domain"
-
name="domain"
-
placeholder="knot.example.com"
-
required
-
/>
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<div id="knots-list" class="flex flex-col gap-6 mb-8">
+
{{ range .Registrations }}
+
{{ if .Registered }}
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
+
<div class="flex flex-col gap-1">
+
<div class="inline-flex items-center gap-4">
+
{{ i "git-branch" "w-3 h-3 dark:text-gray-300" }}
+
<a href="/knots/{{ .Domain }}">
+
<p class="font-bold dark:text-white">{{ .Domain }}</p>
+
</a>
+
</div>
+
<p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p>
</div>
-
<button class="btn" type="submit">generate key</button>
-
</form>
+
</div>
+
{{ end }}
+
{{ else }}
+
<p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p>
+
{{ end }}
+
</div>
</section>
-
<section class="mb-12">
-
<h3 class="text-xl font-semibold mb-4">my knots</h3>
-
<p>This is a list of knots</p>
-
<ul id="my-knots" class="space-y-6">
-
{{ range .Registrations }}
-
{{ if .Registered }}
-
<li class="border rounded p-4 flex flex-col gap-2">
-
<div>
-
<a href="/knots/{{ .Domain }}" class="font-semibold"
-
>{{ .Domain }}</a
-
>
-
</div>
-
<div class="text-gray-600">
-
Owned by
-
{{ .ByDid }}
-
</div>
-
<div class="text-gray-600">
-
Registered on
-
{{ .Registered }}
-
</div>
-
</li>
-
{{ end }}
-
{{ else }}
-
<p class="text-gray-600">you don't have any knots yet</p>
-
{{ end }}
-
</ul>
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<div id="pending-knots-list" class="flex flex-col gap-6 mb-8">
+
{{ range .Registrations }}
+
{{ if not .Registered }}
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
+
<div class="flex flex-col gap-1">
+
<div class="inline-flex items-center gap-4">
+
<p class="font-bold dark:text-white">{{ .Domain }}</p>
+
<div class="inline-flex items-center gap-1">
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">
+
pending
+
</span>
+
</div>
+
</div>
+
<p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p>
+
</div>
+
<div class="flex gap-2 items-center">
+
<button
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 gap-2"
+
hx-post="/knots/{{ .Domain }}/init">
+
{{ i "square-play" "w-5 h-5" }}
+
<span class="hidden md:inline">initialize</span>
+
</button>
+
</div>
+
</div>
+
{{ end }}
+
{{ else }}
+
<p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p>
+
{{ end }}
+
</div>
</section>
-
-
<section>
-
<h3 class="text-xl font-semibold mb-4">pending registrations</h3>
-
<ul id="pending-registrations" class="space-y-6">
-
{{ range .Registrations }}
-
{{ if not .Registered }}
-
<li class="border rounded p-4 flex flex-col gap-2">
-
<div>
-
<a
-
href="/knots/{{ .Domain }}"
-
class="text-blue-600 hover:underline"
-
>{{ .Domain }}</a
-
>
-
</div>
-
<div class="text-gray-600">
-
Opened by
-
{{ .ByDid }}
-
</div>
-
<div class="text-gray-600">
-
Created on
-
{{ .Created }}
-
</div>
-
<div class="flex items-center gap-4 mt-2">
-
<span class="text-amber-600"
-
>pending registration</span
-
>
-
<button
-
class="btn"
-
hx-post="/knots/{{ .Domain }}/init"
-
>
-
initialize
-
</button>
-
</div>
-
</li>
-
{{ end }}
-
{{ else }}
-
<p class="text-gray-600">no registrations yet</p>
-
{{ end }}
-
</ul>
-
</section>
+
</div>
{{ end }}
+5 -6
appview/pages/templates/layouts/base.html
···
{{ define "layouts/base" }}
<!doctype html>
-
<html lang="en">
+
<html lang="en" class="dark:bg-gray-900">
<head>
<meta charset="UTF-8" />
<meta
···
content="width=device-width, initial-scale=1.0"
/>
<script src="/static/htmx.min.js"></script>
-
<link href="/static/tw.css" rel="stylesheet" type="text/css" />
-
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
{{ block "extrameta" . }}{{ end }}
</head>
-
<body class="bg-slate-100">
-
<div class="container mx-auto px-1 pt-4 min-h-screen flex flex-col">
-
<header style="z-index: 5">
+
<body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
+
<div class="container mx-auto px-1 md:pt-4 min-h-screen flex flex-col">
+
<header style="z-index: 20">
{{ block "topbar" . }}
{{ template "layouts/topbar" . }}
{{ end }}
+2 -2
appview/pages/templates/layouts/footer.html
···
{{ define "layouts/footer" }}
-
<div class="w-full p-4 bg-white rounded-t">
-
<div class="container mx-auto text-center text-gray-600 text-sm">
+
<div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t">
+
<div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm">
<span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppili.bsky.social">@oppili.bsky.social</a> and <a href="/@icyphox.sh">@icyphox.sh</a>
</div>
</div>
+34 -19
appview/pages/templates/layouts/repobase.html
···
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
{{ define "content" }}
-
<section id="repo-header" class="mb-4 py-2 px-6">
-
<p class="text-lg">
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
-
<span class="select-none">/</span>
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
-
<span class="ml-3">
-
{{ template "fragments/star" .RepoInfo }}
-
</span>
-
</p>
-
{{ template "fragments/repoDescription" . }}
-
</section>
+
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
+
{{ if .RepoInfo.Source }}
+
<p class="text-sm">
+
<div class="flex items-center">
+
{{ i "git-fork" "w-3 h-3 mr-1"}}
+
forked from
+
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
+
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
+
</div>
+
</p>
+
{{ end }}
+
<div class="text-lg flex items-center justify-between">
+
<div>
+
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
+
<span class="select-none">/</span>
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
+
</div>
+
+
{{ template "repo/fragments/repoActions" .RepoInfo }}
+
</div>
+
{{ template "repo/fragments/repoDescription" . }}
+
</section>
<section class="min-h-screen flex flex-col drop-shadow-sm">
<nav class="w-full pl-4 overflow-auto">
<div class="flex z-60">
-
{{ $activeTabStyles := "-mb-px bg-white" }}
+
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
{{ $tabs := .RepoInfo.GetTabs }}
{{ $tabmeta := .RepoInfo.TabMetadata }}
{{ range $item := $tabs }}
{{ $key := index $item 0 }}
{{ $value := index $item 1 }}
+
{{ $icon := index $item 2 }}
{{ $meta := index $tabmeta $key }}
<a
href="/{{ $.RepoInfo.FullName }}{{ $value }}"
···
hx-boost="true"
>
<div
-
class="px-4 py-1 mr-1 text-black min-w-[80px] text-center relative rounded-t whitespace-nowrap
+
class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap
{{ if eq $.Active $key }}
{{ $activeTabStyles }}
{{ else }}
-
group-hover:bg-gray-200
+
group-hover:bg-gray-200 dark:group-hover:bg-gray-700
{{ end }}
"
>
-
{{ $key }}
-
{{ if not (isNil $meta) }}
-
<span class="bg-gray-200 rounded py-1/2 px-1 text-sm">{{ $meta }}</span>
-
{{ end }}
+
<span class="flex items-center justify-center">
+
{{ i $icon "w-4 h-4 mr-2" }}
+
{{ $key }}
+
{{ if not (isNil $meta) }}
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
+
{{ end }}
+
</span>
</div>
</a>
{{ end }}
</div>
</nav>
<section
-
class="bg-white p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm"
+
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
>
{{ block "repoContent" . }}{{ end }}
</section>
+8 -3
appview/pages/templates/layouts/topbar.html
···
{{ define "layouts/topbar" }}
-
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white drop-shadow-sm">
+
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="container flex justify-between p-0">
<div id="left-items">
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
···
{{ didOrHandle .Did .Handle }}
</summary>
<div
-
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white border border-gray-200"
+
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
>
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
<a href="/knots">knots</a>
<a href="/settings">settings</a>
-
<a href="/logout" class="text-red-400 hover:text-red-700">logout</a>
+
<a href="#"
+
hx-post="/logout"
+
hx-swap="none"
+
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
+
logout
+
</a>
</div>
</details>
{{ end }}
+27 -23
appview/pages/templates/repo/blob.html
···
{{ $lines := split .Contents }}
{{ $tot_lines := len $lines }}
{{ $tot_chars := len (printf "%d" $tot_lines) }}
-
{{ $code_number_style := "text-gray-400 left-0 bg-white text-right mr-6 select-none inline-block w-12" }}
+
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
{{ $linkstyle := "no-underline hover:underline" }}
-
<div class="pb-2 text-base">
-
<div class="flex justify-between">
-
<div id="breadcrumbs">
+
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
+
<div class="flex flex-col md:flex-row md:justify-between gap-2">
+
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
{{ range $idx, $value := .BreadCrumbs }}
{{ if ne $idx (sub (len $.BreadCrumbs) 1) }}
<a
href="{{ index . 1 }}"
-
class="text-bold text-gray-500 {{ $linkstyle }}"
+
class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}"
>{{ index . 0 }}</a
>
/
{{ else }}
-
<span class="text-bold text-gray-500"
+
<span class="text-bold text-black dark:text-white"
>{{ index . 0 }}</span
>
{{ end }}
{{ end }}
</div>
-
<div id="file-info" class="text-gray-500 text-xs">
-
{{ .Lines }} lines
-
<span class="select-none px-2 [&:before]:content-['ยท']"></span>
-
{{ byteFmt .SizeHint }}
+
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
+
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ .Lines }} lines</span>
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ byteFmt .SizeHint }}</span>
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/raw/{{ .Path }}">view raw</a>
+
{{ if .RenderToggle }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<a
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
+
hx-boost="true"
+
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
+
{{ end }}
</div>
</div>
</div>
{{ if .IsBinary }}
-
<p class="text-center text-gray-400">
+
<p class="text-center text-gray-400 dark:text-gray-500">
This is a binary file and will not be displayed.
</p>
{{ else }}
-
<div class="overflow-auto relative text-ellipsis">
-
{{ range $idx, $line := $lines }}
-
{{ $linenr := add $idx 1 }}
-
<div class="flex">
-
<a href="#L{{ $linenr }}" id="L{{ $linenr }}" class="no-underline peer">
-
<span class="{{ $code_number_style }}"
-
style="min-width: {{ $tot_chars }}ch;">
-
{{ $linenr }}
-
</span>
-
</a>
-
<div class="whitespace-pre peer-target:bg-yellow-200">{{ $line | escapeHtml }}</div>
-
</div>
+
<div class="overflow-auto relative">
+
{{ if .ShowRendered }}
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
+
{{ else }}
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
{{ end }}
</div>
{{ end }}
+10 -27
appview/pages/templates/repo/commit.html
···
{{ $repo := .RepoInfo.FullName }}
{{ $commit := .Diff.Commit }}
-
{{ $stat := .Diff.Stat }}
-
{{ $diff := .Diff.Diff }}
-
<section class="commit">
+
<section class="commit dark:text-white">
<div id="commit-message">
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
<div>
<p class="pb-2">{{ index $messageParts 0 }}</p>
{{ if gt (len $messageParts) 1 }}
-
<p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (unwrapText (index $messageParts 1)) }}</p>
+
<p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p>
{{ end }}
</div>
</div>
<div class="flex items-center">
-
<p class="text-sm text-gray-500">
+
<p class="text-sm text-gray-500 dark:text-gray-300">
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
{{ if $didOrHandle }}
-
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500">{{ $didOrHandle }}</a>
+
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
{{ else }}
-
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500">{{ $commit.Author.Name }}</a>
+
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
{{ end }}
<span class="px-1 select-none before:content-['\00B7']"></span>
{{ timeFmt $commit.Author.When }}
<span class="px-1 select-none before:content-['\00B7']"></span>
-
<span>{{ $stat.FilesChanged }}</span> files <span class="font-mono">(+{{ $stat.Insertions }}, -{{ $stat.Deletions }})</span>
-
<span class="px-1 select-none before:content-['\00B7']"></span>
</p>
-
<p class="flex items-center text-sm text-gray-500">
-
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.This 0 8 }}</a>
+
<p class="flex items-center text-sm text-gray-500 dark:text-gray-300">
+
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
{{ if $commit.Parent }}
{{ i "arrow-left" "w-3 h-3 mx-1" }}
-
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.Parent 0 8 }}</a>
+
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
{{ end }}
</p>
</div>
-
-
<div class="diff-stat">
-
<br>
-
<strong class="text-sm uppercase mb-4">Changed files</strong>
-
{{ range $diff }}
-
<ul>
-
{{ if .IsDelete }}
-
<li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li>
-
{{ else }}
-
<li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li>
-
{{ end }}
-
</ul>
-
{{ end }}
-
</div>
+
</section>
{{end}}
{{ define "repoAfter" }}
-
{{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }}
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
{{end}}
+2 -21
appview/pages/templates/repo/empty.html
···
{{ define "repoContent" }}
<main>
-
<p class="text-center pt-5 text-gray-400">
+
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
This is an empty repository. Push some commits here.
</p>
</main>
{{ end }}
{{ define "repoAfter" }}
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto">
-
<strong>push</strong>
-
<div class="py-2">
-
<code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
-
</div>
-
<strong>clone</strong>
-
-
-
<div class="flex flex-col gap-2">
-
<div class="pt-2 flex flex-row gap-2">
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span>
-
<code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
-
</div>
-
<div class="pt-2 flex flex-row gap-2">
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span><code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
-
</div>
-
</div>
-
<p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p>
-
</section>
-
+
{{ template "repo/fragments/cloneInstructions" . }}
{{ end }}
+38
appview/pages/templates/repo/fork.html
···
+
{{ define "title" }}fork &middot; {{ .RepoInfo.FullName }}{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p>
+
</div>
+
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none">
+
<fieldset class="space-y-3">
+
<legend class="dark:text-white">Select a knot to fork into</legend>
+
<div class="space-y-2">
+
<div class="flex flex-col">
+
{{ range .Knots }}
+
<div class="flex items-center">
+
<input
+
type="radio"
+
name="knot"
+
value="{{ . }}"
+
class="mr-2"
+
id="domain-{{ . }}"
+
/>
+
<span class="dark:text-white">{{ . }}</span>
+
</div>
+
{{ else }}
+
<p class="dark:text-white">No knots available.</p>
+
{{ end }}
+
</div>
+
</div>
+
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
+
</fieldset>
+
+
<div class="space-y-2">
+
<button type="submit" class="btn">fork repo</button>
+
<div id="repo" class="error"></div>
+
</div>
+
</form>
+
</div>
+
{{ end }}
+51
appview/pages/templates/repo/fragments/cloneInstructions.html
···
+
{{ define "repo/fragments/cloneInstructions" }}
+
<section
+
class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
+
>
+
<div class="flex flex-col gap-2">
+
<strong>push</strong>
+
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
+
<code class="dark:text-gray-100"
+
>git remote add origin
+
git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
+
>
+
</div>
+
</div>
+
+
<div class="flex flex-col gap-2">
+
<strong>clone</strong>
+
<div class="md:pl-4 flex flex-col gap-2">
+
<div class="flex items-center gap-3">
+
<span
+
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
+
>HTTP</span
+
>
+
<div class="overflow-x-auto whitespace-nowrap flex-1">
+
<code class="dark:text-gray-100"
+
>git clone
+
https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code
+
>
+
</div>
+
</div>
+
+
<div class="flex items-center gap-3">
+
<span
+
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
+
>SSH</span
+
>
+
<div class="overflow-x-auto whitespace-nowrap flex-1">
+
<code class="dark:text-gray-100"
+
>git clone
+
git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
+
>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<p class="py-2 text-gray-500 dark:text-gray-400">
+
Note that for self-hosted knots, clone URLs may be different based
+
on your setup.
+
</p>
+
</section>
+
{{ end }}
+163
appview/pages/templates/repo/fragments/diff.html
···
+
{{ define "repo/fragments/diff" }}
+
{{ $repo := index . 0 }}
+
{{ $diff := index . 1 }}
+
{{ $commit := $diff.Commit }}
+
{{ $stat := $diff.Stat }}
+
{{ $fileTree := fileTree $diff.ChangedFiles }}
+
{{ $diff := $diff.Diff }}
+
+
{{ $this := $commit.This }}
+
{{ $parent := $commit.Parent }}
+
+
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<div class="diff-stat">
+
<div class="flex gap-2 items-center">
+
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+
{{ block "statPill" $stat }} {{ end }}
+
</div>
+
{{ block "fileTree" $fileTree }} {{ end }}
+
</div>
+
</section>
+
+
{{ $last := sub (len $diff) 1 }}
+
{{ range $idx, $hunk := $diff }}
+
{{ with $hunk }}
+
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<div id="file-{{ .Name.New }}">
+
<div id="diff-file">
+
<details open>
+
<summary class="list-none cursor-pointer sticky top-0">
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+
<div class="flex gap-1 items-center">
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
+
{{ if .IsNew }}
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
+
{{ else if .IsDelete }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
+
{{ else if .IsCopy }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
+
{{ else if .IsRename }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
+
{{ else }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
+
{{ end }}
+
+
{{ block "statPill" .Stats }} {{ end }}
+
</div>
+
+
<div class="flex gap-2 items-center overflow-x-auto">
+
{{ if .IsDelete }}
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
+
{{ .Name.Old }}
+
</a>
+
{{ else if (or .IsCopy .IsRename) }}
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
+
{{ .Name.Old }}
+
</a>
+
{{ i "arrow-right" "w-4 h-4" }}
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
+
{{ .Name.New }}
+
</a>
+
{{ else }}
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
+
{{ .Name.New }}
+
</a>
+
{{ end }}
+
</div>
+
</div>
+
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
+
<div id="right-side-items" class="p-2 flex items-center">
+
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
+
{{ if gt $idx 0 }}
+
{{ $prev := index $diff (sub $idx 1) }}
+
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
+
{{ end }}
+
+
{{ if lt $idx $last }}
+
{{ $next := index $diff (add $idx 1) }}
+
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
+
{{ end }}
+
</div>
+
+
</div>
+
</summary>
+
+
<div class="transition-all duration-700 ease-in-out">
+
{{ if .IsDelete }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This file has been deleted.
+
</p>
+
{{ else if .IsCopy }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This file has been copied.
+
</p>
+
{{ else if .IsBinary }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This is a binary file and will not be displayed.
+
</p>
+
{{ else }}
+
{{ $name := .Name.New }}
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div>
+
{{- $oldStart := .OldPosition -}}
+
{{- $newStart := .NewPosition -}}
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
+
{{- $lineNrSepStyle1 := "" -}}
+
{{- $lineNrSepStyle2 := "pr-2" -}}
+
{{- range .Lines -}}
+
{{- if eq .Op.String "+" -}}
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String "-" -}}
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String " " -}}
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- end -}}
+
{{- end -}}</div></div></pre>
+
{{- end -}}
+
</div>
+
+
</details>
+
+
</div>
+
</div>
+
</section>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "statPill" }}
+
<div class="flex items-center font-mono text-sm">
+
{{ if and .Insertions .Deletions }}
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
+
{{ else if .Insertions }}
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
{{ else if .Deletions }}
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
+
{{ end }}
+
</div>
+
{{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
+
{{ define "repo/fragments/editRepoDescription" }}
+
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
+
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
+
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
+
{{ i "check" "w-3 h-3" }} save
+
</button>
+
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
+
{{ i "x" "w-3 h-3" }} cancel
+
</button>
+
</form>
+
{{ end }}
+27
appview/pages/templates/repo/fragments/filetree.html
···
+
{{ define "fileTree" }}
+
{{ if and .Name .IsDirectory }}
+
<details open>
+
<summary class="cursor-pointer list-none pt-1">
+
<span class="inline-flex items-center gap-2 ">
+
{{ i "folder" "w-3 h-3 fill-current" }}
+
<span class="text-black dark:text-white">{{ .Name }}</span>
+
</span>
+
</summary>
+
<div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700">
+
{{ range $child := .Children }}
+
{{ block "fileTree" $child }} {{ end }}
+
{{ end }}
+
</div>
+
</details>
+
{{ else if .Name }}
+
<div class="flex items-center gap-2 pt-1">
+
{{ i "file" "w-3 h-3" }}
+
<a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
+
</div>
+
{{ else }}
+
{{ range $child := .Children }}
+
{{ block "fileTree" $child }} {{ end }}
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+143
appview/pages/templates/repo/fragments/interdiff.html
···
+
{{ define "repo/fragments/interdiff" }}
+
{{ $repo := index . 0 }}
+
{{ $x := index . 1 }}
+
{{ $fileTree := fileTree $x.AffectedFiles }}
+
{{ $diff := $x.Files }}
+
+
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<div class="diff-stat">
+
<div class="flex gap-2 items-center">
+
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
+
</div>
+
{{ block "fileTree" $fileTree }} {{ end }}
+
</div>
+
</section>
+
+
{{ $last := sub (len $diff) 1 }}
+
{{ range $idx, $hunk := $diff }}
+
{{ with $hunk }}
+
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<div id="file-{{ .Name }}">
+
<div id="diff-file">
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
+
<summary class="list-none cursor-pointer sticky top-0">
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+
<div class="flex gap-1 items-center" style="direction: ltr;">
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
+
{{ if .Status.IsOk }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
+
{{ else if .Status.IsUnchanged }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
+
{{ else if .Status.IsOnlyInOne }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
+
{{ else if .Status.IsOnlyInTwo }}
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
+
{{ else if .Status.IsRebased }}
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
+
{{ else }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
+
{{ end }}
+
</div>
+
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
+
{{ .Name }}
+
</a>
+
</div>
+
</div>
+
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
+
<div id="right-side-items" class="p-2 flex items-center">
+
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
+
{{ if gt $idx 0 }}
+
{{ $prev := index $diff (sub $idx 1) }}
+
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
+
{{ end }}
+
+
{{ if lt $idx $last }}
+
{{ $next := index $diff (add $idx 1) }}
+
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
+
{{ end }}
+
</div>
+
+
</div>
+
</summary>
+
+
<div class="transition-all duration-700 ease-in-out">
+
{{ if .Status.IsUnchanged }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This file has not been changed.
+
</p>
+
{{ else if .Status.IsRebased }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This patch was likely rebased, as context lines do not match.
+
</p>
+
{{ else if .Status.IsError }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
Failed to calculate interdiff for this file.
+
</p>
+
{{ else }}
+
{{ $name := .Name }}
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div>
+
{{- $oldStart := .OldPosition -}}
+
{{- $newStart := .NewPosition -}}
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
+
{{- $lineNrSepStyle1 := "" -}}
+
{{- $lineNrSepStyle2 := "pr-2" -}}
+
{{- range .Lines -}}
+
{{- if eq .Op.String "+" -}}
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String "-" -}}
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String " " -}}
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- end -}}
+
{{- end -}}</div></div></pre>
+
{{- end -}}
+
</div>
+
+
</details>
+
+
</div>
+
</div>
+
</section>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "statPill" }}
+
<div class="flex items-center font-mono text-sm">
+
{{ if and .Insertions .Deletions }}
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
+
{{ else if .Insertions }}
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
{{ else if .Deletions }}
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
+
{{ end }}
+
</div>
+
{{ end }}
+47
appview/pages/templates/repo/fragments/repoActions.html
···
+
{{ define "repo/fragments/repoActions" }}
+
<div class="flex items-center gap-2 z-auto">
+
<button
+
id="starBtn"
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed"
+
{{ if .IsStarred }}
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
+
{{ else }}
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
+
{{ end }}
+
+
hx-trigger="click"
+
hx-target="#starBtn"
+
hx-swap="outerHTML"
+
hx-disabled-elt="#starBtn"
+
>
+
<div class="flex gap-2 items-center">
+
{{ if .IsStarred }}
+
{{ i "star" "w-4 h-4 fill-current" }}
+
{{ else }}
+
{{ i "star" "w-4 h-4" }}
+
{{ end }}
+
<span class="text-sm">
+
{{ .Stats.StarCount }}
+
</span>
+
</div>
+
</button>
+
{{ if .DisableFork }}
+
<button
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
+
disabled
+
title="Empty repositories cannot be forked"
+
>
+
{{ i "git-fork" "w-4 h-4" }}
+
fork
+
</button>
+
{{ else }}
+
<a
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2"
+
href="/{{ .FullName }}/fork"
+
>
+
{{ i "git-fork" "w-4 h-4" }}
+
fork
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+15
appview/pages/templates/repo/fragments/repoDescription.html
···
+
{{ define "repo/fragments/repoDescription" }}
+
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
+
{{ if .RepoInfo.Description }}
+
{{ .RepoInfo.Description }}
+
{{ else }}
+
<span class="italic">this repo has no description</span>
+
{{ end }}
+
+
{{ if .RepoInfo.Roles.IsOwner }}
+
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
+
{{ i "pencil" "w-3 h-3" }}
+
</button>
+
{{ end }}
+
</span>
+
{{ end }}
+201 -197
appview/pages/templates/repo/index.html
···
{{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }}
-
{{ define "extrameta" }}
-
<meta name="vcs:clone" content="https://tangled.sh/{{ .RepoInfo.FullName }}"/>
-
<meta name="forge:summary" content="https://tangled.sh/{{ .RepoInfo.FullName }}">
-
<meta name="forge:dir" content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}">
-
<meta name="forge:file" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}">
-
<meta name="forge:line" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}">
-
<meta name="go-import" content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}">
+
<meta
+
name="vcs:clone"
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
+
/>
+
<meta
+
name="forge:summary"
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
+
/>
+
<meta
+
name="forge:dir"
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
+
/>
+
<meta
+
name="forge:file"
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
+
/>
+
<meta
+
name="forge:line"
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
+
/>
+
<meta
+
name="go-import"
+
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
+
/>
{{ end }}
-
{{ define "repoContent" }}
<main>
-
{{ block "branchSelector" . }} {{ end }}
+
{{ block "branchSelector" . }}{{ end }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
-
{{ block "fileTree" . }} {{ end }}
-
{{ block "commitLog" . }} {{ end }}
+
{{ block "fileTree" . }}{{ end }}
+
{{ block "commitLog" . }}{{ end }}
</div>
</main>
{{ end }}
{{ define "branchSelector" }}
-
<div class="flex justify-between pb-5">
-
<select
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
-
class="p-1 border border-gray-200 bg-white"
-
>
-
<optgroup label="branches" class="bold text-sm">
-
{{ range .Branches }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if eq .Reference.Name $.Ref }}
-
selected
-
{{ end }}
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</optgroup>
-
<optgroup label="tags" class="bold text-sm">
-
{{ range .Tags }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if eq .Reference.Name $.Ref }}
-
selected
-
{{ end }}
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ else }}
-
<option class="py-1" disabled>no tags found</option>
-
{{ end }}
-
</optgroup>
-
</select>
-
<a
-
href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}"
-
class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold"
-
>
-
{{ i "logs" "w-4 h-4" }}
-
{{ .TotalCommits }}
-
{{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }}
-
</a>
-
</div>
+
<div class="flex justify-between pb-5">
+
<select
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
+
>
+
<optgroup label="branches" class="bold text-sm">
+
{{ range .Branches }}
+
<option
+
value="{{ .Reference.Name }}"
+
class="py-1"
+
{{ if eq .Reference.Name $.Ref }}
+
selected
+
{{ end }}
+
>
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</optgroup>
+
<optgroup label="tags" class="bold text-sm">
+
{{ range .Tags }}
+
<option
+
value="{{ .Reference.Name }}"
+
class="py-1"
+
{{ if eq .Reference.Name $.Ref }}
+
selected
+
{{ end }}
+
>
+
{{ .Reference.Name }}
+
</option>
+
{{ else }}
+
<option class="py-1" disabled>no tags found</option>
+
{{ end }}
+
</optgroup>
+
</select>
+
<a
+
href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}"
+
class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold dark:text-white"
+
>
+
{{ i "logs" "w-4 h-4" }}
+
{{ .TotalCommits }}
+
{{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }}
+
</a>
+
</div>
{{ end }}
{{ define "fileTree" }}
-
<div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200">
-
{{ $containerstyle := "py-1" }}
-
{{ $linkstyle := "no-underline hover:underline" }}
+
<div
+
id="file-tree"
+
class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700"
+
>
+
{{ $containerstyle := "py-1" }}
+
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
-
{{ range .Files }}
-
{{ if not .IsFile }}
-
<div class="{{ $containerstyle }}">
-
<div class="flex justify-between items-center">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
-
class="{{ $linkstyle }}"
-
>
-
<div class="flex items-center gap-2">
-
{{ i "folder" "w-3 h-3 fill-current" }}
-
{{ .Name }}
-
</div>
-
</a>
+
{{ range .Files }}
+
{{ if not .IsFile }}
+
<div class="{{ $containerstyle }}">
+
<div class="flex justify-between items-center">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
+
class="{{ $linkstyle }}"
+
>
+
<div class="flex items-center gap-2">
+
{{ i "folder" "w-3 h-3 fill-current" }}
+
{{ .Name }}
+
</div>
+
</a>
-
<time class="text-xs text-gray-500"
-
>{{ timeFmt .LastCommit.When }}</time
-
>
+
<time class="text-xs text-gray-500 dark:text-gray-400"
+
>{{ timeFmt .LastCommit.When }}</time
+
>
+
</div>
</div>
-
</div>
+
{{ end }}
{{ end }}
-
{{ end }}
-
{{ range .Files }}
-
{{ if .IsFile }}
-
<div class="{{ $containerstyle }}">
-
<div class="flex justify-between items-center">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
-
class="{{ $linkstyle }}"
-
>
-
<div class="flex items-center gap-2">
-
{{ i "file" "w-3 h-3" }}{{ .Name }}
-
</div>
-
</a>
+
{{ range .Files }}
+
{{ if .IsFile }}
+
<div class="{{ $containerstyle }}">
+
<div class="flex justify-between items-center">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
+
class="{{ $linkstyle }}"
+
>
+
<div class="flex items-center gap-2">
+
{{ i "file" "w-3 h-3" }}{{ .Name }}
+
</div>
+
</a>
-
<time class="text-xs text-gray-500"
-
>{{ timeFmt .LastCommit.When }}</time
-
>
+
<time class="text-xs text-gray-500 dark:text-gray-400"
+
>{{ timeFmt .LastCommit.When }}</time
+
>
+
</div>
</div>
-
</div>
+
{{ end }}
{{ end }}
-
{{ end }}
-
</div>
+
</div>
{{ end }}
-
{{ define "commitLog" }}
-
<div id="commit-log" class="hidden md:block md:col-span-1">
-
{{ range .Commits }}
-
<div class="relative px-2 pb-8">
-
<div id="commit-message">
-
{{ $messageParts := splitN .Message "\n\n" 2 }}
-
<div class="text-base cursor-pointer">
-
<div>
-
<div>
-
<a
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
-
class="inline no-underline hover:underline"
-
>{{ index $messageParts 0 }}</a
-
>
-
{{ if gt (len $messageParts) 1 }}
+
<div id="commit-log" class="hidden md:block md:col-span-1">
+
{{ range .Commits }}
+
<div class="relative px-2 pb-8">
+
<div id="commit-message">
+
{{ $messageParts := splitN .Message "\n\n" 2 }}
+
<div class="text-base cursor-pointer">
+
<div>
+
<div>
+
<a
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
+
class="inline no-underline hover:underline dark:text-white"
+
>{{ index $messageParts 0 }}</a
+
>
+
{{ if gt (len $messageParts) 1 }}
-
<button
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded"
-
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
-
>
-
{{ i "ellipsis" "w-3 h-3" }}
-
</button>
-
{{ end }}
-
</div>
-
{{ if gt (len $messageParts) 1 }}
-
<p
-
class="hidden mt-1 text-sm cursor-text pb-2"
-
>
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
-
</p>
-
{{ end }}
-
</div>
-
</div>
-
</div>
+
<button
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
+
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
+
>
+
{{ i "ellipsis" "w-3 h-3" }}
+
</button>
+
{{ end }}
+
</div>
+
{{ if gt (len $messageParts) 1 }}
+
<p
+
class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"
+
>
+
{{ nl2br (index $messageParts 1) }}
+
</p>
+
{{ end }}
+
</div>
+
</div>
+
</div>
-
<div class="text-xs text-gray-500">
-
<span class="font-mono">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
-
class="text-gray-500 no-underline hover:underline"
-
>{{ slice .Hash.String 0 8 }}</a
-
>
-
</span>
-
<span
-
class="mx-2 before:content-['ยท'] before:select-none"
-
></span>
-
<span>
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
-
<a
-
href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
-
class="text-gray-500 no-underline hover:underline"
-
>{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ .Author.Name }}{{ end }}</a
-
>
-
</span>
-
<div
-
class="inline-block px-1 select-none after:content-['ยท']"
-
></div>
-
<span>{{ timeFmt .Author.When }}</span>
-
{{ $tagsForCommit := index $.TagMap .Hash.String }}
-
{{ if gt (len $tagsForCommit) 0 }}
+
<div class="text-xs text-gray-500 dark:text-gray-400">
+
<span class="font-mono">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
+
>{{ slice .Hash.String 0 8 }}</a></span>
+
<span
+
class="mx-2 before:content-['ยท'] before:select-none"
+
></span>
+
<span>
+
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
+
<a
+
href="{{ if $didOrHandle }}
+
/{{ $didOrHandle }}
+
{{ else }}
+
mailto:{{ .Author.Email }}
+
{{ end }}"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
+
>{{ if $didOrHandle }}
+
{{ $didOrHandle }}
+
{{ else }}
+
{{ .Author.Name }}
+
{{ end }}</a
+
>
+
</span>
<div
class="inline-block px-1 select-none after:content-['ยท']"
></div>
-
{{ end }}
-
{{ range $tagsForCommit }}
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
-
{{ . }}
-
</span>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
</div>
+
<span>{{ timeFmt .Author.When }}</span>
+
{{ $tagsForCommit := index $.TagMap .Hash.String }}
+
{{ if gt (len $tagsForCommit) 0 }}
+
<div
+
class="inline-block px-1 select-none after:content-['ยท']"
+
></div>
+
{{ end }}
+
{{ range $tagsForCommit }}
+
<span
+
class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"
+
>
+
{{ . }}
+
</span>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
</div>
{{ end }}
-
{{ define "repoAfter" }}
{{- if .HTMLReadme }}
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto {{ if not .Raw }} prose {{ end }}">
-
<article class="{{ if .Raw }}whitespace-pre{{end}}">
+
<section
+
class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto {{ if not .Raw }}
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
+
{{ end }}"
+
>
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">
{{ if .Raw }}
-
<pre>{{ .HTMLReadme }}</pre>
+
<pre
+
class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded"
+
>
+
{{ .HTMLReadme }}</pre
+
>
{{ else }}
{{ .HTMLReadme }}
{{ end }}
···
</section>
{{- end -}}
-
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto flex flex-col gap-4">
-
<div class="flex flex-col gap-2">
-
<strong>push</strong>
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
-
<code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
-
</div>
-
</div>
-
-
<div class="flex flex-col gap-2">
-
<strong>clone</strong>
-
<div class="md:pl-4 flex flex-col gap-2">
-
-
<div class="flex items-center gap-3">
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span>
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
-
<code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
-
</div>
-
</div>
-
-
<div class="flex items-center gap-3">
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span>
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
-
<code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
-
</div>
-
</div>
-
</div>
-
</div>
-
-
-
<p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p>
-
</section>
+
{{ template "repo/fragments/cloneInstructions" . }}
{{ end }}
+52
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 text-sm">
+
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
+
+
<!-- show user "hats" -->
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+
{{ if $isIssueAuthor }}
+
<span class="before:content-['ยท']"></span>
+
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
+
author
+
</span>
+
{{ end }}
+
+
<span class="before:content-['ยท']"></span>
+
<a
+
href="#{{ .CommentId }}"
+
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
+
id="{{ .CommentId }}">
+
{{ .Created | timeFmt }}
+
</a>
+
+
<button
+
class="btn px-2 py-1 flex items-center gap-2 text-sm"
+
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" }}
+
</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>
+
+
<div>
+
<textarea
+
id="edit-textarea-{{ .CommentId }}"
+
name="body"
+
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
+59
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">
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
+
+
<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 {{ .Deleted | timeFmt }}
+
{{ else if .Edited }}
+
edited {{ .Edited | timeFmt }}
+
{{ else }}
+
{{ .Created | timeFmt }}
+
{{ end }}
+
</a>
+
+
<!-- show user "hats" -->
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+
{{ if $isIssueAuthor }}
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
+
author
+
</span>
+
{{ end }}
+
+
{{ $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"
+
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" }}
+
</button>
+
{{ end }}
+
+
</div>
+
{{ if not .Deleted }}
+
<div class="prose dark:prose-invert">
+
{{ .Body | markdown }}
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ end }}
+123 -72
appview/pages/templates/repo/issues/issue.html
···
-
{{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot;{{ .RepoInfo.FullName }}{{ end }}
+
{{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
<header class="pb-4">
<h1 class="text-2xl">
{{ .Issue.Title }}
-
<span class="text-gray-500">#{{ .Issue.IssueId }}</span>
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
</h1>
</header>
-
{{ $bgColor := "bg-gray-800" }}
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
{{ $icon := "ban" }}
{{ if eq .State "open" }}
-
{{ $bgColor = "bg-green-600" }}
+
{{ $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 }} text-sm">
+
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 text-sm">
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
opened by
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
<a href="/{{ $owner }}" class="no-underline hover:underline"
>{{ $owner }}</a
>
<span class="px-1 select-none before:content-['\00B7']"></span>
-
<time>{{ .Issue.Created | timeFmt }}</time>
+
<time title="{{ .Issue.Created | longTimeFmt }}">
+
{{ .Issue.Created | timeFmt }}
+
</time>
</span>
</div>
{{ if .Issue.Body }}
-
<article id="body" class="mt-4 prose">
+
<article id="body" class="mt-8 prose dark:prose-invert">
{{ .Issue.Body | markdown }}
</article>
{{ end }}
···
{{ end }}
{{ define "repoAfter" }}
-
{{ if gt (len .Comments) 0 }}
-
<section id="comments" class="mt-8 space-y-4 relative">
+
<section id="comments" class="my-2 mt-2 space-y-2 relative">
{{ range $index, $comment := .Comments }}
<div
id="comment-{{ .CommentId }}"
-
class="rounded bg-white px-6 py-4 relative"
-
>
-
{{ if eq $index 0 }}
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
-
{{ else }}
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300" ></div>
+
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 }}
-
<div class="flex items-center gap-2 mb-2 text-gray-500">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
-
<span class="text-sm">
-
<a
-
href="/{{ $owner }}"
-
class="no-underline hover:underline"
-
>{{ $owner }}</a
-
>
-
</span>
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 text-sm hover:text-gray-500 hover:underline no-underline"
-
id="{{ .CommentId }}"
-
>
-
{{ .Created | timeFmt }}
-
</a>
-
</div>
-
<div class="prose">
-
{{ .Body | markdown }}
-
</div>
+
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
</div>
{{ end }}
</section>
-
{{ end }}
{{ block "newComment" . }} {{ end }}
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
-
{{ if or $isIssueAuthor $isRepoCollaborator }}
-
{{ $action := "close" }}
-
{{ $icon := "circle-x" }}
-
{{ $hoverColor := "red" }}
-
{{ if eq .State "closed" }}
-
{{ $action = "reopen" }}
-
{{ $icon = "circle-dot" }}
-
{{ $hoverColor = "green" }}
-
{{ end }}
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}"
-
hx-swap="none"
-
class="mt-8"
-
>
-
<button type="submit" class="btn hover:bg-{{ $hoverColor }}-300">
-
{{ i $icon "w-4 h-4 mr-2" }}
-
<span class="text-black">{{ $action }}</span>
-
</button>
-
<div id="issue-action" class="error"></div>
-
</form>
-
{{ end }}
{{ end }}
{{ define "newComment" }}
{{ if .LoggedInUser }}
-
<div class="bg-white rounded drop-shadow-sm py-4 px-6 relative w-full flex flex-col gap-2 mt-8">
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
-
<div class="text-sm text-gray-500">
+
<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">
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
</div>
-
<form hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment">
<textarea
+
id="comment-textarea"
name="body"
-
class="w-full p-2 rounded border border-gray-200"
-
placeholder="Add to the discussion..."
+
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>
-
<button type="submit" class="btn mt-2">comment</button>
<div id="issue-comment"></div>
-
</form>
+
<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"
+
disabled
+
>
+
{{ i "message-square-plus" "w-4 h-4" }}
+
comment
+
</button>
+
+
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
+
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
+
{{ if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "open") }}
+
<button
+
id="close-button"
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-trigger="click"
+
>
+
{{ i "ban" "w-4 h-4" }}
+
close
+
</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-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-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) (eq .State "closed") }}
+
<button
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
+
hx-swap="none"
+
>
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
+
reopen
+
</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" }} close with comment';
+
} else {
+
closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close';
+
}
+
}
+
}
+
+
document.addEventListener('DOMContentLoaded', function() {
+
updateCommentForm();
+
});
+
</script>
</div>
+
</form>
{{ else }}
-
<div class="bg-white rounded drop-shadow-sm px-6 py-4 mt-8">
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
+
<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 }}
+48 -10
appview/pages/templates/repo/issues/issues.html
···
<div class="flex justify-between items-center">
<p>
filtering
-
<select class="border px-1 bg-white border-gray-200" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value">
+
<select class="border p-1 bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value">
<option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option>
<option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option>
</select>
···
<a
href="/{{ .RepoInfo.FullName }}/issues/new"
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline">
-
{{ i "plus" "w-4 h-4" }}
-
<span>new issue</span>
+
{{ i "circle-plus" "w-4 h-4" }}
+
<span>new</span>
</a>
</div>
<div class="error" id="issues"></div>
···
{{ 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">
+
<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 }}"
···
<span class="text-gray-500">#{{ .IssueId }}</span>
</a>
</div>
-
<p class="text-sm text-gray-500">
-
{{ $bgColor := "bg-gray-800" }}
+
<p class="text-sm text-gray-500 dark:text-gray-400">
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
{{ $icon := "ban" }}
{{ $state := "closed" }}
{{ if .Open }}
-
{{ $bgColor = "bg-green-600" }}
+
{{ $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" }}
-
<span class="text-white">{{ $state }}</span>
+
{{ 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>
···
{{ if eq .Metadata.CommentCount 1 }}
{{ $s = "" }}
{{ end }}
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500">{{ .Metadata.CommentCount }} comment{{$s}}</a>
+
<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" }}
+
<div class="flex justify-end mt-4 gap-2">
+
{{ $currentState := "closed" }}
+
{{ if .FilteringByOpen }}
+
{{ $currentState = "open" }}
+
{{ end }}
+
+
{{ if gt .Page.Offset 0 }}
+
{{ $prev := .Page.Previous }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
>
+
{{ i "chevron-left" "w-4 h-4" }}
+
previous
+
</a>
+
{{ else }}
+
<div></div>
+
{{ end }}
+
+
{{ if eq (len .Issues) .Page.Limit }}
+
{{ $next := .Page.Next }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
>
+
next
+
{{ i "chevron-right" "w-4 h-4" }}
+
</a>
+
{{ end }}
</div>
{{ end }}
+1 -1
appview/pages/templates/repo/issues/new.html
···
-
{{ define "title" }}new issue | {{ .RepoInfo.FullName }}{{ end }}
+
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
<form
+17 -17
appview/pages/templates/repo/log.html
···
{{ $commit := index .Commits 0 }}
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
<div>
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}">
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white">
<p class="pb-5">{{ index $messageParts 0 }}</p>
{{ if gt (len $messageParts) 1 }}
<p class="mt-1 text-sm cursor-text pb-5">
···
</a>
</div>
-
<div class="text-sm text-gray-500">
+
<div class="text-sm text-gray-500 dark:text-gray-400">
<span class="font-mono">
<a
href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
-
class="text-gray-500 no-underline hover:underline"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
>{{ slice $commit.Hash.String }}</a
>
</span>
···
{{ if $didOrHandle }}
<a
href="/{{ $didOrHandle }}"
-
class="text-gray-500 no-underline hover:underline"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
>{{ $didOrHandle }}</a
>
{{ else }}
<a
href="mailto:{{ $commit.Author.Email }}"
-
class="text-gray-500 no-underline hover:underline"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
>{{ $commit.Author.Name }}</a
>
{{ end }}
···
{{ define "repoAfter" }}
<main>
<div id="commit-log" class="flex-1 relative">
-
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div>
+
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div>
{{ $end := length .Commits }}
{{ $commits := subslice .Commits 1 $end }}
{{ range $commits }}
<div class="flex flex-row justify-between items-center">
<div
-
class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white"
+
class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white dark:bg-gray-800"
>
<div id="commit-message">
{{ $messageParts := splitN .Message "\n\n" 2 }}
···
<div>
<a
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
-
class="inline no-underline hover:underline"
+
class="inline no-underline hover:underline dark:text-white"
>{{ index $messageParts 0 }}</a
>
{{ if gt (len $messageParts) 1 }}
<button
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded"
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded"
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
>
{{ i "ellipsis" "w-3 h-3" }}
···
</div>
{{ if gt (len $messageParts) 1 }}
<p
-
class="hidden mt-1 text-sm cursor-text pb-2"
+
class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"
>
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
+
{{ nl2br (index $messageParts 1) }}
</p>
{{ end }}
</div>
</div>
</div>
-
<div class="text-sm text-gray-500 mt-3">
+
<div class="text-sm text-gray-500 dark:text-gray-400 mt-3">
<span class="font-mono">
<a
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
-
class="text-gray-500 no-underline hover:underline"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
>{{ slice .Hash.String 0 8 }}</a
>
</span>
···
{{ if $didOrHandle }}
<a
href="/{{ $didOrHandle }}"
-
class="text-gray-500 no-underline hover:underline"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
>{{ $didOrHandle }}</a
>
{{ else }}
<a
href="mailto:{{ .Author.Email }}"
-
class="text-gray-500 no-underline hover:underline"
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
>{{ .Author.Name }}</a
>
{{ end }}
···
<div class="flex justify-end mt-4 gap-2">
{{ if gt .Page 1 }}
<a
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
hx-boost="true"
onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'"
>
···
{{ if eq $commits_len 30 }}
<a
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
hx-boost="true"
onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'"
>
+13 -13
appview/pages/templates/repo/new.html
···
{{ define "content" }}
<div class="p-6">
-
<p class="text-xl font-bold">Create a new repository</p>
+
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
</div>
-
<div class="p-6 bg-white drop-shadow-sm rounded">
+
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
<form hx-post="/repo/new" class="space-y-12" hx-swap="none">
<div class="space-y-2">
-
<label for="name" class="-mb-1">Repository name</label>
+
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
<input
type="text"
id="name"
name="name"
required
-
class="w-full max-w-md"
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
/>
-
<p class="text-sm text-gray-500">All repositories are publicly visible.</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
-
<label for="branch">Default branch</label>
+
<label for="branch" class="dark:text-white">Default branch</label>
<input
type="text"
id="branch"
name="branch"
value="main"
required
-
class="w-full max-w-md"
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
/>
-
<label for="description">Description</label>
+
<label for="description" class="dark:text-white">Description</label>
<input
type="text"
id="description"
name="description"
-
class="w-full max-w-md"
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
/>
</div>
<fieldset class="space-y-3">
-
<legend>Select a knot</legend>
+
<legend class="dark:text-white">Select a knot</legend>
<div class="space-y-2">
<div class="flex flex-col">
{{ range .Knots }}
···
class="mr-2"
id="domain-{{ . }}"
/>
-
<span>{{ . }}</span>
+
<span class="dark:text-white">{{ . }}</span>
</div>
{{ else }}
-
<p>No knots available.</p>
+
<p class="dark:text-white">No knots available.</p>
{{ end }}
</div>
</div>
-
<p class="text-sm text-gray-500">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
</fieldset>
<div class="space-y-2">
+90
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
+
{{ define "repo/pulls/fragments/pullActions" }}
+
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
+
{{ $roundNumber := .RoundNumber }}
+
+
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
+
{{ $isMerged := .Pull.State.IsMerged }}
+
{{ $isClosed := .Pull.State.IsClosed }}
+
{{ $isOpen := .Pull.State.IsOpen }}
+
{{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }}
+
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
+
{{ $isLastRound := eq $roundNumber $lastIdx }}
+
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
+
{{ $isUpToDate := .ResubmitCheck.No }}
+
<div class="relative w-fit">
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
+
<button
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
+
{{ i "message-square-plus" "w-4 h-4" }}
+
<span>comment</span>
+
</button>
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isConflicted }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
+
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
+
{{ i "git-merge" "w-4 h-4" }}
+
<span>merge</span>
+
</button>
+
{{ end }}
+
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isUpToDate }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button id="resubmitBtn"
+
{{ if not .Pull.IsPatchBased }}
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
{{ else }}
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
{{ end }}
+
+
hx-disabled-elt="#resubmitBtn"
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" {{ $disabled }}
+
+
{{ if $disabled }}
+
title="Update this branch to resubmit this pull request"
+
{{ else }}
+
title="Resubmit this pull request"
+
{{ end }}
+
>
+
{{ i "rotate-ccw" "w-4 h-4" }}
+
<span>resubmit</span>
+
</button>
+
{{ end }}
+
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2">
+
{{ i "ban" "w-4 h-4" }}
+
<span>close</span>
+
</button>
+
{{ end }}
+
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2">
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
+
<span>reopen</span>
+
</button>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
+
{{ define "repo/pulls/fragments/pullCompareBranches" }}
+
<div id="patch-upload">
+
<label for="targetBranch" class="dark:text-white"
+
>select a branch</label
+
>
+
<div class="flex flex-wrap gap-2 items-center">
+
<select
+
name="sourceBranch"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option disabled selected>source branch</option>
+
{{ range .Branches }}
+
<option value="{{ .Reference.Name }}" class="py-1">
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</select>
+
</div>
+
</div>
+
+
<p class="mt-4">
+
Title and description are optional; if left out, they will be extracted
+
from the first commit.
+
</p>
+
{{ end }}
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
+
{{ define "repo/pulls/fragments/pullCompareForks" }}
+
<div id="patch-upload">
+
<label for="forkSelect" class="dark:text-white"
+
>select a fork to compare</label
+
>
+
<div class="flex flex-wrap gap-4 items-center mb-4">
+
<div class="flex flex-wrap gap-2 items-center">
+
<select
+
id="forkSelect"
+
name="fork"
+
required
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches"
+
hx-target="#branch-selection"
+
hx-vals='{"fork": this.value}'
+
hx-swap="innerHTML"
+
onchange="document.getElementById('hiddenForkInput').value = this.value;"
+
>
+
<option disabled selected>select a fork</option>
+
{{ range .Forks }}
+
<option value="{{ .Name }}" class="py-1">
+
{{ .Name }}
+
</option>
+
{{ end }}
+
</select>
+
+
<input
+
type="hidden"
+
id="hiddenForkInput"
+
name="fork"
+
value=""
+
/>
+
</div>
+
+
<div id="branch-selection">
+
<div class="text-sm text-gray-500 dark:text-gray-400">
+
Select a fork first to view available branches
+
</div>
+
</div>
+
</div>
+
</div>
+
<p class="mt-4">
+
Title and description are optional; if left out, they will be extracted
+
from the first commit.
+
</p>
+
{{ end }}
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
···
+
{{ define "repo/pulls/fragments/pullCompareForksBranches" }}
+
<div class="flex flex-wrap gap-2 items-center">
+
<select
+
name="sourceBranch"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option disabled selected>source branch</option>
+
{{ range .SourceBranches }}
+
<option value="{{ .Reference.Name }}" class="py-1">
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</select>
+
</div>
+
{{ end }}
+70
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
+
{{ define "repo/pulls/fragments/pullHeader" }}
+
<header class="pb-4">
+
<h1 class="text-2xl dark:text-white">
+
{{ .Pull.Title }}
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
+
</h1>
+
</header>
+
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
+
{{ if .Pull.State.IsOpen }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "git-pull-request" }}
+
{{ else if .Pull.State.IsMerged }}
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
+
{{ $icon = "git-merge" }}
+
{{ end }}
+
+
<section class="mt-2">
+
<div class="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">{{ .Pull.State.String }}</span>
+
</div>
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
+
opened by
+
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
+
<a href="/{{ $owner }}" class="no-underline hover:underline"
+
>{{ $owner }}</a
+
>
+
<span class="select-none before:content-['\00B7']"></span>
+
<time>{{ .Pull.Created | timeFmt }}</time>
+
<span class="select-none before:content-['\00B7']"></span>
+
<span>
+
targeting
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
+
<a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a>
+
</span>
+
</span>
+
{{ if not .Pull.IsPatchBased }}
+
<span>from
+
{{ if .Pull.IsForkBased }}
+
{{ if .Pull.PullSource.Repo }}
+
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>
+
{{ else }}
+
<span class="italic">[deleted fork]</span>
+
{{ end }}
+
{{ end }}
+
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
+
{{ .Pull.PullSource.Branch }}
+
</span>
+
</span>
+
{{ end }}
+
</span>
+
</div>
+
+
{{ if .Pull.Body }}
+
<article id="body" class="mt-8 prose dark:prose-invert">
+
{{ .Pull.Body | markdown }}
+
</article>
+
{{ end }}
+
</section>
+
+
+
{{ end }}
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
+
{{ define "repo/pulls/fragments/pullNewComment" }}
+
<div
+
id="pull-comment-card-{{ .RoundNumber }}"
+
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
+
<div class="text-sm text-gray-500 dark:text-gray-400">
+
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
+
</div>
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+
hx-swap="none"
+
class="w-full flex flex-wrap gap-2">
+
<textarea
+
name="body"
+
class="w-full p-2 rounded border border-gray-200"
+
placeholder="Add to the discussion..."></textarea>
+
<button type="submit" class="btn flex items-center gap-2">
+
{{ i "message-square" "w-4 h-4" }} comment
+
</button>
+
<button
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
+
hx-swap="outerHTML"
+
hx-target="#pull-comment-card-{{ .RoundNumber }}">
+
{{ i "x" "w-4 h-4" }}
+
<span>cancel</span>
+
</button>
+
<div id="pull-comment"></div>
+
</form>
+
</div>
+
{{ end }}
+
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
+
{{ define "repo/pulls/fragments/pullPatchUpload" }}
+
<div id="patch-upload">
+
<p>
+
You can paste a <code>git diff</code> or a
+
<code>git format-patch</code> patch series here.
+
</p>
+
<textarea
+
hx-trigger="keyup changed delay:500ms, paste delay:500ms"
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch"
+
hx-swap="none"
+
name="patch"
+
id="patch"
+
rows="12"
+
class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
placeholder="diff --git a/file.txt b/file.txt
+
index 1234567..abcdefg 100644
+
--- a/file.txt
+
+++ b/file.txt"
+
></textarea>
+
</div>
+
{{ end }}
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
···
+
{{ define "repo/pulls/fragments/pullResubmit" }}
+
<div
+
id="resubmit-pull-card"
+
class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2">
+
+
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-50">
+
{{ i "pencil" "w-4 h-4" }}
+
<span class="font-medium">resubmit your patch</span>
+
</div>
+
+
<div class="mt-2 text-sm text-gray-700 dark:text-gray-200">
+
You can update this patch to address any reviews.
+
This will begin a new round of reviews,
+
but you'll still be able to view your previous submissions and feedback.
+
</div>
+
+
<div class="mt-4 flex flex-col">
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
hx-swap="none"
+
class="w-full flex flex-wrap gap-2">
+
<textarea
+
name="patch"
+
class="w-full p-2 mb-2"
+
placeholder="Paste your updated patch here."
+
rows="15"
+
>{{.Pull.LatestPatch}}</textarea>
+
<button
+
type="submit"
+
class="btn flex items-center gap-2"
+
{{ if or .Pull.State.IsClosed }}
+
disabled
+
{{ end }}>
+
{{ i "rotate-ccw" "w-4 h-4" }}
+
<span>resubmit</span>
+
</button>
+
<button
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
+
hx-swap="outerHTML"
+
hx-target="#resubmit-pull-card">
+
{{ i "x" "w-4 h-4" }}
+
<span>cancel</span>
+
</button>
+
</form>
+
+
<div id="resubmit-error" class="error"></div>
+
<div id="resubmit-success" class="success"></div>
+
</div>
+
</div>
+
{{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
···
+
{{ define "title" }}
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }}
+
{{ end }}
+
+
{{ define "content" }}
+
<section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
+
<header class="pb-2">
+
<div class="flex gap-3 items-center mb-3">
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
+
{{ i "arrow-left" "w-5 h-5" }}
+
back
+
</a>
+
<span class="select-none before:content-['\00B7']"></span>
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}
+
</div>
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
+
{{ template "repo/pulls/fragments/pullHeader" . }}
+
</header>
+
</section>
+
+
<section>
+
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }}
+
</section>
+
{{ end }}
+
+84 -38
appview/pages/templates/repo/pulls/new.html
···
-
{{ define "title" }}new pull | {{ .RepoInfo.FullName }}{{ end }}
+
{{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<section class="prose">
-
<p>
-
This is v1 of the pull request flow. Paste your patch in the form below.
-
Here are the steps to get you started:
-
<ul class="list-decimal pl-10 space-y-2 text-gray-700">
-
<li class="leading-relaxed">Clone this repository.</li>
-
<li class="leading-relaxed">Make your changes in your local repository.</li>
-
<li class="leading-relaxed">Grab the diff using <code class="bg-gray-100 px-1 py-0.5 rounded text-gray-800 font-mono text-sm">git diff</code>.</li>
-
<li class="leading-relaxed">Paste the diff output in the form below.</li>
-
</ul>
-
</p>
-
</section>
<form
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
class="mt-6 space-y-6"
hx-swap="none"
>
<div class="flex flex-col gap-4">
-
<div>
-
<label for="title">write a title</label>
-
<input type="text" name="title" id="title" class="w-full" />
+
<label>configure your pull request</label>
-
<label for="targetBranch">select a target branch</label>
-
<p class="text-gray-500">
-
The branch you want to make your change against.
-
</p>
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
+
<div class="pb-2">
<select
+
required
name="targetBranch"
-
class="p-1 mb-2 border border-gray-200 bg-white"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
>
-
<option disabled selected>select a branch</option>
+
<option disabled selected>target branch</option>
{{ range .Branches }}
<option value="{{ .Reference.Name }}" class="py-1">
{{ .Reference.Name }}
</option>
{{ end }}
</select>
-
<label for="body">add a description</label>
+
</div>
+
+
<p>Next, choose a pull strategy.</p>
+
<nav class="flex space-x-4 items-end">
+
<button
+
type="button"
+
class="px-3 py-2 pb-2 btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
paste patch
+
</button>
+
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
+
or
+
</span>
+
<button
+
type="button"
+
class="px-3 py-2 pb-2 btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
compare branches
+
</button>
+
{{ end }}
+
+
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
+
or
+
</span>
+
<button
+
type="button"
+
class="px-3 py-2 pb-2 btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
compare forks
+
</button>
+
</nav>
+
+
<section id="patch-strategy">
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
+
</section>
+
+
<p id="patch-preview"></p>
+
+
<div id="patch-error" class="error dark:text-red-300"></div>
+
+
<div>
+
<label for="title" class="dark:text-white">write a title</label>
+
+
<input
+
type="text"
+
name="title"
+
id="title"
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
placeholder="One-line summary of your change."
+
/>
+
</div>
+
+
<div>
+
<label for="body" class="dark:text-white"
+
>add a description</label
+
>
+
<textarea
name="body"
id="body"
rows="6"
-
class="w-full resize-y"
+
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
placeholder="Describe your change. Markdown is supported."
></textarea>
-
-
<div class="mt-4">
-
<label for="patch">paste your patch here</label>
-
<textarea
-
name="patch"
-
id="patch"
-
rows="10"
-
class="w-full resize-y font-mono"
-
placeholder="Paste your git diff output here."
-
></textarea>
-
</div>
</div>
-
<div>
-
<button type="submit" class="btn">create</button>
+
+
<div class="flex justify-start items-center gap-2 mt-4">
+
<button type="submit" class="btn flex items-center gap-2">
+
{{ i "git-pull-request-create" "w-4 h-4" }}
+
create pull
+
</button>
</div>
</div>
-
<div id="pull" class="error"></div>
+
<div id="pull" class="error dark:text-red-300"></div>
</form>
{{ end }}
+22 -83
appview/pages/templates/repo/pulls/patch.html
···
{{ define "title" }}
-
{{ $oneIndexedRound := add .Round 1 }}
-
patch of {{ .Pull.Title }} &middot; round #{{ $oneIndexedRound }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }}
+
patch of {{ .Pull.Title }} &middot; round #{{ .Round }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }}
{{ end }}
{{ define "content" }}
-
{{ $oneIndexedRound := add .Round 1 }}
-
{{ $stat := .Diff.Stat }}
-
<div class="rounded drop-shadow-sm bg-white py-4 px-6">
-
<header class="pb-2">
-
<div class="flex gap-3 items-center mb-3">
-
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
-
{{ i "arrow-left" "w-5 h-5" }}
-
back
-
</a>
-
<span class="select-none before:content-['\00B7']"></span>
-
round #{{ $oneIndexedRound }}
-
</div>
-
<div class="border-t border-gray-200 my-2"></div>
-
<h1 class="text-2xl mt-3">
-
{{ .Pull.Title }}
-
<span class="text-gray-500">#{{ .Pull.PullId }}</span>
-
</h1>
-
</header>
-
-
{{ $bgColor := "bg-gray-800" }}
-
{{ $icon := "ban" }}
-
-
{{ if .Pull.State.IsOpen }}
-
{{ $bgColor = "bg-green-600" }}
-
{{ $icon = "git-pull-request" }}
-
{{ else if .Pull.State.IsMerged }}
-
{{ $bgColor = "bg-purple-600" }}
-
{{ $icon = "git-merge" }}
-
{{ end }}
-
-
<section>
-
<div class="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">{{ .Pull.State.String }}</span>
-
</div>
-
<span class="text-gray-500 text-sm">
-
opened by
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
-
>{{ $owner }}</a
-
>
-
<span class="select-none before:content-['\00B7']"></span>
-
<time>{{ .Pull.Created | timeFmt }}</time>
-
<span class="select-none before:content-['\00B7']"></span>
-
<span>targeting branch
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
-
{{ .Pull.TargetBranch }}
-
</span>
-
</span>
-
</span>
-
</div>
-
-
{{ if .Pull.Body }}
-
<article id="body" class="mt-2 prose">
-
{{ .Pull.Body | markdown }}
-
</article>
-
{{ end }}
-
</section>
-
-
<div id="diff-stat">
-
<br>
-
<strong class="text-sm uppercase mb-4">Changed files</strong>
-
{{ range .Diff.Diff }}
-
<ul>
-
{{ if .IsDelete }}
-
<li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li>
-
{{ else }}
-
<li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li>
-
{{ end }}
-
</ul>
-
{{ end }}
-
</div>
-
</div>
-
-
<section>
-
{{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }}
-
</section>
+
<section>
+
<section
+
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
+
>
+
<div class="flex gap-3 items-center mb-3">
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
+
{{ i "arrow-left" "w-5 h-5" }}
+
back
+
</a>
+
<span class="select-none before:content-['\00B7']"></span>
+
round<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .Round }}</span>
+
<span class="select-none before:content-['\00B7']"></span>
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch">
+
view raw
+
</a>
+
</div>
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
+
{{ template "repo/pulls/fragments/pullHeader" . }}
+
</section>
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
+
</section>
{{ end }}
+120 -91
appview/pages/templates/repo/pulls/pull.html
···
{{ end }}
{{ define "repoContent" }}
-
<header class="pb-4">
-
<h1 class="text-2xl">
-
{{ .Pull.Title }}
-
<span class="text-gray-500">#{{ .Pull.PullId }}</span>
-
</h1>
-
</header>
-
-
{{ $bgColor := "bg-gray-800" }}
-
{{ $icon := "ban" }}
-
-
{{ if .Pull.State.IsOpen }}
-
{{ $bgColor = "bg-green-600" }}
-
{{ $icon = "git-pull-request" }}
-
{{ else if .Pull.State.IsMerged }}
-
{{ $bgColor = "bg-purple-600" }}
-
{{ $icon = "git-merge" }}
-
{{ end }}
-
-
<section>
-
<div class="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">{{ .Pull.State.String }}</span>
-
</div>
-
<span class="text-gray-500 text-sm">
-
opened by
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
-
>{{ $owner }}</a
-
>
-
<span class="select-none before:content-['\00B7']"></span>
-
<time>{{ .Pull.Created | timeFmt }}</time>
-
<span class="select-none before:content-['\00B7']"></span>
-
<span>targeting branch
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
-
{{ .Pull.TargetBranch }}
-
</span>
-
</span>
-
</span>
-
</div>
-
-
{{ if .Pull.Body }}
-
<article id="body" class="mt-2 prose">
-
{{ .Pull.Body | markdown }}
-
</article>
-
{{ end }}
-
</section>
-
+
{{ template "repo/pulls/fragments/pullHeader" . }}
{{ end }}
{{ define "repoAfter" }}
···
{{ $targetBranch := .Pull.TargetBranch }}
{{ $repoName := .RepoInfo.FullName }}
{{ range $idx, $item := .Pull.Submissions }}
-
{{ $diff := $item.AsNiceDiff $targetBranch }}
{{ with $item }}
-
{{ $oneIndexedRound := add .RoundNumber 1 }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
-
<summary id="round-#{{ $oneIndexedRound }}" class="list-none cursor-pointer">
+
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
<div class="flex flex-wrap gap-2 items-center">
<!-- round number -->
-
<div class="rounded bg-white drop-shadow-sm px-3 py-2">
-
#{{ $oneIndexedRound }}
+
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
+
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
-
<div class="rounded drop-shadow-sm bg-white p-2 text-gray-500">
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span>
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
{{ $re := "re" }}
···
<span class="hidden md:inline">{{$re}}submitted</span>
by <a href="/{{ $owner }}">{{ $owner }}</a>
<span class="select-none before:content-['\00B7']"></span>
-
<a class="text-gray-500 hover:text-gray-500" href="#round-#{{ $oneIndexedRound }}"><time>{{ .Created | shortTimeFmt }}</time></a>
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a>
<span class="select-none before:content-['ยท']"></span>
{{ $s := "s" }}
{{ if eq (len .Comments) 1 }}
···
{{ len .Comments }} comment{{$s}}
</span>
</div>
-
<!-- view patch -->
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
hx-boost="true"
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
</a>
+
{{ if not (eq .RoundNumber 0) }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
+
hx-boost="true"
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
+
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span>
+
</a>
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
+
{{ end }}
</div>
</summary>
-
<div class="md:pl-12 flex flex-col gap-2 mt-2 relative">
-
{{ range .Comments }}
-
<div id="comment-{{.ID}}" class="bg-white rounded drop-shadow-sm py-2 px-4 relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div class="text-sm text-gray-500">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
+
+
{{ if .IsFormatPatch }}
+
{{ $patches := .AsFormatPatch }}
+
{{ $round := .RoundNumber }}
+
<details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm">
+
<summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
{{ $s := "s" }}
+
{{ if eq (len $patches) 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
<div class="group-open:hidden flex items-center gap-2 ml-2">
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}}
+
</div>
+
<div class="hidden group-open:flex items-center gap-2 ml-2">
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}}
+
</div>
+
</summary>
+
{{ range $patches }}
+
<div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col">
+
<div class="flex items-center gap-2">
+
{{ i "git-commit-horizontal" "w-4 h-4" }}
+
<div class="text-sm text-gray-500 dark:text-gray-400">
+
{{ if not $.Pull.IsPatchBased }}
+
{{ $fullRepo := $.RepoInfo.FullName }}
+
{{ if $.Pull.IsForkBased }}
+
{{ if $.Pull.PullSource.Repo }}
+
{{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }}
+
<a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a>
+
{{ else }}
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
+
{{ end }}
+
{{ end }}
+
{{ else }}
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
+
{{ end }}
+
</div>
+
<div class="flex items-center">
+
<span>{{ .Title }}</span>
+
{{ if gt (len .Body) 0 }}
+
<button
+
class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
+
hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')"
+
>
+
{{ i "ellipsis" "w-3 h-3" }}
+
</button>
+
{{ end }}
+
</div>
+
</div>
+
{{ if gt (len .Body) 0 }}
+
<p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">
+
{{ nl2br .Body }}
+
</p>
+
{{ end }}
+
</div>
+
{{ end }}
+
</details>
+
{{ end }}
+
+
+
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
+
{{ range $cidx, $c := .Comments }}
+
<div id="comment-{{$c.ID}}" 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 $cidx 0 }}
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
+
{{ end }}
+
<div class="text-sm text-gray-500 dark:text-gray-400">
+
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
<a href="/{{$owner}}">{{$owner}}</a>
<span class="before:content-['ยท']"></span>
-
<a class="text-gray-500 hover:text-gray-500" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a>
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a>
</div>
-
<div class="prose">
-
{{ .Body | markdown }}
+
<div class="prose dark:prose-invert">
+
{{ $c.Body | markdown }}
</div>
</div>
{{ end }}
{{ if eq $lastIdx .RoundNumber }}
{{ block "mergeStatus" $ }} {{ end }}
+
{{ block "resubmitStatus" $ }} {{ end }}
{{ end }}
{{ if $.LoggedInUser }}
-
{{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck) }}
+
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }}
{{ else }}
-
<div class="bg-white rounded drop-shadow-sm px-6 py-4 w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
<a href="/login" class="underline">login</a> to join the discussion
</div>
{{ end }}
</div>
</details>
-
<hr class="md:hidden"/>
+
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
{{ end }}
{{ end }}
{{ end }}
{{ define "mergeStatus" }}
{{ if .Pull.State.IsClosed }}
-
<div class="bg-gray-50 border border-black rounded drop-shadow-sm px-6 py-2 relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div class="flex items-center gap-2 text-black">
+
<div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="flex items-center gap-2 text-black dark:text-white">
{{ i "ban" "w-4 h-4" }}
<span class="font-medium">closed without merging</span
>
</div>
</div>
{{ else if .Pull.State.IsMerged }}
-
<div class="bg-purple-50 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div class="flex items-center gap-2 text-purple-500">
+
<div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="flex items-center gap-2 text-purple-500 dark:text-purple-300">
{{ i "git-merge" "w-4 h-4" }}
<span class="font-medium">pull request successfully merged</span
>
</div>
</div>
{{ else if and .MergeCheck .MergeCheck.Error }}
-
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div class="flex items-center gap-2 text-red-500">
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
{{ i "triangle-alert" "w-4 h-4" }}
<span class="font-medium">{{ .MergeCheck.Error }}</span>
</div>
</div>
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
-
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div class="flex items-center gap-2 text-red-500">
-
{{ i "triangle-alert" "w-4 h-4" }}
-
<span class="font-medium">merge conflicts detected</span>
-
<ul class="text-sm space-y-1">
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="flex flex-col gap-2 text-red-500 dark:text-red-300">
+
<div class="flex items-center gap-2">
+
{{ i "triangle-alert" "w-4 h-4" }}
+
<span class="font-medium">merge conflicts detected</span>
+
</div>
+
<ul class="space-y-1">
{{ range .MergeCheck.Conflicts }}
{{ if .Filename }}
<li class="flex items-center">
-
{{ i "file-warning" "w-3 h-3 mr-1.5 text-red-500" }}
+
{{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }}
<span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span>
</li>
{{ end }}
···
</div>
</div>
{{ else if .MergeCheck }}
-
<div class="bg-green-50 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div class="flex items-center gap-2 text-green-500">
+
<div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="flex items-center gap-2 text-green-500 dark:text-green-300">
{{ i "circle-check-big" "w-4 h-4" }}
<span class="font-medium">no conflicts, ready to merge</span>
</div>
</div>
{{ end }}
{{ end }}
+
+
{{ define "resubmitStatus" }}
+
{{ if .ResubmitCheck.Yes }}
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-300">
+
{{ i "triangle-alert" "w-4 h-4" }}
+
<span class="font-medium">this branch has been updated, consider resubmitting</span>
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
+
{{ define "commits" }}
+
{{ end }}
+49 -15
appview/pages/templates/repo/pulls/pulls.html
···
{{ define "repoContent" }}
<div class="flex justify-between items-center">
-
<p>
+
<p class="dark:text-white">
filtering
<select
-
class="border px-1 bg-white border-gray-200"
+
class="border p-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white"
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value"
>
<option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}>
···
href="/{{ .RepoInfo.FullName }}/pulls/new"
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"
>
-
{{ i "git-pull-request" "w-4 h-4" }}
-
<span>new pull request</span>
+
{{ i "git-pull-request-create" "w-4 h-4" }}
+
<span>new</span>
</a>
</div>
<div class="error" id="pulls"></div>
···
{{ define "repoAfter" }}
<div class="flex flex-col gap-2 mt-2">
{{ range .Pulls }}
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4">
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 px-6 py-4">
<div class="pb-2">
-
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}">
+
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white">
{{ .Title }}
-
<span class="text-gray-500">#{{ .PullId }}</span>
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
</a>
</div>
-
<p class="text-sm text-gray-500">
-
{{ $bgColor := "bg-gray-800" }}
+
<p class="text-sm text-gray-500 dark:text-gray-400">
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
{{ $icon := "ban" }}
{{ if .State.IsOpen }}
-
{{ $bgColor = "bg-green-600" }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
{{ $icon = "git-pull-request" }}
{{ else if .State.IsMerged }}
-
{{ $bgColor = "bg-purple-600" }}
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
{{ $icon = "git-merge" }}
{{ end }}
···
</span>
<span>
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
-
<a href="/{{ $owner }}">{{ $owner }}</a>
+
<a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a>
</span>
<span class="before:content-['ยท']">
···
</span>
<span class="before:content-['ยท']">
-
targeting branch
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
+
targeting
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
{{ .TargetBranch }}
</span>
+
</span>
+
{{ if not .IsPatchBased }}
+
<span>from
+
{{ if .IsForkBased }}
+
{{ if .PullSource.Repo }}
+
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>
+
{{ else }}
+
<span class="italic">[deleted fork]</span>
+
{{ end }}
+
{{ end }}
+
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
+
{{ .PullSource.Branch }}
+
</span>
+
</span>
+
{{ end }}
+
<span class="before:content-['ยท']">
+
{{ $latestRound := .LastRoundNumber }}
+
{{ $lastSubmission := index .Submissions $latestRound }}
+
round
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
+
#{{ .LastRoundNumber }}
+
</span>
+
{{ $commentCount := len $lastSubmission.Comments }}
+
{{ $s := "s" }}
+
{{ if eq $commentCount 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
+
{{ if eq $commentCount 0 }}
+
awaiting comments
+
{{ else }}
+
recieved {{ len $lastSubmission.Comments}} comment{{$s}}
+
{{ end }}
</span>
</p>
</div>
+52 -8
appview/pages/templates/repo/settings.html
···
{{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<header class="font-bold text-sm mb-4 uppercase">Collaborators</header>
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
+
Collaborators
+
</header>
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
{{ range .Collaborators }}
<div id="collaborator" class="mb-2">
<a
href="/{{ didOrHandle .Did .Handle }}"
-
class="no-underline hover:underline text-black"
+
class="no-underline hover:underline text-black dark:text-white"
>
{{ didOrHandle .Did .Handle }}
</a>
<div>
-
<span class="text-sm text-gray-500">
+
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ .Role }}
</span>
</div>
···
{{ end }}
</div>
-
{{ if .IsCollaboratorInviteAllowed }}
-
<h3>add collaborator</h3>
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
-
<label for="collaborator">did or handle:</label>
-
<input type="text" id="collaborator" name="collaborator" required />
-
<button class="btn my-2" type="text">add collaborator</button>
+
<label for="collaborator" class="dark:text-white"
+
>add collaborator</label
+
>
+
<input
+
type="text"
+
id="collaborator"
+
name="collaborator"
+
required
+
class="dark:bg-gray-700 dark:text-white"
+
placeholder="enter did or handle"
+
/>
+
<button
+
class="btn my-2 dark:text-white dark:hover:bg-gray-700"
+
type="text"
+
>
+
add
+
</button>
</form>
{{ end }}
+
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6">
+
<label for="branch">default branch</label>
+
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
+
{{ range .Branches }}
+
<option
+
value="{{ . }}"
+
class="py-1"
+
{{ if eq . $.DefaultBranch }}
+
selected
+
{{ end }}
+
>
+
{{ . }}
+
</option>
+
{{ end }}
+
</select>
+
<button class="btn my-2" type="text">save</button>
+
</form>
+
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
+
<form hx-confirm="Are you sure you want to delete this repository?" hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" class="mt-6">
+
<label for="branch">delete repository</label>
+
<button class="btn my-2" type="text">delete</button>
+
<span>
+
Deleting a repository is irreversible and permanent.
+
</span>
+
</form>
+
{{ end }}
+
{{ end }}
+24 -22
appview/pages/templates/repo/tree.html
···
{{ $containerstyle := "py-1" }}
{{ $linkstyle := "no-underline hover:underline" }}
-
<div class="pb-2 text-base">
-
<div class="flex justify-between">
-
<div id="breadcrumbs">
+
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
+
<div class="flex flex-col md:flex-row md:justify-between gap-2">
+
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
{{ range .BreadCrumbs }}
-
<a href="{{ index . 1}}" class="text-bold text-gray-500 {{ $linkstyle }}">{{ index . 0 }}</a> /
+
<a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ index . 0 }}</a> /
{{ end }}
</div>
-
<div id="dir-info">
-
<span class="text-gray-500 text-xs">
-
{{ $stats := .TreeStats }}
+
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
+
{{ $stats := .TreeStats }}
-
{{ if eq $stats.NumFolders 1 }}
-
{{ $stats.NumFolders }} folder
-
<span class="px-1 select-none">ยท</span>
-
{{ else if gt $stats.NumFolders 1 }}
-
{{ $stats.NumFolders }} folders
-
<span class="px-1 select-none">ยท</span>
-
{{ end }}
+
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
+
{{ if eq $stats.NumFolders 1 }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ $stats.NumFolders }} folder</span>
+
{{ else if gt $stats.NumFolders 1 }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ $stats.NumFolders }} folders</span>
+
{{ end }}
-
{{ if eq $stats.NumFiles 1 }}
-
{{ $stats.NumFiles }} file
-
{{ else if gt $stats.NumFiles 1 }}
-
{{ $stats.NumFiles }} files
-
{{ end }}
-
</span>
+
{{ if eq $stats.NumFiles 1 }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ $stats.NumFiles }} file</span>
+
{{ else if gt $stats.NumFiles 1 }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ $stats.NumFiles }} files</span>
+
{{ end }}
+
</div>
</div>
</div>
···
{{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }}
</div>
</a>
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time>
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
</div>
</div>
{{ end }}
···
{{ i "file" "w-3 h-3" }}{{ .Name }}
</div>
</a>
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time>
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
</div>
</div>
{{ end }}
+33 -33
appview/pages/templates/settings.html
···
{{ define "content" }}
<div class="p-6">
-
<p class="text-xl font-bold">Settings</p>
+
<p class="text-xl font-bold dark:text-white">Settings</p>
</div>
<div class="flex flex-col">
{{ block "profile" . }} {{ end }}
···
{{ end }}
{{ define "profile" }}
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">profile</h2>
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<dl class="grid grid-cols-[auto_1fr] gap-x-4">
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
{{ if .LoggedInUser.Handle }}
<dt class="font-bold">handle</dt>
<dd>@{{ .LoggedInUser.Handle }}</dd>
···
{{ end }}
{{ define "keys" }}
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2>
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<p class="mb-8">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
<div id="key-list" class="flex flex-col gap-6 mb-8">
{{ range $index, $key := .PubKeys }}
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
<div class="flex flex-col gap-1">
<div class="inline-flex items-center gap-4">
-
{{ i "key" "w-3 h-3" }}
-
<p class="font-bold">{{ .Name }}</p>
+
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
+
<p class="font-bold dark:text-white">{{ .Name }}</p>
</div>
-
<p class="text-sm text-gray-500">added {{ .Created | timeFmt }}</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p>
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
-
<code class="text-sm text-gray-500">{{ .Key }}</code>
+
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
</div>
</div>
<button
-
class="btn text-red-500 hover:text-red-700"
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2"
title="Delete key"
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?">
···
name="name"
placeholder="key name"
required
-
class="w-full"/>
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
<input
id="key"
name="key"
placeholder="ssh-rsa AAAAAA..."
required
-
class="w-full"/>
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
-
<button class="btn" type="submit">add key</button>
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add key</button>
-
<div id="settings-keys" class="error"></div>
+
<div id="settings-keys" class="error dark:text-red-400"></div>
</form>
</section>
{{ end }}
{{ define "emails" }}
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2>
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<p class="mb-8">Commits authored using emails listed here will be associated with your Tangled profile.</p>
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
<div id="email-list" class="flex flex-col gap-6 mb-8">
{{ range $index, $email := .Emails }}
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
<div class="flex flex-col gap-2">
<div class="inline-flex items-center gap-4">
-
{{ i "mail" "w-3 h-3" }}
-
<p class="font-bold">{{ .Address }}</p>
+
{{ i "mail" "w-3 h-3 dark:text-gray-300" }}
+
<p class="font-bold dark:text-white">{{ .Address }}</p>
<div class="inline-flex items-center gap-1">
{{ if .Verified }}
-
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span>
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
{{ else }}
-
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span>
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
{{ end }}
{{ if .Primary }}
-
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span>
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
{{ end }}
</div>
</div>
-
<p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p>
</div>
<div class="flex gap-2 items-center">
{{ if not .Verified }}
<button
-
class="btn flex gap-2"
+
class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
hx-post="/settings/emails/verify/resend"
hx-swap="none"
href="#"
···
{{ end }}
{{ if and (not .Primary) .Verified }}
<a
-
class="text-sm"
+
class="text-sm dark:text-blue-400 dark:hover:text-blue-300"
hx-post="/settings/emails/primary"
hx-swap="none"
href="#"
···
<form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?">
<input type="hidden" name="email" value="{{ .Address }}">
<button
-
class="btn text-red-500 hover:text-red-700"
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Delete email"
type="submit">
{{ i "trash-2" "w-5 h-5" }}
···
name="email"
placeholder="your@email.com"
required
-
class="w-full"/>
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
-
<button class="btn" type="submit">add email</button>
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add email</button>
-
<div id="settings-emails-error" class="error"></div>
-
<div id="settings-emails-success" class="success"></div>
+
<div id="settings-emails-error" class="error dark:text-red-400"></div>
+
<div id="settings-emails-success" class="success dark:text-green-400"></div>
</form>
</section>
-
{{ end }}
+
{{ end }}
+22 -14
appview/pages/templates/timeline.html
···
{{ end }}
{{ define "hero" }}
-
<div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white text-black py-4 px-10">
+
<div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white dark:bg-gray-800 text-black dark:text-white py-4 px-10">
<div class="font-bold italic text-4xl mb-4">
tangled
</div>
<div class="italic text-lg">
tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a>
-
<p class="pt-5 px-10 text-sm text-gray-500">Join our IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>.
+
<p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our <a href="https://chat.tangled.sh">Discord</a>or IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>.
Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p>
</div>
</div>
···
{{ define "timeline" }}
<div>
<div class="p-6">
-
<p class="text-xl font-bold">Timeline</p>
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
</div>
<div class="flex flex-col gap-3 relative">
-
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div>
+
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div>
{{ range .Timeline }}
-
<div class="px-6 py-2 bg-white rounded drop-shadow-sm w-fit">
+
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit">
{{ if .Repo }}
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
<div class="flex items-center">
-
<p class="text-gray-600">
+
<p class="text-gray-600 dark:text-gray-300">
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
-
created
-
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
-
<time class="text-gray-700 text-xs">{{ .Repo.Created | timeFmt }}</time>
+
{{ if .Source }}
+
forked
+
<a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline">
+
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}
+
</a>
+
to
+
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
+
{{ else }}
+
created
+
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
+
{{ end }}
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time>
</p>
</div>
{{ else if .Follow }}
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
<div class="flex items-center">
-
<p class="text-gray-600">
+
<p class="text-gray-600 dark:text-gray-300">
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
followed
<a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a>
-
<time class="text-gray-700 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time>
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time>
</p>
</div>
{{ else if .Star }}
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
<div class="flex items-center">
-
<p class="text-gray-600">
+
<p class="text-gray-600 dark:text-gray-300">
<a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a>
starred
<a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a>
-
<time class="text-gray-700 text-xs">{{ .Star.Created | timeFmt }}</time>
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Star.Created | timeFmt }}</time>
</p>
</div>
{{ end }}
···
</div>
</div>
{{ end }}
-
+17
appview/pages/templates/user/fragments/follow.html
···
+
{{ define "user/fragments/follow" }}
+
<button id="followBtn"
+
class="btn mt-2 w-full"
+
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
+
hx-post="/follow?subject={{.UserDid}}"
+
{{ else }}
+
hx-delete="/follow?subject={{.UserDid}}"
+
{{ end }}
+
+
hx-trigger="click"
+
hx-target="#followBtn"
+
hx-swap="outerHTML"
+
>
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+
</button>
+
{{ end }}
+15 -7
appview/pages/templates/user/login.html
···
{{ define "user/login" }}
<!doctype html>
-
<html lang="en">
+
<html lang="en" class="dark:bg-gray-900">
<head>
<meta charset="UTF-8" />
<meta
···
content="width=device-width, initial-scale=1.0"
/>
<script src="/static/htmx.min.js"></script>
-
<link rel="stylesheet" href="/static/tw.css" type="text/css" />
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>login</title>
</head>
<body class="flex items-center justify-center min-h-screen">
-
<main class="max-w-64">
-
<h1 class="text-center text-2xl font-semibold italic">
+
<main class="max-w-7xl 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">
+
<h2 class="text-center text-xl italic dark:text-white">
tightly-knit social coding.
</h2>
<form
···
>
<div class="flex flex-col">
<label for="handle">handle</label>
-
<input type="text" id="handle" name="handle" required />
+
<input
+
type="text"
+
id="handle"
+
name="handle"
+
tabindex="1"
+
required
+
/>
<span class="text-xs text-gray-500 mt-1">
You need to use your
<a href="https://bsky.app">Bluesky</a> handle to log
···
type="password"
id="app_password"
name="app_password"
+
tabindex="2"
required
/>
<span class="text-xs text-gray-500 mt-1">
···
class="btn w-full my-2 mt-6"
type="submit"
id="login-button"
+
tabindex="3"
>
<span>login</span>
</button>
</form>
<p class="text-sm text-gray-500">
-
Join our IRC channel:
+
Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel:
<a href="https://web.libera.chat/#tangled"
><code>#tangled</code> on Libera Chat</a
>.
+240 -27
appview/pages/templates/user/profile.html
···
{{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }}
{{ define "content" }}
-
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
-
<div class="md:col-span-1">
-
{{ block "profileCard" . }}{{ end }}
+
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
+
<div class="md:col-span-1 order-1 md:order-1">
+
{{ block "profileCard" . }}{{ end }}
+
</div>
+
<div class="md:col-span-2 order-2 md:order-2">
+
{{ block "ownRepos" . }}{{ end }}
+
{{ block "collaboratingRepos" . }}{{ end }}
+
</div>
+
<div class="md:col-span-2 order-3 md:order-3">
+
{{ block "profileTimeline" . }}{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "profileTimeline" }}
+
<p class="text-sm font-bold py-2 dark:text-white px-6">ACTIVITY</p>
+
<div class="flex flex-col gap-6 relative">
+
{{ with .ProfileTimeline }}
+
{{ range $idx, $byMonth := .ByMonth }}
+
{{ with $byMonth }}
+
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
+
{{ if eq $idx 0 }}
+
+
{{ else }}
+
{{ $s := "s" }}
+
{{ if eq $idx 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
+
{{ end }}
+
+
{{ if .IsEmpty }}
+
<div class="text-gray-500 dark:text-gray-400">
+
No activity for this month
+
</div>
+
{{ else }}
+
<div class="flex flex-col gap-1">
+
{{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }}
+
{{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }}
+
{{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }}
+
</div>
+
{{ end }}
</div>
-
<div class="md:col-span-3">
-
{{ block "ownRepos" . }}{{ end }}
-
{{ block "collaboratingRepos" . }}{{ end }}
+
{{ end }}
+
{{ else }}
+
<p class="dark:text-white">This user does not have any activity yet.</p>
+
{{ end }}
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "repoEvents" }}
+
{{ $items := index . 0 }}
+
{{ $handleMap := index . 1 }}
+
+
{{ if gt (len $items) 0 }}
+
<details>
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
<div class="flex flex-wrap items-center gap-2">
+
{{ i "book-plus" "w-4 h-4" }}
+
created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}}
+
</div>
+
</summary>
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
+
{{ range $items }}
+
<div class="flex flex-wrap items-center gap-2">
+
<span class="text-gray-500 dark:text-gray-400">
+
{{ if .Source }}
+
{{ i "git-fork" "w-4 h-4" }}
+
{{ else }}
+
{{ i "book-plus" "w-4 h-4" }}
+
{{ end }}
+
</span>
+
<a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
+
{{- .Repo.Name -}}
+
</a>
+
</div>
+
{{ end }}
+
</div>
+
</details>
+
{{ end }}
+
{{ end }}
+
+
{{ define "issueEvents" }}
+
{{ $i := index . 0 }}
+
{{ $items := $i.Items }}
+
{{ $stats := $i.Stats }}
+
{{ $handleMap := index . 1 }}
+
+
{{ if gt (len $items) 0 }}
+
<details>
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
<div class="flex flex-wrap items-center gap-2">
+
{{ i "circle-dot" "w-4 h-4" }}
+
+
<div>
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
+
</div>
+
+
{{ if gt $stats.Open 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
+
{{$stats.Open}} open
+
</span>
+
{{ end }}
+
+
{{ if gt $stats.Closed 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
+
{{$stats.Closed}} closed
+
</span>
+
{{ end }}
+
+
</div>
+
</summary>
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
+
{{ range $items }}
+
{{ $repoOwner := index $handleMap .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">
+
{{ if .Open }}
+
<span class="text-green-600 dark:text-green-500">
+
{{ i "circle-dot" "w-4 h-4" }}
+
</span>
+
{{ else }}
+
<span class="text-gray-500 dark:text-gray-400">
+
{{ i "ban" "w-4 h-4" }}
+
</span>
+
{{ end }}
+
<div class="flex-none min-w-8 text-right">
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
+
</div>
+
<div class="break-words max-w-full">
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
+
{{ .Title -}}
+
</a>
+
on
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
+
{{$repoUrl}}
+
</a>
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</details>
+
{{ end }}
+
{{ end }}
+
+
{{ define "pullEvents" }}
+
{{ $i := index . 0 }}
+
{{ $items := $i.Items }}
+
{{ $stats := $i.Stats }}
+
{{ $handleMap := index . 1 }}
+
{{ if gt (len $items) 0 }}
+
<details>
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
<div class="flex flex-wrap items-center gap-2">
+
{{ i "git-pull-request" "w-4 h-4" }}
+
+
<div>
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
+
</div>
+
+
{{ if gt $stats.Open 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
+
{{$stats.Open}} open
+
</span>
+
{{ end }}
+
+
{{ if gt $stats.Merged 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
+
{{$stats.Merged}} merged
+
</span>
+
{{ end }}
+
+
+
{{ if gt $stats.Closed 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
+
{{$stats.Closed}} closed
+
</span>
+
{{ end }}
+
</div>
-
</div>
+
</summary>
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
+
{{ range $items }}
+
{{ $repoOwner := index $handleMap .Repo.Did }}
+
{{ $repoName := .Repo.Name }}
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
+
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
+
{{ if .State.IsOpen }}
+
<span class="text-green-600 dark:text-green-500">
+
{{ i "git-pull-request" "w-4 h-4" }}
+
</span>
+
{{ else if .State.IsMerged }}
+
<span class="text-purple-600 dark:text-purple-500">
+
{{ i "git-merge" "w-4 h-4" }}
+
</span>
+
{{ else }}
+
<span class="text-gray-600 dark:text-gray-300">
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
+
</span>
+
{{ end }}
+
<div class="flex-none min-w-8 text-right">
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
+
</div>
+
<div class="break-words max-w-full">
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
+
{{ .Title -}}
+
</a>
+
on
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
+
{{$repoUrl}}
+
</a>
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</details>
+
{{ end }}
{{ end }}
{{ define "profileCard" }}
-
<div class="bg-white px-6 py-4 rounded drop-shadow-sm max-h-fit">
+
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
<div class="flex justify-center items-center">
{{ if .AvatarUri }}
-
<img class="w-1/2 rounded-full p-2" src="{{ .AvatarUri }}" />
+
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
{{ end }}
</div>
-
<p class="text-xl font-bold text-center">
-
{{ truncateAt30 (didOrHandle .UserDid .UserHandle) }}
+
<p
+
title="{{ didOrHandle .UserDid .UserHandle }}"
+
class="text-lg font-bold text-center dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"
+
>
+
{{ didOrHandle .UserDid .UserHandle }}
</p>
-
<div class="text-sm text-center">
+
<div class="text-sm text-center dark:text-gray-300">
<span>{{ .ProfileStats.Followers }} followers</span>
<div
class="inline-block px-1 select-none after:content-['ยท']"
···
</div>
{{ if ne .FollowStatus.String "IsSelf" }}
-
{{ template "fragments/follow" . }}
+
{{ template "user/fragments/follow" . }}
{{ end }}
</div>
{{ end }}
{{ define "ownRepos" }}
-
<p class="text-sm font-bold py-2 px-6">REPOS</p>
-
<div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
+
<p class="text-sm font-bold py-2 px-6 dark:text-white">REPOS</p>
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Repos }}
<div
id="repo-card"
-
class="py-4 px-6 drop-shadow-sm rounded bg-white"
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"
>
-
<div id="repo-card-name" class="font-medium">
+
<div id="repo-card-name" class="font-medium dark:text-white">
<a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}"
>{{ .Name }}</a
>
</div>
{{ if .Description }}
-
<div class="text-gray-600 text-sm">
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
{{ .Description }}
</div>
{{ end }}
···
</div>
</div>
{{ else }}
-
<p class="px-6">This user does not have any repos yet.</p>
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
{{ end }}
</div>
-
{{ end }}
-
{{ define "collaboratingRepos" }}
-
<p class="text-sm font-bold py-2 px-6">COLLABORATING ON</p>
-
<div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
+
<p class="text-sm font-bold py-2 px-6 dark:text-white">COLLABORATING ON</p>
+
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
{{ range .CollaboratingRepos }}
<div
id="repo-card"
-
class="py-4 px-6 drop-shadow-sm rounded bg-white flex flex-col"
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col"
>
-
<div id="repo-card-name" class="font-medium">
+
<div id="repo-card-name" class="font-medium dark:text-white">
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
{{ index $.DidHandleMap .Did }}/{{ .Name }}
</a>
</div>
{{ if .Description }}
-
<div class="text-gray-600 text-sm">
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
{{ .Description }}
</div>
{{ end }}
···
</div>
</div>
{{ else }}
-
<p class="px-6">This user is not collaborating.</p>
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
{{ end }}
</div>
{{ end }}
+31
appview/pagination/page.go
···
+
package pagination
+
+
type Page struct {
+
Offset int // where to start from
+
Limit int // number of items in a page
+
}
+
+
func FirstPage() Page {
+
return Page{
+
Offset: 0,
+
Limit: 10,
+
}
+
}
+
+
func (p Page) Previous() Page {
+
if p.Offset-p.Limit < 0 {
+
return FirstPage()
+
} else {
+
return Page{
+
Offset: p.Offset - p.Limit,
+
Limit: p.Limit,
+
}
+
}
+
}
+
+
func (p Page) Next() Page {
+
return Page{
+
Offset: p.Offset + p.Limit,
+
Limit: p.Limit,
+
}
+
}
+451
appview/settings/settings.go
···
+
package settings
+
+
import (
+
"database/sql"
+
"errors"
+
"fmt"
+
"log"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/email"
+
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/gliderlabs/ssh"
+
"github.com/google/uuid"
+
)
+
+
type Settings struct {
+
Db *db.DB
+
Auth *auth.Auth
+
Pages *pages.Pages
+
Config *appview.Config
+
}
+
+
func (s *Settings) Router() http.Handler {
+
r := chi.NewRouter()
+
+
r.Use(middleware.AuthMiddleware(s.Auth))
+
+
r.Get("/", s.settings)
+
+
r.Route("/keys", func(r chi.Router) {
+
r.Put("/", s.keys)
+
r.Delete("/", s.keys)
+
})
+
+
r.Route("/emails", func(r chi.Router) {
+
r.Put("/", s.emails)
+
r.Delete("/", s.emails)
+
r.Get("/verify", s.emailsVerify)
+
r.Post("/verify/resend", s.emailsVerifyResend)
+
r.Post("/primary", s.emailsPrimary)
+
})
+
+
return r
+
}
+
+
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
+
user := s.Auth.GetUser(r)
+
pubKeys, err := db.GetPublicKeys(s.Db, user.Did)
+
if err != nil {
+
log.Println(err)
+
}
+
+
emails, err := db.GetAllEmails(s.Db, user.Did)
+
if err != nil {
+
log.Println(err)
+
}
+
+
s.Pages.Settings(w, pages.SettingsParams{
+
LoggedInUser: user,
+
PubKeys: pubKeys,
+
Emails: emails,
+
})
+
}
+
+
// buildVerificationEmail creates an email.Email struct for verification emails
+
func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email {
+
verifyURL := s.verifyUrl(did, emailAddr, code)
+
+
return email.Email{
+
APIKey: s.Config.ResendApiKey,
+
From: "noreply@notifs.tangled.sh",
+
To: emailAddr,
+
Subject: "Verify your Tangled email",
+
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
+
` + verifyURL,
+
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
+
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
+
}
+
}
+
+
// sendVerificationEmail handles the common logic for sending verification emails
+
func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
+
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
+
+
err := email.SendEmail(emailToSend)
+
if err != nil {
+
log.Printf("sending email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
+
return err
+
}
+
+
return nil
+
}
+
+
func (s *Settings) emails(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.Notice(w, "settings-emails", "Unimplemented.")
+
log.Println("unimplemented")
+
return
+
case http.MethodPut:
+
did := s.Auth.GetDid(r)
+
emAddr := r.FormValue("email")
+
emAddr = strings.TrimSpace(emAddr)
+
+
if !email.IsValidEmail(emAddr) {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
+
return
+
}
+
+
// check if email already exists in database
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
+
log.Printf("checking for existing email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
if err == nil {
+
if existingEmail.Verified {
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
+
return
+
}
+
+
code := uuid.New().String()
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.AddEmail(tx, db.Email{
+
Did: did,
+
Address: emAddr,
+
Verified: false,
+
VerificationCode: code,
+
}); err != nil {
+
log.Printf("adding email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
+
return
+
case http.MethodDelete:
+
did := s.Auth.GetDid(r)
+
emailAddr := r.FormValue("email")
+
emailAddr = strings.TrimSpace(emailAddr)
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
+
log.Printf("deleting email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
}
+
}
+
+
func (s *Settings) verifyUrl(did string, email string, code string) string {
+
var appUrl string
+
if s.Config.Dev {
+
appUrl = "http://" + s.Config.ListenAddr
+
} else {
+
appUrl = "https://tangled.sh"
+
}
+
+
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+
}
+
+
func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
+
q := r.URL.Query()
+
+
// Get the parameters directly from the query
+
emailAddr := q.Get("email")
+
did := q.Get("did")
+
code := q.Get("code")
+
+
valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code)
+
if err != nil {
+
log.Printf("checking email verification: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
+
return
+
}
+
+
if !valid {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
+
return
+
}
+
+
// Mark email as verified in the database
+
if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil {
+
log.Printf("marking email as verified: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
+
return
+
}
+
+
http.Redirect(w, r, "/settings", http.StatusSeeOther)
+
}
+
+
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid request method.")
+
return
+
}
+
+
did := s.Auth.GetDid(r)
+
emAddr := r.FormValue("email")
+
emAddr = strings.TrimSpace(emAddr)
+
+
if !email.IsValidEmail(emAddr) {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
+
return
+
}
+
+
// Check if email exists and is unverified
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
+
if err != nil {
+
if errors.Is(err, sql.ErrNoRows) {
+
s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
+
} else {
+
log.Printf("checking for existing email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
}
+
return
+
}
+
+
if existingEmail.Verified {
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
+
return
+
}
+
+
// Check if last verification email was sent less than 10 minutes ago
+
if existingEmail.LastSent != nil {
+
timeSinceLastSent := time.Since(*existingEmail.LastSent)
+
if timeSinceLastSent < 10*time.Minute {
+
waitTime := 10*time.Minute - timeSinceLastSent
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
+
return
+
}
+
}
+
+
// Generate new verification code
+
code := uuid.New().String()
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
// Update the verification code and last sent time
+
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
+
log.Printf("updating email verification: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
+
// Send verification email
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
+
}
+
+
func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
+
did := s.Auth.GetDid(r)
+
emailAddr := r.FormValue("email")
+
emailAddr = strings.TrimSpace(emailAddr)
+
+
if emailAddr == "" {
+
s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
+
return
+
}
+
+
if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil {
+
log.Printf("setting primary email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
}
+
+
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.Notice(w, "settings-keys", "Unimplemented.")
+
log.Println("unimplemented")
+
return
+
case http.MethodPut:
+
did := s.Auth.GetDid(r)
+
key := r.FormValue("key")
+
key = strings.TrimSpace(key)
+
name := r.FormValue("name")
+
client, _ := s.Auth.AuthorizedClient(r)
+
+
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
+
if err != nil {
+
log.Printf("parsing public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
+
return
+
}
+
+
rkey := appview.TID()
+
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start tx; adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
+
log.Printf("adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to add public key.")
+
return
+
}
+
+
// store in pds too
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.PublicKeyNSID,
+
Repo: did,
+
Rkey: rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.PublicKey{
+
Created: time.Now().Format(time.RFC3339),
+
Key: key,
+
Name: name,
+
}},
+
})
+
// invalid record
+
if err != nil {
+
log.Printf("failed to create record: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to create record.")
+
return
+
}
+
+
log.Println("created atproto record: ", resp.Uri)
+
+
err = tx.Commit()
+
if err != nil {
+
log.Printf("failed to commit tx; adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
+
case http.MethodDelete:
+
did := s.Auth.GetDid(r)
+
q := r.URL.Query()
+
+
name := q.Get("name")
+
rkey := q.Get("rkey")
+
key := q.Get("key")
+
+
log.Println(name)
+
log.Println(rkey)
+
log.Println(key)
+
+
client, _ := s.Auth.AuthorizedClient(r)
+
+
if err := db.RemovePublicKey(s.Db, did, name, key); err != nil {
+
log.Printf("removing public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to remove public key.")
+
return
+
}
+
+
if rkey != "" {
+
// remove from pds too
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.PublicKeyNSID,
+
Repo: did,
+
Rkey: rkey,
+
})
+
+
// invalid record
+
if err != nil {
+
log.Printf("failed to delete record from PDS: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
+
return
+
}
+
}
+
log.Println("deleted successfully")
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
}
+
}
+2 -1
appview/state/follow.go
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
tangled "tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
switch r.Method {
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
-
rkey := s.TID()
+
rkey := appview.TID()
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.GraphFollowNSID,
Repo: currentUser.Did,
+1 -1
appview/state/jetstream.go
···
defer func() {
eventTime := e.TimeUS
lastTimeUs := eventTime + 1
-
if err := d.UpdateLastTimeUs(lastTimeUs); err != nil {
+
if err := d.SaveLastTimeUs(lastTimeUs); err != nil {
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
}
}()
+16 -93
appview/state/middleware.go
···
"strings"
"time"
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"slices"
+
"github.com/bluesky-social/indigo/atproto/identity"
-
"github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
-
"tangled.sh/tangled.sh/core/appview"
-
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/middleware"
)
-
type Middleware func(http.Handler) http.Handler
-
-
func AuthMiddleware(s *State) Middleware {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
-
}
-
if r.Header.Get("HX-Request") == "true" {
-
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
-
w.Header().Set("HX-Redirect", "/login")
-
w.WriteHeader(http.StatusOK)
-
}
-
}
-
-
session, err := s.auth.GetSession(r)
-
if session.IsNew || err != nil {
-
log.Printf("not logged in, redirecting")
-
redirectFunc(w, r)
-
return
-
}
-
-
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
-
if !ok || !authorized {
-
log.Printf("not logged in, redirecting")
-
redirectFunc(w, r)
-
return
-
}
-
-
// refresh if nearing expiry
-
// TODO: dedup with /login
-
expiryStr := session.Values[appview.SessionExpiry].(string)
-
expiry, err := time.Parse(time.RFC3339, expiryStr)
-
if err != nil {
-
log.Println("invalid expiry time", err)
-
redirectFunc(w, r)
-
return
-
}
-
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
-
did, ok2 := session.Values[appview.SessionDid].(string)
-
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
-
-
if !ok1 || !ok2 || !ok3 {
-
log.Println("invalid expiry time", err)
-
redirectFunc(w, r)
-
return
-
}
-
-
if time.Now().After(expiry) {
-
log.Println("token expired, refreshing ...")
-
-
client := xrpc.Client{
-
Host: pdsUrl,
-
Auth: &xrpc.AuthInfo{
-
Did: did,
-
AccessJwt: refreshJwt,
-
RefreshJwt: refreshJwt,
-
},
-
}
-
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
-
if err != nil {
-
log.Println("failed to refresh session", err)
-
redirectFunc(w, r)
-
return
-
}
-
-
sessionish := auth.RefreshSessionWrapper{atSession}
-
-
err = s.auth.StoreSession(r, w, &sessionish, pdsUrl)
-
if err != nil {
-
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
-
return
-
}
-
-
log.Println("successfully refreshed token")
-
}
-
-
next.ServeHTTP(w, r)
-
})
-
}
-
}
-
-
func knotRoleMiddleware(s *State, group string) Middleware {
+
func knotRoleMiddleware(s *State, group string) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requires auth also
···
}
}
-
func KnotOwner(s *State) Middleware {
+
func KnotOwner(s *State) middleware.Middleware {
return knotRoleMiddleware(s, "server:owner")
}
-
func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware {
+
func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requires auth also
···
return
}
-
ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.OwnerSlashRepo(), requiredPerm)
+
ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
if err != nil || !ok {
// we need a logged in user
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo())
···
})
}
-
func ResolveIdent(s *State) Middleware {
+
func ResolveIdent(s *State) middleware.Middleware {
+
excluded := []string{"favicon.ico"}
+
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
didOrHandle := chi.URLParam(req, "user")
+
if slices.Contains(excluded, didOrHandle) {
+
next.ServeHTTP(w, req)
+
return
+
}
id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle)
if err != nil {
···
}
}
-
func ResolveRepo(s *State) Middleware {
+
func ResolveRepo(s *State) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
repoName := chi.URLParam(req, "repo")
···
}
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
-
func ResolvePull(s *State) Middleware {
+
func ResolvePull(s *State) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, err := fullyResolvedRepo(r)
+102
appview/state/profile.go
···
+
package state
+
+
import (
+
"fmt"
+
"log"
+
"net/http"
+
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
)
+
+
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
+
didOrHandle := chi.URLParam(r, "user")
+
if didOrHandle == "" {
+
http.Error(w, "Bad request", http.StatusBadRequest)
+
return
+
}
+
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
s.pages.Error404(w)
+
return
+
}
+
+
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
+
}
+
+
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
+
}
+
+
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
+
}
+
+
var didsToResolve []string
+
for _, r := range collaboratingRepos {
+
didsToResolve = append(didsToResolve, r.Did)
+
}
+
for _, byMonth := range timeline.ByMonth {
+
for _, pe := range byMonth.PullEvents.Items {
+
didsToResolve = append(didsToResolve, pe.Repo.Did)
+
}
+
for _, ie := range byMonth.IssueEvents.Items {
+
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
+
}
+
for _, re := range byMonth.RepoEvents {
+
didsToResolve = append(didsToResolve, re.Repo.Did)
+
if re.Source != nil {
+
didsToResolve = append(didsToResolve, re.Source.Did)
+
}
+
}
+
}
+
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
+
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
+
}
+
+
loggedInUser := s.auth.GetUser(r)
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
+
}
+
+
profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
+
if err != nil {
+
log.Println("failed to fetch bsky avatar", err)
+
}
+
+
s.pages.ProfilePage(w, pages.ProfilePageParams{
+
LoggedInUser: loggedInUser,
+
UserDid: ident.DID.String(),
+
UserHandle: ident.Handle.String(),
+
Repos: repos,
+
CollaboratingRepos: collaboratingRepos,
+
ProfileStats: pages.ProfileStats{
+
Followers: followers,
+
Following: following,
+
},
+
FollowStatus: db.FollowStatus(followStatus),
+
DidHandleMap: didHandleMap,
+
AvatarUri: profileAvatarUri,
+
ProfileTimeline: timeline,
+
})
+
}
+1011 -146
appview/state/pull.go
···
package state
import (
+
"database/sql"
"encoding/json"
+
"errors"
"fmt"
"io"
"log"
"net/http"
+
"net/url"
"strconv"
-
"strings"
"time"
-
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/go-chi/chi/v5"
)
// htmx fragment
···
}
mergeCheckResponse := s.mergeCheck(f, pull)
+
resubmitResult := pages.Unknown
+
if user.Did == pull.OwnerDid {
+
resubmitResult = s.resubmitCheck(f, pull)
+
}
s.pages.PullActionsFragment(w, pages.PullActionsParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(s, user),
-
Pull: pull,
-
RoundNumber: roundNumber,
-
MergeCheck: mergeCheckResponse,
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
Pull: pull,
+
RoundNumber: roundNumber,
+
MergeCheck: mergeCheckResponse,
+
ResubmitCheck: resubmitResult,
})
return
}
···
}
mergeCheckResponse := s.mergeCheck(f, pull)
+
resubmitResult := pages.Unknown
+
if user != nil && user.Did == pull.OwnerDid {
+
resubmitResult = s.resubmitCheck(f, pull)
+
}
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(s, user),
-
DidHandleMap: didHandleMap,
-
Pull: *pull,
-
MergeCheck: mergeCheckResponse,
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Pull: pull,
+
MergeCheck: mergeCheckResponse,
+
ResubmitCheck: resubmitResult,
})
}
···
return mergeCheckResponse
}
+
func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
+
if pull.State == db.PullMerged || pull.PullSource == nil {
+
return pages.Unknown
+
}
+
+
var knot, ownerDid, repoName string
+
+
if pull.PullSource.RepoAt != nil {
+
// fork-based pulls
+
sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
+
if err != nil {
+
log.Println("failed to get source repo", err)
+
return pages.Unknown
+
}
+
+
knot = sourceRepo.Knot
+
ownerDid = sourceRepo.Did
+
repoName = sourceRepo.Name
+
} else {
+
// pulls within the same repo
+
knot = f.Knot
+
ownerDid = f.OwnerDid()
+
repoName = f.RepoName
+
}
+
+
us, err := NewUnsignedClient(knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
+
return pages.Unknown
+
}
+
+
resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
+
if err != nil {
+
log.Println("failed to reach knotserver", err)
+
return pages.Unknown
+
}
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
log.Printf("error reading response body: %v", err)
+
return pages.Unknown
+
}
+
defer resp.Body.Close()
+
+
var result types.RepoBranchResponse
+
if err := json.Unmarshal(body, &result); err != nil {
+
log.Println("failed to parse response:", err)
+
return pages.Unknown
+
}
+
+
latestSubmission := pull.Submissions[pull.LastRoundNumber()]
+
if latestSubmission.SourceRev != result.Branch.Hash {
+
fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
+
return pages.ShouldResubmit
+
}
+
+
return pages.ShouldNotResubmit
+
}
+
func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
···
}
}
+
diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
+
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
LoggedInUser: user,
DidHandleMap: didHandleMap,
···
Pull: pull,
Round: roundIdInt,
Submission: pull.Submissions[roundIdInt],
-
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
+
Diff: &diff,
})
}
+
func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to get pull.")
+
return
+
}
+
+
roundId := chi.URLParam(r, "round")
+
roundIdInt, err := strconv.Atoi(roundId)
+
if err != nil || roundIdInt >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
return
+
}
+
+
if roundIdInt == 0 {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("cannot interdiff initial submission")
+
return
+
}
+
+
identsToResolve := []string{pull.OwnerDid}
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
+
currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
+
if err != nil {
+
log.Println("failed to interdiff; current patch malformed")
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
+
return
+
}
+
+
previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
+
if err != nil {
+
log.Println("failed to interdiff; previous patch malformed")
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
+
return
+
}
+
+
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
+
+
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
+
LoggedInUser: s.auth.GetUser(r),
+
RepoInfo: f.RepoInfo(s, user),
+
Pull: pull,
+
Round: roundIdInt,
+
DidHandleMap: didHandleMap,
+
Interdiff: interdiff,
+
})
+
return
+
}
+
+
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
return
+
}
+
+
roundId := chi.URLParam(r, "round")
+
roundIdInt, err := strconv.Atoi(roundId)
+
if err != nil || roundIdInt >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
return
+
}
+
+
identsToResolve := []string{pull.OwnerDid}
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
+
w.Header().Set("Content-Type", "text/plain")
+
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
+
}
+
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
user := s.auth.GetUser(r)
params := r.URL.Query()
···
log.Println("failed to get pulls", err)
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
return
+
}
+
+
for _, p := range pulls {
+
var pullSourceRepo *db.Repo
+
if p.PullSource != nil {
+
if p.PullSource.RepoAt != nil {
+
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
+
if err != nil {
+
log.Printf("failed to get repo by at uri: %v", err)
+
continue
+
} else {
+
p.PullSource.Repo = pullSourceRepo
+
}
+
}
+
}
}
identsToResolve := make([]string, len(pulls))
···
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoPullCommentNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoPullComment{
Repo: &atUri,
···
},
},
})
-
log.Println(atResp.Uri)
if err != nil {
log.Println("failed to create pull comment", err)
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
title := r.FormValue("title")
body := r.FormValue("body")
targetBranch := r.FormValue("targetBranch")
+
fromFork := r.FormValue("fork")
+
sourceBranch := r.FormValue("sourceBranch")
patch := r.FormValue("patch")
-
if title == "" || body == "" || patch == "" || targetBranch == "" {
-
s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
+
if targetBranch == "" {
+
s.pages.Notice(w, "pull", "Target branch is required.")
return
}
-
// Validate patch format
-
if !isPatchValid(patch) {
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
-
return
+
// Determine PR type based on input parameters
+
isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
+
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
+
isForkBased := fromFork != "" && sourceBranch != ""
+
isPatchBased := patch != "" && !isBranchBased && !isForkBased
+
+
if isPatchBased && !patchutil.IsFormatPatch(patch) {
+
if title == "" {
+
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
+
return
+
}
}
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start tx")
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
// Validate we have at least one valid PR creation method
+
if !isBranchBased && !isPatchBased && !isForkBased {
+
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
return
}
-
defer tx.Rollback()
-
rkey := s.TID()
-
initialSubmission := db.PullSubmission{
-
Patch: patch,
+
// Can't mix branch-based and patch-based approaches
+
if isBranchBased && patch != "" {
+
s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
+
return
}
-
err = db.NewPull(tx, &db.Pull{
-
Title: title,
-
Body: body,
-
TargetBranch: targetBranch,
-
OwnerDid: user.Did,
-
RepoAt: f.RepoAt,
-
Rkey: rkey,
-
Submissions: []*db.PullSubmission{
-
&initialSubmission,
-
},
-
})
+
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
if err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
return
}
-
client, _ := s.auth.AuthorizedClient(r)
-
pullId, err := db.NextPullId(s.db, f.RepoAt)
+
+
caps, err := us.Capabilities()
if err != nil {
-
log.Println("failed to get pull id", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
log.Println("error fetching knot caps", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
return
+
}
+
+
if !caps.PullRequests.FormatPatch {
+
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
return
}
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoPullNSID,
-
Repo: user.Did,
-
Rkey: rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoPull{
-
Title: title,
-
PullId: int64(pullId),
-
TargetRepo: string(f.RepoAt),
-
TargetBranch: targetBranch,
-
Patch: patch,
-
},
-
},
-
})
+
// Handle the PR creation based on the type
+
if isBranchBased {
+
if !caps.PullRequests.BranchSubmissions {
+
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
+
return
+
}
+
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
+
} else if isForkBased {
+
if !caps.PullRequests.ForkSubmissions {
+
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
+
return
+
}
+
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
+
} else if isPatchBased {
+
if !caps.PullRequests.PatchSubmissions {
+
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
+
return
+
}
+
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
+
}
+
return
+
}
+
}
+
+
func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
+
pullSource := &db.PullSource{
+
Branch: sourceBranch,
+
}
+
recordPullSource := &tangled.RepoPull_Source{
+
Branch: sourceBranch,
+
}
+
+
// Generate a patch using /compare
+
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
+
if err != nil {
+
log.Println("failed to compare", err)
+
s.pages.Notice(w, "pull", err.Error())
+
return
+
}
+
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
+
if !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
+
return
+
}
+
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
+
}
+
+
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
+
if !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
+
return
+
}
+
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
+
}
+
+
func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
+
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
+
if errors.Is(err, sql.ErrNoRows) {
+
s.pages.Notice(w, "pull", "No such fork.")
+
return
+
} else if err != nil {
+
log.Println("failed to fetch fork:", err)
+
s.pages.Notice(w, "pull", "Failed to fetch fork.")
+
return
+
}
+
+
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
+
if err != nil {
+
log.Println("failed to fetch registration key:", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
+
if err != nil {
+
log.Println("failed to create signed client:", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
+
if err != nil {
+
log.Println("failed to create unsigned client:", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
+
if err != nil {
+
log.Println("failed to create hidden ref:", err, resp.StatusCode)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
switch resp.StatusCode {
+
case 404:
+
case 400:
+
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
+
return
+
}
+
+
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
+
// We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
+
// the targetBranch on the target repository. This code is a bit confusing, but here's an example:
+
// hiddenRef: hidden/feature-1/main (on repo-fork)
+
// targetBranch: main (on repo-1)
+
// sourceBranch: feature-1 (on repo-fork)
+
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
+
if err != nil {
+
log.Println("failed to compare across branches", err)
+
s.pages.Notice(w, "pull", err.Error())
+
return
+
}
+
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
+
if !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
+
return
+
}
+
+
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
+
if err != nil {
+
log.Println("failed to parse fork AT URI", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
+
Branch: sourceBranch,
+
RepoAt: &forkAtUri,
+
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
+
}
+
+
func (s *State) createPullRequest(
+
w http.ResponseWriter,
+
r *http.Request,
+
f *FullyResolvedRepo,
+
user *auth.User,
+
title, body, targetBranch string,
+
patch string,
+
sourceRev string,
+
pullSource *db.PullSource,
+
recordPullSource *tangled.RepoPull_Source,
+
) {
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
defer tx.Rollback()
-
err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
+
// We've already checked earlier if it's diff-based and title is empty,
+
// so if it's still empty now, it's intentionally skipped owing to format-patch.
+
if title == "" {
+
formatPatches, err := patchutil.ExtractPatches(patch)
if err != nil {
-
log.Println("failed to get pull id", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
+
return
+
}
+
if len(formatPatches) == 0 {
+
s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
return
}
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
+
title = formatPatches[0].Title
+
body = formatPatches[0].Body
+
}
+
+
rkey := appview.TID()
+
initialSubmission := db.PullSubmission{
+
Patch: patch,
+
SourceRev: sourceRev,
+
}
+
err = db.NewPull(tx, &db.Pull{
+
Title: title,
+
Body: body,
+
TargetBranch: targetBranch,
+
OwnerDid: user.Did,
+
RepoAt: f.RepoAt,
+
Rkey: rkey,
+
Submissions: []*db.PullSubmission{
+
&initialSubmission,
+
},
+
PullSource: pullSource,
+
})
+
if err != nil {
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
client, _ := s.auth.AuthorizedClient(r)
+
pullId, err := db.NextPullId(s.db, f.RepoAt)
+
if err != nil {
+
log.Println("failed to get pull id", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullNSID,
+
Repo: user.Did,
+
Rkey: rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPull{
+
Title: title,
+
PullId: int64(pullId),
+
TargetRepo: string(f.RepoAt),
+
TargetBranch: targetBranch,
+
Patch: patch,
+
Source: recordPullSource,
+
},
+
},
+
})
+
+
err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
+
if err != nil {
+
log.Println("failed to get pull id", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
+
}
+
+
func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
+
_, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
patch := r.FormValue("patch")
+
if patch == "" {
+
s.pages.Notice(w, "patch-error", "Patch is required.")
+
return
+
}
+
+
if patch == "" || !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
+
return
+
}
+
+
if patchutil.IsFormatPatch(patch) {
+
s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
+
} else {
+
s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
+
}
+
}
+
+
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
+
RepoInfo: f.RepoInfo(s, user),
+
})
+
}
+
+
func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create unsigned client for %s", f.Knot)
+
s.pages.Error503(w)
+
return
+
}
+
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
+
if err != nil {
+
log.Println("failed to reach knotserver", err)
+
return
+
}
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
log.Printf("Error reading response body: %v", err)
+
return
+
}
+
+
var result types.RepoBranchesResponse
+
err = json.Unmarshal(body, &result)
+
if err != nil {
+
log.Println("failed to parse response:", err)
+
return
+
}
+
+
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
+
RepoInfo: f.RepoInfo(s, user),
+
Branches: result.Branches,
+
})
+
}
+
+
func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
forks, err := db.GetForksByDid(s.db, user.Did)
+
if err != nil {
+
log.Println("failed to get forks", err)
+
return
+
}
+
+
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
+
RepoInfo: f.RepoInfo(s, user),
+
Forks: forks,
+
})
+
}
+
+
func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
forkVal := r.URL.Query().Get("fork")
+
+
// fork repo
+
repo, err := db.GetRepo(s.db, user.Did, forkVal)
+
if err != nil {
+
log.Println("failed to get repo", user.Did, forkVal)
+
return
+
}
+
+
sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create unsigned client for %s", repo.Knot)
+
s.pages.Error503(w)
+
return
+
}
+
+
sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
+
if err != nil {
+
log.Println("failed to reach knotserver for source branches", err)
+
return
+
}
+
+
sourceBody, err := io.ReadAll(sourceResp.Body)
+
if err != nil {
+
log.Println("failed to read source response body", err)
return
}
+
defer sourceResp.Body.Close()
+
+
var sourceResult types.RepoBranchesResponse
+
err = json.Unmarshal(sourceBody, &sourceResult)
+
if err != nil {
+
log.Println("failed to parse source branches response:", err)
+
return
+
}
+
+
targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
+
s.pages.Error503(w)
+
return
+
}
+
+
targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
+
if err != nil {
+
log.Println("failed to reach knotserver for target branches", err)
+
return
+
}
+
+
targetBody, err := io.ReadAll(targetResp.Body)
+
if err != nil {
+
log.Println("failed to read target response body", err)
+
return
+
}
+
defer targetResp.Body.Close()
+
+
var targetResult types.RepoBranchesResponse
+
err = json.Unmarshal(targetBody, &targetResult)
+
if err != nil {
+
log.Println("failed to parse target branches response:", err)
+
return
+
}
+
+
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
+
RepoInfo: f.RepoInfo(s, user),
+
SourceBranches: sourceResult.Branches,
+
TargetBranches: targetResult.Branches,
+
})
}
func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
···
})
return
case http.MethodPost:
-
patch := r.FormValue("patch")
-
-
if patch == "" {
-
s.pages.Notice(w, "resubmit-error", "Patch is empty.")
+
if pull.IsPatchBased() {
+
s.resubmitPatch(w, r)
+
return
+
} else if pull.IsBranchBased() {
+
s.resubmitBranch(w, r)
+
return
+
} else if pull.IsForkBased() {
+
s.resubmitFork(w, r)
return
}
+
}
+
}
-
if patch == pull.LatestPatch() {
-
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
-
return
-
}
+
func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
-
// Validate patch format
-
if !isPatchValid(patch) {
-
s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
-
return
-
}
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
return
+
}
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start tx")
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
defer tx.Rollback()
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
-
err = db.ResubmitPull(tx, pull, patch)
-
if err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
client, _ := s.auth.AuthorizedClient(r)
+
if user.Did != pull.OwnerDid {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
return
+
}
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
-
if err != nil {
-
// failed to get record
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
-
return
-
}
+
patch := r.FormValue("patch")
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoPullNSID,
-
Repo: user.Did,
-
Rkey: pull.Rkey,
-
SwapRecord: ex.Cid,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoPull{
-
Title: pull.Title,
-
PullId: int64(pull.PullId),
-
TargetRepo: string(f.RepoAt),
-
TargetBranch: pull.TargetBranch,
-
Patch: patch, // new patch
-
},
+
if err = validateResubmittedPatch(pull, patch); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
+
}
+
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.ResubmitPull(tx, pull, patch, "")
+
if err != nil {
+
log.Println("failed to resubmit pull request", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
+
return
+
}
+
client, _ := s.auth.AuthorizedClient(r)
+
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
+
if err != nil {
+
// failed to get record
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
+
return
+
}
+
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullNSID,
+
Repo: user.Did,
+
Rkey: pull.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPull{
+
Title: pull.Title,
+
PullId: int64(pull.PullId),
+
TargetRepo: string(f.RepoAt),
+
TargetBranch: pull.TargetBranch,
+
Patch: patch, // new patch
},
-
})
-
if err != nil {
-
log.Println("failed to update record", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
-
return
-
}
+
},
+
})
+
if err != nil {
+
log.Println("failed to update record", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
+
return
+
}
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to commit transaction", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
-
return
-
}
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
+
return
+
}
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
return
+
}
+
+
func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
return
}
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
if user.Did != pull.OwnerDid {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
return
+
}
+
+
if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
return
+
}
+
+
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create client for %s: %s", f.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
+
if err != nil {
+
log.Printf("compare request failed: %s", err)
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
+
}
+
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
+
if err = validateResubmittedPatch(pull, patch); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
+
}
+
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
+
return
+
}
+
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
+
if err != nil {
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
client, _ := s.auth.AuthorizedClient(r)
+
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
+
if err != nil {
+
// failed to get record
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
+
return
+
}
+
+
recordPullSource := &tangled.RepoPull_Source{
+
Branch: pull.PullSource.Branch,
+
}
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullNSID,
+
Repo: user.Did,
+
Rkey: pull.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPull{
+
Title: pull.Title,
+
PullId: int64(pull.PullId),
+
TargetRepo: string(f.RepoAt),
+
TargetBranch: pull.TargetBranch,
+
Patch: patch, // new patch
+
Source: recordPullSource,
+
},
+
},
+
})
+
if err != nil {
+
log.Println("failed to update record", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
+
return
+
}
+
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
return
+
}
+
+
func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
+
return
+
}
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
if user.Did != pull.OwnerDid {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
return
+
}
+
+
forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
+
if err != nil {
+
log.Println("failed to get source repo", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
// extract patch by performing compare
+
ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
+
if err != nil {
+
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
// update the hidden tracking branch to latest
+
signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
+
if err != nil || resp.StatusCode != http.StatusNoContent {
+
log.Printf("failed to update tracking branch: %s", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
+
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
+
if err != nil {
+
log.Printf("failed to compare branches: %s", err)
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
+
}
+
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
+
+
if err = validateResubmittedPatch(pull, patch); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
+
}
+
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
+
return
+
}
+
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
+
if err != nil {
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
client, _ := s.auth.AuthorizedClient(r)
+
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
+
if err != nil {
+
// failed to get record
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
+
return
+
}
+
+
repoAt := pull.PullSource.RepoAt.String()
+
recordPullSource := &tangled.RepoPull_Source{
+
Branch: pull.PullSource.Branch,
+
Repo: &repoAt,
+
}
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullNSID,
+
Repo: user.Did,
+
Rkey: pull.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPull{
+
Title: pull.Title,
+
PullId: int64(pull.PullId),
+
TargetRepo: string(f.RepoAt),
+
TargetBranch: pull.TargetBranch,
+
Patch: patch, // new patch
+
Source: recordPullSource,
+
},
+
},
+
})
+
if err != nil {
+
log.Println("failed to update record", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
+
return
+
}
+
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
return
+
}
+
+
// validate a resubmission against a pull request
+
func validateResubmittedPatch(pull *db.Pull, patch string) error {
+
if patch == "" {
+
return fmt.Errorf("Patch is empty.")
+
}
+
+
if patch == pull.LatestPatch() {
+
return fmt.Errorf("Patch is identical to previous submission.")
+
}
+
+
if !patchutil.IsPatchValid(patch) {
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
+
}
+
+
return nil
}
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
···
return
}
+
ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
+
if err != nil {
+
log.Printf("resolving identity: %s", err)
+
w.WriteHeader(http.StatusNotFound)
+
return
+
}
+
+
email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
+
if err != nil {
+
log.Printf("failed to get primary email: %s", err)
+
}
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
if err != nil {
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
···
}
// Merge the pull request
-
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "")
+
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
if err != nil {
log.Printf("failed to merge pull request: %s", err)
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
return
}
-
-
// Very basic validation to check if it looks like a diff/patch
-
// A valid patch usually starts with diff or --- lines
-
func isPatchValid(patch string) bool {
-
// Basic validation to check if it looks like a diff/patch
-
// A valid patch usually starts with diff or --- lines
-
if len(patch) == 0 {
-
return false
-
}
-
-
lines := strings.Split(patch, "\n")
-
if len(lines) < 2 {
-
return false
-
}
-
-
// Check for common patch format markers
-
firstLine := strings.TrimSpace(lines[0])
-
return strings.HasPrefix(firstLine, "diff ") ||
-
strings.HasPrefix(firstLine, "--- ") ||
-
strings.HasPrefix(firstLine, "Index: ") ||
-
strings.HasPrefix(firstLine, "+++ ") ||
-
strings.HasPrefix(firstLine, "@@ ")
-
}
+803 -16
appview/state/repo.go
···
import (
"context"
+
"database/sql"
"encoding/json"
+
"errors"
"fmt"
"io"
"log"
-
"math/rand/v2"
+
mathrand "math/rand/v2"
"net/http"
"path"
"slices"
···
"strings"
"time"
+
"github.com/bluesky-social/indigo/atproto/data"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
+
"github.com/go-git/go-git/v5/plumbing"
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"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/types"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
if !s.config.Dev {
protocol = "https"
}
+
+
if !plumbing.IsHash(ref) {
+
s.pages.Error404(w)
+
return
+
}
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
if err != nil {
log.Println("failed to reach knotserver", err)
···
user := s.auth.GetUser(r)
var breadcrumbs [][]string
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
+
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
if treePath != "" {
for idx, elem := range strings.Split(treePath, "/") {
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
}
}
-
baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
-
baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
+
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
+
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
s.pages.RepoTree(w, pages.RepoTreeParams{
LoggedInUser: user,
···
}
var breadcrumbs [][]string
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
+
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
if filePath != "" {
for idx, elem := range strings.Split(filePath, "/") {
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
}
}
+
showRendered := false
+
renderToggle := false
+
+
if markup.GetFormat(result.Path) == markup.FormatMarkdown {
+
renderToggle = true
+
showRendered = r.URL.Query().Get("code") != "true"
+
}
+
user := s.auth.GetUser(r)
s.pages.RepoBlob(w, pages.RepoBlobParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(s, user),
RepoBlobResponse: result,
BreadCrumbs: breadcrumbs,
+
ShowRendered: showRendered,
+
RenderToggle: renderToggle,
})
return
}
+
func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
ref := chi.URLParam(r, "ref")
+
filePath := chi.URLParam(r, "*")
+
+
protocol := "http"
+
if !s.config.Dev {
+
protocol = "https"
+
}
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
+
if err != nil {
+
log.Println("failed to reach knotserver", err)
+
return
+
}
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
log.Printf("Error reading response body: %v", err)
+
return
+
}
+
+
var result types.RepoBlobResponse
+
err = json.Unmarshal(body, &result)
+
if err != nil {
+
log.Println("failed to parse response:", err)
+
return
+
}
+
+
if result.IsBinary {
+
w.Header().Set("Content-Type", "application/octet-stream")
+
w.Write(body)
+
return
+
}
+
+
w.Header().Set("Content-Type", "text/plain")
+
w.Write([]byte(result.Contents))
+
return
+
}
+
func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
f, err := fullyResolvedRepo(r)
if err != nil {
···
}
}()
-
err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
+
err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
if err != nil {
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
return
···
}
+
func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
// remove record from pds
+
xrpcClient, _ := s.auth.AuthorizedClient(r)
+
repoRkey := f.RepoAt.RecordKey().String()
+
_, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.RepoNSID,
+
Repo: user.Did,
+
Rkey: repoRkey,
+
})
+
if err != nil {
+
log.Printf("failed to delete record: %s", err)
+
s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
+
return
+
}
+
log.Println("removed repo record ", f.RepoAt.String())
+
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
+
if err != nil {
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
+
return
+
}
+
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
+
if err != nil {
+
log.Println("failed to create client to ", f.Knot)
+
return
+
}
+
+
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
+
if err != nil {
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
+
return
+
}
+
+
if ksResp.StatusCode != http.StatusNoContent {
+
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
+
} else {
+
log.Println("removed repo from knot ", f.Knot)
+
}
+
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start tx")
+
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
+
return
+
}
+
defer func() {
+
tx.Rollback()
+
err = s.enforcer.E.LoadPolicy()
+
if err != nil {
+
log.Println("failed to rollback policies")
+
}
+
}()
+
+
// remove collaborator RBAC
+
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
+
if err != nil {
+
s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
+
return
+
}
+
for _, c := range repoCollaborators {
+
did := c[0]
+
s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
+
}
+
log.Println("removed collaborators")
+
+
// remove repo RBAC
+
err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
+
if err != nil {
+
s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
+
return
+
}
+
+
// remove repo from db
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
+
if err != nil {
+
s.pages.Notice(w, "settings-delete", "Failed to update appview")
+
return
+
}
+
log.Println("removed repo from db")
+
+
err = tx.Commit()
+
if err != nil {
+
log.Println("failed to commit changes", err)
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
err = s.enforcer.E.SavePolicy()
+
if err != nil {
+
log.Println("failed to update ACLs", err)
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
+
}
+
+
func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
branch := r.FormValue("branch")
+
if branch == "" {
+
http.Error(w, "malformed form", http.StatusBadRequest)
+
return
+
}
+
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
+
if err != nil {
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
+
return
+
}
+
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
+
if err != nil {
+
log.Println("failed to create client to ", f.Knot)
+
return
+
}
+
+
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
+
if err != nil {
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
+
return
+
}
+
+
if ksResp.StatusCode != http.StatusNoContent {
+
s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
+
return
+
}
+
+
w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
+
}
+
func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
f, err := fullyResolvedRepo(r)
if err != nil {
···
isCollaboratorInviteAllowed := false
if user != nil {
-
ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
+
ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
if err == nil && ok {
isCollaboratorInviteAllowed = true
}
}
+
var branchNames []string
+
var defaultBranch string
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Println("failed to create unsigned client", err)
+
} else {
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
+
if err != nil {
+
log.Println("failed to reach knotserver", err)
+
} else {
+
defer resp.Body.Close()
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
log.Printf("Error reading response body: %v", err)
+
} else {
+
var result types.RepoBranchesResponse
+
err = json.Unmarshal(body, &result)
+
if err != nil {
+
log.Println("failed to parse response:", err)
+
} else {
+
for _, branch := range result.Branches {
+
branchNames = append(branchNames, branch.Name)
+
}
+
}
+
}
+
}
+
+
resp, err = us.DefaultBranch(f.OwnerDid(), f.RepoName)
+
if err != nil {
+
log.Println("failed to reach knotserver", err)
+
} else {
+
defer resp.Body.Close()
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
log.Printf("Error reading response body: %v", err)
+
} else {
+
var result types.RepoDefaultBranchResponse
+
err = json.Unmarshal(body, &result)
+
if err != nil {
+
log.Println("failed to parse response:", err)
+
} else {
+
defaultBranch = result.Branch
+
}
+
}
+
}
+
}
+
s.pages.RepoSettings(w, pages.RepoSettingsParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(s, user),
Collaborators: repoCollaborators,
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
+
Branches: branchNames,
+
DefaultBranch: defaultBranch,
})
}
}
···
}
func (f *FullyResolvedRepo) OwnerSlashRepo() string {
+
handle := f.OwnerId.Handle
+
+
var p string
+
if handle != "" && !handle.IsInvalidHandle() {
+
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
+
} else {
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
+
}
+
+
return p
+
}
+
+
func (f *FullyResolvedRepo) DidSlashRepo() string {
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
return p
}
func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
-
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
+
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
if err != nil {
return nil, err
}
···
if err != nil {
log.Println("failed to get issue count for ", f.RepoAt)
}
+
source, err := db.GetRepoSource(s.db, f.RepoAt)
+
if errors.Is(err, sql.ErrNoRows) {
+
source = ""
+
} else if err != nil {
+
log.Println("failed to get repo source for ", f.RepoAt, err)
+
}
+
+
var sourceRepo *db.Repo
+
if source != "" {
+
sourceRepo, err = db.GetRepoByAtUri(s.db, source)
+
if err != nil {
+
log.Println("failed to get repo by at uri", err)
+
}
+
}
+
+
var sourceHandle *identity.Identity
+
if sourceRepo != nil {
+
sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
+
if err != nil {
+
log.Println("failed to resolve source repo", err)
+
}
+
}
knot := f.Knot
+
var disableFork bool
+
us, err := NewUnsignedClient(knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create unsigned client for %s: %v", knot, err)
+
} else {
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
+
if err != nil {
+
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
+
} else {
+
defer resp.Body.Close()
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
log.Printf("error reading branch response body: %v", err)
+
} else {
+
var branchesResp types.RepoBranchesResponse
+
if err := json.Unmarshal(body, &branchesResp); err != nil {
+
log.Printf("error parsing branch response: %v", err)
+
} else {
+
disableFork = false
+
}
+
+
if len(branchesResp.Branches) == 0 {
+
disableFork = true
+
}
+
}
+
}
+
}
+
if knot == "knot1.tangled.sh" {
knot = "tangled.sh"
}
-
return pages.RepoInfo{
+
repoInfo := pages.RepoInfo{
OwnerDid: f.OwnerDid(),
OwnerHandle: f.OwnerHandle(),
Name: f.RepoName,
···
IssueCount: issueCount,
PullCount: pullCount,
},
+
DisableFork: disableFork,
}
+
+
if sourceRepo != nil {
+
repoInfo.Source = sourceRepo
+
repoInfo.SourceHandle = sourceHandle.Handle.String()
+
}
+
+
return repoInfo
}
func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
···
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueStateNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssueState{
Issue: issue.IssueAt,
···
}
}
-
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
+
func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
if err != nil {
···
return
}
-
commentId := rand.IntN(1000000)
+
commentId := mathrand.IntN(1000000)
+
rkey := appview.TID()
-
err := db.NewComment(s.db, &db.Comment{
+
err := db.NewIssueComment(s.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)
···
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssueComment{
Repo: &atUri,
···
}
}
+
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", 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)
+
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
+
}
+
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to get issue", err)
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
return
+
}
+
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
+
if err != nil {
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
return
+
}
+
+
identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
+
if err != nil {
+
log.Println("failed to resolve did")
+
return
+
}
+
+
didHandleMap := make(map[string]string)
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Issue: issue,
+
Comment: comment,
+
})
+
}
+
+
func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", 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)
+
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
+
}
+
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to get issue", err)
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
return
+
}
+
+
comment, err := db.GetComment(s.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
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
Issue: issue,
+
Comment: comment,
+
})
+
case http.MethodPost:
+
// extract form value
+
newBody := r.FormValue("body")
+
client, _ := s.auth.AuthorizedClient(r)
+
rkey := comment.Rkey
+
+
// optimistic update
+
edited := time.Now()
+
err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
+
if err != nil {
+
log.Println("failed to perferom update-description query", err)
+
s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
+
return
+
}
+
+
// rkey is optional, it was introduced later
+
if comment.Rkey != "" {
+
// update the record on pds
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
+
if err != nil {
+
// failed to get record
+
log.Println(err, rkey)
+
s.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)
+
commentIdInt64 := int64(commentIdInt)
+
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueCommentNSID,
+
Repo: user.Did,
+
Rkey: rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoIssueComment{
+
Repo: &repoAt,
+
Issue: issueAt,
+
CommentId: &commentIdInt64,
+
Owner: &comment.OwnerDid,
+
Body: &newBody,
+
CreatedAt: &createdAt,
+
},
+
},
+
})
+
if err != nil {
+
log.Println(err)
+
}
+
}
+
+
// optimistic update for htmx
+
didHandleMap := map[string]string{
+
user.Did: user.Handle,
+
}
+
comment.Body = newBody
+
comment.Edited = &edited
+
+
// return new comment body with htmx
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Issue: issue,
+
Comment: comment,
+
})
+
return
+
+
}
+
+
}
+
+
func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", 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)
+
return
+
}
+
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to get issue", err)
+
s.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(s.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
+
}
+
+
if comment.Deleted != nil {
+
http.Error(w, "comment already deleted", http.StatusBadRequest)
+
return
+
}
+
+
// optimistic deletion
+
deleted := time.Now()
+
err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
+
if err != nil {
+
log.Println("failed to delete comment")
+
s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
+
return
+
}
+
+
// delete from pds
+
if comment.Rkey != "" {
+
client, _ := s.auth.AuthorizedClient(r)
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.GraphFollowNSID,
+
Repo: user.Did,
+
Rkey: comment.Rkey,
+
})
+
if err != nil {
+
log.Println(err)
+
}
+
}
+
+
// optimistic update for htmx
+
didHandleMap := map[string]string{
+
user.Did: user.Handle,
+
}
+
comment.Body = ""
+
comment.Deleted = &deleted
+
+
// htmx fragment of comment after deletion
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Issue: issue,
+
Comment: comment,
+
})
+
return
+
}
+
func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
state := params.Get("state")
···
isOpen = false
default:
isOpen = true
+
}
+
+
page, ok := r.Context().Value("page").(pagination.Page)
+
if !ok {
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
}
user := s.auth.GetUser(r)
···
return
}
-
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
+
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
if err != nil {
log.Println("failed to get issues", err)
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
Issues: issues,
DidHandleMap: didHandleMap,
FilteringByOpen: isOpen,
+
Page: page,
})
return
}
···
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssue{
Repo: atUri,
···
return
+
+
func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Printf("failed to resolve source repo: %v", err)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
user := s.auth.GetUser(r)
+
knots, err := s.enforcer.GetDomainsForUser(user.Did)
+
if err != nil {
+
s.pages.Notice(w, "repo", "Invalid user account.")
+
return
+
}
+
+
s.pages.ForkRepo(w, pages.ForkRepoParams{
+
LoggedInUser: user,
+
Knots: knots,
+
RepoInfo: f.RepoInfo(s, user),
+
})
+
+
case http.MethodPost:
+
+
knot := r.FormValue("knot")
+
if knot == "" {
+
s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.")
+
return
+
}
+
+
ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
+
if err != nil || !ok {
+
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
+
return
+
}
+
+
forkName := fmt.Sprintf("%s", f.RepoName)
+
+
// this check is *only* to see if the forked repo name already exists
+
// in the user's account.
+
existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
+
if err != nil {
+
if errors.Is(err, sql.ErrNoRows) {
+
// no existing repo with this name found, we can use the name as is
+
} else {
+
log.Println("error fetching existing repo from db", err)
+
s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
+
return
+
}
+
} else if existingRepo != nil {
+
// repo with this name already exists, append random string
+
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
+
}
+
secret, err := db.GetRegistrationKey(s.db, knot)
+
if err != nil {
+
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
+
return
+
}
+
+
client, err := NewSignedClient(knot, secret, s.config.Dev)
+
if err != nil {
+
s.pages.Notice(w, "repo", "Failed to reach knot server.")
+
return
+
}
+
+
var uri string
+
if s.config.Dev {
+
uri = "http"
+
} else {
+
uri = "https"
+
}
+
sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
+
sourceAt := f.RepoAt.String()
+
+
rkey := appview.TID()
+
repo := &db.Repo{
+
Did: user.Did,
+
Name: forkName,
+
Knot: knot,
+
Rkey: rkey,
+
Source: sourceAt,
+
}
+
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
+
return
+
}
+
defer func() {
+
tx.Rollback()
+
err = s.enforcer.E.LoadPolicy()
+
if err != nil {
+
log.Println("failed to rollback policies")
+
}
+
}()
+
+
resp, err := client.ForkRepo(user.Did, sourceUrl, forkName)
+
if err != nil {
+
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
+
return
+
}
+
+
switch resp.StatusCode {
+
case http.StatusConflict:
+
s.pages.Notice(w, "repo", "A repository with that name already exists.")
+
return
+
case http.StatusInternalServerError:
+
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
+
case http.StatusNoContent:
+
// continue
+
}
+
+
xrpcClient, _ := s.auth.AuthorizedClient(r)
+
+
addedAt := time.Now().Format(time.RFC3339)
+
atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoNSID,
+
Repo: user.Did,
+
Rkey: rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.Repo{
+
Knot: repo.Knot,
+
Name: repo.Name,
+
AddedAt: &addedAt,
+
Owner: user.Did,
+
Source: &sourceAt,
+
}},
+
})
+
if err != nil {
+
log.Printf("failed to create record: %s", err)
+
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
+
return
+
}
+
log.Println("created repo record: ", atresp.Uri)
+
+
repo.AtUri = atresp.Uri
+
err = db.AddRepo(tx, repo)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
+
return
+
}
+
+
// acls
+
p, _ := securejoin.SecureJoin(user.Did, forkName)
+
err = s.enforcer.AddRepo(user.Did, knot, p)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
+
return
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
log.Println("failed to commit changes", err)
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
err = s.enforcer.E.SavePolicy()
+
if err != nil {
+
log.Println("failed to update ACLs", err)
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
+
return
+
}
+
}
+15 -1
appview/state/repo_util.go
···
import (
"context"
+
"crypto/rand"
"fmt"
"log"
+
"math/big"
"net/http"
"github.com/bluesky-social/indigo/atproto/identity"
···
func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
if u != nil {
-
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
+
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
return pages.RolesInRepo{r}
} else {
return pages.RolesInRepo{}
···
return emailToDidOrHandle
}
+
+
func randomString(n int) string {
+
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
result := make([]byte, n)
+
+
for i := 0; i < n; i++ {
+
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
+
result[i] = letters[n.Int64()]
+
}
+
+
return string(result)
+
}
+51 -28
appview/state/router.go
···
"strings"
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/settings"
"tangled.sh/tangled.sh/core/appview/state/userutil"
)
···
r.Get("/branches", s.RepoBranches)
r.Get("/tags", s.RepoTags)
r.Get("/blob/{ref}/*", s.RepoBlob)
+
r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw)
r.Route("/issues", func(r chi.Router) {
-
r.Get("/", s.RepoIssues)
+
r.With(middleware.Paginate).Get("/", s.RepoIssues)
r.Get("/{issue}", s.RepoSingleIssue)
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/new", s.NewIssue)
r.Post("/new", s.NewIssue)
-
r.Post("/{issue}/comment", s.IssueComment)
+
r.Post("/{issue}/comment", s.NewIssueComment)
+
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
+
r.Get("/", s.IssueComment)
+
r.Delete("/", s.DeleteIssueComment)
+
r.Get("/edit", s.EditIssueComment)
+
r.Post("/edit", s.EditIssueComment)
+
})
r.Post("/{issue}/close", s.CloseIssue)
r.Post("/{issue}/reopen", s.ReopenIssue)
})
})
+
r.Route("/fork", func(r chi.Router) {
+
r.Use(middleware.AuthMiddleware(s.auth))
+
r.Get("/", s.ForkRepo)
+
r.Post("/", s.ForkRepo)
+
})
+
r.Route("/pulls", func(r chi.Router) {
r.Get("/", s.RepoPulls)
-
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) {
r.Get("/", s.NewPull)
+
r.Get("/patch-upload", s.PatchUploadFragment)
+
r.Post("/validate-patch", s.ValidatePatch)
+
r.Get("/compare-branches", s.CompareBranchesFragment)
+
r.Get("/compare-forks", s.CompareForksFragment)
+
r.Get("/fork-branches", s.CompareForksBranchesFragment)
r.Post("/", s.NewPull)
})
···
r.Route("/round/{round}", func(r chi.Router) {
r.Get("/", s.RepoPullPatch)
+
r.Get("/interdiff", s.RepoPullInterdiff)
r.Get("/actions", s.PullActions)
-
r.Route("/comment", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) {
r.Get("/", s.PullComment)
r.Post("/", s.PullComment)
})
})
-
// authorized requests below this point
+
r.Route("/round/{round}.patch", func(r chi.Router) {
+
r.Get("/", s.RepoPullPatchRaw)
+
})
+
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Route("/resubmit", func(r chi.Router) {
r.Get("/", s.ResubmitPull)
r.Post("/", s.ResubmitPull)
})
-
r.Route("/comment", func(r chi.Router) {
-
r.Get("/", s.PullComment)
-
r.Post("/", s.PullComment)
-
})
r.Post("/close", s.ClosePull)
r.Post("/reopen", s.ReopenPull)
// collaborators only
···
// settings routes, needs auth
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
// repo description can only be edited by owner
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
r.Put("/", s.RepoDescription)
···
r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
r.Get("/", s.RepoSettings)
r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
+
r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo)
+
r.Put("/branches/default", s.SetDefaultBranch)
})
})
})
···
r.Get("/", s.Timeline)
-
r.With(AuthMiddleware(s)).Get("/logout", s.Logout)
+
r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout)
r.Route("/login", func(r chi.Router) {
r.Get("/", s.Login)
···
})
r.Route("/knots", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.Knots)
r.Post("/key", s.RegistrationKey)
···
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.NewRepo)
r.Post("/", s.NewRepo)
})
// r.Post("/import", s.ImportRepo)
})
-
r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) {
r.Post("/", s.Follow)
r.Delete("/", s.Follow)
})
-
r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) {
r.Post("/", s.Star)
r.Delete("/", s.Star)
})
-
r.Route("/settings", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
-
r.Get("/", s.Settings)
-
r.Put("/keys", s.SettingsKeys)
-
r.Delete("/keys", s.SettingsKeys)
-
r.Put("/emails", s.SettingsEmails)
-
r.Delete("/emails", s.SettingsEmails)
-
r.Get("/emails/verify", s.SettingsEmailsVerify)
-
r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend)
-
r.Post("/emails/primary", s.SettingsEmailsPrimary)
-
})
+
r.Mount("/settings", s.SettingsRouter())
r.Get("/keys/{user}", s.Keys)
···
})
return r
}
+
+
func (s *State) SettingsRouter() http.Handler {
+
settings := &settings.Settings{
+
Db: s.db,
+
Auth: s.auth,
+
Pages: s.pages,
+
Config: s.config,
+
}
+
+
return settings.Router()
+
}
-416
appview/state/settings.go
···
-
package state
-
-
import (
-
"database/sql"
-
"errors"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"strings"
-
"time"
-
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
"github.com/gliderlabs/ssh"
-
"github.com/google/uuid"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/email"
-
"tangled.sh/tangled.sh/core/appview/pages"
-
)
-
-
func (s *State) Settings(w http.ResponseWriter, r *http.Request) {
-
user := s.auth.GetUser(r)
-
pubKeys, err := db.GetPublicKeys(s.db, user.Did)
-
if err != nil {
-
log.Println(err)
-
}
-
-
emails, err := db.GetAllEmails(s.db, user.Did)
-
if err != nil {
-
log.Println(err)
-
}
-
-
s.pages.Settings(w, pages.SettingsParams{
-
LoggedInUser: user,
-
PubKeys: pubKeys,
-
Emails: emails,
-
})
-
}
-
-
// buildVerificationEmail creates an email.Email struct for verification emails
-
func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email {
-
verifyURL := s.verifyUrl(did, emailAddr, code)
-
-
return email.Email{
-
APIKey: s.config.ResendApiKey,
-
From: "noreply@notifs.tangled.sh",
-
To: emailAddr,
-
Subject: "Verify your Tangled email",
-
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
-
` + verifyURL,
-
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
-
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
-
}
-
}
-
-
// sendVerificationEmail handles the common logic for sending verification emails
-
func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
-
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
-
-
err := email.SendEmail(emailToSend)
-
if err != nil {
-
log.Printf("sending email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
-
return err
-
}
-
-
return nil
-
}
-
-
func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.Notice(w, "settings-emails", "Unimplemented.")
-
log.Println("unimplemented")
-
return
-
case http.MethodPut:
-
did := s.auth.GetDid(r)
-
emAddr := r.FormValue("email")
-
emAddr = strings.TrimSpace(emAddr)
-
-
if !email.IsValidEmail(emAddr) {
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
-
return
-
}
-
-
// check if email already exists in database
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
-
if err != nil && !errors.Is(err, sql.ErrNoRows) {
-
log.Printf("checking for existing email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
if err == nil {
-
if existingEmail.Verified {
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
-
return
-
}
-
-
code := uuid.New().String()
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.AddEmail(tx, db.Email{
-
Did: did,
-
Address: emAddr,
-
Verified: false,
-
VerificationCode: code,
-
}); err != nil {
-
log.Printf("adding email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
-
return
-
case http.MethodDelete:
-
did := s.auth.GetDid(r)
-
emailAddr := r.FormValue("email")
-
emailAddr = strings.TrimSpace(emailAddr)
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
-
log.Printf("deleting email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
}
-
}
-
-
func (s *State) verifyUrl(did string, email string, code string) string {
-
var appUrl string
-
if s.config.Dev {
-
appUrl = "http://" + s.config.ListenAddr
-
} else {
-
appUrl = "https://tangled.sh"
-
}
-
-
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
-
}
-
-
func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) {
-
q := r.URL.Query()
-
-
// Get the parameters directly from the query
-
emailAddr := q.Get("email")
-
did := q.Get("did")
-
code := q.Get("code")
-
-
valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code)
-
if err != nil {
-
log.Printf("checking email verification: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
-
return
-
}
-
-
if !valid {
-
s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
-
return
-
}
-
-
// Mark email as verified in the database
-
if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil {
-
log.Printf("marking email as verified: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
-
return
-
}
-
-
http.Redirect(w, r, "/settings", http.StatusSeeOther)
-
}
-
-
func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) {
-
if r.Method != http.MethodPost {
-
s.pages.Notice(w, "settings-emails-error", "Invalid request method.")
-
return
-
}
-
-
did := s.auth.GetDid(r)
-
emAddr := r.FormValue("email")
-
emAddr = strings.TrimSpace(emAddr)
-
-
if !email.IsValidEmail(emAddr) {
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
-
return
-
}
-
-
// Check if email exists and is unverified
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
-
if err != nil {
-
if errors.Is(err, sql.ErrNoRows) {
-
s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
-
} else {
-
log.Printf("checking for existing email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
}
-
return
-
}
-
-
if existingEmail.Verified {
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
-
return
-
}
-
-
// Check if last verification email was sent less than 10 minutes ago
-
if existingEmail.LastSent != nil {
-
timeSinceLastSent := time.Since(*existingEmail.LastSent)
-
if timeSinceLastSent < 10*time.Minute {
-
waitTime := 10*time.Minute - timeSinceLastSent
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
-
return
-
}
-
}
-
-
// Generate new verification code
-
code := uuid.New().String()
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
// Update the verification code and last sent time
-
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
-
log.Printf("updating email verification: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
-
// Send verification email
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
-
}
-
-
func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {
-
did := s.auth.GetDid(r)
-
emailAddr := r.FormValue("email")
-
emailAddr = strings.TrimSpace(emailAddr)
-
-
if emailAddr == "" {
-
s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
-
return
-
}
-
-
if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil {
-
log.Printf("setting primary email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
}
-
-
func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.Notice(w, "settings-keys", "Unimplemented.")
-
log.Println("unimplemented")
-
return
-
case http.MethodPut:
-
did := s.auth.GetDid(r)
-
key := r.FormValue("key")
-
key = strings.TrimSpace(key)
-
name := r.FormValue("name")
-
client, _ := s.auth.AuthorizedClient(r)
-
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
-
if err != nil {
-
log.Printf("parsing public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
-
return
-
}
-
-
rkey := s.TID()
-
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start tx; adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
-
log.Printf("adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to add public key.")
-
return
-
}
-
-
// store in pds too
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
-
Collection: tangled.PublicKeyNSID,
-
Repo: did,
-
Rkey: rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.PublicKey{
-
Created: time.Now().Format(time.RFC3339),
-
Key: key,
-
Name: name,
-
}},
-
})
-
// invalid record
-
if err != nil {
-
log.Printf("failed to create record: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to create record.")
-
return
-
}
-
-
log.Println("created atproto record: ", resp.Uri)
-
-
err = tx.Commit()
-
if err != nil {
-
log.Printf("failed to commit tx; adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
-
case http.MethodDelete:
-
did := s.auth.GetDid(r)
-
q := r.URL.Query()
-
-
name := q.Get("name")
-
rkey := q.Get("rkey")
-
key := q.Get("key")
-
-
log.Println(name)
-
log.Println(rkey)
-
log.Println(key)
-
-
client, _ := s.auth.AuthorizedClient(r)
-
-
if err := db.RemovePublicKey(s.db, did, name, key); err != nil {
-
log.Printf("removing public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to remove public key.")
-
return
-
}
-
-
if rkey != "" {
-
// remove from pds too
-
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
-
Collection: tangled.PublicKeyNSID,
-
Repo: did,
-
Rkey: rkey,
-
})
-
-
// invalid record
-
if err != nil {
-
log.Printf("failed to delete record from PDS: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
-
return
-
}
-
}
-
log.Println("deleted successfully")
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
}
-
}
+150
appview/state/signer.go
···
"encoding/hex"
"encoding/json"
"fmt"
+
"io"
+
"log"
"net/http"
"net/url"
"time"
···
return s.client.Do(req)
}
+
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
+
const (
+
Method = "POST"
+
Endpoint = "/repo/fork"
+
)
+
+
body, _ := json.Marshal(map[string]any{
+
"did": ownerDid,
+
"source": source,
+
"name": name,
+
})
+
+
req, err := s.newRequest(Method, Endpoint, body)
+
if err != nil {
+
return nil, err
+
}
+
+
return s.client.Do(req)
+
}
+
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
const (
Method = "DELETE"
···
return s.client.Do(req)
}
+
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
+
const (
+
Method = "PUT"
+
)
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
+
+
body, _ := json.Marshal(map[string]any{
+
"branch": branch,
+
})
+
+
req, err := s.newRequest(Method, endpoint, body)
+
if err != nil {
+
return nil, err
+
}
+
+
return s.client.Do(req)
+
}
+
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
const (
Method = "POST"
···
return s.client.Do(req)
}
+
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
+
const (
+
Method = "POST"
+
)
+
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, forkBranch, remoteBranch)
+
+
req, err := s.newRequest(Method, endpoint, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
return s.client.Do(req)
+
}
+
type UnsignedClient struct {
Url *url.URL
client *http.Client
···
return us.client.Do(req)
}
+
+
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) {
+
const (
+
Method = "GET"
+
)
+
+
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, branch)
+
+
req, err := us.newRequest(Method, endpoint, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
return us.client.Do(req)
+
}
+
+
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*http.Response, error) {
+
const (
+
Method = "GET"
+
)
+
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
+
+
req, err := us.newRequest(Method, endpoint, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
return us.client.Do(req)
+
}
+
+
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
+
const (
+
Method = "GET"
+
Endpoint = "/capabilities"
+
)
+
+
req, err := us.newRequest(Method, Endpoint, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
resp, err := us.client.Do(req)
+
if err != nil {
+
return nil, err
+
}
+
defer resp.Body.Close()
+
+
var capabilities types.Capabilities
+
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
+
return nil, err
+
}
+
+
return &capabilities, nil
+
}
+
+
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
+
const (
+
Method = "GET"
+
)
+
+
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
+
+
req, err := us.newRequest(Method, endpoint, nil)
+
if err != nil {
+
return nil, fmt.Errorf("Failed to create request.")
+
}
+
+
compareResp, err := us.client.Do(req)
+
if err != nil {
+
return nil, fmt.Errorf("Failed to create request.")
+
}
+
defer compareResp.Body.Close()
+
+
switch compareResp.StatusCode {
+
case 404:
+
case 400:
+
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
+
}
+
+
respBody, err := io.ReadAll(compareResp.Body)
+
if err != nil {
+
log.Println("failed to compare across branches")
+
return nil, fmt.Errorf("Failed to compare branches.")
+
}
+
defer compareResp.Body.Close()
+
+
var formatPatchResponse types.RepoFormatPatchResponse
+
err = json.Unmarshal(respBody, &formatPatchResponse)
+
if err != nil {
+
log.Println("failed to unmarshal format-patch response", err)
+
return nil, fmt.Errorf("failed to compare branches.")
+
}
+
+
return &formatPatchResponse, nil
+
}
+4 -3
appview/state/star.go
···
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
tangled "tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
switch r.Method {
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
-
rkey := s.TID()
+
rkey := appview.TID()
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.FeedStarNSID,
Repo: currentUser.Did,
···
log.Println("created atproto record: ", resp.Uri)
-
s.pages.StarFragment(w, pages.StarFragmentParams{
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
IsStarred: true,
RepoAt: subjectUri,
Stats: db.RepoStats{
···
log.Println("failed to get star count for ", subjectUri)
}
-
s.pages.StarFragment(w, pages.StarFragmentParams{
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
IsStarred: false,
RepoAt: subjectUri,
Stats: db.RepoStats{
+35 -76
appview/state/state.go
···
clock := syntax.NewTIDClock(0)
-
pgs := pages.NewPages()
+
pgs := pages.NewPages(config.Dev)
resolver := appview.NewResolver()
wrapper := db.DbWrapper{d}
-
jc, err := jetstream.NewJetstreamClient(config.JetstreamEndpoint, "appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), wrapper, false)
+
jc, err := jetstream.NewJetstreamClient(
+
config.JetstreamEndpoint,
+
"appview",
+
[]string{tangled.GraphFollowNSID, tangled.FeedStarNSID},
+
nil,
+
slog.Default(),
+
wrapper,
+
false,
+
)
if err != nil {
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
}
···
return state, nil
}
-
func (s *State) TID() string {
-
return s.tidClock.Next().String()
+
func TID(c *syntax.TIDClock) string {
+
return c.Next().String()
}
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
···
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
s.auth.ClearSession(r, w)
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
+
w.Header().Set("HX-Redirect", "/login")
+
w.WriteHeader(http.StatusSeeOther)
}
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
for _, ev := range timeline {
if ev.Repo != nil {
didsToResolve = append(didsToResolve, ev.Repo.Did)
+
if ev.Source != nil {
+
didsToResolve = append(didsToResolve, ev.Source.Did)
+
}
}
if ev.Follow != nil {
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
···
}
}
+
var didsToResolve []string
+
for _, m := range members {
+
didsToResolve = append(didsToResolve, m)
+
}
+
didsToResolve = append(didsToResolve, reg.ByDid)
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
ok, err := s.enforcer.IsServerOwner(user.Did, domain)
isOwner := err == nil && ok
p := pages.KnotParams{
LoggedInUser: user,
+
DidHandleMap: didHandleMap,
Registration: reg,
Members: members,
IsOwner: isOwner,
···
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotMemberNSID,
Repo: currentUser.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.KnotMember{
Member: memberIdent.DID.String(),
···
return
}
-
rkey := s.TID()
+
rkey := appview.TID()
repo := &db.Repo{
Did: user.Did,
Name: repoName,
···
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
return
}
-
}
-
-
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
-
didOrHandle := chi.URLParam(r, "user")
-
if didOrHandle == "" {
-
http.Error(w, "Bad request", http.StatusBadRequest)
-
return
-
}
-
-
ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
-
if err != nil {
-
log.Printf("resolving identity: %s", err)
-
w.WriteHeader(http.StatusNotFound)
-
return
-
}
-
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
-
if err != nil {
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
-
}
-
-
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
-
if err != nil {
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
-
}
-
var didsToResolve []string
-
for _, r := range collaboratingRepos {
-
didsToResolve = append(didsToResolve, r.Did)
-
}
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
-
if err != nil {
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
-
}
-
-
loggedInUser := s.auth.GetUser(r)
-
followStatus := db.IsNotFollowing
-
if loggedInUser != nil {
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
-
}
-
-
profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
-
if err != nil {
-
log.Println("failed to fetch bsky avatar", err)
-
}
-
-
s.pages.ProfilePage(w, pages.ProfilePageParams{
-
LoggedInUser: loggedInUser,
-
UserDid: ident.DID.String(),
-
UserHandle: ident.Handle.String(),
-
Repos: repos,
-
CollaboratingRepos: collaboratingRepos,
-
ProfileStats: pages.ProfileStats{
-
Followers: followers,
-
Following: following,
-
},
-
FollowStatus: db.FollowStatus(followStatus),
-
DidHandleMap: didHandleMap,
-
AvatarUri: profileAvatarUri,
-
})
}
func GetAvatarUri(handle string) (string, error) {
+11
appview/tid.go
···
+
package appview
+
+
import (
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
var c *syntax.TIDClock = syntax.NewTIDClock(0)
+
+
func TID() string {
+
return c.Next().String()
+
}
+38
cmd/combinediff/main.go
···
+
package main
+
+
import (
+
"fmt"
+
"os"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"tangled.sh/tangled.sh/core/patchutil"
+
)
+
+
func main() {
+
if len(os.Args) != 3 {
+
fmt.Println("Usage: combinediff <patch1> <patch2>")
+
os.Exit(1)
+
}
+
+
patch1, err := os.Open(os.Args[1])
+
if err != nil {
+
fmt.Println(err)
+
}
+
patch2, err := os.Open(os.Args[2])
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files1, _, err := gitdiff.Parse(patch1)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files2, _, err := gitdiff.Parse(patch2)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
combined := patchutil.CombineDiff(files1, files2)
+
fmt.Println(combined)
+
}
+1
cmd/gen.go
···
shtangled.RepoIssue{},
shtangled.Repo{},
shtangled.RepoPull{},
+
shtangled.RepoPull_Source{},
shtangled.RepoPullStatus{},
shtangled.RepoPullComment{},
); err != nil {
+38
cmd/interdiff/main.go
···
+
package main
+
+
import (
+
"fmt"
+
"os"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"tangled.sh/tangled.sh/core/patchutil"
+
)
+
+
func main() {
+
if len(os.Args) != 3 {
+
fmt.Println("Usage: interdiff <patch1> <patch2>")
+
os.Exit(1)
+
}
+
+
patch1, err := os.Open(os.Args[1])
+
if err != nil {
+
fmt.Println(err)
+
}
+
patch2, err := os.Open(os.Args[2])
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files1, _, err := gitdiff.Parse(patch1)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files2, _, err := gitdiff.Parse(patch2)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
interDiffResult := patchutil.Interdiff(files1, files2)
+
fmt.Println(interDiffResult)
+
}
-150
cmd/jstest/main.go
···
-
package main
-
-
import (
-
"context"
-
"flag"
-
"log/slog"
-
"os"
-
"os/signal"
-
"strings"
-
"syscall"
-
"time"
-
-
"github.com/bluesky-social/jetstream/pkg/client"
-
"github.com/bluesky-social/jetstream/pkg/models"
-
"tangled.sh/tangled.sh/core/jetstream"
-
)
-
-
// Simple in-memory implementation of DB interface
-
type MemoryDB struct {
-
lastTimeUs int64
-
}
-
-
func (m *MemoryDB) GetLastTimeUs() (int64, error) {
-
if m.lastTimeUs == 0 {
-
return time.Now().UnixMicro(), nil
-
}
-
return m.lastTimeUs, nil
-
}
-
-
func (m *MemoryDB) SaveLastTimeUs(ts int64) error {
-
m.lastTimeUs = ts
-
return nil
-
}
-
-
func (m *MemoryDB) UpdateLastTimeUs(ts int64) error {
-
m.lastTimeUs = ts
-
return nil
-
}
-
-
func main() {
-
// Setup logger
-
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
-
Level: slog.LevelInfo,
-
}))
-
-
// Create in-memory DB
-
db := &MemoryDB{}
-
-
// Get query URL from flag
-
var queryURL string
-
flag.StringVar(&queryURL, "query-url", "", "Jetstream query URL containing DIDs")
-
flag.Parse()
-
-
if queryURL == "" {
-
logger.Error("No query URL provided, use --query-url flag")
-
os.Exit(1)
-
}
-
-
// Extract wantedDids parameters
-
didParams := strings.Split(queryURL, "&wantedDids=")
-
dids := make([]string, 0, len(didParams)-1)
-
for i, param := range didParams {
-
if i == 0 {
-
// Skip the first part (the base URL with cursor)
-
continue
-
}
-
dids = append(dids, param)
-
}
-
-
// Extract collections
-
collections := []string{"sh.tangled.publicKey", "sh.tangled.knot.member"}
-
-
// Create client configuration
-
cfg := client.DefaultClientConfig()
-
cfg.WebsocketURL = "wss://jetstream2.us-west.bsky.network/subscribe"
-
cfg.WantedCollections = collections
-
-
// Create jetstream client
-
jsClient, err := jetstream.NewJetstreamClient(
-
cfg.WebsocketURL,
-
"tangled-jetstream",
-
collections,
-
cfg,
-
logger,
-
db,
-
false,
-
)
-
if err != nil {
-
logger.Error("Failed to create jetstream client", "error", err)
-
os.Exit(1)
-
}
-
-
// Update DIDs
-
jsClient.UpdateDids(dids)
-
-
// Create a context that will be canceled on SIGINT or SIGTERM
-
ctx, cancel := context.WithCancel(context.Background())
-
defer cancel()
-
-
// Setup signal handling with a buffered channel
-
sigCh := make(chan os.Signal, 1)
-
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
-
-
// Process function for events
-
processFunc := func(ctx context.Context, event *models.Event) error {
-
// Log the event details
-
logger.Info("Received event",
-
"collection", event.Commit.Collection,
-
"did", event.Did,
-
"rkey", event.Commit.RKey,
-
"action", event.Kind,
-
"time_us", event.TimeUS,
-
)
-
-
// Save the last time_us
-
if err := db.UpdateLastTimeUs(event.TimeUS); err != nil {
-
logger.Error("Failed to update last time_us", "error", err)
-
}
-
-
return nil
-
}
-
-
// Start jetstream
-
if err := jsClient.StartJetstream(ctx, processFunc); err != nil {
-
logger.Error("Failed to start jetstream", "error", err)
-
os.Exit(1)
-
}
-
-
// Wait for signal instead of context.Done()
-
sig := <-sigCh
-
logger.Info("Received signal, shutting down", "signal", sig)
-
cancel() // Cancel context after receiving signal
-
-
// Shutdown gracefully with a timeout
-
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
-
defer shutdownCancel()
-
-
done := make(chan struct{})
-
go func() {
-
jsClient.Shutdown()
-
close(done)
-
}()
-
-
select {
-
case <-done:
-
logger.Info("Jetstream client shut down gracefully")
-
case <-shutdownCtx.Done():
-
logger.Warn("Shutdown timed out, forcing exit")
-
}
-
}
+1 -1
cmd/knotserver/main.go
···
jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{
tangled.PublicKeyNSID,
tangled.KnotMemberNSID,
-
}, nil, l, db, false)
+
}, nil, l, db, true)
if err != nil {
l.Error("failed to setup jetstream", "error", err)
}
+52
docker/Dockerfile
···
+
FROM docker.io/golang:1.24-alpine3.21 AS build
+
+
ENV CGO_ENABLED=1
+
+
RUN apk add --no-cache gcc musl-dev
+
+
WORKDIR /usr/src/app
+
+
COPY go.mod go.sum ./
+
RUN go mod download
+
+
COPY . .
+
RUN go build -v \
+
-o /usr/local/bin/knotserver \
+
-ldflags='-s -w -extldflags "-static"' \
+
./cmd/knotserver && \
+
go build -v \
+
-o /usr/local/bin/keyfetch \
+
./cmd/keyfetch && \
+
go build -v \
+
-o /usr/local/bin/repoguard \
+
./cmd/repoguard
+
+
FROM docker.io/alpine:3.21
+
+
LABEL org.opencontainers.image.title=Tangled
+
LABEL org.opencontainers.image.description="Tangled is a decentralized and open code collaboration platform, built on atproto."
+
LABEL org.opencontainers.image.vendor=Tangled.sh
+
LABEL org.opencontainers.image.licenses=MIT
+
LABEL org.opencontainers.image.url=https://tangled.sh
+
LABEL org.opencontainers.image.source=https://tangled.sh/@tangled.sh/core
+
+
RUN apk add --no-cache shadow s6-overlay execline openssh git && \
+
adduser --disabled-password git && \
+
# We need to set password anyway since otherwise ssh won't work
+
head -c 32 /dev/random | base64 | tr -dc 'a-zA-Z0-9' | passwd git --stdin && \
+
mkdir /app && mkdir /home/git/repositories
+
+
COPY --from=build /usr/local/bin/knotserver /usr/local/bin
+
COPY --from=build /usr/local/bin/keyfetch /usr/local/libexec/tangled-keyfetch
+
COPY --from=build /usr/local/bin/repoguard /home/git/repoguard
+
COPY docker/rootfs/ .
+
+
RUN chown root:root /usr/local/libexec/tangled-keyfetch && \
+
chmod 755 /usr/local/libexec/tangled-keyfetch && \
+
chown git:git /home/git/repoguard && \
+
chown git:git /app && chown git:git /home/git/repositories
+
+
EXPOSE 22
+
EXPOSE 5555
+
+
ENTRYPOINT ["/init"]
+17
docker/docker-compose.yml
···
+
services:
+
knot:
+
build:
+
context: ..
+
dockerfile: docker/Dockerfile
+
environment:
+
KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME}
+
KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET}
+
KNOT_SERVER_DB_PATH: "/app/knotserver.db"
+
KNOT_REPO_SCAN_PATH: "/home/git/repositories"
+
volumes:
+
- "./keys:/etc/ssh/keys"
+
- "./repositories:/home/git/repositories"
+
- "./server:/app"
+
ports:
+
- "5555:5555"
+
- "2222:22"
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
···
+
oneshot
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
···
+
/etc/s6-overlay/scripts/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base

This is a binary file and will not be displayed.

+3
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/run
···
+
#!/command/with-contenv ash
+
+
exec s6-setuidgid git /usr/local/bin/knotserver
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
···
+
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base

This is a binary file and will not be displayed.

docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys

This is a binary file and will not be displayed.

+3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
···
+
#!/usr/bin/execlineb -P
+
+
/usr/sbin/sshd -e -D
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
···
+
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver

This is a binary file and will not be displayed.

docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd

This is a binary file and will not be displayed.

+21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
···
+
#!/usr/bin/execlineb -P
+
+
foreground {
+
if -n { test -d /etc/ssh/keys }
+
mkdir /etc/ssh/keys
+
}
+
+
foreground {
+
if -n { test -f /etc/ssh/keys/ssh_host_rsa_key }
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N ""
+
}
+
+
foreground {
+
if -n { test -f /etc/ssh/keys/ssh_host_ecdsa_key }
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N ""
+
}
+
+
foreground {
+
if -n { test -f /etc/ssh/keys/ssh_host_ed25519_key }
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ed25519_key -q -N ""
+
}
+9
docker/rootfs/etc/ssh/sshd_config.d/tangled_sshd.conf
···
+
HostKey /etc/ssh/keys/ssh_host_rsa_key
+
HostKey /etc/ssh/keys/ssh_host_ecdsa_key
+
HostKey /etc/ssh/keys/ssh_host_ed25519_key
+
+
PasswordAuthentication no
+
+
Match User git
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories
+
AuthorizedKeysCommandUser nobody
+76
docs/contributing.md
···
+
# tangled contributing guide
+
+
## commit guidelines
+
+
We follow a commit style similar to the Go project. Please keep commits:
+
+
* **atomic**: each commit should represent one logical change
+
* **descriptive**: the commit message should clearly describe what the
+
change does and why it's needed
+
+
### message format
+
+
```
+
<service/top-level directory>: <affected package/directory>: <short summary of change>
+
+
+
Optional longer description can go here, if necessary. Explain what the
+
change does and why, especially if not obvious. Reference relevant
+
issues or PRs when applicable. These can be links for now since we don't
+
auto-link issues/PRs yet.
+
```
+
+
Here are some examples:
+
+
```
+
appview: state: fix token expiry check in middleware
+
+
The previous check did not account for clock drift, leading to premature
+
token invalidation.
+
```
+
+
```
+
knotserver: git/service: improve error checking in upload-pack
+
```
+
+
### general notes
+
+
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
+
using `git am`. At present, there is no squashing -- so please author
+
your commits as they would appear on `master`, following the above
+
guidelines.
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
+
"fixed bug" or "fixes bug").
+
- Try to keep the summary line under 72 characters, but we aren't too
+
fussed about this.
+
- Don't include unrelated changes in the same commit.
+
- Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history
+
before submitting if necessary.
+
+
## proposals for bigger changes
+
+
Small fixes like typos, minor bugs, or trivial refactors can be
+
submitted directly as PRs.
+
+
For larger changesโ€”especially those introducing new features,
+
significant refactoring, or altering system behaviorโ€”please open a
+
proposal first. This helps us evaluate the scope, design, and potential
+
impact before implementation.
+
+
### proposal format
+
+
Create a new issue titled:
+
+
```
+
proposal: <affected scope>: <summary of change>
+
```
+
+
In the description, explain:
+
+
- What the change is
+
- Why it's needed
+
- How you plan to implement it (roughly)
+
- Any open questions or tradeoffs
+
+
We'll use the issue thread to discuss and refine the idea before moving
+
forward.
+108
docs/knot-hosting.md
···
+
# knot self-hosting guide
+
+
So you want to run your own knot server? Great! Here are a few prerequisites:
+
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
+
2. A (sub)domain name. People generally use `knot.example.com`.
+
3. A valid SSL certificate for your domain.
+
+
There's a couple of ways to get started:
+
* NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
+
* Docker: Documented below.
+
* Manual: Documented below.
+
+
## docker setup
+
+
Clone this repository:
+
+
```
+
git clone https://tangled.sh/@tangled.sh/core
+
```
+
+
Modify the `docker/docker-compose.yml`, specifically the
+
`KNOT_SERVER_SECRET` and `KNOT_SERVER_HOSTNAME` env vars. Then run:
+
+
```
+
docker compose -f docker/docker-compose.yml up
+
```
+
+
## manual setup
+
+
First, clone this repository:
+
+
```
+
git clone https://tangled.sh/@tangled.sh/core
+
```
+
+
Then, build our binaries (you need to have Go installed):
+
* `knotserver`: the main server program
+
* `keyfetch`: utility to fetch ssh pubkeys
+
* `repoguard`: enforces repository access control
+
+
```
+
cd core
+
export CGO_ENABLED=1
+
go build -o knot ./cmd/knotserver
+
go build -o keyfetch ./cmd/keyfetch
+
go build -o repoguard ./cmd/repoguard
+
```
+
+
Next, move the `keyfetch` binary to a location owned by `root` --
+
`/usr/local/libexec/tangled-keyfetch` is a good choice:
+
+
```
+
sudo mv keyfetch /usr/local/libexec/tangled-keyfetch
+
sudo chown root:root /usr/local/libexec/tangled-keyfetch
+
sudo chmod 755 /usr/local/libexec/tangled-keyfetch
+
```
+
+
This is necessary because SSH `AuthorizedKeysCommand` requires [really specific
+
permissions](https://stackoverflow.com/a/27638306). Let's set that up:
+
+
```
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
+
Match User git
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch
+
AuthorizedKeysCommandUser nobody
+
EOF
+
```
+
+
Next, create the `git` user:
+
+
```
+
sudo adduser git
+
```
+
+
Copy the `repoguard` binary to the `git` user's home directory:
+
+
```
+
sudo cp repoguard /home/git
+
sudo chown git:git /home/git/repoguard
+
```
+
+
Now, let's set up the server. Copy the `knot` binary to
+
`/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the
+
following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be
+
obtaind from the [/knots](/knots) page on Tangled.
+
+
```
+
KNOT_REPO_SCAN_PATH=/home/git
+
KNOT_SERVER_HOSTNAME=knot.example.com
+
APPVIEW_ENDPOINT=https://tangled.sh
+
KNOT_SERVER_SECRET=secret
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
+
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
+
```
+
+
If you run a Linux distribution that uses systemd, you can use the provided
+
service file to run the server. Copy
+
[`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service)
+
to `/etc/systemd/system/`. Then, run:
+
+
```
+
systemctl enable knotserver
+
systemctl start knotserver
+
```
+
+
You should now have a running knot server! You can finalize your registration by hitting the
+
`initialize` button on the [/knots](/knots) page.
+29 -17
flake.lock
···
"url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"
}
},
-
"ia-fonts-src": {
+
"ibm-plex-mono-src": {
"flake": false,
"locked": {
-
"lastModified": 1686932517,
-
"narHash": "sha256-2T165nFfCzO65/PIHauJA//S+zug5nUwPcg8NUEydfc=",
-
"owner": "iaolo",
-
"repo": "iA-Fonts",
-
"rev": "f32c04c3058a75d7ce28919ce70fe8800817491b",
-
"type": "github"
+
"lastModified": 1731402384,
+
"narHash": "sha256-OwUmrPfEehLDz0fl2ChYLK8FQM2p0G1+EMrGsYEq+6g=",
+
"type": "tarball",
+
"url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip"
},
"original": {
-
"owner": "iaolo",
-
"repo": "iA-Fonts",
-
"type": "github"
+
"type": "tarball",
+
"url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip"
}
},
"indigo": {
"flake": false,
"locked": {
-
"lastModified": 1738491661,
-
"narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=",
+
"lastModified": 1745333930,
+
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
"owner": "oppiliappan",
"repo": "indigo",
-
"rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71",
+
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
"type": "github"
},
"original": {
···
"type": "github"
}
},
+
"inter-fonts-src": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1731687360,
+
"narHash": "sha256-5vdKKvHAeZi6igrfpbOdhZlDX2/5+UvzlnCQV6DdqoQ=",
+
"type": "tarball",
+
"url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
+
},
+
"original": {
+
"type": "tarball",
+
"url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
+
}
+
},
"lucide-src": {
"flake": false,
"locked": {
···
},
"nixpkgs": {
"locked": {
-
"lastModified": 1740938536,
-
"narHash": "sha256-m6Lz7cRoZ8GS7tziYrNWv0WXTYtKx3oOC9Bwa6a13EA=",
+
"lastModified": 1743813633,
+
"narHash": "sha256-BgkBz4NpV6Kg8XF7cmHDHRVGZYnKbvG0Y4p+jElwxaM=",
"owner": "nixos",
"repo": "nixpkgs",
-
"rev": "2ffed2bc3d27861b821f9bec127cf51a4dbfabb4",
+
"rev": "7819a0d29d1dd2bc331bec4b327f0776359b1fa6",
"type": "github"
},
"original": {
"owner": "nixos",
+
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
···
"inputs": {
"gitignore": "gitignore",
"htmx-src": "htmx-src",
-
"ia-fonts-src": "ia-fonts-src",
+
"ibm-plex-mono-src": "ibm-plex-mono-src",
"indigo": "indigo",
+
"inter-fonts-src": "inter-fonts-src",
"lucide-src": "lucide-src",
"nixpkgs": "nixpkgs"
}
+82 -38
flake.nix
···
description = "atproto github";
inputs = {
-
nixpkgs.url = "github:nixos/nixpkgs";
+
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
indigo = {
url = "github:oppiliappan/indigo";
flake = false;
···
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
flake = false;
};
-
ia-fonts-src = {
-
url = "github:iaolo/iA-Fonts";
+
inter-fonts-src = {
+
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
+
flake = false;
+
};
+
ibm-plex-mono-src = {
+
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
flake = false;
};
gitignore = {
···
htmx-src,
lucide-src,
gitignore,
-
ia-fonts-src,
+
inter-fonts-src,
+
ibm-plex-mono-src,
}: let
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
inherit (gitignore.lib) gitignoreSource;
in {
overlays.default = final: prev: let
-
goModHash = "sha256-3gmXhututsJTFVPQi2uekTBP/qSJGgsDsVr7YU+z7d0=";
+
goModHash = "sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA=";
buildCmdPackage = name:
final.buildGoModule {
pname = name;
···
src = gitignoreSource ./.;
subPackages = ["cmd/${name}"];
vendorHash = goModHash;
-
env.CGO_ENABLED = 0;
+
CGO_ENABLED = 0;
};
in {
indigo-lexgen = final.buildGoModule {
···
mkdir -p appview/pages/static/{fonts,icons}
cp -f ${htmx-src} appview/pages/static/htmx.min.js
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
-
cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/
-
cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
popd
'';
doCheck = false;
subPackages = ["cmd/appview"];
vendorHash = goModHash;
-
env.CGO_ENABLED = 1;
+
CGO_ENABLED = 1;
stdenv = pkgsStatic.stdenv;
};
···
runHook postInstall
'';
-
env.CGO_ENABLED = 1;
+
CGO_ENABLED = 1;
};
knotserver-unwrapped = final.pkgsStatic.buildGoModule {
pname = "knotserver";
···
src = gitignoreSource ./.;
subPackages = ["cmd/knotserver"];
vendorHash = goModHash;
-
env.CGO_ENABLED = 1;
+
CGO_ENABLED = 1;
};
repoguard = buildCmdPackage "repoguard";
keyfetch = buildCmdPackage "keyfetch";
···
mkdir -p appview/pages/static/{fonts,icons}
cp -f ${htmx-src} appview/pages/static/htmx.min.js
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
-
cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/
-
cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
'';
};
});
···
${pkgs.air}/bin/air -c /dev/null \
-build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
-build.bin "./out/${name}.out" \
-
-build.include_ext "go,html,css"
+
-build.include_ext "go"
+
'';
+
tailwind-watcher =
+
pkgs.writeShellScriptBin "run"
+
''
+
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
'';
in {
watch-appview = {
···
watch-knotserver = {
type = "app";
program = ''${air-watcher "knotserver"}/bin/run'';
+
};
+
watch-tailwind = {
+
type = "app";
+
program = ''${tailwind-watcher}/bin/run'';
};
});
···
pkgs,
lib,
...
-
}:
+
}: let
+
cfg = config.services.tangled-knotserver;
+
in
with lib; {
options = {
services.tangled-knotserver = {
···
type = types.str;
default = "git";
description = "User that hosts git repos and performs git operations";
+
};
+
+
openFirewall = mkOption {
+
type = types.bool;
+
default = true;
+
description = "Open port 22 in the firewall for ssh";
+
};
+
+
stateDir = mkOption {
+
type = types.path;
+
default = "/home/${cfg.gitUser}";
+
description = "Tangled knot data directory";
};
repo = {
scanPath = mkOption {
type = types.path;
-
default = "/home/git";
+
default = cfg.stateDir;
description = "Path where repositories are scanned from";
};
···
dbPath = mkOption {
type = types.path;
-
default = "knotserver.db";
+
default = "${cfg.stateDir}/knotserver.db";
description = "Path to the database file";
};
···
};
};
-
config = mkIf config.services.tangled-knotserver.enable {
+
config = mkIf cfg.enable {
environment.systemPackages = with pkgs; [git];
system.activationScripts.gitConfig = ''
-
mkdir -p /home/git/.config/git
-
cat > /home/git/.config/git/config << EOF
+
mkdir -p "${cfg.repo.scanPath}"
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
+
"${cfg.repo.scanPath}"
+
+
mkdir -p "${cfg.stateDir}/.config/git"
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
[user]
name = Git User
email = git@example.com
EOF
-
chown -R git:git /home/git/.config
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
+
"${cfg.stateDir}"
'';
-
users.users.git = {
-
isNormalUser = true;
-
home = "/home/git";
+
users.users.${cfg.gitUser} = {
+
isSystemUser = true;
+
useDefaultShell = true;
+
home = cfg.stateDir;
createHome = true;
-
group = "git";
+
group = cfg.gitUser;
};
-
users.groups.git = {};
+
users.groups.${cfg.gitUser} = {};
services.openssh = {
enable = true;
extraConfig = ''
-
Match User git
+
Match User ${cfg.gitUser}
AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
AuthorizedKeysCommandUser nobody
'';
···
#!${pkgs.stdenv.shell}
${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \
-repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \
+
-internal-api "http://${cfg.server.internalListenAddr}" \
+
-git-dir "${cfg.repo.scanPath}" \
-log-path /tmp/repoguard.log
'';
};
···
after = ["network.target" "sshd.service"];
wantedBy = ["multi-user.target"];
serviceConfig = {
-
User = "git";
-
WorkingDirectory = "/home/git";
+
User = cfg.gitUser;
+
WorkingDirectory = cfg.stateDir;
Environment = [
-
"KNOT_REPO_SCAN_PATH=${config.services.tangled-knotserver.repo.scanPath}"
-
"APPVIEW_ENDPOINT=${config.services.tangled-knotserver.appviewEndpoint}"
-
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${config.services.tangled-knotserver.server.internalListenAddr}"
-
"KNOT_SERVER_LISTEN_ADDR=${config.services.tangled-knotserver.server.listenAddr}"
-
"KNOT_SERVER_HOSTNAME=${config.services.tangled-knotserver.server.hostname}"
+
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
+
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
+
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
+
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
+
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
+
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
+
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
];
-
EnvironmentFile = config.services.tangled-knotserver.server.secretFile;
+
EnvironmentFile = cfg.server.secretFile;
ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver";
Restart = "always";
};
};
-
networking.firewall.allowedTCPPorts = [22];
+
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22];
};
};
···
virtualisation.cores = 2;
services.getty.autologinUser = "root";
environment.systemPackages = with pkgs; [curl vim git];
-
systemd.tmpfiles.rules = [
-
"w /var/lib/knotserver/secret 0660 git git - KNOT_SERVER_SECRET=6995e040e80e2d593b5e5e9ca611a70140b9ef8044add0a28b48b1ee34aa3e85"
+
systemd.tmpfiles.rules = let
+
u = config.services.tangled-knotserver.gitUser;
+
g = config.services.tangled-knotserver.gitUser;
+
in [
+
"d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first
+
"f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=5b42390da4c6659f34c9a545adebd8af82c4a19960d735f651e3d582623ba9f2"
];
services.tangled-knotserver = {
enable = true;
+3 -3
go.mod
···
github.com/sethvargo/go-envconfig v1.1.0
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
github.com/yuin/goldmark v1.4.13
-
golang.org/x/crypto v0.36.0
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
)
···
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
-
golang.org/x/net v0.37.0 // indirect
-
golang.org/x/sys v0.31.0 // indirect
+
golang.org/x/crypto v0.37.0 // indirect
+
golang.org/x/net v0.39.0 // indirect
+
golang.org/x/sys v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
+10 -10
go.sum
···
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
-
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
-
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
+
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+191 -91
input.css
···
@tailwind utilities;
@layer base {
@font-face {
-
font-family: "iA Writer Quattro S";
-
src: url("/static/fonts/iAWriterQuattroS-Regular.ttf")
-
format("truetype");
+
font-family: "InterVariable";
+
src: url("/static/fonts/InterVariable.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
-
}
-
@font-face {
-
font-family: "iA Writer Quattro S";
-
src: url("/static/fonts/iAWriterQuattroS-Bold.ttf") format("truetype");
-
font-weight: bold;
-
font-style: normal;
-
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
}
+
@font-face {
-
font-family: "iA Writer Quattro S";
-
src: url("/static/fonts/iAWriterQuattroS-Italic.ttf") format("truetype");
+
font-family: "InterVariable";
+
src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2");
font-weight: normal;
font-style: italic;
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
-
}
-
@font-face {
-
font-family: "iA Writer Quattro S";
-
src: url("/static/fonts/iAWriterQuattroS-BoldItalic.ttf")
-
format("truetype");
-
font-weight: bold;
-
font-style: italic;
-
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
}
@font-face {
-
font-family: "iA Writer Mono S";
-
src: url("/static/fonts/iAWriterMonoS-Regular.ttf") format("truetype");
-
font-weight: normal;
-
font-style: normal;
-
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
-
}
-
@font-face {
-
font-family: "iA Writer Mono S";
-
src: url("/static/fonts/iAWriterMonoS-Bold.ttf") format("truetype");
-
font-weight: bold;
-
font-style: normal;
-
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
-
}
-
@font-face {
-
font-family: "iA Writer Mono S";
-
src: url("/static/fonts/iAWriterMonoS-Italic.ttf") format("truetype");
+
font-family: "IBMPlexMono";
+
src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2");
font-weight: normal;
font-style: italic;
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
-
}
-
@font-face {
-
font-family: "iA Writer Mono S";
-
src: url("/static/fonts/iAWriterMonoS-BoldItalic.ttf")
-
format("truetype");
-
font-weight: bold;
-
font-style: italic;
-
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
-
}
-
-
@font-face {
-
font-family: "Inter";
-
font-style: normal;
-
font-weight: 400;
-
font-display: swap;
-
font-feature-settings:
-
"calt" 1,
-
"kern" 1;
}
::selection {
-
@apply bg-yellow-400;
-
@apply text-black;
-
@apply bg-opacity-30;
+
@apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white;
}
@layer base {
html {
-
letter-spacing: -0.01em;
-
word-spacing: -0.07em;
-
font-size: 14px;
+
font-size: 15px;
+
}
+
@supports (font-variation-settings: normal) {
+
html {
+
font-feature-settings:
+
"ss01" 1,
+
"kern" 1,
+
"liga" 1,
+
"cv05" 1,
+
"tnum" 1;
+
}
}
+
a {
-
@apply no-underline text-black hover:underline hover:text-gray-800;
+
@apply no-underline text-black hover:underline hover:text-gray-800 dark:text-white dark:hover:text-gray-300;
}
label {
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase;
+
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
}
input {
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3;
+
@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;
}
textarea {
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3;
+
@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;
}
details summary::-webkit-details-marker {
display: none;
···
focus-visible:before:outline-4 focus-visible:before:outline-gray-500
active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)]
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200
-
disabled:hover:before:bg-white disabled:hover:before:shadow-none;
+
disabled:hover:before:bg-white disabled:hover:before:shadow-none
+
dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700
+
dark:hover:before:border-gray-600 dark:hover:before:bg-gray-700
+
dark:hover:before:shadow-[0_2px_2px_0_rgba(0,0,0,0.2),inset_0_-2px_0_0_#2d3748]
+
dark:focus-visible:before:outline-gray-400
+
dark:active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.3)]
+
dark:disabled:hover:before:bg-gray-800 dark:disabled:hover:before:border-gray-700;
}
}
@layer utilities {
.error {
-
@apply py-1 text-red-400;
+
@apply py-1 text-red-400 dark:text-red-300;
}
.success {
-
@apply py-1 text-gray-900;
+
@apply py-1 text-gray-900 dark:text-gray-100;
}
}
}
+
+
/* Background */ .bg { color: #4c4f69; background-color: #eff1f5; }
+
/* PreWrapper */ .chroma { color: #4c4f69; background-color: #eff1f5; }
+
/* Error */ .chroma .err { color: #d20f39 }
+
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
+
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
+
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
+
/* LineHighlight */ .chroma .hl { background-color: #bcc0cc }
+
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 }
+
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 }
+
/* Line */ .chroma .line { display: flex; }
+
/* Keyword */ .chroma .k { color: #8839ef }
+
/* KeywordConstant */ .chroma .kc { color: #fe640b }
+
/* KeywordDeclaration */ .chroma .kd { color: #d20f39 }
+
/* KeywordNamespace */ .chroma .kn { color: #179299 }
+
/* KeywordPseudo */ .chroma .kp { color: #8839ef }
+
/* KeywordReserved */ .chroma .kr { color: #8839ef }
+
/* KeywordType */ .chroma .kt { color: #d20f39 }
+
/* NameAttribute */ .chroma .na { color: #1e66f5 }
+
/* NameBuiltin */ .chroma .nb { color: #04a5e5 }
+
/* NameBuiltinPseudo */ .chroma .bp { color: #04a5e5 }
+
/* NameClass */ .chroma .nc { color: #df8e1d }
+
/* NameConstant */ .chroma .no { color: #df8e1d }
+
/* NameDecorator */ .chroma .nd { color: #1e66f5; font-weight: bold }
+
/* NameEntity */ .chroma .ni { color: #179299 }
+
/* NameException */ .chroma .ne { color: #fe640b }
+
/* NameFunction */ .chroma .nf { color: #1e66f5 }
+
/* NameFunctionMagic */ .chroma .fm { color: #1e66f5 }
+
/* NameLabel */ .chroma .nl { color: #04a5e5 }
+
/* NameNamespace */ .chroma .nn { color: #fe640b }
+
/* NameProperty */ .chroma .py { color: #fe640b }
+
/* NameTag */ .chroma .nt { color: #8839ef }
+
/* NameVariable */ .chroma .nv { color: #dc8a78 }
+
/* NameVariableClass */ .chroma .vc { color: #dc8a78 }
+
/* NameVariableGlobal */ .chroma .vg { color: #dc8a78 }
+
/* NameVariableInstance */ .chroma .vi { color: #dc8a78 }
+
/* NameVariableMagic */ .chroma .vm { color: #dc8a78 }
+
/* LiteralString */ .chroma .s { color: #40a02b }
+
/* LiteralStringAffix */ .chroma .sa { color: #d20f39 }
+
/* LiteralStringBacktick */ .chroma .sb { color: #40a02b }
+
/* LiteralStringChar */ .chroma .sc { color: #40a02b }
+
/* LiteralStringDelimiter */ .chroma .dl { color: #1e66f5 }
+
/* LiteralStringDoc */ .chroma .sd { color: #9ca0b0 }
+
/* LiteralStringDouble */ .chroma .s2 { color: #40a02b }
+
/* LiteralStringEscape */ .chroma .se { color: #1e66f5 }
+
/* LiteralStringHeredoc */ .chroma .sh { color: #9ca0b0 }
+
/* LiteralStringInterpol */ .chroma .si { color: #40a02b }
+
/* LiteralStringOther */ .chroma .sx { color: #40a02b }
+
/* LiteralStringRegex */ .chroma .sr { color: #179299 }
+
/* LiteralStringSingle */ .chroma .s1 { color: #40a02b }
+
/* LiteralStringSymbol */ .chroma .ss { color: #40a02b }
+
/* LiteralNumber */ .chroma .m { color: #fe640b }
+
/* LiteralNumberBin */ .chroma .mb { color: #fe640b }
+
/* LiteralNumberFloat */ .chroma .mf { color: #fe640b }
+
/* LiteralNumberHex */ .chroma .mh { color: #fe640b }
+
/* LiteralNumberInteger */ .chroma .mi { color: #fe640b }
+
/* LiteralNumberIntegerLong */ .chroma .il { color: #fe640b }
+
/* LiteralNumberOct */ .chroma .mo { color: #fe640b }
+
/* Operator */ .chroma .o { color: #04a5e5; font-weight: bold }
+
/* OperatorWord */ .chroma .ow { color: #04a5e5; font-weight: bold }
+
/* Comment */ .chroma .c { color: #9ca0b0; font-style: italic }
+
/* CommentHashbang */ .chroma .ch { color: #9ca0b0; font-style: italic }
+
/* CommentMultiline */ .chroma .cm { color: #9ca0b0; font-style: italic }
+
/* CommentSingle */ .chroma .c1 { color: #9ca0b0; font-style: italic }
+
/* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic }
+
/* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic }
+
/* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic }
+
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) }
+
/* GenericEmph */ .chroma .ge { font-style: italic }
+
/* GenericError */ .chroma .gr { color: #d20f39 }
+
/* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold }
+
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) }
+
/* GenericStrong */ .chroma .gs { font-weight: bold }
+
/* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold }
+
/* GenericTraceback */ .chroma .gt { color: #d20f39 }
+
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
+
+
@media (prefers-color-scheme: dark) {
+
/* Background */ .bg { color: #cad3f5; background-color: #24273a; }
+
/* PreWrapper */ .chroma { color: #cad3f5; background-color: #24273a; }
+
/* Error */ .chroma .err { color: #ed8796 }
+
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
+
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
+
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
+
/* LineHighlight */ .chroma .hl { background-color: #494d64 }
+
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 }
+
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 }
+
/* Line */ .chroma .line { display: flex; }
+
/* Keyword */ .chroma .k { color: #c6a0f6 }
+
/* KeywordConstant */ .chroma .kc { color: #f5a97f }
+
/* KeywordDeclaration */ .chroma .kd { color: #ed8796 }
+
/* KeywordNamespace */ .chroma .kn { color: #8bd5ca }
+
/* KeywordPseudo */ .chroma .kp { color: #c6a0f6 }
+
/* KeywordReserved */ .chroma .kr { color: #c6a0f6 }
+
/* KeywordType */ .chroma .kt { color: #ed8796 }
+
/* NameAttribute */ .chroma .na { color: #8aadf4 }
+
/* NameBuiltin */ .chroma .nb { color: #91d7e3 }
+
/* NameBuiltinPseudo */ .chroma .bp { color: #91d7e3 }
+
/* NameClass */ .chroma .nc { color: #eed49f }
+
/* NameConstant */ .chroma .no { color: #eed49f }
+
/* NameDecorator */ .chroma .nd { color: #8aadf4; font-weight: bold }
+
/* NameEntity */ .chroma .ni { color: #8bd5ca }
+
/* NameException */ .chroma .ne { color: #f5a97f }
+
/* NameFunction */ .chroma .nf { color: #8aadf4 }
+
/* NameFunctionMagic */ .chroma .fm { color: #8aadf4 }
+
/* NameLabel */ .chroma .nl { color: #91d7e3 }
+
/* NameNamespace */ .chroma .nn { color: #f5a97f }
+
/* NameProperty */ .chroma .py { color: #f5a97f }
+
/* NameTag */ .chroma .nt { color: #c6a0f6 }
+
/* NameVariable */ .chroma .nv { color: #f4dbd6 }
+
/* NameVariableClass */ .chroma .vc { color: #f4dbd6 }
+
/* NameVariableGlobal */ .chroma .vg { color: #f4dbd6 }
+
/* NameVariableInstance */ .chroma .vi { color: #f4dbd6 }
+
/* NameVariableMagic */ .chroma .vm { color: #f4dbd6 }
+
/* LiteralString */ .chroma .s { color: #a6da95 }
+
/* LiteralStringAffix */ .chroma .sa { color: #ed8796 }
+
/* LiteralStringBacktick */ .chroma .sb { color: #a6da95 }
+
/* LiteralStringChar */ .chroma .sc { color: #a6da95 }
+
/* LiteralStringDelimiter */ .chroma .dl { color: #8aadf4 }
+
/* LiteralStringDoc */ .chroma .sd { color: #6e738d }
+
/* LiteralStringDouble */ .chroma .s2 { color: #a6da95 }
+
/* LiteralStringEscape */ .chroma .se { color: #8aadf4 }
+
/* LiteralStringHeredoc */ .chroma .sh { color: #6e738d }
+
/* LiteralStringInterpol */ .chroma .si { color: #a6da95 }
+
/* LiteralStringOther */ .chroma .sx { color: #a6da95 }
+
/* LiteralStringRegex */ .chroma .sr { color: #8bd5ca }
+
/* LiteralStringSingle */ .chroma .s1 { color: #a6da95 }
+
/* LiteralStringSymbol */ .chroma .ss { color: #a6da95 }
+
/* LiteralNumber */ .chroma .m { color: #f5a97f }
+
/* LiteralNumberBin */ .chroma .mb { color: #f5a97f }
+
/* LiteralNumberFloat */ .chroma .mf { color: #f5a97f }
+
/* LiteralNumberHex */ .chroma .mh { color: #f5a97f }
+
/* LiteralNumberInteger */ .chroma .mi { color: #f5a97f }
+
/* LiteralNumberIntegerLong */ .chroma .il { color: #f5a97f }
+
/* LiteralNumberOct */ .chroma .mo { color: #f5a97f }
+
/* Operator */ .chroma .o { color: #91d7e3; font-weight: bold }
+
/* OperatorWord */ .chroma .ow { color: #91d7e3; font-weight: bold }
+
/* Comment */ .chroma .c { color: #6e738d; font-style: italic }
+
/* CommentHashbang */ .chroma .ch { color: #6e738d; font-style: italic }
+
/* CommentMultiline */ .chroma .cm { color: #6e738d; font-style: italic }
+
/* CommentSingle */ .chroma .c1 { color: #6e738d; font-style: italic }
+
/* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic }
+
/* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic }
+
/* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic }
+
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) }
+
/* GenericEmph */ .chroma .ge { font-style: italic }
+
/* GenericError */ .chroma .gr { color: #ed8796 }
+
/* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold }
+
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) }
+
/* GenericStrong */ .chroma .gs { font-weight: bold }
+
/* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold }
+
/* GenericTraceback */ .chroma .gt { color: #ed8796 }
+
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
+
}
+
+
.chroma .line:has(.ln:target) {
+
@apply bg-amber-400/30 dark:bg-amber-500/20
+
}
+91 -24
jetstream/jetstream.go
···
"context"
"fmt"
"log/slog"
+
"os"
+
"os/signal"
"sync"
+
"syscall"
"time"
"github.com/bluesky-social/jetstream/pkg/client"
···
type DB interface {
GetLastTimeUs() (int64, error)
SaveLastTimeUs(int64) error
-
UpdateLastTimeUs(int64) error
}
+
type Set[T comparable] map[T]struct{}
+
type JetstreamClient struct {
cfg *client.ClientConfig
client *client.Client
ident string
l *slog.Logger
+
wantedDids Set[string]
db DB
waitForDid bool
mu sync.RWMutex
···
if did == "" {
return
}
+
+
j.l.Info("adding did to in-memory filter", "did", did)
j.mu.Lock()
-
j.cfg.WantedDids = append(j.cfg.WantedDids, did)
+
j.wantedDids[did] = struct{}{}
j.mu.Unlock()
}
-
func (j *JetstreamClient) UpdateDids(dids []string) {
-
j.mu.Lock()
-
for _, did := range dids {
-
if did != "" {
-
j.cfg.WantedDids = append(j.cfg.WantedDids, did)
-
}
-
}
-
j.mu.Unlock()
+
type processor func(context.Context, *models.Event) error
-
j.cancelMu.Lock()
-
if j.cancel != nil {
-
j.cancel()
+
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+
// empty filter => all dids allowed
+
if len(j.wantedDids) == 0 {
+
return processFunc
}
-
j.cancelMu.Unlock()
+
// since this closure references j.WantedDids; it should auto-update
+
// existing instances of the closure when j.WantedDids is mutated
+
return func(ctx context.Context, evt *models.Event) error {
+
if _, ok := j.wantedDids[evt.Did]; ok {
+
return processFunc(ctx, evt)
+
} else {
+
return nil
+
}
+
}
}
func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) {
···
}
return &JetstreamClient{
-
cfg: cfg,
-
ident: ident,
-
db: db,
-
l: logger,
+
cfg: cfg,
+
ident: ident,
+
db: db,
+
l: logger,
+
wantedDids: make(map[string]struct{}),
// This will make the goroutine in StartJetstream wait until
-
// cfg.WantedDids has been populated, typically using UpdateDids.
+
// j.wantedDids has been populated, typically using addDids.
waitForDid: waitForDid,
}, nil
}
// StartJetstream starts the jetstream client and processes events using the provided processFunc.
-
// The caller is responsible for saving the last time_us to the database (just use your db.SaveLastTimeUs).
+
// The caller is responsible for saving the last time_us to the database (just use your db.UpdateLastTimeUs).
func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error {
logger := j.l
-
sched := sequential.NewScheduler(j.ident, logger, processFunc)
+
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
if err != nil {
···
go func() {
if j.waitForDid {
-
for len(j.cfg.WantedDids) == 0 {
+
for len(j.wantedDids) == 0 {
time.Sleep(time.Second)
}
}
logger.Info("done waiting for did")
+
+
go j.periodicLastTimeSave(ctx)
+
j.saveIfKilled(ctx)
+
j.connectAndRead(ctx)
}()
···
}
}
+
// save cursor periodically
+
func (j *JetstreamClient) periodicLastTimeSave(ctx context.Context) {
+
ticker := time.NewTicker(time.Minute)
+
defer ticker.Stop()
+
+
for {
+
select {
+
case <-ctx.Done():
+
return
+
case <-ticker.C:
+
j.db.SaveLastTimeUs(time.Now().UnixMicro())
+
}
+
}
+
}
+
func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 {
l := log.FromContext(ctx)
lastTimeUs, err := j.db.GetLastTimeUs()
···
}
}
-
// If last time is older than a week, start from now
+
// If last time is older than 2 days, start from now
if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 {
lastTimeUs = time.Now().UnixMicro()
l.Warn("last time us is older than 2 days; discarding that and starting from now")
-
err = j.db.UpdateLastTimeUs(lastTimeUs)
+
err = j.db.SaveLastTimeUs(lastTimeUs)
if err != nil {
l.Error("failed to save last time us", "error", err)
}
···
l.Info("found last time_us", "time_us", lastTimeUs)
return &lastTimeUs
}
+
+
func (j *JetstreamClient) saveIfKilled(ctx context.Context) context.Context {
+
ctxWithCancel, cancel := context.WithCancel(ctx)
+
+
sigChan := make(chan os.Signal, 1)
+
+
signal.Notify(sigChan,
+
syscall.SIGINT,
+
syscall.SIGTERM,
+
syscall.SIGQUIT,
+
syscall.SIGHUP,
+
syscall.SIGKILL,
+
syscall.SIGSTOP,
+
)
+
+
go func() {
+
sig := <-sigChan
+
j.l.Info("Received signal, initiating graceful shutdown", "signal", sig)
+
+
lastTimeUs := time.Now().UnixMicro()
+
if err := j.db.SaveLastTimeUs(lastTimeUs); err != nil {
+
j.l.Error("Failed to save last time during shutdown", "error", err)
+
}
+
j.l.Info("Saved lastTimeUs before shutdown", "lastTimeUs", lastTimeUs)
+
+
j.cancelMu.Lock()
+
if j.cancel != nil {
+
j.cancel()
+
}
+
j.cancelMu.Unlock()
+
+
cancel()
+
+
os.Exit(0)
+
}()
+
+
return ctxWithCancel
+
}
+6 -10
knotserver/db/jetstream.go
···
package db
func (d *DB) SaveLastTimeUs(lastTimeUs int64) error {
-
_, err := d.db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs)
+
_, err := d.db.Exec(`
+
insert into _jetstream (id, last_time_us)
+
values (1, ?)
+
on conflict(id) do update set last_time_us = excluded.last_time_us
+
`, lastTimeUs)
return err
}
-
func (d *DB) UpdateLastTimeUs(lastTimeUs int64) error {
-
_, err := d.db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs)
-
if err != nil {
-
return err
-
}
-
return nil
-
}
-
func (d *DB) GetLastTimeUs() (int64, error) {
var lastTimeUs int64
-
row := d.db.QueryRow(`select last_time_us from _jetstream`)
+
row := d.db.QueryRow(`select last_time_us from _jetstream where id = 1;`)
err := row.Scan(&lastTimeUs)
return lastTimeUs, err
}
+2 -2
knotserver/db/pubkeys.go
···
return err
}
-
func (pk *PublicKey) JSON() map[string]interface{} {
-
return map[string]interface{}{
+
func (pk *PublicKey) JSON() map[string]any {
+
return map[string]any{
"did": pk.Did,
"key": pk.Key,
"created": pk.Created,
+112 -10
knotserver/git/diff.go
···
package git
import (
+
"bytes"
"fmt"
"log"
+
"os"
+
"os/exec"
"strings"
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
)
···
}
nd := types.NiceDiff{}
-
nd.Commit.This = c.Hash.String()
-
-
if parent.Hash.IsZero() {
-
nd.Commit.Parent = ""
-
} else {
-
nd.Commit.Parent = parent.Hash.String()
-
}
-
nd.Commit.Author = c.Author
-
nd.Commit.Message = c.Message
-
for _, d := range diffs {
ndiff := types.Diff{}
ndiff.Name.New = d.NewName
···
}
nd.Stat.FilesChanged = len(diffs)
+
nd.Commit.This = c.Hash.String()
+
+
if parent.Hash.IsZero() {
+
nd.Commit.Parent = ""
+
} else {
+
nd.Commit.Parent = parent.Hash.String()
+
}
+
nd.Commit.Author = c.Author
+
nd.Commit.Message = c.Message
return &nd, nil
}
+
+
func (g *GitRepo) DiffTree(commit1, commit2 *object.Commit) (*types.DiffTree, error) {
+
tree1, err := commit1.Tree()
+
if err != nil {
+
return nil, err
+
}
+
+
tree2, err := commit2.Tree()
+
if err != nil {
+
return nil, err
+
}
+
+
diff, err := object.DiffTree(tree1, tree2)
+
if err != nil {
+
return nil, err
+
}
+
+
patch, err := diff.Patch()
+
if err != nil {
+
return nil, err
+
}
+
+
diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String()))
+
if err != nil {
+
return nil, err
+
}
+
+
return &types.DiffTree{
+
Rev1: commit1.Hash.String(),
+
Rev2: commit2.Hash.String(),
+
Patch: patch.String(),
+
Diff: diffs,
+
}, nil
+
}
+
+
// FormatPatch generates a git-format-patch output between two commits,
+
// and returns the raw format-patch series, a parsed FormatPatch and an error.
+
func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) {
+
var stdout bytes.Buffer
+
cmd := exec.Command(
+
"git",
+
"-C",
+
g.path,
+
"format-patch",
+
fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()),
+
"--stdout",
+
)
+
cmd.Stdout = &stdout
+
cmd.Stderr = os.Stderr
+
err := cmd.Run()
+
if err != nil {
+
return "", nil, err
+
}
+
+
formatPatch, err := patchutil.ExtractPatches(stdout.String())
+
if err != nil {
+
return "", nil, err
+
}
+
+
return stdout.String(), formatPatch, nil
+
}
+
+
func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
+
isAncestor, err := commit1.IsAncestor(commit2)
+
if err != nil {
+
return nil, err
+
}
+
+
if isAncestor {
+
return commit1, nil
+
}
+
+
mergeBase, err := commit1.MergeBase(commit2)
+
if err != nil {
+
return nil, err
+
}
+
+
if len(mergeBase) == 0 {
+
return nil, fmt.Errorf("failed to find a merge-base")
+
}
+
+
return mergeBase[0], nil
+
}
+
+
func (g *GitRepo) ResolveRevision(revStr string) (*object.Commit, error) {
+
rev, err := g.r.ResolveRevision(plumbing.Revision(revStr))
+
if err != nil {
+
return nil, fmt.Errorf("resolving revision %s: %w", revStr, err)
+
}
+
+
commit, err := g.r.CommitObject(*rev)
+
if err != nil {
+
+
return nil, fmt.Errorf("getting commit for %s: %w", revStr, err)
+
}
+
+
return commit, nil
+
}
+50
knotserver/git/fork.go
···
+
package git
+
+
import (
+
"errors"
+
"fmt"
+
"os/exec"
+
+
"github.com/go-git/go-git/v5"
+
"github.com/go-git/go-git/v5/config"
+
)
+
+
func Fork(repoPath, source string) error {
+
_, err := git.PlainClone(repoPath, true, &git.CloneOptions{
+
URL: source,
+
SingleBranch: false,
+
})
+
+
if err != nil {
+
return fmt.Errorf("failed to bare clone repository: %w", err)
+
}
+
+
err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run()
+
if err != nil {
+
return fmt.Errorf("failed to configure hidden refs: %w", err)
+
}
+
+
return nil
+
}
+
+
// TrackHiddenRemoteRef tracks a hidden remote in the repository. For example,
+
// if the feature branch on the fork (forkRef) is feature-1, and the remoteRef,
+
// i.e. the branch we want to merge into, is main, this will result in a refspec:
+
//
+
// +refs/heads/main:refs/hidden/feature-1/main
+
func (g *GitRepo) TrackHiddenRemoteRef(forkRef, remoteRef string) error {
+
fetchOpts := &git.FetchOptions{
+
RefSpecs: []config.RefSpec{
+
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/hidden/%s/%s", remoteRef, forkRef, remoteRef)),
+
},
+
RemoteName: "origin",
+
}
+
+
err := g.r.Fetch(fetchOpts)
+
if errors.Is(git.NoErrAlreadyUpToDate, err) {
+
return nil
+
} else if err != nil {
+
return fmt.Errorf("failed to fetch hidden remote: %s: %w", forkRef, err)
+
}
+
return nil
+
}
+29 -1
knotserver/git/git.go
···
return &g, nil
}
+
func PlainOpen(path string) (*GitRepo, error) {
+
var err error
+
g := GitRepo{path: path}
+
g.r, err = git.PlainOpen(path)
+
if err != nil {
+
return nil, fmt.Errorf("opening %s: %w", path, err)
+
}
+
return &g, nil
+
}
+
func (g *GitRepo) Commits() ([]*object.Commit, error) {
ci, err := g.r.Log(&git.LogOptions{From: g.h})
if err != nil {
···
return branches, nil
}
+
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+
ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
+
if err != nil {
+
return nil, fmt.Errorf("branch: %w", err)
+
}
+
+
if !ref.Name().IsBranch() {
+
return nil, fmt.Errorf("branch: %s is not a branch", ref.Name())
+
}
+
+
return ref, nil
+
}
+
+
func (g *GitRepo) SetDefaultBranch(branch string) error {
+
ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
+
return g.r.Storer.SetReference(ref)
+
}
+
func (g *GitRepo) FindMainBranch() (string, error) {
ref, err := g.r.Head()
if err != nil {
···
}
cacheMu.RUnlock()
-
cmd := exec.Command("git", "-C", g.path, "log", "-1", "--format=%H %ct", "--", path)
+
cmd := exec.Command("git", "-C", g.path, "log", g.h.String(), "-1", "--format=%H %ct", "--", path)
var out bytes.Buffer
cmd.Stdout = &out
+17 -2
knotserver/git/merge.go
···
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
+
"tangled.sh/tangled.sh/core/patchutil"
)
type ErrMerge struct {
···
CommitBody string
AuthorName string
AuthorEmail string
+
FormatPatch bool
}
func (e ErrMerge) Error() string {
···
if checkOnly {
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
} else {
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
+
// if patch is a format-patch, apply using 'git am'
+
if opts.FormatPatch {
+
amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
+
amCmd.Stderr = &stderr
+
if err := amCmd.Run(); err != nil {
+
return fmt.Errorf("patch application failed: %s", stderr.String())
+
}
+
return nil
+
}
+
// else, apply using 'git apply' and commit it manually
+
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
if opts != nil {
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
applyCmd.Stderr = &stderr
···
}
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
+
var opts MergeOptions
+
opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
+
patchFile, err := g.createTempFileWithPatch(patchData)
if err != nil {
return &ErrMerge{
···
}
defer os.RemoveAll(tmpDir)
-
return g.applyPatch(tmpDir, patchFile, true, nil)
+
return g.applyPatch(tmpDir, patchFile, true, &opts)
}
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
+31 -24
knotserver/git/service/service.go
···
"net/http"
"os/exec"
"strings"
+
"sync"
"syscall"
)
···
}
func (c *ServiceCommand) UploadPack() error {
-
cmd := exec.Command("git", []string{
-
"-c", "uploadpack.allowFilter=true",
-
"upload-pack",
-
"--stateless-rpc",
-
".",
-
}...)
+
var stderr bytes.Buffer
+
+
cmd := exec.Command("git", "-c", "uploadpack.allowFilter=true",
+
"upload-pack", "--stateless-rpc", ".")
cmd.Dir = c.Dir
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
-
stdoutPipe, _ := cmd.StdoutPipe()
-
cmd.Stderr = cmd.Stdout
-
defer stdoutPipe.Close()
+
stdoutPipe, err := cmd.StdoutPipe()
+
if err != nil {
+
return fmt.Errorf("failed to create stdout pipe: %w", err)
+
}
+
+
cmd.Stderr = &stderr
stdinPipe, err := cmd.StdinPipe()
if err != nil {
-
return err
+
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
-
defer stdinPipe.Close()
if err := cmd.Start(); err != nil {
-
log.Printf("git: failed to start git-upload-pack: %s", err)
-
return err
+
return fmt.Errorf("failed to start git-upload-pack: %w", err)
}
-
if _, err := io.Copy(stdinPipe, c.Stdin); err != nil {
-
log.Printf("git: failed to copy stdin: %s", err)
-
return err
-
}
-
stdinPipe.Close()
+
var wg sync.WaitGroup
+
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
defer stdinPipe.Close()
+
io.Copy(stdinPipe, c.Stdin)
+
}()
+
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
io.Copy(newWriteFlusher(c.Stdout), stdoutPipe)
+
stdoutPipe.Close()
+
}()
+
+
wg.Wait()
-
if _, err := io.Copy(newWriteFlusher(c.Stdout), stdoutPipe); err != nil {
-
log.Printf("git: failed to copy stdout: %s", err)
-
return err
-
}
if err := cmd.Wait(); err != nil {
-
log.Printf("git: failed to wait for git-upload-pack: %s", err)
-
return err
+
return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String())
}
return nil
+8 -1
knotserver/git/tree.go
···
import (
"fmt"
+
"time"
"github.com/go-git/go-git/v5/plumbing/object"
"tangled.sh/tangled.sh/core/types"
···
lastCommit, err := g.LastCommitForPath(fpath)
if err != nil {
fmt.Println("error getting last commit time:", err)
-
continue
+
// We don't want to skip the file, so worst case lets just
+
// populate it with "defaults".
+
lastCommit = &types.LastCommitInfo{
+
Hash: g.h,
+
Message: "",
+
When: time.Now(),
+
}
}
nts = append(nts, types.NiceTree{
+22 -17
knotserver/git.go
···
func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) {
did := chi.URLParam(r, "did")
name := chi.URLParam(r, "name")
-
repo, _ := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
-
-
w.Header().Set("content-type", "application/x-git-upload-pack-result")
-
w.Header().Set("Connection", "Keep-Alive")
-
w.Header().Set("Transfer-Encoding", "chunked")
-
w.WriteHeader(http.StatusOK)
-
-
cmd := service.ServiceCommand{
-
Dir: repo,
-
Stdout: w,
+
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
+
if err != nil {
+
writeError(w, err.Error(), 500)
+
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
+
return
}
-
var reader io.ReadCloser
-
reader = r.Body
-
+
var bodyReader io.ReadCloser = r.Body
if r.Header.Get("Content-Encoding") == "gzip" {
-
reader, err := gzip.NewReader(r.Body)
+
gzipReader, err := gzip.NewReader(r.Body)
if err != nil {
writeError(w, err.Error(), 500)
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
return
}
-
defer reader.Close()
+
defer gzipReader.Close()
+
bodyReader = gzipReader
+
}
+
+
w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
+
w.Header().Set("Connection", "Keep-Alive")
+
+
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
+
+
cmd := service.ServiceCommand{
+
Dir: repo,
+
Stdout: w,
+
Stdin: bodyReader,
}
-
cmd.Stdin = reader
+
w.WriteHeader(http.StatusOK)
+
if err := cmd.UploadPack(); err != nil {
-
writeError(w, err.Error(), 500)
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
return
}
+47 -2
knotserver/handler.go
···
"fmt"
"log/slog"
"net/http"
+
"runtime/debug"
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/jetstream"
···
if err != nil {
return nil, fmt.Errorf("failed to get all Dids: %w", err)
}
+
if len(dids) > 0 {
h.knotInitialized = true
close(h.init)
-
// h.jc.UpdateDids(dids)
+
for _, d := range dids {
+
h.jc.AddDid(d)
+
}
}
r.Get("/", h.Index)
+
r.Get("/capabilities", h.Capabilities)
+
r.Get("/version", h.Version)
r.Route("/{did}", func(r chi.Router) {
// Repo routes
r.Route("/{name}", func(r chi.Router) {
···
r.Get("/", h.RepoIndex)
r.Get("/info/refs", h.InfoRefs)
r.Post("/git-upload-pack", h.UploadPack)
+
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
+
+
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
r.Route("/merge", func(r chi.Router) {
r.With(h.VerifySignature)
···
r.Get("/archive/{file}", h.Archive)
r.Get("/commit/{ref}", h.Diff)
r.Get("/tags", h.Tags)
-
r.Get("/branches", h.Branches)
+
r.Route("/branches", func(r chi.Router) {
+
r.Get("/", h.Branches)
+
r.Get("/{branch}", h.Branch)
+
r.Route("/default", func(r chi.Router) {
+
r.Get("/", h.DefaultBranch)
+
r.With(h.VerifySignature).Put("/", h.SetDefaultBranch)
+
})
+
})
})
})
···
r.Use(h.VerifySignature)
r.Put("/new", h.NewRepo)
r.Delete("/", h.RemoveRepo)
+
r.Post("/fork", h.RepoFork)
})
r.Route("/member", func(r chi.Router) {
···
return r, nil
}
+
+
// version is set during build time.
+
var version string
+
+
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
+
if version == "" {
+
info, ok := debug.ReadBuildInfo()
+
if !ok {
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
+
return
+
}
+
+
var modVer string
+
for _, mod := range info.Deps {
+
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
+
version = mod.Version
+
break
+
}
+
}
+
+
if modVer == "" {
+
version = "unknown"
+
}
+
}
+
+
w.Header().Set("Content-Type", "text/plain")
+
fmt.Fprintf(w, "knotserver/%s", version)
+
}
+2 -2
knotserver/jetstream.go
···
l.Error("failed to add did", "error", err)
return fmt.Errorf("failed to add did: %w", err)
}
+
h.jc.AddDid(did)
if err := h.fetchAndAddKeys(ctx, did); err != nil {
return fmt.Errorf("failed to fetch and add keys: %w", err)
···
eventTime := event.TimeUS
lastTimeUs := eventTime + 1
fmt.Println("lastTimeUs", lastTimeUs)
-
if err := h.db.UpdateLastTimeUs(lastTimeUs); err != nil {
+
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
}
-
// h.jc.UpdateDids([]string{did})
}()
raw := json.RawMessage(event.Commit.Record)
+237 -3
knotserver/routes.go
···
"github.com/go-git/go-git/v5/plumbing/object"
"tangled.sh/tangled.sh/core/knotserver/db"
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
)
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
+
}
+
+
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "application/json")
+
+
capabilities := map[string]any{
+
"pull_requests": map[string]any{
+
"format_patch": true,
+
"patch_submissions": true,
+
"branch_submissions": true,
+
"fork_submissions": true,
+
},
+
}
+
+
jsonData, err := json.Marshal(capabilities)
+
if err != nil {
+
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
+
return
+
}
+
+
w.Write(jsonData)
}
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
···
return
}
+
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
+
branchName := chi.URLParam(r, "branch")
+
l := h.l.With("handler", "Branch")
+
+
gr, err := git.PlainOpen(path)
+
if err != nil {
+
notFound(w)
+
return
+
}
+
+
ref, err := gr.Branch(branchName)
+
if err != nil {
+
l.Error("getting branches", "error", err.Error())
+
writeError(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
resp := types.RepoBranchResponse{
+
Branch: types.Branch{
+
Reference: types.Reference{
+
Name: ref.Name().Short(),
+
Hash: ref.Hash().String(),
+
},
+
},
+
}
+
+
writeJSON(w, resp)
+
return
+
}
+
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "Keys")
···
return
}
-
data := make([]map[string]interface{}, 0)
+
data := make([]map[string]any, 0)
for _, key := range keys {
j := key.JSON()
data = append(data, j)
···
w.WriteHeader(http.StatusNoContent)
}
+
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
+
l := h.l.With("handler", "RepoFork")
+
+
data := struct {
+
Did string `json:"did"`
+
Source string `json:"source"`
+
Name string `json:"name,omitempty"`
+
}{}
+
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
writeError(w, "invalid request body", http.StatusBadRequest)
+
return
+
}
+
+
did := data.Did
+
source := data.Source
+
+
if did == "" || source == "" {
+
l.Error("invalid request body, empty did or name")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
var name string
+
if data.Name != "" {
+
name = data.Name
+
} else {
+
name = filepath.Base(source)
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
+
+
err := git.Fork(repoPath, source)
+
if err != nil {
+
l.Error("forking repo", "error", err.Error())
+
writeError(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
// add perms for this user to access the repo
+
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
+
if err != nil {
+
l.Error("adding repo permissions", "error", err.Error())
+
writeError(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusNoContent)
+
}
+
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "RemoveRepo")
···
notFound(w)
return
}
+
+
mo.FormatPatch = patchutil.IsFormatPatch(patch)
+
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
var mergeErr *git.ErrMerge
if errors.As(err, &mergeErr) {
···
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
}
+
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
+
rev1 := chi.URLParam(r, "rev1")
+
rev1, _ = url.PathUnescape(rev1)
+
+
rev2 := chi.URLParam(r, "rev2")
+
rev2, _ = url.PathUnescape(rev2)
+
+
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
+
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
+
gr, err := git.PlainOpen(path)
+
if err != nil {
+
notFound(w)
+
return
+
}
+
+
commit1, err := gr.ResolveRevision(rev1)
+
if err != nil {
+
l.Error("error resolving revision 1", "msg", err.Error())
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
+
return
+
}
+
+
commit2, err := gr.ResolveRevision(rev2)
+
if err != nil {
+
l.Error("error resolving revision 2", "msg", err.Error())
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
+
return
+
}
+
+
mergeBase, err := gr.MergeBase(commit1, commit2)
+
if err != nil {
+
l.Error("failed to find merge-base", "msg", err.Error())
+
writeError(w, "failed to calculate diff", http.StatusBadRequest)
+
return
+
}
+
+
rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2)
+
if err != nil {
+
l.Error("error comparing revisions", "msg", err.Error())
+
writeError(w, "error comparing revisions", http.StatusBadRequest)
+
return
+
}
+
+
writeJSON(w, types.RepoFormatPatchResponse{
+
Rev1: commit1.Hash.String(),
+
Rev2: commit2.Hash.String(),
+
FormatPatch: formatPatch,
+
Patch: rawPatch,
+
})
+
return
+
}
+
+
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
+
l := h.l.With("handler", "NewHiddenRef")
+
+
forkRef := chi.URLParam(r, "forkRef")
+
remoteRef := chi.URLParam(r, "remoteRef")
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
+
gr, err := git.PlainOpen(path)
+
if err != nil {
+
notFound(w)
+
return
+
}
+
+
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
+
if err != nil {
+
l.Error("error tracking hidden remote ref", "msg", err.Error())
+
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
+
return
+
}
+
+
w.WriteHeader(http.StatusNoContent)
+
return
+
}
+
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "AddMember")
···
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
-
h.jc.AddDid(did)
+
if err := h.e.AddMember(ThisServer, did); err != nil {
l.Error("adding member", "error", err.Error())
writeError(w, err.Error(), http.StatusInternalServerError)
···
w.WriteHeader(http.StatusNoContent)
}
+
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
+
l := h.l.With("handler", "DefaultBranch")
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
+
+
gr, err := git.Open(path, "")
+
if err != nil {
+
notFound(w)
+
return
+
}
+
+
branch, err := gr.FindMainBranch()
+
if err != nil {
+
writeError(w, err.Error(), http.StatusInternalServerError)
+
l.Error("getting default branch", "error", err.Error())
+
return
+
}
+
+
writeJSON(w, types.RepoDefaultBranchResponse{
+
Branch: branch,
+
})
+
}
+
+
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+
l := h.l.With("handler", "SetDefaultBranch")
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
+
+
data := struct {
+
Branch string `json:"branch"`
+
}{}
+
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
writeError(w, err.Error(), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(path, "")
+
if err != nil {
+
notFound(w)
+
return
+
}
+
+
err = gr.SetDefaultBranch(data.Branch)
+
if err != nil {
+
writeError(w, err.Error(), http.StatusInternalServerError)
+
l.Error("setting default branch", "error", err.Error())
+
return
+
}
+
+
w.WriteHeader(http.StatusNoContent)
+
}
+
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "Init")
···
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
+
h.jc.AddDid(data.Did)
-
// h.jc.UpdateDids([]string{data.Did})
if err := h.e.AddOwner(ThisServer, data.Did); err != nil {
l.Error("adding owner", "error", err.Error())
writeError(w, err.Error(), http.StatusInternalServerError)
+24 -5
lexicons/pulls/pull.json
···
"key": "tid",
"record": {
"type": "object",
-
"required": ["targetRepo", "targetBranch", "pullId", "title", "patch"],
+
"required": [
+
"targetRepo",
+
"targetBranch",
+
"pullId",
+
"title",
+
"patch"
+
],
"properties": {
"targetRepo": {
"type": "string",
···
"targetBranch": {
"type": "string"
},
-
"sourceRepo": {
-
"type": "string",
-
"format": "at-uri"
-
},
"pullId": {
"type": "integer"
},
···
},
"patch": {
"type": "string"
+
},
+
"source": {
+
"type": "ref",
+
"ref": "#source"
}
+
}
+
}
+
},
+
"source": {
+
"type": "object",
+
"required": ["branch"],
+
"properties": {
+
"branch": {
+
"type": "string"
+
},
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
}
}
}
+5
lexicons/repo.json
···
"format": "datetime",
"minLength": 1,
"maxLength": 140
+
},
+
"source": {
+
"type": "string",
+
"format": "uri",
+
"description": "source of the repo"
}
}
}
+168
patchutil/combinediff.go
···
+
package patchutil
+
+
import (
+
"fmt"
+
"strings"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
// original1 -> patch1 -> rev1
+
// original2 -> patch2 -> rev2
+
//
+
// original2 must be equal to rev1, so we can merge them to get maximal context
+
//
+
// finally,
+
// rev2' <- apply(patch2, merged)
+
// combineddiff <- diff(rev2', original1)
+
func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) {
+
fileName := bestName(file1)
+
+
o1 := CreatePreImage(file1)
+
r1 := CreatePostImage(file1)
+
o2 := CreatePreImage(file2)
+
+
merged, err := r1.Merge(&o2)
+
if err != nil {
+
return nil, err
+
}
+
+
r2Prime, err := merged.Apply(file2)
+
if err != nil {
+
return nil, err
+
}
+
+
// produce combined diff
+
diff, err := Unified(o1.String(), fileName, r2Prime, fileName)
+
if err != nil {
+
return nil, err
+
}
+
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
+
+
if len(parsed) != 1 {
+
// no diff? the second commit reverted the changes from the first
+
return nil, nil
+
}
+
+
return parsed[0], nil
+
}
+
+
// use empty lines for lines we are unaware of
+
//
+
// this raises an error only if the two patches were invalid or non-contiguous
+
func mergeLines(old, new string) (string, error) {
+
var i, j int
+
+
// TODO: use strings.Lines
+
linesOld := strings.Split(old, "\n")
+
linesNew := strings.Split(new, "\n")
+
+
result := []string{}
+
+
for i < len(linesOld) || j < len(linesNew) {
+
if i >= len(linesOld) {
+
// rest of the file is populated from `new`
+
result = append(result, linesNew[j])
+
j++
+
continue
+
}
+
+
if j >= len(linesNew) {
+
// rest of the file is populated from `old`
+
result = append(result, linesOld[i])
+
i++
+
continue
+
}
+
+
oldLine := linesOld[i]
+
newLine := linesNew[j]
+
+
if oldLine != newLine && (oldLine != "" && newLine != "") {
+
// context mismatch
+
return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine)
+
}
+
+
if oldLine == newLine {
+
result = append(result, oldLine)
+
} else if oldLine == "" {
+
result = append(result, newLine)
+
} else if newLine == "" {
+
result = append(result, oldLine)
+
}
+
i++
+
j++
+
}
+
+
return strings.Join(result, "\n"), nil
+
}
+
+
func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File {
+
fileToIdx1 := make(map[string]int)
+
fileToIdx2 := make(map[string]int)
+
visited := make(map[string]struct{})
+
var result []*gitdiff.File
+
+
for idx, f := range patch1 {
+
fileToIdx1[bestName(f)] = idx
+
}
+
+
for idx, f := range patch2 {
+
fileToIdx2[bestName(f)] = idx
+
}
+
+
for _, f1 := range patch1 {
+
fileName := bestName(f1)
+
if idx, ok := fileToIdx2[fileName]; ok {
+
f2 := patch2[idx]
+
+
// we have f1 and f2, combine them
+
combined, err := combineFiles(f1, f2)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
result = append(result, combined)
+
} else {
+
// only in patch1; add as-is
+
result = append(result, f1)
+
}
+
+
visited[fileName] = struct{}{}
+
}
+
+
// for all files in patch2 that remain unvisited; we can just add them into the output
+
for _, f2 := range patch2 {
+
fileName := bestName(f2)
+
if _, ok := visited[fileName]; ok {
+
continue
+
}
+
+
result = append(result, f2)
+
}
+
+
return result
+
}
+
+
// pairwise combination from first to last patch
+
func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File {
+
if len(patches) == 0 {
+
return nil
+
}
+
+
if len(patches) == 1 {
+
return patches[0]
+
}
+
+
combined := combineTwo(patches[0], patches[1])
+
+
newPatches := [][]*gitdiff.File{}
+
newPatches = append(newPatches, combined)
+
for i, p := range patches {
+
if i >= 2 {
+
newPatches = append(newPatches, p)
+
}
+
}
+
+
return CombineDiff(newPatches...)
+
}
+178
patchutil/image.go
···
+
package patchutil
+
+
import (
+
"bytes"
+
"fmt"
+
"strings"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
type Line struct {
+
LineNumber int64
+
Content string
+
IsUnknown bool
+
}
+
+
func NewLineAt(lineNumber int64, content string) Line {
+
return Line{
+
LineNumber: lineNumber,
+
Content: content,
+
IsUnknown: false,
+
}
+
}
+
+
type Image struct {
+
File string
+
Data []*Line
+
}
+
+
func (r *Image) String() string {
+
var i, j int64
+
var b strings.Builder
+
for {
+
i += 1
+
+
if int(j) >= (len(r.Data)) {
+
break
+
}
+
+
if r.Data[j].LineNumber == i {
+
// b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber))
+
b.WriteString(r.Data[j].Content)
+
j += 1
+
} else {
+
//b.WriteString(fmt.Sprintf("%d:\n", i))
+
b.WriteString("\n")
+
}
+
}
+
+
return b.String()
+
}
+
+
func (r *Image) AddLine(line *Line) {
+
r.Data = append(r.Data, line)
+
}
+
+
// rebuild the original file from a patch
+
func CreatePreImage(file *gitdiff.File) Image {
+
rf := Image{
+
File: bestName(file),
+
}
+
+
for _, fragment := range file.TextFragments {
+
position := fragment.OldPosition
+
for _, line := range fragment.Lines {
+
switch line.Op {
+
case gitdiff.OpContext:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpDelete:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpAdd:
+
// do nothing here
+
}
+
}
+
}
+
+
return rf
+
}
+
+
// rebuild the revised file from a patch
+
func CreatePostImage(file *gitdiff.File) Image {
+
rf := Image{
+
File: bestName(file),
+
}
+
+
for _, fragment := range file.TextFragments {
+
position := fragment.NewPosition
+
for _, line := range fragment.Lines {
+
switch line.Op {
+
case gitdiff.OpContext:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpAdd:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpDelete:
+
// do nothing here
+
}
+
}
+
}
+
+
return rf
+
}
+
+
type MergeError struct {
+
msg string
+
mismatchingLine int64
+
}
+
+
func (m MergeError) Error() string {
+
return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine)
+
}
+
+
// best effort merging of two reconstructed files
+
func (this *Image) Merge(other *Image) (*Image, error) {
+
mergedFile := Image{}
+
+
var i, j int64
+
+
for int(i) < len(this.Data) || int(j) < len(other.Data) {
+
if int(i) >= len(this.Data) {
+
// first file is done; the rest of the lines from file 2 can go in
+
mergedFile.AddLine(other.Data[j])
+
j++
+
continue
+
}
+
+
if int(j) >= len(other.Data) {
+
// first file is done; the rest of the lines from file 2 can go in
+
mergedFile.AddLine(this.Data[i])
+
i++
+
continue
+
}
+
+
line1 := this.Data[i]
+
line2 := other.Data[j]
+
+
if line1.LineNumber == line2.LineNumber {
+
if line1.Content != line2.Content {
+
return nil, MergeError{
+
msg: "mismatching lines, this patch might have undergone rebase",
+
mismatchingLine: line1.LineNumber,
+
}
+
} else {
+
mergedFile.AddLine(line1)
+
}
+
i++
+
j++
+
} else if line1.LineNumber < line2.LineNumber {
+
mergedFile.AddLine(line1)
+
i++
+
} else {
+
mergedFile.AddLine(line2)
+
j++
+
}
+
}
+
+
return &mergedFile, nil
+
}
+
+
func (r *Image) Apply(patch *gitdiff.File) (string, error) {
+
original := r.String()
+
var buffer bytes.Buffer
+
reader := strings.NewReader(original)
+
+
err := gitdiff.Apply(&buffer, reader, patch)
+
if err != nil {
+
return "", err
+
}
+
+
return buffer.String(), nil
+
}
+244
patchutil/interdiff.go
···
+
package patchutil
+
+
import (
+
"fmt"
+
"strings"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
type InterdiffResult struct {
+
Files []*InterdiffFile
+
}
+
+
func (i *InterdiffResult) AffectedFiles() []string {
+
files := make([]string, len(i.Files))
+
for _, f := range i.Files {
+
files = append(files, f.Name)
+
}
+
return files
+
}
+
+
func (i *InterdiffResult) String() string {
+
var b strings.Builder
+
for _, f := range i.Files {
+
b.WriteString(f.String())
+
b.WriteString("\n")
+
}
+
+
return b.String()
+
}
+
+
type InterdiffFile struct {
+
*gitdiff.File
+
Name string
+
Status InterdiffFileStatus
+
}
+
+
func (s *InterdiffFile) String() string {
+
var b strings.Builder
+
b.WriteString(s.Status.String())
+
b.WriteString(" ")
+
+
if s.File != nil {
+
b.WriteString(bestName(s.File))
+
b.WriteString("\n")
+
b.WriteString(s.File.String())
+
}
+
+
return b.String()
+
}
+
+
type InterdiffFileStatus struct {
+
StatusKind StatusKind
+
Error error
+
}
+
+
func (s *InterdiffFileStatus) String() string {
+
kind := s.StatusKind.String()
+
if s.Error != nil {
+
return fmt.Sprintf("%s [%s]", kind, s.Error.Error())
+
} else {
+
return kind
+
}
+
}
+
+
func (s *InterdiffFileStatus) IsOk() bool {
+
return s.StatusKind == StatusOk
+
}
+
+
func (s *InterdiffFileStatus) IsUnchanged() bool {
+
return s.StatusKind == StatusUnchanged
+
}
+
+
func (s *InterdiffFileStatus) IsOnlyInOne() bool {
+
return s.StatusKind == StatusOnlyInOne
+
}
+
+
func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
+
return s.StatusKind == StatusOnlyInTwo
+
}
+
+
func (s *InterdiffFileStatus) IsRebased() bool {
+
return s.StatusKind == StatusRebased
+
}
+
+
func (s *InterdiffFileStatus) IsError() bool {
+
return s.StatusKind == StatusError
+
}
+
+
type StatusKind int
+
+
func (k StatusKind) String() string {
+
switch k {
+
case StatusOnlyInOne:
+
return "only in one"
+
case StatusOnlyInTwo:
+
return "only in two"
+
case StatusUnchanged:
+
return "unchanged"
+
case StatusRebased:
+
return "rebased"
+
case StatusError:
+
return "error"
+
default:
+
return "changed"
+
}
+
}
+
+
const (
+
StatusOk StatusKind = iota
+
StatusOnlyInOne
+
StatusOnlyInTwo
+
StatusUnchanged
+
StatusRebased
+
StatusError
+
)
+
+
func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile {
+
re1 := CreatePreImage(f1)
+
re2 := CreatePreImage(f2)
+
+
interdiffFile := InterdiffFile{
+
Name: bestName(f1),
+
}
+
+
merged, err := re1.Merge(&re2)
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusRebased,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
rev1, err := merged.Apply(f1)
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
rev2, err := merged.Apply(f2)
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2))
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
if len(parsed) != 1 {
+
// files are identical?
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusUnchanged,
+
}
+
return &interdiffFile
+
}
+
+
if interdiffFile.Status.StatusKind == StatusOk {
+
interdiffFile.File = parsed[0]
+
}
+
+
return &interdiffFile
+
}
+
+
func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult {
+
fileToIdx1 := make(map[string]int)
+
fileToIdx2 := make(map[string]int)
+
visited := make(map[string]struct{})
+
var result InterdiffResult
+
+
for idx, f := range patch1 {
+
fileToIdx1[bestName(f)] = idx
+
}
+
+
for idx, f := range patch2 {
+
fileToIdx2[bestName(f)] = idx
+
}
+
+
for _, f1 := range patch1 {
+
var interdiffFile *InterdiffFile
+
+
fileName := bestName(f1)
+
if idx, ok := fileToIdx2[fileName]; ok {
+
f2 := patch2[idx]
+
+
// we have f1 and f2, calculate interdiff
+
interdiffFile = interdiffFiles(f1, f2)
+
} else {
+
// only in patch 1, this change would have to be "inverted" to dissapear
+
// from patch 2, so we reverseDiff(f1)
+
reverseDiff(f1)
+
+
interdiffFile = &InterdiffFile{
+
File: f1,
+
Name: fileName,
+
Status: InterdiffFileStatus{
+
StatusKind: StatusOnlyInOne,
+
},
+
}
+
}
+
+
result.Files = append(result.Files, interdiffFile)
+
visited[fileName] = struct{}{}
+
}
+
+
// for all files in patch2 that remain unvisited; we can just add them into the output
+
for _, f2 := range patch2 {
+
fileName := bestName(f2)
+
if _, ok := visited[fileName]; ok {
+
continue
+
}
+
+
result.Files = append(result.Files, &InterdiffFile{
+
File: f2,
+
Name: fileName,
+
Status: InterdiffFileStatus{
+
StatusKind: StatusOnlyInTwo,
+
},
+
})
+
}
+
+
return &result
+
}
+196
patchutil/patchutil.go
···
+
package patchutil
+
+
import (
+
"fmt"
+
"os"
+
"os/exec"
+
"regexp"
+
"strings"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
type FormatPatch struct {
+
Files []*gitdiff.File
+
*gitdiff.PatchHeader
+
}
+
+
func ExtractPatches(formatPatch string) ([]FormatPatch, error) {
+
patches := splitFormatPatch(formatPatch)
+
+
result := []FormatPatch{}
+
+
for _, patch := range patches {
+
files, headerStr, err := gitdiff.Parse(strings.NewReader(patch))
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse patch: %w", err)
+
}
+
+
header, err := gitdiff.ParsePatchHeader(headerStr)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse patch header: %w", err)
+
}
+
+
result = append(result, FormatPatch{
+
Files: files,
+
PatchHeader: header,
+
})
+
}
+
+
return result, nil
+
}
+
+
// IsPatchValid checks if the given patch string is valid.
+
// It performs very basic sniffing for either git-diff or git-format-patch
+
// header lines. For format patches, it attempts to extract and validate each one.
+
func IsPatchValid(patch string) bool {
+
if len(patch) == 0 {
+
return false
+
}
+
+
lines := strings.Split(patch, "\n")
+
if len(lines) < 2 {
+
return false
+
}
+
+
firstLine := strings.TrimSpace(lines[0])
+
+
// check if it's a git diff
+
if strings.HasPrefix(firstLine, "diff ") ||
+
strings.HasPrefix(firstLine, "--- ") ||
+
strings.HasPrefix(firstLine, "Index: ") ||
+
strings.HasPrefix(firstLine, "+++ ") ||
+
strings.HasPrefix(firstLine, "@@ ") {
+
return true
+
}
+
+
// check if it's format-patch
+
if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
+
strings.HasPrefix(firstLine, "From: ") {
+
// ExtractPatches already runs it through gitdiff.Parse so if that errors,
+
// it's safe to say it's broken.
+
patches, err := ExtractPatches(patch)
+
if err != nil {
+
return false
+
}
+
return len(patches) > 0
+
}
+
+
return false
+
}
+
+
func IsFormatPatch(patch string) bool {
+
lines := strings.Split(patch, "\n")
+
if len(lines) < 2 {
+
return false
+
}
+
+
firstLine := strings.TrimSpace(lines[0])
+
if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") {
+
return true
+
}
+
+
headerCount := 0
+
for i := range min(10, len(lines)) {
+
line := strings.TrimSpace(lines[i])
+
if strings.HasPrefix(line, "From: ") ||
+
strings.HasPrefix(line, "Date: ") ||
+
strings.HasPrefix(line, "Subject: ") ||
+
strings.HasPrefix(line, "commit ") {
+
headerCount++
+
}
+
}
+
+
return headerCount >= 2
+
}
+
+
func splitFormatPatch(patchText string) []string {
+
re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`)
+
+
indexes := re.FindAllStringIndex(patchText, -1)
+
+
if len(indexes) == 0 {
+
return []string{}
+
}
+
+
patches := make([]string, len(indexes))
+
+
for i := range indexes {
+
startPos := indexes[i][0]
+
endPos := len(patchText)
+
+
if i < len(indexes)-1 {
+
endPos = indexes[i+1][0]
+
}
+
+
patches[i] = strings.TrimSpace(patchText[startPos:endPos])
+
}
+
return patches
+
}
+
+
func bestName(file *gitdiff.File) string {
+
if file.IsDelete {
+
return file.OldName
+
} else {
+
return file.NewName
+
}
+
}
+
+
// in-place reverse of a diff
+
func reverseDiff(file *gitdiff.File) {
+
file.OldName, file.NewName = file.NewName, file.OldName
+
file.OldMode, file.NewMode = file.NewMode, file.OldMode
+
file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment
+
+
for _, fragment := range file.TextFragments {
+
// swap postions
+
fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition
+
fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines
+
fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded
+
+
for i := range fragment.Lines {
+
switch fragment.Lines[i].Op {
+
case gitdiff.OpAdd:
+
fragment.Lines[i].Op = gitdiff.OpDelete
+
case gitdiff.OpDelete:
+
fragment.Lines[i].Op = gitdiff.OpAdd
+
default:
+
// do nothing
+
}
+
}
+
}
+
}
+
+
func Unified(oldText, oldFile, newText, newFile string) (string, error) {
+
oldTemp, err := os.CreateTemp("", "old_*")
+
if err != nil {
+
return "", fmt.Errorf("failed to create temp file for oldText: %w", err)
+
}
+
defer os.Remove(oldTemp.Name())
+
if _, err := oldTemp.WriteString(oldText); err != nil {
+
return "", fmt.Errorf("failed to write to old temp file: %w", err)
+
}
+
oldTemp.Close()
+
+
newTemp, err := os.CreateTemp("", "new_*")
+
if err != nil {
+
return "", fmt.Errorf("failed to create temp file for newText: %w", err)
+
}
+
defer os.Remove(newTemp.Name())
+
if _, err := newTemp.WriteString(newText); err != nil {
+
return "", fmt.Errorf("failed to write to new temp file: %w", err)
+
}
+
newTemp.Close()
+
+
cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
+
output, err := cmd.CombinedOutput()
+
+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+
return string(output), nil
+
}
+
if err != nil {
+
return "", fmt.Errorf("diff command failed: %w", err)
+
}
+
+
return string(output), nil
+
}
+324
patchutil/patchutil_test.go
···
+
package patchutil
+
+
import (
+
"reflect"
+
"testing"
+
)
+
+
func TestIsPatchValid(t *testing.T) {
+
tests := []struct {
+
name string
+
patch string
+
expected bool
+
}{
+
{
+
name: `empty patch`,
+
patch: ``,
+
expected: false,
+
},
+
{
+
name: `single line patch`,
+
patch: `single line`,
+
expected: false,
+
},
+
{
+
name: `valid diff patch`,
+
patch: `diff --git a/file.txt b/file.txt
+
index abc..def 100644
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with ---`,
+
patch: `--- a/file.txt
+
+++ b/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with Index`,
+
patch: `Index: file.txt
+
==========
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with +++`,
+
patch: `+++ b/file.txt
+
--- a/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with @@`,
+
patch: `@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context
+
`,
+
expected: true,
+
},
+
{
+
name: `valid format patch`,
+
patch: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
+
Subject: [PATCH] Example patch
+
+
diff --git a/file.txt b/file.txt
+
index 123456..789012 100644
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1 +1 @@
+
-old content
+
+new content
+
--
+
2.48.1`,
+
expected: true,
+
},
+
{
+
name: `invalid format patch`,
+
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
This is not a valid patch format`,
+
expected: false,
+
},
+
{
+
name: `not a patch at all`,
+
patch: `This is
+
just some
+
random text
+
that isn't a patch`,
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := IsPatchValid(tt.patch)
+
if result != tt.expected {
+
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
+
}
+
})
+
}
+
}
+
+
func TestSplitPatches(t *testing.T) {
+
tests := []struct {
+
name string
+
input string
+
expected []string
+
}{
+
{
+
name: "Empty input",
+
input: "",
+
expected: []string{},
+
},
+
{
+
name: "No valid patches",
+
input: "This is not a \nJust some random text",
+
expected: []string{},
+
},
+
{
+
name: "Single patch",
+
input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
+
Subject: [PATCH] Example patch
+
+
diff --git a/file.txt b/file.txt
+
index 123456..789012 100644
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1 +1 @@
+
-old content
+
+new content
+
--
+
2.48.1`,
+
expected: []string{
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
+
Subject: [PATCH] Example patch
+
+
diff --git a/file.txt b/file.txt
+
index 123456..789012 100644
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1 +1 @@
+
-old content
+
+new content
+
--
+
2.48.1`,
+
},
+
},
+
{
+
name: "Two patches",
+
input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
+
Subject: [PATCH 1/2] First patch
+
+
diff --git a/file1.txt b/file1.txt
+
index 123456..789012 100644
+
--- a/file1.txt
+
+++ b/file1.txt
+
@@ -1 +1 @@
+
-old content
+
+new content
+
--
+
2.48.1
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:03:11 +0300
+
Subject: [PATCH 2/2] Second patch
+
+
diff --git a/file2.txt b/file2.txt
+
index abcdef..ghijkl 100644
+
--- a/file2.txt
+
+++ b/file2.txt
+
@@ -1 +1 @@
+
-foo bar
+
+baz qux
+
--
+
2.48.1`,
+
expected: []string{
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
+
Subject: [PATCH 1/2] First patch
+
+
diff --git a/file1.txt b/file1.txt
+
index 123456..789012 100644
+
--- a/file1.txt
+
+++ b/file1.txt
+
@@ -1 +1 @@
+
-old content
+
+new content
+
--
+
2.48.1`,
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:03:11 +0300
+
Subject: [PATCH 2/2] Second patch
+
+
diff --git a/file2.txt b/file2.txt
+
index abcdef..ghijkl 100644
+
--- a/file2.txt
+
+++ b/file2.txt
+
@@ -1 +1 @@
+
-foo bar
+
+baz qux
+
--
+
2.48.1`,
+
},
+
},
+
{
+
name: "Patches with additional text between them",
+
input: `Some text before the patches
+
+
From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: [PATCH] First patch
+
+
diff content here
+
--
+
2.48.1
+
+
Some text between patches
+
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: [PATCH] Second patch
+
+
more diff content
+
--
+
2.48.1
+
+
Text after patches`,
+
expected: []string{
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: [PATCH] First patch
+
+
diff content here
+
--
+
2.48.1
+
+
Some text between patches`,
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: [PATCH] Second patch
+
+
more diff content
+
--
+
2.48.1
+
+
Text after patches`,
+
},
+
},
+
{
+
name: "Patches with whitespace padding",
+
input: `
+
+
From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: Patch
+
+
content
+
--
+
2.48.1
+
+
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: Another patch
+
+
content
+
--
+
2.48.1
+
`,
+
expected: []string{
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: Patch
+
+
content
+
--
+
2.48.1`,
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Subject: Another patch
+
+
content
+
--
+
2.48.1`,
+
},
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := splitFormatPatch(tt.input)
+
if !reflect.DeepEqual(result, tt.expected) {
+
t.Errorf("splitPatches() = %v, want %v", result, tt.expected)
+
}
+
})
+
}
+
}
+55 -34
rbac/rbac.go
···
import (
"database/sql"
"fmt"
-
"path"
"strings"
adapter "github.com/Blank-Xu/sql-adapter"
···
e = some(where (p.eft == allow))
[matchers]
-
m = r.act == p.act && r.dom == p.dom && keyMatch2(r.obj, p.obj) && g(r.sub, p.sub, r.dom)
+
m = r.act == p.act && r.dom == p.dom && r.obj == p.obj && g(r.sub, p.sub, r.dom)
`
)
···
E *casbin.Enforcer
}
-
func keyMatch2(key1 string, key2 string) bool {
-
matched, _ := path.Match(key2, key1)
-
return matched
-
}
-
func NewEnforcer(path string) (*Enforcer, error) {
m, err := model.NewModelFromString(Model)
if err != nil {
···
}
e.EnableAutoSave(false)
-
-
e.AddFunction("keyMatch2", keyMatch2Func)
return &Enforcer{e}, nil
}
···
return err
}
-
func (e *Enforcer) AddRepo(member, domain, repo string) error {
-
// sanity check, repo must be of the form ownerDid/repo
-
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
-
return fmt.Errorf("invalid repo: %s", repo)
-
}
-
-
_, err := e.E.AddPolicies([][]string{
+
func repoPolicies(member, domain, repo string) [][]string {
+
return [][]string{
{member, domain, repo, "repo:settings"},
{member, domain, repo, "repo:push"},
{member, domain, repo, "repo:owner"},
{member, domain, repo, "repo:invite"},
{member, domain, repo, "repo:delete"},
{"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo
-
})
+
}
+
}
+
func (e *Enforcer) AddRepo(member, domain, repo string) error {
+
err := checkRepoFormat(repo)
+
if err != nil {
+
return err
+
}
+
+
_, err = e.E.AddPolicies(repoPolicies(member, domain, repo))
return err
}
+
func (e *Enforcer) RemoveRepo(member, domain, repo string) error {
+
err := checkRepoFormat(repo)
+
if err != nil {
+
return err
+
}
+
+
_, err = e.E.RemovePolicies(repoPolicies(member, domain, repo))
+
return err
+
}
+
+
var (
+
collaboratorPolicies = func(collaborator, domain, repo string) [][]string {
+
return [][]string{
+
{collaborator, domain, repo, "repo:collaborator"},
+
{collaborator, domain, repo, "repo:settings"},
+
{collaborator, domain, repo, "repo:push"},
+
}
+
}
+
)
func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error {
-
// sanity check, repo must be of the form ownerDid/repo
-
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
-
return fmt.Errorf("invalid repo: %s", repo)
+
err := checkRepoFormat(repo)
+
if err != nil {
+
return err
}
-
_, err := e.E.AddPolicies([][]string{
-
{collaborator, domain, repo, "repo:collaborator"},
-
{collaborator, domain, repo, "repo:settings"},
-
{collaborator, domain, repo, "repo:push"},
-
})
+
_, err = e.E.AddPolicies(collaboratorPolicies(collaborator, domain, repo))
+
return err
+
}
+
+
func (e *Enforcer) RemoveCollaborator(collaborator, domain, repo string) error {
+
err := checkRepoFormat(repo)
+
if err != nil {
+
return err
+
}
+
+
_, err = e.E.RemovePolicies(collaboratorPolicies(collaborator, domain, repo))
return err
}
···
return e.E.Enforce(user, domain, repo, "repo:settings")
}
+
func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) {
+
return e.E.Enforce(user, domain, repo, "repo:invite")
+
}
+
// given a repo, what permissions does this user have? repo:owner? repo:invite? etc.
func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string {
var permissions []string
···
return permissions
}
-
func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) {
-
return e.E.Enforce(user, domain, repo, "repo:invite")
-
}
+
func checkRepoFormat(repo string) error {
+
// sanity check, repo must be of the form ownerDid/repo
+
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
+
return fmt.Errorf("invalid repo: %s", repo)
+
}
-
// keyMatch2Func is a wrapper for keyMatch2 to make it compatible with Casbin
-
func keyMatch2Func(args ...interface{}) (interface{}, error) {
-
name1 := args[0].(string)
-
name2 := args[1].(string)
-
-
return keyMatch2(name1, name2), nil
+
return nil
}
+8 -89
readme.md
···
Read the introduction to Tangled [here](https://blog.tangled.sh/intro).
-
## knot self-hosting guide
+
## docs
-
So you want to run your own knot server? Great! Here are a few prerequisites:
+
* [knot hosting
+
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md)
+
* [contributing
+
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/contributing.md)&mdash;**read this before opening a PR!**
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
-
2. A (sub)domain name. People generally use `knot.example.com`.
-
3. A valid SSL certificate for your domain.
+
## security
-
There's a couple of ways to get started:
-
* NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
-
* Manual: Documented below.
-
-
### manual setup
-
-
First, clone this repository:
-
-
```
-
git clone https://tangled.sh/@tangled.sh/core
-
```
-
-
Then, build our binaries (you need to have Go installed):
-
* `knotserver`: the main server program
-
* `keyfetch`: utility to fetch ssh pubkeys
-
* `repoguard`: enforces repository access control
-
-
```
-
cd core
-
export CGO_ENABLED=1
-
go build -o knot ./cmd/knotserver
-
go build -o keyfetch ./cmd/keyfetch
-
go build -o repoguard ./cmd/repoguard
-
```
-
-
Next, move the `keyfetch` binary to a location owned by `root` --
-
`/usr/local/libexec/tangled-keyfetch` is a good choice:
-
-
```
-
sudo mv keyfetch /usr/local/libexec/tangled-keyfetch
-
sudo chown root:root /usr/local/libexec/tangled-keyfetch
-
sudo chmod 755 /usr/local/libexec/tangled-keyfetch
-
```
-
-
This is necessary because SSH `AuthorizedKeysCommand` requires [really specific
-
permissions](https://stackoverflow.com/a/27638306). Let's set that up:
-
-
```
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
-
Match User git
-
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch
-
AuthorizedKeysCommandUser nobody
-
EOF
-
```
-
-
Next, create the `git` user:
-
-
```
-
sudo adduser git
-
```
-
-
Copy the `repoguard` binary to the `git` user's home directory:
-
-
```
-
sudo cp repoguard /home/git
-
sudo chown git:git /home/git/repoguard
-
```
-
-
Now, let's set up the server. Copy the `knot` binary to
-
`/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the
-
following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be
-
obtaind from the [/knots](/knots) page on Tangled.
-
-
```
-
KNOT_REPO_SCAN_PATH=/home/git
-
KNOT_SERVER_HOSTNAME=knot.example.com
-
APPVIEW_ENDPOINT=https://tangled.sh
-
KNOT_SERVER_SECRET=secret
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
-
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
-
```
-
-
If you run a Linux distribution that uses systemd, you can use the provided
-
service file to run the server. Copy
-
[`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service)
-
to `/etc/systemd/system/`. Then, run:
-
-
```
-
systemctl enable knotserver
-
systemctl start knotserver
-
```
-
-
You should now have a running knot server! You can finalize your registration by hitting the
-
`initialize` button on the [/knots](/knots) page.
+
If you've identified a security issue in Tangled, please email
+
[security@tangled.sh](mailto:security@tangled.sh) with details!
+43 -9
tailwind.config.js
···
/** @type {import('tailwindcss').Config} */
-
const colors = require('tailwindcss/colors')
+
const colors = require("tailwindcss/colors");
module.exports = {
-
content: ["./appview/pages/templates/**/*.html"],
+
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"],
+
darkMode: "media",
theme: {
container: {
padding: "2rem",
···
md: "600px",
lg: "800px",
xl: "1000px",
-
"2xl": "1200px"
+
"2xl": "1200px",
},
},
extend: {
fontFamily: {
-
sans: ["iA Writer Quattro S", "Inter", "system-ui", "sans-serif", "ui-sans-serif"],
-
mono: ["iA Writer Mono S", "ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", "monospace"],
+
sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"],
+
mono: [
+
"IBMPlexMono",
+
"ui-monospace",
+
"SFMono-Regular",
+
"Menlo",
+
"Monaco",
+
"Consolas",
+
"Liberation Mono",
+
"Courier New",
+
"monospace",
+
],
},
typography: {
DEFAULT: {
css: {
-
maxWidth: 'none',
+
maxWidth: "none",
pre: {
backgroundColor: colors.gray[100],
color: colors.black,
+
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {},
+
},
+
code: {
+
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+
},
+
"code::before": {
+
content: '""',
+
},
+
"code::after": {
+
content: '""',
+
},
+
blockquote: {
+
quotes: "none",
+
},
+
'h1, h2, h3, h4': {
+
"@apply mt-4 mb-2": {}
+
},
+
h1: {
+
"@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {}
+
},
+
h2: {
+
"@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {}
+
},
+
h3: {
+
"@apply mt-2": {}
},
},
},
},
},
},
-
plugins: [
-
require('@tailwindcss/typography'),
-
]
+
plugins: [require("@tailwindcss/typography")],
};
+10
types/capabilities.go
···
+
package types
+
+
type Capabilities struct {
+
PullRequests struct {
+
FormatPatch bool `json:"format_patch"`
+
PatchSubmissions bool `json:"patch_submissions"`
+
BranchSubmissions bool `json:"branch_submissions"`
+
ForkSubmissions bool `json:"fork_submissions"`
+
} `json:"pull_requests"`
+
}
+35
types/diff.go
···
IsRename bool `json:"is_rename"`
}
+
type DiffStat struct {
+
Insertions int64
+
Deletions int64
+
}
+
+
func (d *Diff) Stats() DiffStat {
+
var stats DiffStat
+
for _, f := range d.TextFragments {
+
stats.Insertions += f.LinesAdded
+
stats.Deletions += f.LinesDeleted
+
}
+
return stats
+
}
+
// A nicer git diff representation.
type NiceDiff struct {
Commit struct {
···
} `json:"stat"`
Diff []Diff `json:"diff"`
}
+
+
type DiffTree struct {
+
Rev1 string `json:"rev1"`
+
Rev2 string `json:"rev2"`
+
Patch string `json:"patch"`
+
Diff []*gitdiff.File `json:"diff"`
+
}
+
+
func (d *NiceDiff) ChangedFiles() []string {
+
files := make([]string, len(d.Diff))
+
+
for i, f := range d.Diff {
+
if f.IsDelete {
+
files[i] = f.Name.Old
+
} else {
+
files[i] = f.Name.New
+
}
+
}
+
+
return files
+
}
+16
types/repo.go
···
import (
"github.com/go-git/go-git/v5/plumbing/object"
+
"tangled.sh/tangled.sh/core/patchutil"
)
type RepoIndexResponse struct {
···
type RepoCommitResponse struct {
Ref string `json:"ref,omitempty"`
Diff *NiceDiff `json:"diff,omitempty"`
+
}
+
+
type RepoFormatPatchResponse struct {
+
Rev1 string `json:"rev1,omitempty"`
+
Rev2 string `json:"rev2,omitempty"`
+
FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"`
+
Patch string `json:"patch,omitempty"`
}
type RepoTreeResponse struct {
···
type RepoBranchesResponse struct {
Branches []Branch `json:"branches,omitempty"`
+
}
+
+
type RepoBranchResponse struct {
+
Branch Branch `json:"branch,omitempty"`
+
}
+
+
type RepoDefaultBranchResponse struct {
+
Branch string `json:"branch,omitempty"`
}
type RepoBlobResponse struct {