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

Compare changes

Choose any two refs to compare.

Changed files
+4728 -1538
api
appview
cmd
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
knotserver
lexicons
rbac
types
+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"`
}
+16 -1
appview/db/db.go
···
})
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
-
// add unconstrained column
_, 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
})
+48 -18
appview/db/issues.go
···
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.
}
···
if err != nil {
return nil, err
}
-
issue.Created = &createdTime
+
issue.Created = createdTime
issue.Metadata = &metadata
issues = append(issues, issue)
···
return issues, nil
}
-
func GetIssuesByOwnerDid(e Execer, ownerDid string) ([]Issue, error) {
+
// 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(
···
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
+
join
+
repos r on i.repo_at = r.at_uri
where
-
i.owner_did = ?
-
group by
-
i.id, i.owner_did, i.repo_at, i.issue_id, i.created, i.title, i.body, i.open
+
i.owner_did = ? and i.created >= date ('now', ?)
order by
i.created desc`,
-
ownerDid)
+
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.RepoAt, &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 = issueCreatedTime
+
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
if err != nil {
return nil, err
}
-
issue.Created = &createdTime
-
issue.Metadata = &metadata
+
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 {
+129 -45
appview/db/profile.go
···
package db
import (
-
"sort"
+
"fmt"
"time"
)
-
type ProfileTimelineEvent struct {
-
EventAt time.Time
-
Type string
-
*Issue
-
*Pull
-
*Repo
+
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
}
-
func MakeProfileTimeline(e Execer, forDid string) ([]ProfileTimelineEvent, error) {
-
timeline := []ProfileTimelineEvent{}
-
limit := 30
+
const TimeframeMonths = 3
-
pulls, err := GetPullsByOwnerDid(e, forDid)
+
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 timeline, err
+
return nil, fmt.Errorf("error getting pulls by owner did: %w", err)
}
+
// group pulls by month
for _, pull := range pulls {
-
repo, err := GetRepoByAtUri(e, string(pull.RepoAt))
-
if err != nil {
-
return timeline, err
+
pullMonth := pull.Created.Month()
+
+
if currentMonth-pullMonth > TimeframeMonths {
+
// shouldn't happen; but times are weird
+
continue
}
-
timeline = append(timeline, ProfileTimelineEvent{
-
EventAt: pull.Created,
-
Type: "pull",
-
Pull: &pull,
-
Repo: repo,
-
})
+
idx := currentMonth - pullMonth
+
items := &timeline.ByMonth[idx].PullEvents.Items
+
+
*items = append(*items, &pull)
}
-
issues, err := GetIssuesByOwnerDid(e, forDid)
+
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
if err != nil {
-
return timeline, err
+
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
}
for _, issue := range issues {
-
repo, err := GetRepoByAtUri(e, string(issue.RepoAt))
-
if err != nil {
-
return timeline, err
+
issueMonth := issue.Created.Month()
+
+
if currentMonth-issueMonth > TimeframeMonths {
+
// shouldn't happen; but times are weird
+
continue
}
-
timeline = append(timeline, ProfileTimelineEvent{
-
EventAt: *issue.Created,
-
Type: "issue",
-
Issue: &issue,
-
Repo: repo,
-
})
+
idx := currentMonth - issueMonth
+
items := &timeline.ByMonth[idx].IssueEvents.Items
+
+
*items = append(*items, &issue)
}
repos, err := GetAllReposByDid(e, forDid)
if err != nil {
-
return timeline, err
+
return nil, fmt.Errorf("error getting all repos by did: %w", err)
}
for _, repo := range repos {
-
timeline = append(timeline, ProfileTimelineEvent{
-
EventAt: repo.Created,
-
Type: "repo",
-
Repo: &repo,
-
})
-
}
+
// 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
+
}
-
sort.Slice(timeline, func(i, j int) bool {
-
return timeline[i].EventAt.After(timeline[j].EventAt)
-
})
+
idx := currentMonth - repoMonth
-
if len(timeline) > limit {
-
timeline = timeline[:limit]
+
items := &timeline.ByMonth[idx].RepoEvents
+
*items = append(*items, RepoEvent{
+
Repo: &repo,
+
Source: sourceRepo,
+
})
}
-
return timeline, nil
+
return &timeline, nil
}
+154 -29
appview/db/pulls.go
···
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 (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) AsNiceDiff(targetBranch string) types.NiceDiff {
patch := s.Patch
···
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
}
···
target_branch,
pull_at,
body,
-
rkey
+
rkey,
+
source_branch,
+
source_repo_at
from
pulls
where
···
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
+
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 = append(pulls, pull)
}
···
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 &pull, nil
}
-
func GetPullsByOwnerDid(e Execer, did string) ([]Pull, error) {
+
// 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
-
owner_did,
-
repo_at,
-
pull_id,
-
created,
-
title,
-
state
+
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
+
pulls p
+
join
+
repos r on p.repo_at = r.at_uri
where
-
owner_did = ?
+
p.owner_did = ? and p.created >= date ('now', ?)
order by
-
created desc`, did)
+
p.created desc`, did, timeframe)
if err != nil {
return nil, err
}
···
for rows.Next() {
var pull Pull
-
var createdAt string
+
var repo Repo
+
var pullCreatedAt, repoCreatedAt string
err := rows.Scan(
&pull.OwnerDid,
&pull.RepoAt,
&pull.PullId,
-
&createdAt,
+
&pullCreatedAt,
&pull.Title,
&pull.State,
+
&repo.Did,
+
&repo.Name,
+
&repo.Knot,
+
&repo.Rkey,
+
&repoCreatedAt,
)
if err != nil {
return nil, err
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt)
if err != nil {
return nil, err
}
-
pull.Created = createdTime
+
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)
}
···
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,
})
}
+7 -1
appview/pages/funcmap.go
···
"time"
"github.com/dustin/go-humanize"
+
"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 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,
}
}
-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
+
}
+221 -72
appview/pages/pages.go
···
import (
"bytes"
+
"crypto/sha256"
"embed"
+
"encoding/hex"
"fmt"
"html/template"
"io"
···
"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/pages/markup"
"tangled.sh/tangled.sh/core/appview/state/userutil"
"tangled.sh/tangled.sh/core/types"
)
···
func NewPages() *Pages {
templates := make(map[string]*template.Template)
-
// Walk through embedded templates directory and parse all .html files
+
var fragmentPaths []string
+
// First, collect all fragment paths
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
-
if !d.IsDir() && strings.HasSuffix(path, ".html") {
-
name := strings.TrimPrefix(path, "templates/")
-
name = strings.TrimSuffix(name, ".html")
+
if d.IsDir() {
+
return nil
+
}
-
// 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)
-
}
+
if !strings.HasSuffix(path, ".html") {
+
return nil
+
}
-
templates[name] = tmpl
-
log.Printf("loaded fragment: %s", name)
-
}
+
if !strings.Contains(path, "fragments/") {
+
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)
-
}
+
name := strings.TrimPrefix(path, "templates/")
+
name = strings.TrimSuffix(name, ".html")
+
+
tmpl, err := template.New(name).
+
Funcs(funcMap()).
+
ParseFS(Files, 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)
+
}
+
+
// Then walk through and setup the rest of the templates
+
err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
+
if err != nil {
+
return err
+
}
-
templates[name] = tmpl
-
log.Printf("loaded template: %s", name)
-
}
+
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(Files, 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 {
···
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.ProfileTimelineEvent
+
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 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
}
···
b := style.Builder()
b.Add(chroma.LiteralString, "noitalic")
style, _ = b.Build()
+
+
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
···
}
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
-
return p.executePlain("fragments/editIssueComment", w, params)
+
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
}
type SingleIssueCommentParams struct {
···
}
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
-
return p.executePlain("fragments/issueComment", w, params)
+
return p.executePlain("repo/issues/fragments/issueComment", w, params)
}
type RepoNewPullParams struct {
···
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
+
PullSourceRepo *db.Repo
+
MergeCheck types.MergeCheckResponse
+
ResubmitCheck ResubmitResult
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
return p.execute("repo/pulls/patch", 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 {
···
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 {
-33
appview/pages/templates/fragments/cloneInstructions.html
···
-
{{ define "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 }}
-114
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 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">
-
{{ $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 }}
-
-
{{ if .IsDelete }}
-
<a class="dark:text-white" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
-
{{ .Name.Old }}
-
</a>
-
{{ else if (or .IsCopy .IsRename) }}
-
<a class="dark:text-white" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
-
{{ .Name.Old }}
-
</a>
-
{{ i "arrow-right" "w-4 h-4" }}
-
<a class="dark:text-white" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
-
{{ .Name.New }}
-
</a>
-
{{ else }}
-
<a class="dark:text-white" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
-
{{ .Name.New }}
-
</a>
-
{{ end }}
-
</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 in this commit.
-
</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 }}
-
<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">{{- .Header -}}</div>{{- 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">
-
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
-
<div class="p-1 whitespace-pre">{{ .Line }}</div>
-
</div>
-
{{- 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">
-
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
-
<div class="p-1 whitespace-pre">{{ .Line }}</div>
-
</div>
-
{{- end -}}
-
{{- if eq .Op.String " " -}}
-
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full">
-
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
-
<div class="p-1 whitespace-pre">{{ .Line }}</div>
-
</div>
-
{{- end -}}
-
{{- end -}}
-
{{- end -}}</div></div></pre>
-
{{- end -}}
-
{{ end }}
-
</div>
-
-
</details>
-
-
</div>
-
</div>
-
</section>
-
{{ end }}
-
{{ end }}
-
{{ end }}
-52
appview/pages/templates/fragments/editIssueComment.html
···
-
{{ define "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 }}
-
-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 }}
-60
appview/pages/templates/fragments/issueComment.html
···
-
{{ define "fragments/issueComment" }}
-
{{ with .Comment }}
-
<div id="comment-container-{{.CommentId}}">
-
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
-
<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 dark:bg-gray-700 text-black dark:text-white 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 }}">
-
{{ if .Deleted }}
-
deleted {{ .Deleted | timeFmt }}
-
{{ else if .Edited }}
-
edited {{ .Edited | timeFmt }}
-
{{ else }}
-
{{ .Created | timeFmt }}
-
{{ end }}
-
</a>
-
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
-
{{ if and $isCommentOwner (not .Deleted) }}
-
<button
-
class="btn px-2 py-1 text-sm"
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
-
hx-swap="outerHTML"
-
hx-target="#comment-container-{{.CommentId}}"
-
>
-
{{ i "pencil" "w-4 h-4" }}
-
</button>
-
<button
-
class="btn px-2 py-1 text-sm text-red-500"
-
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 }}
-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 dark:bg-gray-600"></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 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 }}
-
-52
appview/pages/templates/fragments/pullResubmit.html
···
-
{{ define "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 }}
-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 }}
-
+2 -3
appview/pages/templates/layouts/base.html
···
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 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: 5">
+
<header style="z-index: 20">
{{ block "topbar" . }}
{{ template "layouts/topbar" . }}
{{ end }}
+30 -15
appview/pages/templates/layouts/repobase.html
···
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
{{ define "content" }}
-
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
-
<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">
···
{{ range $item := $tabs }}
{{ $key := index $item 0 }}
{{ $value := index $item 1 }}
+
{{ $icon := index $item 2 }}
{{ $meta := index $tabmeta $key }}
<a
href="/{{ $.RepoInfo.FullName }}{{ $value }}"
···
{{ end }}
"
>
-
{{ $key }}
-
{{ if not (isNil $meta) }}
-
<span class="bg-gray-200 dark:bg-gray-700 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 }}
+13 -2
appview/pages/templates/repo/blob.html
···
>
/
{{ else }}
-
<span class="text-bold text-gray-600 dark:text-gray-300"
+
<span class="text-bold text-black dark:text-white"
>{{ index . 0 }}</span
>
{{ end }}
···
<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>
···
</p>
{{ else }}
<div class="overflow-auto relative">
-
<div class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
+
{{ 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 }}
{{ end }}
+2 -19
appview/pages/templates/repo/commit.html
···
{{ $repo := .RepoInfo.FullName }}
{{ $commit := .Diff.Commit }}
-
{{ $stat := .Diff.Stat }}
-
{{ $diff := .Diff.Diff }}
<section class="commit dark:text-white">
<div id="commit-message">
···
<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>
···
{{ 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>
</div>
-
<div class="diff-stat">
-
<br>
-
<strong class="text-sm uppercase mb-4 dark:text-gray-200">Changed files</strong>
-
{{ range $diff }}
-
<ul class="dark:text-gray-200">
-
{{ if .IsDelete }}
-
<li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li>
-
{{ else }}
-
<li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .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}}
+1 -1
appview/pages/templates/repo/empty.html
···
{{ end }}
{{ define "repoAfter" }}
-
{{ template "fragments/cloneInstructions" . }}
+
{{ 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 }}
+175
appview/pages/templates/repo/fragments/diff.html
···
+
{{ define "repo/fragments/diff" }}
+
{{ $repo := index . 0 }}
+
{{ $diff := index . 1 }}
+
{{ $commit := $diff.Commit }}
+
{{ $stat := $diff.Stat }}
+
{{ $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>
+
<div class="overflow-x-auto">
+
{{ range $diff }}
+
<ul class="dark:text-gray-200">
+
{{ if .IsDelete }}
+
<li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li>
+
{{ else }}
+
<li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li>
+
{{ end }}
+
</ul>
+
{{ end }}
+
</div>
+
</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" style="direction: rtl;">
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
+
+
<div class="flex gap-2 items-center" style="direction: ltr;">
+
{{ 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 }}
+
+
{{ 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 .IsRename }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This file has been renamed.
+
</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 }}
+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 }}
+207 -172
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 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>
+
<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 dark:md:border-gray-700">
-
{{ $containerstyle := "py-1" }}
-
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
+
<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 dark:text-gray-400"
-
>{{ 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 dark:text-gray-400"
-
>{{ 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 dark:text-white"
-
>{{ 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 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 (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 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>
-
<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 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>
+
<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 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 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 }}
-
</article>
-
</section>
+
<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
+
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 }}
+
</article>
+
</section>
{{- end -}}
-
{{ template "fragments/cloneInstructions" . }}
+
{{ 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 }}
+112 -42
appview/pages/templates/repo/issues/issue.html
···
{{ 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 dark:bg-gray-800">
-
{{ if eq $index 0 }}
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div>
-
{{ else }}
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-700" ></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 }}
-
-
{{ template "fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
+
{{ 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 dark:text-gray-400">{{ $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 dark:bg-gray-800 dark:text-gray-400">
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div>
-
<div class="text-sm text-gray-500 dark:text-gray-400">
+
<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 dark:border-gray-700"
-
placeholder="Add to the discussion..."
+
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 dark:bg-gray-800 dark:text-gray-400 rounded drop-shadow-sm px-6 py-4 mt-8">
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></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 }}
+3 -3
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 dark:bg-gray-800 dark:border-gray-700" 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>
+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
+1 -1
appview/pages/templates/repo/log.html
···
<p
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>
+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 }}
+
+
+20
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>
+
{{ end }}
+42
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>
+
{{ 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 }}
+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 }}
+
+14
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
+
{{ define "repo/pulls/fragments/pullPatchUpload" }}
+
<div id="patch-upload">
+
<textarea
+
name="patch"
+
id="patch"
+
rows="12"
+
class="w-full 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 }}
+89 -52
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 dark:prose-invert">
-
<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 dark:text-gray-300">
-
<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 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200 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" 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" />
+
<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" />
+
</div>
-
<label for="targetBranch" class="dark:text-white">select a target branch</label>
-
<p class="text-gray-500 dark:text-gray-400">
-
The branch you want to make your change against.
-
</p>
-
<select
-
name="targetBranch"
-
class="p-1 mb-2 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
>
-
<option disabled selected>select a branch</option>
-
{{ range .Branches }}
-
<option value="{{ .Reference.Name }}" class="py-1">
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</select>
-
<label for="body" class="dark:text-white">add a description</label>
-
<textarea
-
name="body"
-
id="body"
-
rows="6"
-
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
placeholder="Describe your change. Markdown is supported."
+
<div>
+
<label for="body" class="dark:text-white">add a description</label>
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
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>
+
+
+
<label>configure your pull request</label>
+
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
+
<div class="pb-2">
+
<select
+
required
+
name="targetBranch"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option disabled selected>target branch</option>
+
{{ range .Branches }}
+
<option value="{{ .Reference.Name }}" class="py-1">
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</select>
+
</div>
-
<div class="mt-4">
-
<label for="patch" class="dark:text-white">paste your patch here</label>
-
<textarea
-
name="patch"
-
id="patch"
-
rows="10"
-
class="w-full resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
placeholder="Paste your git diff output here."
-
></textarea>
-
</div>
-
</div>
-
<div>
-
<button type="submit" class="btn dark:bg-gray-600 dark:hover:bg-gray-500 dark:text-white">create</button>
-
</div>
+
<p>Then, 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>
+
+
<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 dark:text-red-300"></div>
</form>
{{ end }}
+
+
{{ define "repoAfter" }}
+
<div id="patch-preview" class="error dark:text-red-300"></div>
+
{{ end }}
+1 -14
appview/pages/templates/repo/pulls/patch.html
···
{{ 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) }}
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
</section>
{{ end }}
+37 -8
appview/pages/templates/repo/pulls/pull.html
···
{{ $icon = "git-merge" }}
{{ end }}
-
<section>
+
<section class="mt-2">
<div class="flex items-center gap-2">
<div
id="state"
···
<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>
+
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 not .Pull.IsBranchBased }}
+
<a href="/{{ $owner }}/{{ .PullSourceRepo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSourceRepo.Name }}</a>
+
{{ end }}
+
+
{{ $fullRepo := .RepoInfo.FullName }}
+
{{ if not .Pull.IsBranchBased }}
+
{{ $fullRepo = printf "%s/%s" $owner .PullSourceRepo.Name }}
+
{{ 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.TargetBranch }}
+
<a href="/{{ $fullRepo }}/tree/{{ .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
</span>
</span>
+
{{ end }}
</span>
</div>
{{ if .Pull.Body }}
-
<article id="body" class="mt-2 prose dark:prose-invert">
+
<article id="body" class="mt-8 prose dark:prose-invert">
{{ .Pull.Body | markdown }}
</article>
{{ end }}
···
</summary>
<div class="md:pl-12 flex flex-col gap-2 mt-2 relative">
{{ range .Comments }}
-
<div id="comment-{{.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-fit">
+
<div id="comment-{{.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">
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $owner := index $.DidHandleMap .OwnerDid }}
···
{{ 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 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>
···
{{ end }}
</div>
</details>
-
<hr class="md:hidden"/>
+
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
{{ end }}
{{ end }}
{{ end }}
···
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
<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="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
-
<div class="flex flex-col items-center gap-2 text-red-500 dark:text-red-300">
+
<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>
{{ 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="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
+
<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 }}
+22 -7
appview/pages/templates/repo/pulls/pulls.html
···
<p class="dark:text-white">
filtering
<select
-
class="border px-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white"
+
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 }}>
···
</p>
<a
href="/{{ .RepoInfo.FullName }}/pulls/new"
-
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:bg-gray-700 dark:hover:bg-gray-600"
+
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>
···
</a>
</div>
<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" }}
···
</span>
<span>
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
<a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a>
</span>
···
</span>
<span class="before:content-['ยท']">
-
targeting branch
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-600 text-black dark:text-white 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 }}
</p>
</div>
{{ end }}
+34 -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 dark:text-white">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 }}
···
{{ end }}
</div>
-
{{ if .IsCollaboratorInviteAllowed }}
-
<h3 class="dark:text-white">add collaborator</h3>
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
-
<label for="collaborator" class="dark:text-white">did or handle:</label>
-
<input type="text" id="collaborator" name="collaborator" required class="dark:bg-gray-700 dark:text-white" />
-
<button class="btn my-2 dark:text-white dark:hover:bg-gray-700" 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">
+
<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="{{ . }}"
···
</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 }}
+7 -6
appview/pages/templates/repo/tree.html
···
{{ $containerstyle := "py-1" }}
{{ $linkstyle := "no-underline hover:underline" }}
-
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-500">
+
<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">
+
<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 dark:text-gray-400 {{ $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" 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 }}
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
-
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
{{ if eq $stats.NumFolders 1 }}
-
<span>{{ $stats.NumFolders }} folder</span>
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ $stats.NumFolders }} folder</span>
{{ else if gt $stats.NumFolders 1 }}
-
<span>{{ $stats.NumFolders }} folders</span>
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ $stats.NumFolders }} folders</span>
{{ end }}
{{ 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 }}
+11 -2
appview/pages/templates/timeline.html
···
<div class="flex items-center">
<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>
+
{{ 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>
+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 }}
+10 -2
appview/pages/templates/user/login.html
···
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">
···
>
<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>
+200 -56
appview/pages/templates/user/profile.html
···
{{ 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" }}
-
<div class="flex flex-col gap-3 relative">
-
<p class="px-6 text-sm font-bold py-2 dark:text-white">ACTIVITY</p>
-
{{ range .ProfileTimeline }}
-
{{ if eq .Type "issue" }}
-
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit max-w-full flex items-center gap-2">
-
{{ $textColor := "text-gray-800 dark:text-gray-400" }}
-
{{ $icon := "ban" }}
-
{{ if .Issue.Open }}
-
{{ $textColor = "text-green-600 dark:text-green-500" }}
-
{{ $icon = "circle-dot" }}
-
{{ end }}
-
<div class="p-1 {{ $textColor }}">
-
{{ i $icon "w-5 h-5" }}
+
<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>
-
<div>
-
<p class="text-gray-600 dark:text-gray-300">
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .Issue.IssueId }}" class="no-underline hover:underline">{{ .Issue.Title }} <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span></a>
-
on
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ index $.DidHandleMap .Repo.Did }}<span class="select-none">/</span>{{ .Repo.Name }}</a>
-
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Issue.Created | shortTimeFmt }}</time>
-
</p>
+
{{ 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>
+
+
{{ 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>
-
{{ else if eq .Type "pull" }}
-
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3">
-
{{ $textColor := "text-gray-800 dark:text-gray-400" }}
-
{{ $icon := "git-pull-request-closed" }}
-
{{ if .Pull.State.IsOpen }}
-
{{ $textColor = "text-green-600 dark:text-green-500" }}
-
{{ $icon = "git-pull-request" }}
-
{{ else if .Pull.State.IsMerged }}
-
{{ $textColor = "text-purple-600 dark:text-purple-500" }}
-
{{ $icon = "git-merge" }}
+
{{ 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="{{ $textColor }} p-1">
-
{{ i $icon "w-5 h-5" }}
+
<div class="flex-none min-w-8 text-right">
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
</div>
-
<div>
-
<p class="text-gray-600 dark:text-gray-300">
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}/pulls/{{ .Pull.PullId }}" class="no-underline hover:underline">{{ .Pull.Title }} <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span></a>
+
<div class="break-words max-w-full">
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
+
{{ .Title -}}
+
</a>
on
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
-
{{ index $.DidHandleMap .Repo.Did }}<span class="select-none">/</span>{{ .Repo.Name }}</a>
-
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Pull.Created | shortTimeFmt }}</time>
-
</p>
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
+
{{$repoUrl}}
+
</a>
</div>
</div>
-
{{ else if eq .Type "repo" }}
-
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3">
-
<div class="text-gray-800 dark:text-gray-400 p-1">
-
{{ i "book-plus" "w-5 h-5" }}
+
{{ 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-black dark:text-white bg-gray-50 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 .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>
-
<p class="text-gray-600 dark:text-gray-300">
-
created <a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
-
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time>
-
</p>
+
<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 }}
-
{{ else }}
-
<p class="px-6 dark:text-white">This user does not have any activity yet.</p>
{{ end }}
</div>
+
</details>
+
{{ end }}
{{ end }}
{{ define "profileCard" }}
···
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
{{ end }}
</div>
-
<p class="text-xl font-bold text-center dark:text-white">
-
{{ 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 dark:text-gray-300">
<span>{{ .ProfileStats.Followers }} followers</span>
···
</div>
{{ if ne .FollowStatus.String "IsSelf" }}
-
{{ template "fragments/follow" . }}
+
{{ template "user/fragments/follow" . }}
{{ end }}
</div>
{{ end }}
+13 -2
appview/state/profile.go
···
for _, r := range collaboratingRepos {
didsToResolve = append(didsToResolve, r.Did)
}
-
for _, evt := range timeline {
-
didsToResolve = append(didsToResolve, evt.Repo.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)
+840 -120
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/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
"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"
)
···
}
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)
+
}
+
+
var pullSourceRepo *db.Repo
+
if pull.PullSource != nil {
+
if pull.PullSource.RepoAt != nil {
+
pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
+
if err != nil {
+
log.Printf("failed to get repo by at uri: %v", err)
+
return
+
}
+
}
+
}
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,
+
PullSourceRepo: pullSourceRepo,
+
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 {
+
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)
···
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))
···
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.")
+
// Validate required fields for all PR types
+
if title == "" || body == "" || targetBranch == "" {
+
s.pages.Notice(w, "pull", "Title, body and target branch are required.")
return
}
-
// Validate patch format
-
if !isPatchValid(patch) {
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Println("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
}
-
tx, err := s.db.BeginTx(r.Context(), nil)
+
caps, err := us.Capabilities()
if err != nil {
-
log.Println("failed to start tx")
-
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
}
-
defer tx.Rollback()
-
rkey := s.TID()
-
initialSubmission := db.PullSubmission{
-
Patch: patch,
-
}
-
err = db.NewPull(tx, &db.Pull{
-
Title: title,
-
Body: body,
-
TargetBranch: targetBranch,
-
OwnerDid: user.Did,
-
RepoAt: f.RepoAt,
-
Rkey: rkey,
-
Submissions: []*db.PullSubmission{
-
&initialSubmission,
-
},
-
})
-
if err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
// 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
+
+
// 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
}
-
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.")
+
+
// 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
}
-
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
+
}
+
+
diffTreeResponse, 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 := diffTreeResponse.DiffTree.Rev2
+
patch := diffTreeResponse.DiffTree.Patch
+
+
if !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 !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)
+
diffTreeResponse, 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 := diffTreeResponse.DiffTree.Rev2
+
patch := diffTreeResponse.DiffTree.Patch
+
+
if !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, patch, 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()
+
+
rkey := s.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
-
}
+
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))
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
+
}
+
+
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
-
}
-
-
if patch == pull.LatestPatch() {
-
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
+
} else if pull.IsBranchBased() {
+
s.resubmitBranch(w, r)
return
-
}
-
-
// Validate patch format
-
if !isPatchValid(patch) {
-
s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
+
} else if pull.IsForkBased() {
+
s.resubmitFork(w, r)
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()
+
func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
-
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)
+
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
+
}
-
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
-
}
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
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 user.Did != pull.OwnerDid {
+
log.Println("unauthorized user")
+
w.WriteHeader(http.StatusUnauthorized)
+
return
+
}
+
+
patch := r.FormValue("patch")
+
+
if err = validateResubmittedPatch(pull, patch); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
}
+
+
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))
+
return
+
}
+
+
func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
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
+
}
+
+
diffTreeResponse, 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 := diffTreeResponse.DiffTree.Rev2
+
patch := diffTreeResponse.DiffTree.Patch
+
+
if err = validateResubmittedPatch(pull, patch); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
}
+
+
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))
+
diffTreeResponse, 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 := diffTreeResponse.DiffTree.Rev2
+
patch := diffTreeResponse.DiffTree.Patch
+
+
if err = validateResubmittedPatch(pull, patch); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
+
}
+
+
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 !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) {
+360 -3
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"
···
"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/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/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)
···
}
}
+
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) 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.OwnerSlashRepo(), 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.OwnerSlashRepo())
+
}
+
log.Println("removed collaborators")
+
+
// remove repo RBAC
+
err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.OwnerSlashRepo())
+
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 {
···
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) {
···
return
-
commentId := rand.IntN(1000000)
+
commentId := mathrand.IntN(1000000)
rkey := s.TID()
err := db.NewIssueComment(s.db, &db.Comment{
···
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 := s.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
+
}
+
}
+14
appview/state/repo_util.go
···
import (
"context"
+
"crypto/rand"
"fmt"
"log"
+
"math/big"
"net/http"
"github.com/bluesky-social/indigo/atproto/identity"
···
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)
+
}
+11
appview/state/router.go
···
})
})
+
r.Route("/fork", func(r chi.Router) {
+
r.Use(AuthMiddleware(s))
+
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.Get("/", s.NewPull)
+
r.Get("/patch-upload", s.PatchUploadFragment)
+
r.Get("/compare-branches", s.CompareBranchesFragment)
+
r.Get("/compare-forks", s.CompareForksFragment)
+
r.Get("/fork-branches", s.CompareForksBranchesFragment)
r.Post("/", s.NewPull)
})
···
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)
})
})
+105 -2
appview/state/signer.go
···
"encoding/hex"
"encoding/json"
"fmt"
+
"io"
+
"log"
"net/http"
"net/url"
"time"
···
"did": did,
"name": repoName,
"default_branch": defaultBranch,
+
})
+
+
req, err := s.newRequest(Method, Endpoint, body)
+
if err != nil {
+
return nil, err
+
}
+
+
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)
···
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"
···
return us.client.Do(req)
}
-
func (us *UnsignedClient) Capabilities(ownerDid, repoName string) (*http.Response, error) {
+
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
const (
Method = "GET"
Endpoint = "/capabilities"
···
return nil, err
}
-
return us.client.Do(req)
+
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.RepoDiffTreeResponse, 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 diffTreeResponse types.RepoDiffTreeResponse
+
err = json.Unmarshal(respBody, &diffTreeResponse)
+
if err != nil {
+
log.Println("failed to unmarshal diff tree response", err)
+
return nil, fmt.Errorf("Failed to compare branches.")
+
}
+
+
return &diffTreeResponse, nil
}
+2 -2
appview/state/star.go
···
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{
+3
appview/state/state.go
···
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)
+1
cmd/gen.go
···
shtangled.RepoIssue{},
shtangled.Repo{},
shtangled.RepoPull{},
+
shtangled.RepoPull_Source{},
shtangled.RepoPullStatus{},
shtangled.RepoPullComment{},
); err != nil {
+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.example.org"
+
KNOT_SERVER_SECRET: "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
+74
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>: <package/path>: <short summary of change>
+
+
+
Optional longer description, if needed. 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 a single commit, so keep PRs small and focused. Use
+
the above guidelines for the PR title and description.
+
- 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.
+25 -14
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": {
···
"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": 1742268799,
-
"narHash": "sha256-IhnK4LhkBlf14/F8THvUy3xi/TxSQkp9hikfDZRD4Ic=",
+
"lastModified": 1743813633,
+
"narHash": "sha256-BgkBz4NpV6Kg8XF7cmHDHRVGZYnKbvG0Y4p+jElwxaM=",
"owner": "nixos",
"repo": "nixpkgs",
-
"rev": "da044451c6a70518db5b730fe277b70f494188f1",
+
"rev": "7819a0d29d1dd2bc331bec4b327f0776359b1fa6",
"type": "github"
},
"original": {
···
"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"
}
+20 -9
flake.nix
···
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;
···
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
'';
···
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/
'';
};
});
···
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;
+1 -1
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/crypto v0.36.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/time v0.5.0 // indirect
+14 -80
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 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 dark:text-white dark:hover:text-gray-300;
}
+81 -10
knotserver/git/diff.go
···
"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/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
+
}
+
+
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
+
}
+23
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 {
+5
knotserver/handler.go
···
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("/tags", h.Tags)
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) {
+156 -1
knotserver/routes.go
···
capabilities := map[string]any{
"pull_requests": map[string]any{
-
"patch_submissions": true,
+
"patch_submissions": true,
+
"branch_submissions": true,
+
"fork_submissions": true,
},
}
···
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")
···
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")
···
}
writeError(w, err.Error(), http.StatusInternalServerError)
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
+
}
+
+
difftree, err := gr.DiffTree(mergeBase, commit2)
+
if err != nil {
+
l.Error("error comparing revisions", "msg", err.Error())
+
writeError(w, "error comparing revisions", http.StatusBadRequest)
+
return
+
}
+
+
writeJSON(w, types.RepoDiffTreeResponse{difftree})
+
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) {
+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"
}
}
}
+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!
+40 -8
tailwind.config.js
···
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 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border': {}
+
"@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")],
};
+9
types/capabilities.go
···
+
package types
+
+
type Capabilities struct {
+
PullRequests struct {
+
PatchSubmissions bool `json:"patch_submissions"`
+
BranchSubmissions bool `json:"branch_submissions"`
+
ForkSubmissions bool `json:"fork_submissions"`
+
} `json:"pull_requests"`
+
}
+21
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"`
+
}
+8
types/repo.go
···
Diff *NiceDiff `json:"diff,omitempty"`
}
+
type RepoDiffTreeResponse struct {
+
DiffTree *DiffTree `json:"difftree,omitempty"`
+
}
+
type RepoTreeResponse struct {
Ref string `json:"ref,omitempty"`
Parent string `json:"parent,omitempty"`
···
type RepoBranchesResponse struct {
Branches []Branch `json:"branches,omitempty"`
+
}
+
+
type RepoBranchResponse struct {
+
Branch Branch `json:"branch,omitempty"`
}
type RepoDefaultBranchResponse struct {