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

Compare changes

Choose any two refs to compare.

Changed files
+4276 -1259
.tangled
workflows
api
appview
cmd
docs
spindle
knotserver
legal
lexicons
nix
types
+6
.tangled/workflows/test.yml
···
command: |
mkdir -p appview/pages/static; touch appview/pages/static/x
- name: run all tests
environment:
CGO_ENABLED: 1
···
command: |
mkdir -p appview/pages/static; touch appview/pages/static/x
+
- name: run linter
+
environment:
+
CGO_ENABLED: 1
+
command: |
+
go vet -v ./...
+
- name: run all tests
environment:
CGO_ENABLED: 1
+202 -1
api/tangled/cbor_gen.go
···
}
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
if t.Body == nil {
fieldCount--
}
if t.Source == nil {
fieldCount--
}
···
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
}
return nil
}
···
t.CreatedAt = string(sval)
}
default:
// Field doesn't exist on this type, so ignore it
···
}
t.CreatedAt = string(sval)
}
default:
···
}
cw := cbg.NewCborWriter(w)
+
fieldCount := 8
if t.Body == nil {
fieldCount--
}
if t.Source == nil {
+
fieldCount--
+
}
+
+
if t.StackInfo == nil {
fieldCount--
}
···
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
}
+
+
// t.StackInfo (tangled.RepoPull_StackInfo) (struct)
+
if t.StackInfo != nil {
+
+
if len("stackInfo") > 1000000 {
+
return xerrors.Errorf("Value in field \"stackInfo\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stackInfo"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("stackInfo")); err != nil {
+
return err
+
}
+
+
if err := t.StackInfo.MarshalCBOR(cw); err != nil {
+
return err
+
}
+
}
return nil
}
···
t.CreatedAt = string(sval)
}
+
// t.StackInfo (tangled.RepoPull_StackInfo) (struct)
+
case "stackInfo":
+
+
{
+
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.StackInfo = new(RepoPull_StackInfo)
+
if err := t.StackInfo.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.StackInfo pointer: %w", err)
+
}
+
}
+
+
}
default:
// Field doesn't exist on this type, so ignore it
···
}
t.CreatedAt = 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_StackInfo) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
fieldCount := 2
+
+
if t.Parent == nil {
+
fieldCount--
+
}
+
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
return err
+
}
+
+
// t.Parent (string) (string)
+
if t.Parent != nil {
+
+
if len("parent") > 1000000 {
+
return xerrors.Errorf("Value in field \"parent\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("parent"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("parent")); err != nil {
+
return err
+
}
+
+
if t.Parent == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Parent) > 1000000 {
+
return xerrors.Errorf("Value in field t.Parent was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Parent))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Parent)); err != nil {
+
return err
+
}
+
}
+
}
+
+
// t.ChangeId (string) (string)
+
if len("changeId") > 1000000 {
+
return xerrors.Errorf("Value in field \"changeId\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("changeId"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("changeId")); err != nil {
+
return err
+
}
+
+
if len(t.ChangeId) > 1000000 {
+
return xerrors.Errorf("Value in field t.ChangeId was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ChangeId))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.ChangeId)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *RepoPull_StackInfo) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = RepoPull_StackInfo{}
+
+
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_StackInfo: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 8)
+
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.Parent (string) (string)
+
case "parent":
+
+
{
+
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.Parent = (*string)(&sval)
+
}
+
}
+
// t.ChangeId (string) (string)
+
case "changeId":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.ChangeId = string(sval)
}
default:
+16 -7
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" cborgen:"createdAt"`
-
Patch string `json:"patch" cborgen:"patch"`
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
-
Title string `json:"title" cborgen:"title"`
}
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
···
Branch string `json:"branch" cborgen:"branch"`
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
Sha string `json:"sha" cborgen:"sha"`
}
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
···
} //
// 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" cborgen:"createdAt"`
+
Patch string `json:"patch" cborgen:"patch"`
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
+
StackInfo *RepoPull_StackInfo `json:"stackInfo,omitempty" cborgen:"stackInfo,omitempty"`
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
+
Title string `json:"title" cborgen:"title"`
}
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
···
Branch string `json:"branch" cborgen:"branch"`
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
Sha string `json:"sha" cborgen:"sha"`
+
}
+
+
// RepoPull_StackInfo is a "stackInfo" in the sh.tangled.repo.pull schema.
+
type RepoPull_StackInfo struct {
+
// changeId: Change ID of this commit/change.
+
ChangeId string `json:"changeId" cborgen:"changeId"`
+
// parent: AT-URI of the PR for the parent commit/change in the change stack.
+
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
}
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
+10
api/tangled/repotree.go
···
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
// parent: The parent path in the tree
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
// ref: The git reference used
Ref string `json:"ref" cborgen:"ref"`
}
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
···
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
// parent: The parent path in the tree
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
+
// readme: Readme for this file tree
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
// ref: The git reference used
Ref string `json:"ref" cborgen:"ref"`
+
}
+
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
+
type RepoTree_Readme struct {
+
// contents: Contents of the readme file
+
Contents string `json:"contents" cborgen:"contents"`
+
// filename: Name of the readme file
+
Filename string `json:"filename" cborgen:"filename"`
}
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4 -2
appview/config/config.go
···
}
type Cloudflare struct {
-
ApiToken string `env:"API_TOKEN"`
-
ZoneId string `env:"ZONE_ID"`
}
func (cfg RedisConfig) ToURL() string {
···
}
type Cloudflare struct {
+
ApiToken string `env:"API_TOKEN"`
+
ZoneId string `env:"ZONE_ID"`
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
}
func (cfg RedisConfig) ToURL() string {
+179 -10
appview/db/db.go
···
-- label to subscribe to
label_at text not null,
-
unique (repo_at, label_at),
-
foreign key (label_at) references label_definitions (at_uri)
);
create table if not exists migrations (
···
name text unique
);
-
-- indexes for better star query performance
create index if not exists idx_stars_created on stars(created);
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
`)
···
_, err := tx.Exec(`
alter table spindles add column needs_upgrade integer not null default 0;
`)
-
if err != nil {
-
return err
-
}
-
-
_, err = tx.Exec(`
-
update spindles set needs_upgrade = 1;
-
`)
return err
})
···
// drop old table
_, err = tx.Exec(`drop table comments`)
return err
})
···
-- label to subscribe to
label_at text not null,
+
unique (repo_at, label_at)
+
);
+
+
create table if not exists notifications (
+
id integer primary key autoincrement,
+
recipient_did text not null,
+
actor_did text not null,
+
type text not null,
+
entity_type text not null,
+
entity_id text not null,
+
read integer not null default 0,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
repo_id integer references repos(id),
+
issue_id integer references issues(id),
+
pull_id integer references pulls(id)
+
);
+
+
create table if not exists notification_preferences (
+
id integer primary key autoincrement,
+
user_did text not null unique,
+
repo_starred integer not null default 1,
+
issue_created integer not null default 1,
+
issue_commented integer not null default 1,
+
pull_created integer not null default 1,
+
pull_commented integer not null default 1,
+
followed integer not null default 1,
+
pull_merged integer not null default 1,
+
issue_closed integer not null default 1,
+
email_notifications integer not null default 0
);
create table if not exists migrations (
···
name text unique
);
+
-- indexes for better performance
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
create index if not exists idx_stars_created on stars(created);
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
`)
···
_, err := tx.Exec(`
alter table spindles add column needs_upgrade integer not null default 0;
`)
return err
})
···
// drop old table
_, err = tx.Exec(`drop table comments`)
+
return err
+
})
+
+
// add generated at_uri column to pulls table
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists pulls_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
pull_id integer not null,
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
+
+
-- at identifiers
+
repo_at text not null,
+
owner_did text not null,
+
rkey text not null,
+
+
-- content
+
title text not null,
+
body text not null,
+
target_branch text not null,
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
+
+
-- source info
+
source_branch text,
+
source_repo_at text,
+
+
-- stacking
+
stack_id text,
+
change_id text,
+
parent_change_id text,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique(repo_at, pull_id),
+
unique(at_uri),
+
foreign key (repo_at) references repos(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data
+
_, err = tx.Exec(`
+
insert into pulls_new (
+
id, pull_id, repo_at, owner_did, rkey,
+
title, body, target_branch, state,
+
source_branch, source_repo_at,
+
stack_id, change_id, parent_change_id,
+
created
+
)
+
select
+
id, pull_id, repo_at, owner_did, rkey,
+
title, body, target_branch, state,
+
source_branch, source_repo_at,
+
stack_id, change_id, parent_change_id,
+
created
+
from pulls;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table pulls`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists pull_submissions_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
pull_at text not null,
+
+
-- content, these are immutable, and require a resubmission to update
+
round_number integer not null default 0,
+
patch text,
+
source_rev text,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique(pull_at, round_number),
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data, constructing pull_at from pulls table
+
_, err = tx.Exec(`
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
+
select
+
ps.id,
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
+
ps.round_number,
+
ps.patch,
+
ps.created
+
from pull_submissions ps
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table pull_submissions`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
+
runMigration(conn, "add-parent-at-for-stacks-to-pulls", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table pulls add column parent_at text;
+
`)
return err
})
+13 -9
appview/db/email.go
···
return did, nil
}
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
-
if len(ems) == 0 {
return make(map[string]string), nil
}
···
if isVerifiedFilter {
verifiedFilter = 1
}
// Create placeholders for the IN clause
-
placeholders := make([]string, len(ems))
-
args := make([]any, len(ems)+1)
args[0] = verifiedFilter
-
for i, em := range ems {
-
placeholders[i] = "?"
-
args[i+1] = em
}
query := `
···
return nil, err
}
defer rows.Close()
-
-
assoc := make(map[string]string)
for rows.Next() {
var email, did string
···
return did, nil
}
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
+
if len(emails) == 0 {
return make(map[string]string), nil
}
···
if isVerifiedFilter {
verifiedFilter = 1
}
+
+
assoc := make(map[string]string)
// Create placeholders for the IN clause
+
placeholders := make([]string, 0, len(emails))
+
args := make([]any, 1, len(emails)+1)
args[0] = verifiedFilter
+
for _, email := range emails {
+
if strings.HasPrefix(email, "did:") {
+
assoc[email] = email
+
continue
+
}
+
placeholders = append(placeholders, "?")
+
args = append(args, email)
}
query := `
···
return nil, err
}
defer rows.Close()
for rows.Next() {
var email, did string
+34
appview/db/language.go
···
package db
import (
"fmt"
"strings"
"tangled.org/core/appview/models"
)
···
return nil
}
···
package db
import (
+
"database/sql"
"fmt"
"strings"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
···
return nil
}
+
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
+
return err
+
}
+
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
+
err := DeleteRepoLanguages(
+
tx,
+
FilterEq("repo_at", repoAt),
+
FilterEq("ref", ref),
+
)
+
if err != nil {
+
return fmt.Errorf("failed to delete existing languages: %w", err)
+
}
+
+
return InsertRepoLanguages(tx, langs)
+
}
+450
appview/db/notifications.go
···
···
+
package db
+
+
import (
+
"context"
+
"database/sql"
+
"errors"
+
"fmt"
+
"strings"
+
"time"
+
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/pagination"
+
)
+
+
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
+
query := `
+
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+
`
+
+
result, err := d.DB.ExecContext(ctx, query,
+
notification.RecipientDid,
+
notification.ActorDid,
+
string(notification.Type),
+
notification.EntityType,
+
notification.EntityId,
+
notification.Read,
+
notification.RepoId,
+
notification.IssueId,
+
notification.PullId,
+
)
+
if err != nil {
+
return fmt.Errorf("failed to create notification: %w", err)
+
}
+
+
id, err := result.LastInsertId()
+
if err != nil {
+
return fmt.Errorf("failed to get notification ID: %w", err)
+
}
+
+
notification.ID = id
+
return nil
+
}
+
+
// GetNotificationsPaginated retrieves notifications with filters and pagination
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
+
var conditions []string
+
var args []any
+
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if len(conditions) > 0 {
+
whereClause = "WHERE " + conditions[0]
+
for _, condition := range conditions[1:] {
+
whereClause += " AND " + condition
+
}
+
}
+
+
query := fmt.Sprintf(`
+
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
+
from notifications
+
%s
+
order by created desc
+
limit ? offset ?
+
`, whereClause)
+
+
args = append(args, page.Limit, page.Offset)
+
+
rows, err := e.QueryContext(context.Background(), query, args...)
+
if err != nil {
+
return nil, fmt.Errorf("failed to query notifications: %w", err)
+
}
+
defer rows.Close()
+
+
var notifications []*models.Notification
+
for rows.Next() {
+
var n models.Notification
+
var typeStr string
+
var createdStr string
+
err := rows.Scan(
+
&n.ID,
+
&n.RecipientDid,
+
&n.ActorDid,
+
&typeStr,
+
&n.EntityType,
+
&n.EntityId,
+
&n.Read,
+
&createdStr,
+
&n.RepoId,
+
&n.IssueId,
+
&n.PullId,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan notification: %w", err)
+
}
+
n.Type = models.NotificationType(typeStr)
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
+
}
+
notifications = append(notifications, &n)
+
}
+
+
return notifications, nil
+
}
+
+
// GetNotificationsWithEntities retrieves notifications with their related entities
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
+
var conditions []string
+
var args []any
+
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if len(conditions) > 0 {
+
whereClause = "WHERE " + conditions[0]
+
for _, condition := range conditions[1:] {
+
whereClause += " AND " + condition
+
}
+
}
+
+
query := fmt.Sprintf(`
+
select
+
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
+
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
+
i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
+
p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
+
from notifications n
+
left join repos r on n.repo_id = r.id
+
left join issues i on n.issue_id = i.id
+
left join pulls p on n.pull_id = p.id
+
%s
+
order by n.created desc
+
limit ? offset ?
+
`, whereClause)
+
+
args = append(args, page.Limit, page.Offset)
+
+
rows, err := e.QueryContext(context.Background(), query, args...)
+
if err != nil {
+
return nil, fmt.Errorf("failed to query notifications with entities: %w", err)
+
}
+
defer rows.Close()
+
+
var notifications []*models.NotificationWithEntity
+
for rows.Next() {
+
var n models.Notification
+
var typeStr string
+
var createdStr string
+
var repo models.Repo
+
var issue models.Issue
+
var pull models.Pull
+
var rId, iId, pId sql.NullInt64
+
var rDid, rName, rDescription sql.NullString
+
var iDid sql.NullString
+
var iIssueId sql.NullInt64
+
var iTitle sql.NullString
+
var iOpen sql.NullBool
+
var pOwnerDid sql.NullString
+
var pPullId sql.NullInt64
+
var pTitle sql.NullString
+
var pState sql.NullInt64
+
+
err := rows.Scan(
+
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
+
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
+
&rId, &rDid, &rName, &rDescription,
+
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
+
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan notification with entities: %w", err)
+
}
+
+
n.Type = models.NotificationType(typeStr)
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
+
}
+
+
nwe := &models.NotificationWithEntity{Notification: &n}
+
+
// populate repo if present
+
if rId.Valid {
+
repo.Id = rId.Int64
+
if rDid.Valid {
+
repo.Did = rDid.String
+
}
+
if rName.Valid {
+
repo.Name = rName.String
+
}
+
if rDescription.Valid {
+
repo.Description = rDescription.String
+
}
+
nwe.Repo = &repo
+
}
+
+
// populate issue if present
+
if iId.Valid {
+
issue.Id = iId.Int64
+
if iDid.Valid {
+
issue.Did = iDid.String
+
}
+
if iIssueId.Valid {
+
issue.IssueId = int(iIssueId.Int64)
+
}
+
if iTitle.Valid {
+
issue.Title = iTitle.String
+
}
+
if iOpen.Valid {
+
issue.Open = iOpen.Bool
+
}
+
nwe.Issue = &issue
+
}
+
+
// populate pull if present
+
if pId.Valid {
+
pull.ID = int(pId.Int64)
+
if pOwnerDid.Valid {
+
pull.OwnerDid = pOwnerDid.String
+
}
+
if pPullId.Valid {
+
pull.PullId = int(pPullId.Int64)
+
}
+
if pTitle.Valid {
+
pull.Title = pTitle.String
+
}
+
if pState.Valid {
+
pull.State = models.PullState(pState.Int64)
+
}
+
nwe.Pull = &pull
+
}
+
+
notifications = append(notifications, nwe)
+
}
+
+
return notifications, nil
+
}
+
+
// GetNotifications retrieves notifications with filters
+
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
+
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
+
}
+
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
+
var count int64
+
err := e.QueryRow(query, args...).Scan(&count)
+
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
+
}
+
+
return count, nil
+
}
+
+
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
+
idFilter := FilterEq("id", notificationID)
+
recipientFilter := FilterEq("recipient_did", userDID)
+
+
query := fmt.Sprintf(`
+
UPDATE notifications
+
SET read = 1
+
WHERE %s AND %s
+
`, idFilter.Condition(), recipientFilter.Condition())
+
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
+
+
result, err := d.DB.ExecContext(ctx, query, args...)
+
if err != nil {
+
return fmt.Errorf("failed to mark notification as read: %w", err)
+
}
+
+
rowsAffected, err := result.RowsAffected()
+
if err != nil {
+
return fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
if rowsAffected == 0 {
+
return fmt.Errorf("notification not found or access denied")
+
}
+
+
return nil
+
}
+
+
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
+
recipientFilter := FilterEq("recipient_did", userDID)
+
readFilter := FilterEq("read", 0)
+
+
query := fmt.Sprintf(`
+
UPDATE notifications
+
SET read = 1
+
WHERE %s AND %s
+
`, recipientFilter.Condition(), readFilter.Condition())
+
+
args := append(recipientFilter.Arg(), readFilter.Arg()...)
+
+
_, err := d.DB.ExecContext(ctx, query, args...)
+
if err != nil {
+
return fmt.Errorf("failed to mark all notifications as read: %w", err)
+
}
+
+
return nil
+
}
+
+
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
+
idFilter := FilterEq("id", notificationID)
+
recipientFilter := FilterEq("recipient_did", userDID)
+
+
query := fmt.Sprintf(`
+
DELETE FROM notifications
+
WHERE %s AND %s
+
`, idFilter.Condition(), recipientFilter.Condition())
+
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
+
+
result, err := d.DB.ExecContext(ctx, query, args...)
+
if err != nil {
+
return fmt.Errorf("failed to delete notification: %w", err)
+
}
+
+
rowsAffected, err := result.RowsAffected()
+
if err != nil {
+
return fmt.Errorf("failed to get rows affected: %w", err)
+
}
+
+
if rowsAffected == 0 {
+
return fmt.Errorf("notification not found or access denied")
+
}
+
+
return nil
+
}
+
+
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
+
userFilter := FilterEq("user_did", userDID)
+
+
query := fmt.Sprintf(`
+
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
+
pull_commented, followed, pull_merged, issue_closed, email_notifications
+
FROM notification_preferences
+
WHERE %s
+
`, userFilter.Condition())
+
+
var prefs models.NotificationPreferences
+
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
+
&prefs.ID,
+
&prefs.UserDid,
+
&prefs.RepoStarred,
+
&prefs.IssueCreated,
+
&prefs.IssueCommented,
+
&prefs.PullCreated,
+
&prefs.PullCommented,
+
&prefs.Followed,
+
&prefs.PullMerged,
+
&prefs.IssueClosed,
+
&prefs.EmailNotifications,
+
)
+
+
if err != nil {
+
if err == sql.ErrNoRows {
+
return &models.NotificationPreferences{
+
UserDid: userDID,
+
RepoStarred: true,
+
IssueCreated: true,
+
IssueCommented: true,
+
PullCreated: true,
+
PullCommented: true,
+
Followed: true,
+
PullMerged: true,
+
IssueClosed: true,
+
EmailNotifications: false,
+
}, nil
+
}
+
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
+
}
+
+
return &prefs, nil
+
}
+
+
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
+
query := `
+
INSERT OR REPLACE INTO notification_preferences
+
(user_did, repo_starred, issue_created, issue_commented, pull_created,
+
pull_commented, followed, pull_merged, issue_closed, email_notifications)
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+
`
+
+
result, err := d.DB.ExecContext(ctx, query,
+
prefs.UserDid,
+
prefs.RepoStarred,
+
prefs.IssueCreated,
+
prefs.IssueCommented,
+
prefs.PullCreated,
+
prefs.PullCommented,
+
prefs.Followed,
+
prefs.PullMerged,
+
prefs.IssueClosed,
+
prefs.EmailNotifications,
+
)
+
if err != nil {
+
return fmt.Errorf("failed to update notification preferences: %w", err)
+
}
+
+
if prefs.ID == 0 {
+
id, err := result.LastInsertId()
+
if err != nil {
+
return fmt.Errorf("failed to get preferences ID: %w", err)
+
}
+
prefs.ID = id
+
}
+
+
return nil
+
}
+
+
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
+
cutoff := time.Now().Add(-olderThan)
+
createdFilter := FilterLte("created", cutoff)
+
+
query := fmt.Sprintf(`
+
DELETE FROM notifications
+
WHERE %s
+
`, createdFilter.Condition())
+
+
_, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...)
+
if err != nil {
+
return fmt.Errorf("failed to cleanup old notifications: %w", err)
+
}
+
+
return nil
+
}
+172 -232
appview/db/pulls.go
···
package db
import (
"database/sql"
"fmt"
-
"log"
"sort"
"strings"
"time"
···
}
}
-
var stackId, changeId, parentChangeId *string
if pull.StackId != "" {
stackId = &pull.StackId
}
if pull.ChangeId != "" {
changeId = &pull.ChangeId
}
if pull.ParentChangeId != "" {
parentChangeId = &pull.ParentChangeId
}
-
_, err = tx.Exec(
`
insert into pulls (
-
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
)
-
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
pull.RepoAt,
pull.OwnerDid,
pull.PullId,
···
sourceRepoAt,
stackId,
changeId,
parentChangeId,
)
if err != nil {
return err
}
_, err = tx.Exec(`
-
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)
return err
}
···
}
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
-
pulls := make(map[int]*models.Pull)
var conditions []string
var args []any
···
query := fmt.Sprintf(`
select
owner_did,
repo_at,
pull_id,
···
source_repo_at,
stack_id,
change_id,
parent_change_id
from
pulls
···
for rows.Next() {
var pull models.Pull
var createdAt string
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
err := rows.Scan(
&pull.OwnerDid,
&pull.RepoAt,
&pull.PullId,
···
&sourceRepoAt,
&stackId,
&changeId,
&parentChangeId,
)
if err != nil {
···
if changeId.Valid {
pull.ChangeId = changeId.String
}
if parentChangeId.Valid {
pull.ParentChangeId = parentChangeId.String
}
-
pulls[pull.PullId] = &pull
}
-
// get latest round no. for each pull
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
-
submissionsQuery := fmt.Sprintf(`
-
select
-
id, pull_id, round_number, patch, created, source_rev
-
from
-
pull_submissions
-
where
-
repo_at in (%s) and pull_id in (%s)
-
`, inClause, inClause)
-
-
args = make([]any, len(pulls)*2)
-
idx := 0
for _, p := range pulls {
-
args[idx] = p.RepoAt
-
idx += 1
}
-
for _, p := range pulls {
-
args[idx] = p.PullId
-
idx += 1
-
}
-
submissionsRows, err := e.Query(submissionsQuery, args...)
if err != nil {
-
return nil, err
}
-
defer submissionsRows.Close()
-
for submissionsRows.Next() {
-
var s models.PullSubmission
-
var sourceRev sql.NullString
-
var createdAt string
-
err := submissionsRows.Scan(
-
&s.ID,
-
&s.PullId,
-
&s.RoundNumber,
-
&s.Patch,
-
&createdAt,
-
&sourceRev,
-
)
-
if err != nil {
-
return nil, err
-
}
-
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
}
-
s.Created = createdTime
-
-
if sourceRev.Valid {
-
s.SourceRev = sourceRev.String
-
}
-
if p, ok := pulls[s.PullId]; ok {
-
p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1)
-
p.Submissions[s.RoundNumber] = &s
-
}
}
-
if err := rows.Err(); err != nil {
-
return nil, err
}
-
// get comment count on latest submission on each pull
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
-
commentsQuery := fmt.Sprintf(`
-
select
-
count(id), pull_id
-
from
-
pull_comments
-
where
-
submission_id in (%s)
-
group by
-
submission_id
-
`, inClause)
-
-
args = []any{}
for _, p := range pulls {
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
}
-
commentsRows, err := e.Query(commentsQuery, args...)
-
if err != nil {
-
return nil, err
}
-
defer commentsRows.Close()
-
-
for commentsRows.Next() {
-
var commentCount, pullId int
-
err := commentsRows.Scan(
-
&commentCount,
-
&pullId,
-
)
-
if err != nil {
-
return nil, err
-
}
-
if p, ok := pulls[pullId]; ok {
-
p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount)
}
}
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
orderedByPullId := []*models.Pull{}
for _, p := range pulls {
···
}
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
-
query := `
-
select
-
owner_did,
-
pull_id,
-
created,
-
title,
-
state,
-
target_branch,
-
repo_at,
-
body,
-
rkey,
-
source_branch,
-
source_repo_at,
-
stack_id,
-
change_id,
-
parent_change_id
-
from
-
pulls
-
where
-
repo_at = ? and pull_id = ?
-
`
-
row := e.QueryRow(query, repoAt, pullId)
-
-
var pull models.Pull
-
var createdAt string
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
-
err := row.Scan(
-
&pull.OwnerDid,
-
&pull.PullId,
-
&createdAt,
-
&pull.Title,
-
&pull.State,
-
&pull.TargetBranch,
-
&pull.RepoAt,
-
&pull.Body,
-
&pull.Rkey,
-
&sourceBranch,
-
&sourceRepoAt,
-
&stackId,
-
&changeId,
-
&parentChangeId,
-
)
if err != nil {
return nil, err
}
-
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
}
-
pull.Created = createdTime
-
// populate source
-
if sourceBranch.Valid {
-
pull.PullSource = &models.PullSource{
-
Branch: sourceBranch.String,
-
}
-
if sourceRepoAt.Valid {
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
-
if err != nil {
-
return nil, err
-
}
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
-
}
}
-
if stackId.Valid {
-
pull.StackId = stackId.String
-
}
-
if changeId.Valid {
-
pull.ChangeId = changeId.String
-
}
-
if parentChangeId.Valid {
-
pull.ParentChangeId = parentChangeId.String
}
-
submissionsQuery := `
select
-
id, pull_id, repo_at, round_number, patch, created, source_rev
from
pull_submissions
-
where
-
repo_at = ? and pull_id = ?
-
`
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
if err != nil {
return nil, err
}
-
defer submissionsRows.Close()
-
submissionsMap := make(map[int]*models.PullSubmission)
-
for submissionsRows.Next() {
var submission models.PullSubmission
-
var submissionCreatedStr string
-
var submissionSourceRev sql.NullString
-
err := submissionsRows.Scan(
&submission.ID,
-
&submission.PullId,
-
&submission.RepoAt,
&submission.RoundNumber,
&submission.Patch,
-
&submissionCreatedStr,
-
&submissionSourceRev,
)
if err != nil {
return nil, err
}
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
if err != nil {
return nil, err
}
-
submission.Created = submissionCreatedTime
-
if submissionSourceRev.Valid {
-
submission.SourceRev = submissionSourceRev.String
}
-
submissionsMap[submission.ID] = &submission
}
-
if err = submissionsRows.Close(); err != nil {
return nil, err
}
-
if len(submissionsMap) == 0 {
-
return &pull, nil
}
var args []any
-
for k := range submissionsMap {
-
args = append(args, k)
}
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
-
commentsQuery := fmt.Sprintf(`
select
id,
pull_id,
···
created
from
pull_comments
-
where
-
submission_id IN (%s)
order by
created asc
-
`, inClause)
-
commentsRows, err := e.Query(commentsQuery, args...)
if err != nil {
return nil, err
}
-
defer commentsRows.Close()
-
for commentsRows.Next() {
var comment models.PullComment
-
var commentCreatedStr string
-
err := commentsRows.Scan(
&comment.ID,
&comment.PullId,
&comment.SubmissionId,
···
&comment.OwnerDid,
&comment.CommentAt,
&comment.Body,
-
&commentCreatedStr,
)
if err != nil {
return nil, err
}
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
-
if err != nil {
-
return nil, err
}
-
comment.Created = commentCreatedTime
-
// Add the comment to its submission
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
-
submission.Comments = append(submission.Comments, comment)
-
}
-
}
-
if err = commentsRows.Err(); err != nil {
return nil, err
}
-
var pullSourceRepo *models.Repo
-
if pull.PullSource != nil {
-
if pull.PullSource.RepoAt != nil {
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
-
if err != nil {
-
log.Printf("failed to get repo by at uri: %v", err)
-
} else {
-
pull.PullSource.Repo = pullSourceRepo
-
}
-
}
-
}
-
-
pull.Submissions = make([]*models.PullSubmission, len(submissionsMap))
-
for _, submission := range submissionsMap {
-
pull.Submissions[submission.RoundNumber] = submission
-
}
-
-
return &pull, nil
}
// timeframe here is directly passed into the sql query filter, and any
···
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
newRoundNumber := len(pull.Submissions)
_, err := e.Exec(`
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
-
values (?, ?, ?, ?, ?)
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
return err
}
···
package db
import (
+
"cmp"
"database/sql"
+
"errors"
"fmt"
+
"maps"
+
"slices"
"sort"
"strings"
"time"
···
}
}
+
var stackId, changeId, parentAt, parentChangeId *string
if pull.StackId != "" {
stackId = &pull.StackId
}
if pull.ChangeId != "" {
changeId = &pull.ChangeId
}
+
if pull.ParentAt != nil {
+
parentAt = (*string)(pull.ParentAt)
+
}
if pull.ParentChangeId != "" {
parentChangeId = &pull.ParentChangeId
}
+
result, err := tx.Exec(
`
insert into pulls (
+
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_at, parent_change_id
)
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
pull.RepoAt,
pull.OwnerDid,
pull.PullId,
···
sourceRepoAt,
stackId,
changeId,
+
parentAt,
parentChangeId,
)
if err != nil {
return err
}
+
// Set the database primary key ID
+
id, err := result.LastInsertId()
+
if err != nil {
+
return err
+
}
+
pull.ID = int(id)
+
_, err = tx.Exec(`
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
+
values (?, ?, ?, ?)
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
return err
}
···
}
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
+
pulls := make(map[syntax.ATURI]*models.Pull)
var conditions []string
var args []any
···
query := fmt.Sprintf(`
select
+
id,
owner_did,
repo_at,
pull_id,
···
source_repo_at,
stack_id,
change_id,
+
parent_at,
parent_change_id
from
pulls
···
for rows.Next() {
var pull models.Pull
var createdAt string
+
var sourceBranch, sourceRepoAt, stackId, changeId, parentAt, parentChangeId sql.NullString
err := rows.Scan(
+
&pull.ID,
&pull.OwnerDid,
&pull.RepoAt,
&pull.PullId,
···
&sourceRepoAt,
&stackId,
&changeId,
+
&parentAt,
&parentChangeId,
)
if err != nil {
···
if changeId.Valid {
pull.ChangeId = changeId.String
}
+
if parentAt.Valid {
+
parentAtParsed, err := syntax.ParseATURI(parentAt.String)
+
if err != nil {
+
return nil, err
+
}
+
pull.ParentAt = &parentAtParsed
+
}
if parentChangeId.Valid {
pull.ParentChangeId = parentChangeId.String
}
+
pulls[pull.PullAt()] = &pull
}
+
var pullAts []syntax.ATURI
for _, p := range pulls {
+
pullAts = append(pullAts, p.PullAt())
}
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
if err != nil {
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
}
+
for pullAt, submissions := range submissionsMap {
+
if p, ok := pulls[pullAt]; ok {
+
p.Submissions = submissions
}
+
}
+
// collect allLabels for each issue
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query labels: %w", err)
}
+
for pullAt, labels := range allLabels {
+
if p, ok := pulls[pullAt]; ok {
+
p.Labels = labels
+
}
}
+
// collect pull source for all pulls that need it
+
var sourceAts []syntax.ATURI
for _, p := range pulls {
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
+
}
}
+
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
+
return nil, fmt.Errorf("failed to get source repos: %w", err)
+
}
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
+
for _, r := range sourceRepos {
+
sourceRepoMap[r.RepoAt()] = &r
}
+
for _, p := range pulls {
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
+
p.PullSource.Repo = sourceRepo
+
}
}
}
orderedByPullId := []*models.Pull{}
for _, p := range pulls {
···
}
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
if err != nil {
return nil, err
}
+
if pulls == nil {
+
return nil, sql.ErrNoRows
}
+
+
return pulls[0], nil
+
}
+
// mapping from pull -> pull submissions
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
+
query := fmt.Sprintf(`
select
+
id,
+
pull_at,
+
round_number,
+
patch,
+
created,
+
source_rev
from
pull_submissions
+
%s
+
order by
+
round_number asc
+
`, whereClause)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
+
defer rows.Close()
+
submissionMap := make(map[int]*models.PullSubmission)
+
for rows.Next() {
var submission models.PullSubmission
+
var createdAt string
+
var sourceRev sql.NullString
+
err := rows.Scan(
&submission.ID,
+
&submission.PullAt,
&submission.RoundNumber,
&submission.Patch,
+
&createdAt,
+
&sourceRev,
)
if err != nil {
return nil, err
}
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
if err != nil {
return nil, err
}
+
submission.Created = createdTime
+
if sourceRev.Valid {
+
submission.SourceRev = sourceRev.String
}
+
submissionMap[submission.ID] = &submission
}
+
+
if err := rows.Err(); err != nil {
return nil, err
}
+
+
// Get comments for all submissions using GetPullComments
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
+
if err != nil {
+
return nil, err
+
}
+
for _, comment := range comments {
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
+
submission.Comments = append(submission.Comments, comment)
+
}
}
+
// group the submissions by pull_at
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
+
for _, s := range submissionMap {
+
m[s.PullAt] = append(m[s.PullAt], s)
+
}
+
+
// sort each one by round number
+
for _, s := range m {
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
+
})
+
}
+
+
return m, nil
+
}
+
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
+
var conditions []string
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`
select
id,
pull_id,
···
created
from
pull_comments
+
%s
order by
created asc
+
`, whereClause)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
+
defer rows.Close()
+
var comments []models.PullComment
+
for rows.Next() {
var comment models.PullComment
+
var createdAt string
+
err := rows.Scan(
&comment.ID,
&comment.PullId,
&comment.SubmissionId,
···
&comment.OwnerDid,
&comment.CommentAt,
&comment.Body,
+
&createdAt,
)
if err != nil {
return nil, err
}
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
comment.Created = t
}
+
comments = append(comments, comment)
+
}
+
if err := rows.Err(); err != nil {
return nil, err
}
+
return comments, nil
}
// timeframe here is directly passed into the sql query filter, and any
···
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
newRoundNumber := len(pull.Submissions)
_, err := e.Exec(`
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
+
values (?, ?, ?, ?)
+
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
return err
}
+65 -23
appview/db/repos.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
repoMap := make(map[syntax.ATURI]*models.Repo)
···
repoQuery := fmt.Sprintf(
`select
did,
name,
knot,
···
var description, source, spindle sql.NullString
err := rows.Scan(
&repo.Did,
&repo.Name,
&repo.Knot,
···
languageQuery := fmt.Sprintf(
`
-
select
-
repo_at, language
-
from
-
repo_languages r1
-
where
-
repo_at IN (%s)
and is_default_ref = 1
-
and id = (
-
select id
-
from repo_languages r2
-
where r2.repo_at = r1.repo_at
-
and r2.is_default_ref = 1
-
order by bytes desc
-
limit 1
-
);
`,
inClause,
)
···
var repo models.Repo
var nullableDescription sql.NullString
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
var createdAt string
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
return &repo, nil
}
-
func AddRepo(e Execer, repo *models.Repo) error {
-
_, err := e.Exec(
`insert into repos
(did, name, knot, rkey, at_uri, description, source)
values (?, ?, ?, ?, ?, ?, ?)`,
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
)
-
return err
}
func RemoveRepo(e Execer, did, name string) error {
···
var repos []models.Repo
rows, err := e.Query(
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
from repos r
left join collaborators c on r.at_uri = c.repo_at
where (r.did = ? or c.subject_did = ?)
···
var nullableDescription sql.NullString
var nullableSource sql.NullString
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
var nullableSource sql.NullString
row := e.QueryRow(
-
`select did, name, knot, rkey, description, created, 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, &nullableSource)
if err != nil {
return nil, err
}
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.org/core/api/tangled"
"tangled.org/core/appview/models"
)
+
type Repo struct {
+
Id int64
+
Did string
+
Name string
+
Knot string
+
Rkey string
+
Created time.Time
+
Description string
+
Spindle string
+
+
// optionally, populate this when querying for reverse mappings
+
RepoStats *models.RepoStats
+
+
// optional
+
Source string
+
}
+
+
func (r Repo) RepoAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
+
}
+
+
func (r Repo) DidSlashRepo() string {
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
+
return p
+
}
+
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
repoMap := make(map[syntax.ATURI]*models.Repo)
···
repoQuery := fmt.Sprintf(
`select
+
id,
did,
name,
knot,
···
var description, source, spindle sql.NullString
err := rows.Scan(
+
&repo.Id,
&repo.Did,
&repo.Name,
&repo.Knot,
···
languageQuery := fmt.Sprintf(
`
+
select repo_at, language
+
from (
+
select
+
repo_at,
+
language,
+
row_number() over (
+
partition by repo_at
+
order by bytes desc
+
) as rn
+
from repo_languages
+
where repo_at in (%s)
and is_default_ref = 1
+
)
+
where rn = 1
`,
inClause,
)
···
var repo models.Repo
var nullableDescription sql.NullString
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
var createdAt string
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
return &repo, nil
}
+
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
+
_, err := tx.Exec(
`insert into repos
(did, name, knot, rkey, at_uri, description, source)
values (?, ?, ?, ?, ?, ?, ?)`,
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
)
+
if err != nil {
+
return fmt.Errorf("failed to insert repo: %w", err)
+
}
+
+
for _, dl := range repo.Labels {
+
if err := SubscribeLabel(tx, &models.RepoLabel{
+
RepoAt: repo.RepoAt(),
+
LabelAt: syntax.ATURI(dl),
+
}); err != nil {
+
return fmt.Errorf("failed to subscribe to label: %w", err)
+
}
+
}
+
+
return nil
}
func RemoveRepo(e Execer, did, name string) error {
···
var repos []models.Repo
rows, err := e.Query(
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
from repos r
left join collaborators c on r.at_uri = c.repo_at
where (r.did = ? or c.subject_did = ?)
···
var nullableDescription sql.NullString
var nullableSource sql.NullString
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
var nullableSource sql.NullString
row := e.QueryRow(
+
`select id, did, name, knot, rkey, description, created, source
from repos
where did = ? and name = ? and source is not null and source != ''`,
did, name,
)
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
+11
appview/db/star.go
···
"errors"
"fmt"
"log"
"strings"
"time"
···
for _, s := range starMap {
stars = append(stars, s...)
}
return stars, nil
}
···
"errors"
"fmt"
"log"
+
"slices"
"strings"
"time"
···
for _, s := range starMap {
stars = append(stars, s...)
}
+
+
slices.SortFunc(stars, func(a, b models.Star) int {
+
if a.Created.After(b.Created) {
+
return -1
+
}
+
if b.Created.After(a.Created) {
+
return 1
+
}
+
return 0
+
})
return stars, nil
}
+80
appview/ingester.go
···
"encoding/json"
"fmt"
"log/slog"
"time"
···
err = i.ingestIssueComment(e)
case tangled.LabelDefinitionNSID:
err = i.ingestLabelDefinition(e)
}
l = i.Logger.With("nsid", e.Commit.Collection)
}
···
return nil
}
···
"encoding/json"
"fmt"
"log/slog"
+
"maps"
+
"slices"
"time"
···
err = i.ingestIssueComment(e)
case tangled.LabelDefinitionNSID:
err = i.ingestLabelDefinition(e)
+
case tangled.LabelOpNSID:
+
err = i.ingestLabelOp(e)
}
l = i.Logger.With("nsid", e.Commit.Collection)
}
···
return nil
}
+
+
func (i *Ingester) ingestLabelOp(e *jmodels.Event) error {
+
did := e.Did
+
rkey := e.Commit.RKey
+
+
var err error
+
+
l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
+
l.Info("ingesting record")
+
+
ddb, ok := i.Db.Execer.(*db.DB)
+
if !ok {
+
return fmt.Errorf("failed to index label op, invalid db cast")
+
}
+
+
switch e.Commit.Operation {
+
case jmodels.CommitOperationCreate:
+
raw := json.RawMessage(e.Commit.Record)
+
record := tangled.LabelOp{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
return fmt.Errorf("invalid record: %w", err)
+
}
+
+
subject := syntax.ATURI(record.Subject)
+
collection := subject.Collection()
+
+
var repo *models.Repo
+
switch collection {
+
case tangled.RepoIssueNSID:
+
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
+
if err != nil || len(i) != 1 {
+
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
+
}
+
repo = i[0].Repo
+
default:
+
return fmt.Errorf("unsupport label subject: %s", collection)
+
}
+
+
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
+
if err != nil {
+
return fmt.Errorf("failed to build label application ctx: %w", err)
+
}
+
+
ops := models.LabelOpsFromRecord(did, rkey, record)
+
+
for _, o := range ops {
+
def, ok := actx.Defs[o.OperandKey]
+
if !ok {
+
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
+
}
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
+
return fmt.Errorf("failed to validate labelop: %w", err)
+
}
+
}
+
+
tx, err := ddb.Begin()
+
if err != nil {
+
return err
+
}
+
defer tx.Rollback()
+
+
for _, o := range ops {
+
_, err = db.AddLabelOp(tx, &o)
+
if err != nil {
+
return fmt.Errorf("failed to add labelop: %w", err)
+
}
+
}
+
+
if err = tx.Commit(); err != nil {
+
return err
+
}
+
}
+
+
return nil
+
}
+13 -1
appview/issues/issues.go
···
return
}
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
} else {
···
// reset atUri to make rollback a no-op
atUri = ""
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
}
···
return
}
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
if err != nil {
log.Println("failed to fetch labels", err)
rp.pages.Error503(w)
···
return
}
+
// notify about the issue closure
+
rp.notifier.NewIssueClosed(r.Context(), issue)
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
} else {
···
// reset atUri to make rollback a no-op
atUri = ""
+
+
// notify about the new comment
+
comment.Id = commentId
+
rp.notifier.NewIssueComment(r.Context(), &comment)
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
}
···
return
}
+
labelDefs, err := db.GetLabelDefinitions(
+
rp.db,
+
db.FilterIn("at_uri", f.Repo.Labels),
+
db.FilterContains("scope", tangled.RepoIssueNSID),
+
)
if err != nil {
log.Println("failed to fetch labels", err)
rp.pages.Error503(w)
+14 -7
appview/labels/labels.go
···
"tangled.org/core/appview/validator"
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/log"
"tangled.org/core/tid"
)
···
db *db.DB
logger *slog.Logger
validator *validator.Validator
}
func New(
···
pages *pages.Pages,
db *db.DB,
validator *validator.Validator,
) *Labels {
logger := log.New("labels")
···
db: db,
logger: logger,
validator: validator,
}
}
···
repoAt := r.Form.Get("repo")
subjectUri := r.Form.Get("subject")
// find all the labels that this repo subscribes to
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
if err != nil {
···
return
}
-
l.logger.Info("actx", "labels", labelAts)
-
l.logger.Info("actx", "defs", actx.Defs)
-
// calculate the start state by applying already known labels
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
if err != nil {
···
}
}
-
// reduce the opset
-
labelOps = models.ReduceLabelOps(labelOps)
-
for i := range labelOps {
def := actx.Defs[labelOps[i].OperandKey]
-
if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil {
fail(fmt.Sprintf("Invalid form data: %s", err), err)
return
}
}
// next, apply all ops introduced in this request and filter out ones that are no-ops
validLabelOps := labelOps[:0]
···
"tangled.org/core/appview/validator"
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/log"
+
"tangled.org/core/rbac"
"tangled.org/core/tid"
)
···
db *db.DB
logger *slog.Logger
validator *validator.Validator
+
enforcer *rbac.Enforcer
}
func New(
···
pages *pages.Pages,
db *db.DB,
validator *validator.Validator,
+
enforcer *rbac.Enforcer,
) *Labels {
logger := log.New("labels")
···
db: db,
logger: logger,
validator: validator,
+
enforcer: enforcer,
}
}
···
repoAt := r.Form.Get("repo")
subjectUri := r.Form.Get("subject")
+
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
+
if err != nil {
+
fail("Failed to get repository.", err)
+
return
+
}
+
// find all the labels that this repo subscribes to
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
if err != nil {
···
return
}
// calculate the start state by applying already known labels
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
if err != nil {
···
}
}
for i := range labelOps {
def := actx.Defs[labelOps[i].OperandKey]
+
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
fail(fmt.Sprintf("Invalid form data: %s", err), err)
return
}
}
+
+
// reduce the opset
+
labelOps = models.ReduceLabelOps(labelOps)
// next, apply all ops introduced in this request and filter out ones that are no-ops
validLabelOps := labelOps[:0]
+9
appview/middleware/middleware.go
···
type middlewareFunc func(http.Handler) http.Handler
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
type middlewareFunc func(http.Handler) http.Handler
+
func (mw *Middleware) TryRefreshSession() middlewareFunc {
+
return func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
_, _, _ = mw.oauth.GetSession(r)
+
next.ServeHTTP(w, r)
+
})
+
}
+
}
+
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+72 -4
appview/models/label.go
···
package models
import (
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"slices"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/api/tangled"
"tangled.org/core/consts"
)
type ConcreteType string
···
}
var ops []LabelOp
-
for _, o := range record.Add {
if o != nil {
op := mkOp(o)
-
op.Operation = LabelOperationAdd
ops = append(ops, op)
}
}
-
for _, o := range record.Delete {
if o != nil {
op := mkOp(o)
-
op.Operation = LabelOperationDel
ops = append(ops, op)
}
}
···
return defs
}
···
package models
import (
+
"context"
"crypto/sha1"
"encoding/hex"
+
"encoding/json"
"errors"
"fmt"
"slices"
"time"
+
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
"tangled.org/core/api/tangled"
"tangled.org/core/consts"
+
"tangled.org/core/idresolver"
)
type ConcreteType string
···
}
var ops []LabelOp
+
// deletes first, then additions
+
for _, o := range record.Delete {
if o != nil {
op := mkOp(o)
+
op.Operation = LabelOperationDel
ops = append(ops, op)
}
}
+
for _, o := range record.Add {
if o != nil {
op := mkOp(o)
+
op.Operation = LabelOperationAdd
ops = append(ops, op)
}
}
···
return defs
}
+
+
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
+
}
+
pdsEndpoint := resolved.PDSEndpoint()
+
if pdsEndpoint == "" {
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
+
}
+
client := &xrpc.Client{
+
Host: pdsEndpoint,
+
}
+
+
var labelDefs []LabelDefinition
+
+
for _, dl := range DefaultLabelDefs() {
+
atUri := syntax.ATURI(dl)
+
parsedUri, err := syntax.ParseATURI(string(atUri))
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
+
}
+
record, err := atproto.RepoGetRecord(
+
context.Background(),
+
client,
+
"",
+
parsedUri.Collection().String(),
+
parsedUri.Authority().String(),
+
parsedUri.RecordKey().String(),
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
+
}
+
+
if record != nil {
+
bytes, err := record.Value.MarshalJSON()
+
if err != nil {
+
return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err)
+
}
+
+
raw := json.RawMessage(bytes)
+
labelRecord := tangled.LabelDefinition{}
+
err = json.Unmarshal(raw, &labelRecord)
+
if err != nil {
+
return nil, fmt.Errorf("invalid record for %s: %w", atUri, err)
+
}
+
+
labelDef, err := LabelDefinitionFromRecord(
+
parsedUri.Authority().String(),
+
parsedUri.RecordKey().String(),
+
labelRecord,
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err)
+
}
+
+
labelDefs = append(labelDefs, *labelDef)
+
}
+
}
+
+
return labelDefs, nil
+
}
+82
appview/models/notifications.go
···
···
+
package models
+
+
import (
+
"time"
+
)
+
+
type NotificationType string
+
+
const (
+
NotificationTypeRepoStarred NotificationType = "repo_starred"
+
NotificationTypeIssueCreated NotificationType = "issue_created"
+
NotificationTypeIssueCommented NotificationType = "issue_commented"
+
NotificationTypePullCreated NotificationType = "pull_created"
+
NotificationTypePullCommented NotificationType = "pull_commented"
+
NotificationTypeFollowed NotificationType = "followed"
+
NotificationTypePullMerged NotificationType = "pull_merged"
+
NotificationTypeIssueClosed NotificationType = "issue_closed"
+
NotificationTypePullClosed NotificationType = "pull_closed"
+
)
+
+
type Notification struct {
+
ID int64
+
RecipientDid string
+
ActorDid string
+
Type NotificationType
+
EntityType string
+
EntityId string
+
Read bool
+
Created time.Time
+
+
// foreign key references
+
RepoId *int64
+
IssueId *int64
+
PullId *int64
+
}
+
+
// lucide icon that represents this notification
+
func (n *Notification) Icon() string {
+
switch n.Type {
+
case NotificationTypeRepoStarred:
+
return "star"
+
case NotificationTypeIssueCreated:
+
return "circle-dot"
+
case NotificationTypeIssueCommented:
+
return "message-square"
+
case NotificationTypeIssueClosed:
+
return "ban"
+
case NotificationTypePullCreated:
+
return "git-pull-request-create"
+
case NotificationTypePullCommented:
+
return "message-square"
+
case NotificationTypePullMerged:
+
return "git-merge"
+
case NotificationTypePullClosed:
+
return "git-pull-request-closed"
+
case NotificationTypeFollowed:
+
return "user-plus"
+
default:
+
return ""
+
}
+
}
+
+
type NotificationWithEntity struct {
+
*Notification
+
Repo *Repo
+
Issue *Issue
+
Pull *Pull
+
}
+
+
type NotificationPreferences struct {
+
ID int64
+
UserDid string
+
RepoStarred bool
+
IssueCreated bool
+
IssueCommented bool
+
PullCreated bool
+
PullCommented bool
+
Followed bool
+
PullMerged bool
+
IssueClosed bool
+
EmailNotifications bool
+
}
+51 -4
appview/models/pull.go
···
// stacking
StackId string // nullable string
ChangeId string // nullable string
ParentChangeId string // nullable string
// meta
···
PullSource *PullSource
// optionally, populate this when querying for reverse mappings
-
Repo *Repo
}
func (p Pull) AsRecord() tangled.RepoPull {
···
},
Patch: p.LatestPatch(),
Source: source,
}
return record
}
···
type PullSubmission struct {
// ids
-
ID int
-
PullId int
// at ids
-
RepoAt syntax.ATURI
// content
RoundNumber int
···
return p.StackId != ""
}
func (s PullSubmission) IsFormatPatch() bool {
return patchutil.IsFormatPatch(s.Patch)
}
···
}
return patches
}
type Stack []*Pull
···
// stacking
StackId string // nullable string
ChangeId string // nullable string
+
ParentAt *syntax.ATURI
ParentChangeId string // nullable string
// meta
···
PullSource *PullSource
// optionally, populate this when querying for reverse mappings
+
Labels LabelState
+
Repo *Repo
}
func (p Pull) AsRecord() tangled.RepoPull {
···
},
Patch: p.LatestPatch(),
Source: source,
+
StackInfo: &tangled.RepoPull_StackInfo{
+
ChangeId: p.ChangeId,
+
Parent: (*string)(p.ParentAt),
+
},
}
return record
}
···
type PullSubmission struct {
// ids
+
ID int
// at ids
+
PullAt syntax.ATURI
// content
RoundNumber int
···
return p.StackId != ""
}
+
func (p *Pull) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(p.OwnerDid)
+
+
for _, s := range p.Submissions {
+
for _, sp := range s.Participants() {
+
addParticipant(sp)
+
}
+
}
+
+
return participants
+
}
+
func (s PullSubmission) IsFormatPatch() bool {
return patchutil.IsFormatPatch(s.Patch)
}
···
}
return patches
+
}
+
+
func (s *PullSubmission) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(s.PullAt.Authority().String())
+
+
for _, c := range s.Comments {
+
addParticipant(c.OwnerDid)
+
}
+
+
return participants
}
type Stack []*Pull
+1
appview/models/repo.go
···
)
type Repo struct {
Did string
Name string
Knot string
···
)
type Repo struct {
+
Id int64
Did string
Name string
Knot string
+168
appview/notifications/notifications.go
···
···
+
package notifications
+
+
import (
+
"fmt"
+
"log"
+
"net/http"
+
"strconv"
+
+
"github.com/go-chi/chi/v5"
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/middleware"
+
"tangled.org/core/appview/oauth"
+
"tangled.org/core/appview/pages"
+
"tangled.org/core/appview/pagination"
+
)
+
+
type Notifications struct {
+
db *db.DB
+
oauth *oauth.OAuth
+
pages *pages.Pages
+
}
+
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
+
return &Notifications{
+
db: database,
+
oauth: oauthHandler,
+
pages: pagesHandler,
+
}
+
}
+
+
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
+
r := chi.NewRouter()
+
+
r.Use(middleware.AuthMiddleware(n.oauth))
+
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
+
+
r.Get("/count", n.getUnreadCount)
+
r.Post("/{id}/read", n.markRead)
+
r.Post("/read-all", n.markAllRead)
+
r.Delete("/{id}", n.deleteNotification)
+
+
return r
+
}
+
+
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
+
userDid := n.oauth.GetDid(r)
+
+
page, ok := r.Context().Value("page").(pagination.Page)
+
if !ok {
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
+
}
+
+
total, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", userDid),
+
)
+
if err != nil {
+
log.Println("failed to get total notifications:", err)
+
n.pages.Error500(w)
+
return
+
}
+
+
notifications, err := db.GetNotificationsWithEntities(
+
n.db,
+
page,
+
db.FilterEq("recipient_did", userDid),
+
)
+
if err != nil {
+
log.Println("failed to get notifications:", err)
+
n.pages.Error500(w)
+
return
+
}
+
+
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
+
if err != nil {
+
log.Println("failed to mark notifications as read:", err)
+
}
+
+
unreadCount := 0
+
+
user := n.oauth.GetUser(r)
+
if user == nil {
+
http.Error(w, "Failed to get user", http.StatusInternalServerError)
+
return
+
}
+
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
+
LoggedInUser: user,
+
Notifications: notifications,
+
UnreadCount: unreadCount,
+
Page: page,
+
Total: total,
+
}))
+
}
+
+
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
+
user := n.oauth.GetUser(r)
+
count, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", user.Did),
+
db.FilterEq("read", 0),
+
)
+
if err != nil {
+
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
+
return
+
}
+
+
params := pages.NotificationCountParams{
+
Count: count,
+
}
+
err = n.pages.NotificationCount(w, params)
+
if err != nil {
+
http.Error(w, "Failed to render count", http.StatusInternalServerError)
+
return
+
}
+
}
+
+
func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) {
+
userDid := n.oauth.GetDid(r)
+
+
idStr := chi.URLParam(r, "id")
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
+
if err != nil {
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
+
return
+
}
+
+
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
+
if err != nil {
+
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusNoContent)
+
}
+
+
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
+
userDid := n.oauth.GetDid(r)
+
+
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
+
if err != nil {
+
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
+
return
+
}
+
+
http.Redirect(w, r, "/notifications", http.StatusSeeOther)
+
}
+
+
func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) {
+
userDid := n.oauth.GetDid(r)
+
+
idStr := chi.URLParam(r, "id")
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
+
if err != nil {
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
+
return
+
}
+
+
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
+
if err != nil {
+
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+429
appview/notify/db/db.go
···
···
+
package db
+
+
import (
+
"context"
+
"log"
+
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/notify"
+
"tangled.org/core/idresolver"
+
)
+
+
type databaseNotifier struct {
+
db *db.DB
+
res *idresolver.Resolver
+
}
+
+
func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier {
+
return &databaseNotifier{
+
db: database,
+
res: resolver,
+
}
+
}
+
+
var _ notify.Notifier = &databaseNotifier{}
+
+
func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+
// no-op for now
+
}
+
+
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
+
var err error
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
+
if err != nil {
+
log.Printf("NewStar: failed to get repos: %v", err)
+
return
+
}
+
+
// don't notify yourself
+
if repo.Did == star.StarredByDid {
+
return
+
}
+
+
// check if user wants these notifications
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
+
if err != nil {
+
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
+
return
+
}
+
if !prefs.RepoStarred {
+
return
+
}
+
+
notification := &models.Notification{
+
RecipientDid: repo.Did,
+
ActorDid: star.StarredByDid,
+
Type: models.NotificationTypeRepoStarred,
+
EntityType: "repo",
+
EntityId: string(star.RepoAt),
+
RepoId: &repo.Id,
+
}
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewStar: failed to create notification: %v", err)
+
return
+
}
+
}
+
+
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
+
// no-op
+
}
+
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
+
if err != nil {
+
log.Printf("NewIssue: failed to get repos: %v", err)
+
return
+
}
+
+
if repo.Did == issue.Did {
+
return
+
}
+
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
+
if err != nil {
+
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
+
return
+
}
+
if !prefs.IssueCreated {
+
return
+
}
+
+
notification := &models.Notification{
+
RecipientDid: repo.Did,
+
ActorDid: issue.Did,
+
Type: models.NotificationTypeIssueCreated,
+
EntityType: "issue",
+
EntityId: string(issue.AtUri()),
+
RepoId: &repo.Id,
+
IssueId: &issue.Id,
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewIssue: failed to create notification: %v", err)
+
return
+
}
+
}
+
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
+
if err != nil {
+
log.Printf("NewIssueComment: failed to get issues: %v", err)
+
return
+
}
+
if len(issues) == 0 {
+
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
+
return
+
}
+
issue := issues[0]
+
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
+
if err != nil {
+
log.Printf("NewIssueComment: failed to get repos: %v", err)
+
return
+
}
+
+
recipients := make(map[string]bool)
+
+
// notify issue author (if not the commenter)
+
if issue.Did != comment.Did {
+
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
+
if err == nil && prefs.IssueCommented {
+
recipients[issue.Did] = true
+
} else if err != nil {
+
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
+
}
+
}
+
+
// notify repo owner (if not the commenter and not already added)
+
if repo.Did != comment.Did && repo.Did != issue.Did {
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
+
if err == nil && prefs.IssueCommented {
+
recipients[repo.Did] = true
+
} else if err != nil {
+
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
+
}
+
}
+
+
// create notifications for all recipients
+
for recipientDid := range recipients {
+
notification := &models.Notification{
+
RecipientDid: recipientDid,
+
ActorDid: comment.Did,
+
Type: models.NotificationTypeIssueCommented,
+
EntityType: "issue",
+
EntityId: string(issue.AtUri()),
+
RepoId: &repo.Id,
+
IssueId: &issue.Id,
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
+
}
+
}
+
}
+
+
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
+
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
+
if err != nil {
+
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
+
return
+
}
+
if !prefs.Followed {
+
return
+
}
+
+
notification := &models.Notification{
+
RecipientDid: follow.SubjectDid,
+
ActorDid: follow.UserDid,
+
Type: models.NotificationTypeFollowed,
+
EntityType: "follow",
+
EntityId: follow.UserDid,
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewFollow: failed to create notification: %v", err)
+
return
+
}
+
}
+
+
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
+
// no-op
+
}
+
+
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
+
if err != nil {
+
log.Printf("NewPull: failed to get repos: %v", err)
+
return
+
}
+
+
if repo.Did == pull.OwnerDid {
+
return
+
}
+
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
+
if err != nil {
+
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
+
return
+
}
+
if !prefs.PullCreated {
+
return
+
}
+
+
notification := &models.Notification{
+
RecipientDid: repo.Did,
+
ActorDid: pull.OwnerDid,
+
Type: models.NotificationTypePullCreated,
+
EntityType: "pull",
+
EntityId: string(pull.RepoAt),
+
RepoId: &repo.Id,
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewPull: failed to create notification: %v", err)
+
return
+
}
+
}
+
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
+
pulls, err := db.GetPulls(n.db,
+
db.FilterEq("repo_at", comment.RepoAt),
+
db.FilterEq("pull_id", comment.PullId))
+
if err != nil {
+
log.Printf("NewPullComment: failed to get pulls: %v", err)
+
return
+
}
+
if len(pulls) == 0 {
+
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
+
return
+
}
+
pull := pulls[0]
+
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
+
if err != nil {
+
log.Printf("NewPullComment: failed to get repos: %v", err)
+
return
+
}
+
+
recipients := make(map[string]bool)
+
+
// notify pull request author (if not the commenter)
+
if pull.OwnerDid != comment.OwnerDid {
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
+
if err == nil && prefs.PullCommented {
+
recipients[pull.OwnerDid] = true
+
} else if err != nil {
+
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
+
}
+
}
+
+
// notify repo owner (if not the commenter and not already added)
+
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
+
if err == nil && prefs.PullCommented {
+
recipients[repo.Did] = true
+
} else if err != nil {
+
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
+
}
+
}
+
+
for recipientDid := range recipients {
+
notification := &models.Notification{
+
RecipientDid: recipientDid,
+
ActorDid: comment.OwnerDid,
+
Type: models.NotificationTypePullCommented,
+
EntityType: "pull",
+
EntityId: comment.RepoAt,
+
RepoId: &repo.Id,
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
+
}
+
}
+
}
+
+
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
+
// no-op
+
}
+
+
func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) {
+
// no-op
+
}
+
+
func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) {
+
// no-op
+
}
+
+
func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) {
+
// no-op
+
}
+
+
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
+
// Get repo details
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
+
if err != nil {
+
log.Printf("NewIssueClosed: failed to get repos: %v", err)
+
return
+
}
+
+
// Don't notify yourself
+
if repo.Did == issue.Did {
+
return
+
}
+
+
// Check if user wants these notifications
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
+
if err != nil {
+
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
+
return
+
}
+
if !prefs.IssueClosed {
+
return
+
}
+
+
notification := &models.Notification{
+
RecipientDid: repo.Did,
+
ActorDid: issue.Did,
+
Type: models.NotificationTypeIssueClosed,
+
EntityType: "issue",
+
EntityId: string(issue.AtUri()),
+
RepoId: &repo.Id,
+
IssueId: &issue.Id,
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewIssueClosed: failed to create notification: %v", err)
+
return
+
}
+
}
+
+
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
// Get repo details
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
+
if err != nil {
+
log.Printf("NewPullMerged: failed to get repos: %v", err)
+
return
+
}
+
+
// Don't notify yourself
+
if repo.Did == pull.OwnerDid {
+
return
+
}
+
+
// Check if user wants these notifications
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
+
if err != nil {
+
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
+
return
+
}
+
if !prefs.PullMerged {
+
return
+
}
+
+
notification := &models.Notification{
+
RecipientDid: pull.OwnerDid,
+
ActorDid: repo.Did,
+
Type: models.NotificationTypePullMerged,
+
EntityType: "pull",
+
EntityId: string(pull.RepoAt),
+
RepoId: &repo.Id,
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewPullMerged: failed to create notification: %v", err)
+
return
+
}
+
}
+
+
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
// Get repo details
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
+
if err != nil {
+
log.Printf("NewPullClosed: failed to get repos: %v", err)
+
return
+
}
+
+
// Don't notify yourself
+
if repo.Did == pull.OwnerDid {
+
return
+
}
+
+
// Check if user wants these notifications - reuse pull_merged preference for now
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
+
if err != nil {
+
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
+
return
+
}
+
if !prefs.PullMerged {
+
return
+
}
+
+
notification := &models.Notification{
+
RecipientDid: pull.OwnerDid,
+
ActorDid: repo.Did,
+
Type: models.NotificationTypePullClosed,
+
EntityType: "pull",
+
EntityId: string(pull.RepoAt),
+
RepoId: &repo.Id,
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
+
}
+
+
err = n.db.CreateNotification(ctx, notification)
+
if err != nil {
+
log.Printf("NewPullClosed: failed to create notification: %v", err)
+
return
+
}
+
}
+23
appview/notify/merged_notifier.go
···
notifier.NewIssue(ctx, issue)
}
}
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
for _, notifier := range m.notifiers {
···
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
for _, notifier := range m.notifiers {
notifier.NewPullComment(ctx, comment)
}
}
···
notifier.NewIssue(ctx, issue)
}
}
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
for _, notifier := range m.notifiers {
+
notifier.NewIssueComment(ctx, comment)
+
}
+
}
+
+
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
+
for _, notifier := range m.notifiers {
+
notifier.NewIssueClosed(ctx, issue)
+
}
+
}
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
for _, notifier := range m.notifiers {
···
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
for _, notifier := range m.notifiers {
notifier.NewPullComment(ctx, comment)
+
}
+
}
+
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
for _, notifier := range m.notifiers {
+
notifier.NewPullMerged(ctx, pull)
+
}
+
}
+
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
for _, notifier := range m.notifiers {
+
notifier.NewPullClosed(ctx, pull)
}
}
+9 -1
appview/notify/notifier.go
···
DeleteStar(ctx context.Context, star *models.Star)
NewIssue(ctx context.Context, issue *models.Issue)
NewFollow(ctx context.Context, follow *models.Follow)
DeleteFollow(ctx context.Context, follow *models.Follow)
NewPull(ctx context.Context, pull *models.Pull)
NewPullComment(ctx context.Context, comment *models.PullComment)
UpdateProfile(ctx context.Context, profile *models.Profile)
···
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
···
DeleteStar(ctx context.Context, star *models.Star)
NewIssue(ctx context.Context, issue *models.Issue)
+
NewIssueComment(ctx context.Context, comment *models.IssueComment)
+
NewIssueClosed(ctx context.Context, issue *models.Issue)
NewFollow(ctx context.Context, follow *models.Follow)
DeleteFollow(ctx context.Context, follow *models.Follow)
NewPull(ctx context.Context, pull *models.Pull)
NewPullComment(ctx context.Context, comment *models.PullComment)
+
NewPullMerged(ctx context.Context, pull *models.Pull)
+
NewPullClosed(ctx context.Context, pull *models.Pull)
UpdateProfile(ctx context.Context, profile *models.Profile)
···
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
+
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+219
appview/notify/posthog/notifier.go
···
···
+
package posthog
+
+
import (
+
"context"
+
"log"
+
+
"github.com/posthog/posthog-go"
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/notify"
+
)
+
+
type posthogNotifier struct {
+
client posthog.Client
+
notify.BaseNotifier
+
}
+
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
+
return &posthogNotifier{
+
client,
+
notify.BaseNotifier{},
+
}
+
}
+
+
var _ notify.Notifier = &posthogNotifier{}
+
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: repo.Did,
+
Event: "new_repo",
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: star.StarredByDid,
+
Event: "star",
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: star.StarredByDid,
+
Event: "unstar",
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: issue.Did,
+
Event: "new_issue",
+
Properties: posthog.Properties{
+
"repo_at": issue.RepoAt.String(),
+
"issue_id": issue.IssueId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "new_pull",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: comment.OwnerDid,
+
Event: "new_pull_comment",
+
Properties: posthog.Properties{
+
"repo_at": comment.RepoAt,
+
"pull_id": comment.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_closed",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: follow.UserDid,
+
Event: "follow",
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: follow.UserDid,
+
Event: "unfollow",
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: profile.Did,
+
Event: "edit_profile",
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: did,
+
Event: "delete_string",
+
Properties: posthog.Properties{"rkey": rkey},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: string.Did.String(),
+
Event: "edit_string",
+
Properties: posthog.Properties{"rkey": string.Rkey},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: string.Did.String(),
+
Event: "new_string",
+
Properties: posthog.Properties{"rkey": string.Rkey},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: comment.Did,
+
Event: "new_issue_comment",
+
Properties: posthog.Properties{
+
"issue_at": comment.IssueAt,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: issue.Did,
+
Event: "issue_closed",
+
Properties: posthog.Properties{
+
"repo_at": issue.RepoAt.String(),
+
"issue_id": issue.IssueId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_merged",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+156
appview/pages/legal/privacy.md
···
···
+
**Last updated:** September 26, 2025
+
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
+
collects, uses, and shares your personal information when you use our
+
platform and services (the "Service").
+
+
## 1. Information We Collect
+
+
### Account Information
+
+
When you create an account, we collect:
+
+
- Your chosen username
+
- Email address
+
- Profile information you choose to provide
+
- Authentication data
+
+
### Content and Activity
+
+
We store:
+
+
- Code repositories and associated metadata
+
- Issues, pull requests, and comments
+
- Activity logs and usage patterns
+
- Public keys for authentication
+
+
## 2. Data Location and Hosting
+
+
### EU Data Hosting
+
+
**All Tangled service data is hosted within the European Union.**
+
Specifically:
+
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
+
(*.tngl.sh) are located in Finland
+
- **Application Data:** All other service data is stored on EU-based
+
servers
+
- **Data Processing:** All data processing occurs within EU
+
jurisdiction
+
+
### External PDS Notice
+
+
**Important:** If your account is hosted on Bluesky's PDS or other
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
+
that data. The data protection, storage location, and privacy
+
practices for such accounts are governed by the respective PDS
+
provider's policies, not this Privacy Policy. We only control data
+
processing within our own services and infrastructure.
+
+
## 3. Third-Party Data Processors
+
+
We only share your data with the following third-party processors:
+
+
### Resend (Email Services)
+
+
- **Purpose:** Sending transactional emails (account verification,
+
notifications)
+
- **Data Shared:** Email address and necessary message content
+
+
### Cloudflare (Image Caching)
+
+
- **Purpose:** Caching and optimizing image delivery
+
- **Data Shared:** Public images and associated metadata for caching
+
purposes
+
+
### Posthog (Usage Metrics Tracking)
+
+
- **Purpose:** Tracking usage and platform metrics
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
+
information
+
+
## 4. How We Use Your Information
+
+
We use your information to:
+
+
- Provide and maintain the Service
+
- Process your transactions and requests
+
- Send you technical notices and support messages
+
- Improve and develop new features
+
- Ensure security and prevent fraud
+
- Comply with legal obligations
+
+
## 5. Data Sharing and Disclosure
+
+
We do not sell, trade, or rent your personal information. We may share
+
your information only in the following circumstances:
+
+
- With the third-party processors listed above
+
- When required by law or legal process
+
- To protect our rights, property, or safety, or that of our users
+
- In connection with a merger, acquisition, or sale of assets (with
+
appropriate protections)
+
+
## 6. Data Security
+
+
We implement appropriate technical and organizational measures to
+
protect your personal information against unauthorized access,
+
alteration, disclosure, or destruction. However, no method of
+
transmission over the Internet is 100% secure.
+
+
## 7. Data Retention
+
+
We retain your personal information for as long as necessary to provide
+
the Service and fulfill the purposes outlined in this Privacy Policy,
+
unless a longer retention period is required by law.
+
+
## 8. Your Rights
+
+
Under applicable data protection laws, you have the right to:
+
+
- Access your personal information
+
- Correct inaccurate information
+
- Request deletion of your information
+
- Object to processing of your information
+
- Data portability
+
- Withdraw consent (where applicable)
+
+
## 9. Cookies and Tracking
+
+
We use cookies and similar technologies to:
+
+
- Maintain your login session
+
- Remember your preferences
+
- Analyze usage patterns to improve the Service
+
+
You can control cookie settings through your browser preferences.
+
+
## 10. Children's Privacy
+
+
The Service is not intended for children under 16 years of age. We do
+
not knowingly collect personal information from children under 16. If
+
we become aware that we have collected such information, we will take
+
steps to delete it.
+
+
## 11. International Data Transfers
+
+
While all our primary data processing occurs within the EU, some of our
+
third-party processors may process data outside the EU. When this
+
occurs, we ensure appropriate safeguards are in place, such as Standard
+
Contractual Clauses or adequacy decisions.
+
+
## 12. Changes to This Privacy Policy
+
+
We may update this Privacy Policy from time to time. We will notify you
+
of any changes by posting the new Privacy Policy on this page and
+
updating the "Last updated" date.
+
+
## 13. Contact Information
+
+
If you have any questions about this Privacy Policy or wish to exercise
+
your rights, please contact us through our platform or via email.
+
+
---
+
+
This Privacy Policy complies with the EU General Data Protection
+
Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
···
···
+
**Last updated:** September 26, 2025
+
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
+
to and use of the Tangled platform and services (the "Service")
+
operated by us ("Tangled," "we," "us," or "our").
+
+
## 1. Acceptance of Terms
+
+
By accessing or using our Service, you agree to be bound by these Terms.
+
If you disagree with any part of these terms, then you may not access
+
the Service.
+
+
## 2. Account Registration
+
+
To use certain features of the Service, you must register for an
+
account. You agree to provide accurate, current, and complete
+
information during the registration process and to update such
+
information to keep it accurate, current, and complete.
+
+
## 3. Account Termination
+
+
> **Important Notice**
+
>
+
> **We reserve the right to terminate, suspend, or restrict access to
+
> your account at any time, for any reason, or for no reason at all, at
+
> our sole discretion.** This includes, but is not limited to,
+
> termination for violation of these Terms, inappropriate conduct, spam,
+
> abuse, or any other behavior we deem harmful to the Service or other
+
> users.
+
>
+
> Account termination may result in the loss of access to your
+
> repositories, data, and other content associated with your account. We
+
> are not obligated to provide advance notice of termination, though we
+
> may do so in our discretion.
+
+
## 4. Acceptable Use
+
+
You agree not to use the Service to:
+
+
- Violate any applicable laws or regulations
+
- Infringe upon the rights of others
+
- Upload, store, or share content that is illegal, harmful, threatening,
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
+
objectionable
+
- Engage in spam, phishing, or other deceptive practices
+
- Attempt to gain unauthorized access to the Service or other users'
+
accounts
+
- Interfere with or disrupt the Service or servers connected to the
+
Service
+
+
## 5. Content and Intellectual Property
+
+
You retain ownership of the content you upload to the Service. By
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
+
license to use, reproduce, modify, and distribute your content as
+
necessary to provide the Service.
+
+
## 6. Privacy
+
+
Your privacy is important to us. Please review our [Privacy
+
Policy](/privacy), which also governs your use of the Service.
+
+
## 7. Disclaimers
+
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
+
no warranties, expressed or implied, and hereby disclaim and negate all
+
other warranties including without limitation, implied warranties or
+
conditions of merchantability, fitness for a particular purpose, or
+
non-infringement of intellectual property or other violation of rights.
+
+
## 8. Limitation of Liability
+
+
In no event shall Tangled, nor its directors, employees, partners,
+
agents, suppliers, or affiliates, be liable for any indirect,
+
incidental, special, consequential, or punitive damages, including
+
without limitation, loss of profits, data, use, goodwill, or other
+
intangible losses, resulting from your use of the Service.
+
+
## 9. Indemnification
+
+
You agree to defend, indemnify, and hold harmless Tangled and its
+
affiliates, officers, directors, employees, and agents from and against
+
any and all claims, damages, obligations, losses, liabilities, costs,
+
or debt, and expenses (including attorney's fees).
+
+
## 10. Governing Law
+
+
These Terms shall be interpreted and governed by the laws of Finland,
+
without regard to its conflict of law provisions.
+
+
## 11. Changes to Terms
+
+
We reserve the right to modify or replace these Terms at any time. If a
+
revision is material, we will try to provide at least 30 days notice
+
prior to any new terms taking effect.
+
+
## 12. Contact Information
+
+
If you have any questions about these Terms of Service, please contact
+
us through our platform or via email.
+
+
---
+
+
These terms are effective as of the last updated date shown above and
+
will remain in effect except with respect to any changes in their
+
provisions in the future, which will be in effect immediately after
+
being posted on this page.
+15 -17
appview/pages/markup/format.go
···
package markup
-
import "strings"
type Format string
···
)
var FileTypes map[Format][]string = map[Format][]string{
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
}
-
// ReadmeFilenames contains the list of common README filenames to search for,
-
// in order of preference. Only includes well-supported formats.
-
var ReadmeFilenames = []string{
-
"README.md", "readme.md",
-
"README",
-
"readme",
-
"README.markdown",
-
"readme.markdown",
-
"README.txt",
-
"readme.txt",
}
func GetFormat(filename string) Format {
-
for format, extensions := range FileTypes {
-
for _, extension := range extensions {
-
if strings.HasSuffix(filename, extension) {
-
return format
-
}
}
}
// default format
···
package markup
+
import (
+
"regexp"
+
)
type Format string
···
)
var FileTypes map[Format][]string = map[Format][]string{
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
}
+
var FileTypePatterns = map[Format]*regexp.Regexp{
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
+
}
+
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
+
+
func IsReadmeFile(filename string) bool {
+
return ReadmePattern.MatchString(filename)
}
func GetFormat(filename string) Format {
+
for format, pattern := range FileTypePatterns {
+
if pattern.MatchString(filename) {
+
return format
}
}
// default format
+95 -25
appview/pages/pages.go
···
"github.com/go-git/go-git/v5/plumbing/object"
)
-
//go:embed templates/* static
var Files embed.FS
type Pages struct {
···
return p.executePlain("user/login", w, params)
}
-
func (p *Pages) Signup(w io.Writer) error {
-
return p.executePlain("user/signup", w, nil)
}
func (p *Pages) CompleteSignup(w io.Writer) error {
···
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
filename := "terms.md"
filePath := filepath.Join("legal", filename)
-
markdownBytes, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read %s: %w", filename, err)
}
···
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
filename := "privacy.md"
filePath := filepath.Join("legal", filename)
-
markdownBytes, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read %s: %w", filename, err)
}
···
return p.execute("legal/privacy", w, params)
}
type TimelineParams struct {
LoggedInUser *oauth.User
Timeline []models.TimelineEvent
···
return p.execute("user/settings/profile", w, params)
}
type UserKeysSettingsParams struct {
LoggedInUser *oauth.User
PubKeys []models.PublicKey
···
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
return p.execute("user/settings/emails", w, params)
}
type UpgradeBannerParams struct {
···
type FollowCard struct {
UserDid string
FollowStatus models.FollowStatus
FollowersCount int64
FollowingCount int64
···
}
type RepoTreeParams struct {
-
LoggedInUser *oauth.User
-
RepoInfo repoinfo.RepoInfo
-
Active string
-
BreadCrumbs [][]string
-
TreePath string
-
Readme string
-
ReadmeFileName string
-
HTMLReadme template.HTML
-
Raw bool
types.RepoTreeResponse
}
···
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
params.Active = "overview"
-
if params.ReadmeFileName != "" {
-
params.ReadmeFileName = filepath.Base(params.ReadmeFileName)
ext := filepath.Ext(params.ReadmeFileName)
switch ext {
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
}
type RepoGeneralSettingsParams struct {
-
LoggedInUser *oauth.User
-
RepoInfo repoinfo.RepoInfo
-
Labels []models.LabelDefinition
-
DefaultLabels []models.LabelDefinition
-
SubscribedLabels map[string]struct{}
-
Active string
-
Tabs []map[string]any
-
Tab string
-
Branches []types.Branch
}
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
FilteringBy models.PullState
Stacks map[string]models.Stack
Pipelines map[string]models.Pipeline
}
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
OrderedReactionKinds []models.ReactionKind
Reactions map[models.ReactionKind]int
UserReacted map[models.ReactionKind]bool
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
"github.com/go-git/go-git/v5/plumbing/object"
)
+
//go:embed templates/* static legal
var Files embed.FS
type Pages struct {
···
return p.executePlain("user/login", w, params)
}
+
type SignupParams struct {
+
CloudflareSiteKey string
+
}
+
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
+
return p.executePlain("user/signup", w, params)
}
func (p *Pages) CompleteSignup(w io.Writer) error {
···
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
filename := "terms.md"
filePath := filepath.Join("legal", filename)
+
+
file, err := p.embedFS.Open(filePath)
+
if err != nil {
+
return fmt.Errorf("failed to read %s: %w", filename, err)
+
}
+
defer file.Close()
+
+
markdownBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read %s: %w", filename, err)
}
···
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
filename := "privacy.md"
filePath := filepath.Join("legal", filename)
+
+
file, err := p.embedFS.Open(filePath)
+
if err != nil {
+
return fmt.Errorf("failed to read %s: %w", filename, err)
+
}
+
defer file.Close()
+
+
markdownBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read %s: %w", filename, err)
}
···
return p.execute("legal/privacy", w, params)
}
+
type BrandParams struct {
+
LoggedInUser *oauth.User
+
}
+
+
func (p *Pages) Brand(w io.Writer, params BrandParams) error {
+
return p.execute("brand/brand", w, params)
+
}
+
type TimelineParams struct {
LoggedInUser *oauth.User
Timeline []models.TimelineEvent
···
return p.execute("user/settings/profile", w, params)
}
+
type NotificationsParams struct {
+
LoggedInUser *oauth.User
+
Notifications []*models.NotificationWithEntity
+
UnreadCount int
+
Page pagination.Page
+
Total int64
+
}
+
+
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
+
return p.execute("notifications/list", w, params)
+
}
+
+
type NotificationItemParams struct {
+
Notification *models.Notification
+
}
+
+
func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
+
return p.executePlain("notifications/fragments/item", w, params)
+
}
+
+
type NotificationCountParams struct {
+
Count int64
+
}
+
+
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
+
return p.executePlain("notifications/fragments/count", w, params)
+
}
+
type UserKeysSettingsParams struct {
LoggedInUser *oauth.User
PubKeys []models.PublicKey
···
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
return p.execute("user/settings/emails", w, params)
+
}
+
+
type UserNotificationSettingsParams struct {
+
LoggedInUser *oauth.User
+
Preferences *models.NotificationPreferences
+
Tabs []map[string]any
+
Tab string
+
}
+
+
func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
+
return p.execute("user/settings/notifications", w, params)
}
type UpgradeBannerParams struct {
···
type FollowCard struct {
UserDid string
+
LoggedInUser *oauth.User
FollowStatus models.FollowStatus
FollowersCount int64
FollowingCount int64
···
}
type RepoTreeParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
BreadCrumbs [][]string
+
TreePath string
+
Raw bool
+
HTMLReadme template.HTML
types.RepoTreeResponse
}
···
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
params.Active = "overview"
+
p.rctx.RepoInfo = params.RepoInfo
+
p.rctx.RepoInfo.Ref = params.Ref
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
+
if params.ReadmeFileName != "" {
ext := filepath.Ext(params.ReadmeFileName)
switch ext {
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
}
type RepoGeneralSettingsParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Labels []models.LabelDefinition
+
DefaultLabels []models.LabelDefinition
+
SubscribedLabels map[string]struct{}
+
ShouldSubscribeAll bool
+
Active string
+
Tabs []map[string]any
+
Tab string
+
Branches []types.Branch
}
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
FilteringBy models.PullState
Stacks map[string]models.Stack
Pipelines map[string]models.Pipeline
+
LabelDefs map[string]*models.LabelDefinition
}
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
OrderedReactionKinds []models.ReactionKind
Reactions map[models.ReactionKind]int
UserReacted map[models.ReactionKind]bool
+
+
LabelDefs map[string]*models.LabelDefinition
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+224
appview/pages/templates/brand/brand.html
···
···
+
{{ define "title" }}brand{{ end }}
+
+
{{ define "content" }}
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Assets and guidelines for using Tangled's logo and brand elements.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="space-y-16">
+
+
<!-- Introduction Section -->
+
<section>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
+
follow the below guidelines when using Dolly and the logotype.
+
</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
+
</p>
+
</section>
+
+
<!-- Black Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Tangled logo - black version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
+
backgrounds and designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- White Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-black p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
+
alt="Tangled logo - white version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This version features white text and elements, ideal for dark backgrounds
+
and inverted designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- Mark Only Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Black mark on light background -->
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Dolly face - black version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- White mark on dark background -->
+
<div class="bg-black p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Dolly face - white version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
<strong class="font-semibold">Note</strong>: for situations where the background
+
is unknown, use the black version for ideal contrast in most environments.
+
</p>
+
</div>
+
</section>
+
+
<!-- Colored Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Red background -->
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel red background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
White logo mark on colored backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The white logo mark provides contrast on colored backgrounds.
+
Perfect for more fun design contexts.
+
</p>
+
</div>
+
</section>
+
+
<!-- Black Logo on Pastel Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Pink background -->
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel pink background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Dark logo mark on lighter, pastel backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The dark logo mark works beautifully on pastel backgrounds,
+
providing crisp contrast.
+
</p>
+
</div>
+
</section>
+
+
<!-- Recoloring Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Recolored Tangled logotype in gray/sand color"
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Custom coloring of the logotype is permitted.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
Recoloring the logotype is allowed as long as readability is maintained.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
+
</p>
+
</div>
+
</section>
+
+
<!-- Silhouette Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
+
alt="Dolly silhouette"
+
class="w-full max-w-32 mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
The silhouette can be used where a subtle brand presence is needed,
+
or as a background element. Works on any background color with proper contrast.
+
For example, we use this as the site's favicon.
+
</p>
+
</div>
+
</section>
+
+
</div>
+
</main>
+
</div>
+
{{ end }}
+4 -11
appview/pages/templates/errors/500.html
···
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
<div class="mb-6">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
</div>
</div>
···
500 &mdash; internal server error
</h1>
<p class="text-gray-600 dark:text-gray-300">
-
Something went wrong on our end. We've been notified and are working to fix the issue.
-
</p>
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
-
<div class="flex items-center gap-2">
-
{{ i "info" "w-4 h-4" }}
-
<span class="font-medium">we're on it!</span>
-
</div>
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
-
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
<a href="/" class="btn no-underline hover:no-underline gap-2">
-
{{ i "home" "w-4 h-4" }}
back to home
</a>
</div>
···
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
<div class="mb-6">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
</div>
</div>
···
500 &mdash; internal server error
</h1>
<p class="text-gray-600 dark:text-gray-300">
+
We encountered an error while processing your request. Please try again later.
+
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
<a href="/" class="btn no-underline hover:no-underline gap-2">
+
{{ i "arrow-left" "w-4 h-4" }}
back to home
</a>
</div>
+3
appview/pages/templates/layouts/base.html
···
<link rel="preconnect" href="https://avatar.tangled.sh" />
<link rel="preconnect" href="https://camo.tangled.sh" />
<!-- preload main font -->
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
···
<link rel="preconnect" href="https://avatar.tangled.sh" />
<link rel="preconnect" href="https://camo.tangled.sh" />
+
<!-- pwa manifest -->
+
<link rel="manifest" href="/pwa-manifest.json" />
+
<!-- preload main font -->
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
+1
appview/pages/templates/layouts/fragments/footer.html
···
<a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a>
<a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a>
<a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a>
</div>
<div class="flex flex-col gap-1">
···
<a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a>
<a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a>
<a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a>
+
<a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a>
</div>
<div class="flex flex-col gap-1">
+16 -6
appview/pages/templates/layouts/fragments/topbar.html
···
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
-
{{ template "fragments/logotypeSmall" }}
</a>
</div>
-
<div id="right-items" class="flex items-center gap-2">
{{ with .LoggedInUser }}
{{ block "newButton" . }} {{ end }}
{{ block "dropDown" . }} {{ end }}
{{ else }}
<a href="/login">login</a>
···
{{ define "newButton" }}
<details class="relative inline-block text-left nav-dropdown">
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
-
{{ i "plus" "w-4 h-4" }} new
</summary>
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
<a href="/repo/new" class="flex items-center gap-2">
···
{{ define "dropDown" }}
<details class="relative inline-block text-left nav-dropdown">
<summary
-
class="cursor-pointer list-none flex items-center"
>
{{ $user := didOrHandle .Did .Handle }}
-
{{ template "user/fragments/picHandle" $user }}
</summary>
<div
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
···
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
+
alpha
+
</span>
</a>
</div>
+
<div id="right-items" class="flex items-center gap-4">
{{ with .LoggedInUser }}
{{ block "newButton" . }} {{ end }}
+
{{ template "notifications/fragments/bell" }}
{{ block "dropDown" . }} {{ end }}
{{ else }}
<a href="/login">login</a>
···
{{ define "newButton" }}
<details class="relative inline-block text-left nav-dropdown">
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
</summary>
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
<a href="/repo/new" class="flex items-center gap-2">
···
{{ define "dropDown" }}
<details class="relative inline-block text-left nav-dropdown">
<summary
+
class="cursor-pointer list-none flex items-center gap-1"
>
{{ $user := didOrHandle .Did .Handle }}
+
<img
+
src="{{ tinyAvatar $user }}"
+
alt=""
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
+
/>
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
</summary>
<div
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+13 -6
appview/pages/templates/legal/privacy.html
···
{{ define "title" }}privacy policy{{ end }}
{{ define "content" }}
-
<div class="max-w-4xl mx-auto px-4 py-8">
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
-
<div class="prose prose-gray dark:prose-invert max-w-none">
-
{{ .Content }}
-
</div>
</div>
</div>
-
{{ end }}
···
{{ define "title" }}privacy policy{{ end }}
{{ define "content" }}
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Learn how we collect, use, and protect your personal information.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="prose prose-gray dark:prose-invert max-w-none">
+
{{ .Content }}
</div>
+
</main>
</div>
+
{{ end }}
+13 -6
appview/pages/templates/legal/terms.html
···
{{ define "title" }}terms of service{{ end }}
{{ define "content" }}
-
<div class="max-w-4xl mx-auto px-4 py-8">
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
-
<div class="prose prose-gray dark:prose-invert max-w-none">
-
{{ .Content }}
-
</div>
</div>
</div>
-
{{ end }}
···
{{ define "title" }}terms of service{{ end }}
{{ define "content" }}
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
A few things you should know.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="prose prose-gray dark:prose-invert max-w-none">
+
{{ .Content }}
</div>
+
</main>
</div>
+
{{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
···
···
+
{{define "notifications/fragments/bell"}}
+
<div class="relative"
+
hx-get="/notifications/count"
+
hx-target="#notification-count"
+
hx-trigger="load, every 30s">
+
<a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group">
+
{{ i "bell" "w-5 h-5" }}
+
<span id="notification-count"></span>
+
</a>
+
</div>
+
{{end}}
+7
appview/pages/templates/notifications/fragments/count.html
···
···
+
{{define "notifications/fragments/count"}}
+
{{if and .Count (gt .Count 0)}}
+
<span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center">
+
{{if gt .Count 99}}99+{{else}}{{.Count}}{{end}}
+
</span>
+
{{end}}
+
{{end}}
+81
appview/pages/templates/notifications/fragments/item.html
···
···
+
{{define "notifications/fragments/item"}}
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
+
<div
+
class="
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
+
flex gap-2 items-center
+
">
+
{{ template "notificationIcon" . }}
+
<div class="flex-1 w-full flex flex-col gap-1">
+
<span>{{ template "notificationHeader" . }}</span>
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
+
</div>
+
+
</div>
+
</a>
+
{{end}}
+
+
{{ define "notificationIcon" }}
+
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
+
{{ i .Icon "size-3 text-black dark:text-white" }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "notificationHeader" }}
+
{{ $actor := resolve .ActorDid }}
+
+
<span class="text-black dark:text-white w-fit">{{ $actor }}</span>
+
{{ if eq .Type "repo_starred" }}
+
starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span>
+
{{ else if eq .Type "issue_created" }}
+
opened an issue
+
{{ else if eq .Type "issue_commented" }}
+
commented on an issue
+
{{ else if eq .Type "issue_closed" }}
+
closed an issue
+
{{ else if eq .Type "pull_created" }}
+
created a pull request
+
{{ else if eq .Type "pull_commented" }}
+
commented on a pull request
+
{{ else if eq .Type "pull_merged" }}
+
merged a pull request
+
{{ else if eq .Type "pull_closed" }}
+
closed a pull request
+
{{ else if eq .Type "followed" }}
+
followed you
+
{{ else }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "notificationSummary" }}
+
{{ if eq .Type "repo_starred" }}
+
<!-- no summary -->
+
{{ else if .Issue }}
+
#{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
+
{{ else if .Pull }}
+
#{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
+
{{ else if eq .Type "followed" }}
+
<!-- no summary -->
+
{{ else }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "notificationUrl" }}
+
{{ $url := "" }}
+
{{ if eq .Type "repo_starred" }}
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
+
{{ else if .Issue }}
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
+
{{ else if .Pull }}
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
+
{{ else if eq .Type "followed" }}
+
{{$url = printf "/%s" (resolve .ActorDid)}}
+
{{ else }}
+
{{ end }}
+
+
{{ $url }}
+
{{ end }}
+65
appview/pages/templates/notifications/list.html
···
···
+
{{ define "title" }}notifications{{ end }}
+
+
{{ define "content" }}
+
<div class="px-6 py-4">
+
<div class="flex items-center justify-between">
+
<p class="text-xl font-bold dark:text-white">Notifications</p>
+
<a href="/settings/notifications" class="flex items-center gap-2">
+
{{ i "settings" "w-4 h-4" }}
+
preferences
+
</a>
+
</div>
+
</div>
+
+
{{if .Notifications}}
+
<div class="flex flex-col gap-2" id="notifications-list">
+
{{range .Notifications}}
+
{{template "notifications/fragments/item" .}}
+
{{end}}
+
</div>
+
+
{{else}}
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<div class="text-center py-12">
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
+
{{ i "bell-off" "w-16 h-16" }}
+
</div>
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
+
</div>
+
</div>
+
{{end}}
+
+
{{ template "pagination" . }}
+
{{ end }}
+
+
{{ define "pagination" }}
+
<div class="flex justify-end mt-4 gap-2">
+
{{ if gt .Page.Offset 0 }}
+
{{ $prev := .Page.Previous }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
>
+
{{ i "chevron-left" "w-4 h-4" }}
+
previous
+
</a>
+
{{ else }}
+
<div></div>
+
{{ end }}
+
+
{{ $next := .Page.Next }}
+
{{ if lt $next.Offset .Total }}
+
{{ $next := .Page.Next }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
>
+
next
+
{{ i "chevron-right" "w-4 h-4" }}
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+7
appview/pages/templates/repo/fork.html
···
</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" hx-indicator="#spinner">
<fieldset class="space-y-3">
<legend class="dark:text-white">Select a knot to fork into</legend>
<div class="space-y-2">
···
</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" hx-indicator="#spinner">
+
+
<fieldset class="space-y-3">
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
+
</fieldset>
+
<fieldset class="space-y-3">
<legend class="dark:text-white">Select a knot to fork into</legend>
<div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
···
{{ define "repo/fragments/cloneDropdown" }}
{{ $knot := .RepoInfo.Knot }}
{{ if eq $knot "knot1.tangled.sh" }}
-
{{ $knot = "tangled.sh" }}
{{ end }}
<details id="clone-dropdown" class="relative inline-block text-left group">
···
{{ define "repo/fragments/cloneDropdown" }}
{{ $knot := .RepoInfo.Knot }}
{{ if eq $knot "knot1.tangled.sh" }}
+
{{ $knot = "tangled.org" }}
{{ end }}
<details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
···
{{ define "repo/fragments/labelPanel" }}
-
<div id="label-panel" class="flex flex-col gap-6">
{{ template "basicLabels" . }}
{{ template "kvLabels" . }}
</div>
···
{{ define "repo/fragments/labelPanel" }}
+
<div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0">
{{ template "basicLabels" . }}
{{ template "kvLabels" . }}
</div>
+26
appview/pages/templates/repo/fragments/participants.html
···
···
+
{{ define "repo/fragments/participants" }}
+
{{ $all := . }}
+
{{ $ps := take $all 5 }}
+
<div class="px-6 md:px-0">
+
<div class="py-1 flex items-center text-sm">
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+
</div>
+
<div class="flex items-center -space-x-3 mt-2">
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
+
{{ range $i, $p := $ps }}
+
<img
+
src="{{ tinyAvatar . }}"
+
alt=""
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
+
/>
+
{{ end }}
+
+
{{ if gt (len $all) 5 }}
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
+
+{{ sub (len $all) 5 }}
+
</span>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
···
{{ define "repo/fragments/readme" }}
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
{{- if .ReadmeFileName -}}
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
</div>
{{- end -}}
<section
-
class="p-6 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
···
{{ define "repo/fragments/readme" }}
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
{{- if .ReadmeFileName -}}
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
</div>
{{- end -}}
<section
+
class="px-6 pb-6 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
+1 -27
appview/pages/templates/repo/issues/issue.html
···
"Defs" $.LabelDefs
"Subject" $.Issue.AtUri
"State" $.Issue.Labels) }}
-
{{ template "issueParticipants" . }}
</div>
</div>
{{ end }}
···
</div>
{{ end }}
-
{{ define "issueParticipants" }}
-
{{ $all := .Issue.Participants }}
-
{{ $ps := take $all 5 }}
-
<div>
-
<div class="py-1 flex items-center text-sm">
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
-
</div>
-
<div class="flex items-center -space-x-3 mt-2">
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
-
{{ range $i, $p := $ps }}
-
<img
-
src="{{ tinyAvatar . }}"
-
alt=""
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
-
/>
-
{{ end }}
-
-
{{ if gt (len $all) 5 }}
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
-
+{{ sub (len $all) 5 }}
-
</span>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
{{ define "repoAfter" }}
<div class="flex flex-col gap-4 mt-4">
···
"Defs" $.LabelDefs
"Subject" $.Issue.AtUri
"State" $.Issue.Labels) }}
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
</div>
</div>
{{ end }}
···
</div>
{{ end }}
{{ define "repoAfter" }}
<div class="flex flex-col gap-4 mt-4">
+163 -61
appview/pages/templates/repo/new.html
···
{{ define "title" }}new repo{{ end }}
{{ define "content" }}
-
<div class="p-6">
-
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
</div>
-
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
-
<div class="space-y-2">
-
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
-
<input
-
type="text"
-
id="name"
-
name="name"
-
required
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
/>
-
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
-
<label for="branch" class="dark:text-white">Default branch</label>
-
<input
-
type="text"
-
id="branch"
-
name="branch"
-
value="main"
-
required
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
/>
-
<label for="description" class="dark:text-white">Description</label>
-
<input
-
type="text"
-
id="description"
-
name="description"
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
/>
</div>
-
<fieldset class="space-y-3">
-
<legend class="dark:text-white">Select a knot</legend>
<div class="space-y-2">
-
<div class="flex flex-col">
-
{{ range .Knots }}
-
<div class="flex items-center">
-
<input
-
type="radio"
-
name="domain"
-
value="{{ . }}"
-
class="mr-2"
-
id="domain-{{ . }}"
-
/>
-
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
-
</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-create flex items-center gap-2">
-
{{ i "book-plus" "w-4 h-4" }}
-
create repo
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<div id="repo" class="error"></div>
</div>
-
</form>
-
</div>
{{ end }}
···
{{ define "title" }}new repo{{ end }}
{{ define "content" }}
+
<div class="grid grid-cols-12">
+
<div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4">
+
<h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Repositories contain a project's files and version history. All
+
repositories are publicly accessible.
+
</p>
+
</div>
+
{{ template "newRepoPanel" . }}
</div>
+
{{ end }}
+
{{ define "newRepoPanel" }}
+
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
{{ template "newRepoForm" . }}
+
</div>
+
{{ end }}
+
{{ define "newRepoForm" }}
+
<form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner">
+
{{ template "step-1" . }}
+
{{ template "step-2" . }}
+
+
<div class="mt-8 flex justify-end">
+
<button type="submit" class="btn-create flex items-center gap-2">
+
{{ i "book-plus" "w-4 h-4" }}
+
create repo
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
</div>
+
<div id="repo" class="error mt-2"></div>
+
</form>
+
{{ end }}
+
+
{{ define "step-1" }}
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
+
<div class="absolute -left-3 -top-0">
+
{{ template "numberCircle" 1 }}
+
</div>
+
+
<!-- Content column -->
+
<div class="flex-1 pb-12">
+
<h2 class="text-lg font-semibold dark:text-white">General</h2>
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div>
+
<div class="space-y-2">
+
{{ template "name" . }}
+
{{ template "description" . }}
</div>
+
</div>
+
</div>
+
{{ end }}
+
{{ define "step-2" }}
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
+
<div class="absolute -left-3 -top-0">
+
{{ template "numberCircle" 2 }}
</div>
+
+
<div class="flex-1">
+
<h2 class="text-lg font-semibold dark:text-white">Configuration</h2>
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div>
+
+
<div class="space-y-2">
+
{{ template "defaultBranch" . }}
+
{{ template "knot" . }}
+
</div>
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "name" }}
+
<!-- Repository Name with Owner -->
+
<div>
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
+
Repository name
+
</label>
+
<div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full">
+
<div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700">
+
{{ template "user/fragments/picHandle" .LoggedInUser.Did }}
+
</div>
+
<input
+
type="text"
+
id="name"
+
name="name"
+
required
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2"
+
placeholder="repository-name"
+
/>
+
</div>
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
+
Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens.
+
</p>
+
</div>
+
{{ end }}
+
+
{{ define "description" }}
+
<!-- Description -->
+
<div>
+
<label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1">
+
Description
+
</label>
+
<input
+
type="text"
+
id="description"
+
name="description"
+
class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
+
placeholder="A brief description of your project..."
+
/>
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
+
Optional. A short description to help others understand what your project does.
+
</p>
+
</div>
+
{{ end }}
+
+
{{ define "defaultBranch" }}
+
<!-- Default Branch -->
+
<div>
+
<label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1">
+
Default branch
+
</label>
+
<input
+
type="text"
+
id="branch"
+
name="branch"
+
value="main"
+
required
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
+
/>
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
+
The primary branch where development happens. Common choices are "main" or "master".
+
</p>
+
</div>
+
{{ end }}
+
+
{{ define "knot" }}
+
<!-- Knot Selection -->
+
<div>
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
+
Select a knot
+
</label>
+
<div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2">
+
{{ range .Knots }}
+
<div class="flex items-center">
+
<input
+
type="radio"
+
name="domain"
+
value="{{ . }}"
+
class="mr-2"
+
id="domain-{{ . }}"
+
required
+
/>
+
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
+
</div>
+
{{ else }}
+
<p class="dark:text-white">no knots available.</p>
+
{{ end }}
+
</div>
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
+
A knot hosts repository data and handles Git operations.
+
You can also <a href="/knots" class="underline">register your own knot</a>.
+
</p>
+
</div>
+
{{ end }}
+
+
{{ define "numberCircle" }}
+
<div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1">
+
{{.}}
+
</div>
{{ end }}
+30 -12
appview/pages/templates/repo/pulls/pull.html
···
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
{{ define "repoContent" }}
{{ template "repo/pulls/fragments/pullHeader" . }}
···
{{ with $item }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
-
<div class="flex flex-wrap gap-2 items-center">
<!-- round number -->
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span class="gap-1 flex items-center">
{{ $owner := resolve $.Pull.OwnerDid }}
{{ $re := "re" }}
···
<span class="hidden md:inline">diff</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
-
{{ if not (eq .RoundNumber 0) }}
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
-
hx-boost="true"
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
-
<span class="hidden md:inline">interdiff</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</a>
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
{{ end }}
</div>
</summary>
···
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
{{ range $cidx, $c := .Comments }}
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
{{ if gt $cidx 0 }}
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
{{ end }}
···
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
+
{{ define "repoContentLayout" }}
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
+
<div class="col-span-1 md:col-span-8">
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
+
{{ block "repoContent" . }}{{ end }}
+
</section>
+
{{ block "repoAfter" . }}{{ end }}
+
</div>
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
+
{{ template "repo/fragments/labelPanel"
+
(dict "RepoInfo" $.RepoInfo
+
"Defs" $.LabelDefs
+
"Subject" $.Pull.PullAt
+
"State" $.Pull.Labels) }}
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
+
</div>
+
</div>
+
{{ end }}
{{ define "repoContent" }}
{{ template "repo/pulls/fragments/pullHeader" . }}
···
{{ with $item }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
+
<div class="flex flex-wrap gap-2 items-stretch">
<!-- round number -->
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span class="gap-1 flex items-center">
{{ $owner := resolve $.Pull.OwnerDid }}
{{ $re := "re" }}
···
<span class="hidden md:inline">diff</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
+
{{ if ne $idx 0 }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
+
hx-boost="true"
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
+
<span class="hidden md:inline">interdiff</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
{{ end }}
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
</div>
</summary>
···
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
{{ range $cidx, $c := .Comments }}
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
{{ if gt $cidx 0 }}
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
{{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
···
<span class="before:content-['ยท']"></span>
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
{{ end }}
</div>
</div>
{{ if .StackId }}
···
<span class="before:content-['ยท']"></span>
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
{{ end }}
+
+
{{ $state := .Labels }}
+
{{ range $k, $d := $.LabelDefs }}
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
+
{{ end }}
+
{{ end }}
</div>
</div>
{{ if .StackId }}
+36 -6
appview/pages/templates/repo/settings/general.html
···
{{ define "defaultLabelSettings" }}
<div class="flex flex-col gap-2">
-
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
-
<p class="text-gray-500 dark:text-gray-400">
-
Manage your issues and pulls by creating labels to categorize them. Only
-
repository owners may configure labels. You may choose to subscribe to
-
default labels, or create entirely custom labels.
-
</p>
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
{{ range .DefaultLabels }}
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
···
{{ define "defaultLabelSettings" }}
<div class="flex flex-col gap-2">
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Manage your issues and pulls by creating labels to categorize them. Only
+
repository owners may configure labels. You may choose to subscribe to
+
default labels, or create entirely custom labels.
+
<p>
+
</div>
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
+
{{ $title := "Unubscribe from all labels" }}
+
{{ $icon := "x" }}
+
{{ $text := "unsubscribe all" }}
+
{{ $action := "unsubscribe" }}
+
{{ if $.ShouldSubscribeAll }}
+
{{ $title = "Subscribe to all labels" }}
+
{{ $icon = "check-check" }}
+
{{ $text = "subscribe all" }}
+
{{ $action = "subscribe" }}
+
{{ end }}
+
{{ range .DefaultLabels }}
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
+
{{ end }}
+
<button
+
type="submit"
+
title="{{$title}}"
+
class="btn flex items-center gap-2 group"
+
hx-swap="none"
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
+
{{ i $icon "size-4" }}
+
{{ $text }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</form>
+
</div>
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
{{ range .DefaultLabels }}
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+1 -1
appview/pages/templates/repo/tree.html
···
{{ define "repoAfter" }}
{{- if or .HTMLReadme .Readme -}}
-
{{ template "repo/fragments/readme" . }}
{{- end -}}
{{ end }}
···
{{ define "repoAfter" }}
{{- if or .HTMLReadme .Readme -}}
+
{{ template "repo/fragments/readme" . }}
{{- end -}}
{{ end }}
+2 -2
appview/pages/templates/strings/put.html
···
{{ define "content" }}
<div class="px-6 py-2 mb-4">
{{ if eq .Action "new" }}
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
-
<p class="">Store and share code snippets with ease.</p>
{{ else }}
<p class="text-xl font-bold dark:text-white">Edit string</p>
{{ end }}
···
{{ define "content" }}
<div class="px-6 py-2 mb-4">
{{ if eq .Action "new" }}
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
{{ else }}
<p class="text-xl font-bold dark:text-white">Edit string</p>
{{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
···
{{ end }}
{{ define "stringCard" }}
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
-
<div class="font-medium dark:text-white flex gap-2 items-center">
-
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
</div>
{{ with .Description }}
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
{{ define "stringCardInfo" }}
{{ $stat := .Stats }}
-
{{ $resolved := resolve .Did.String }}
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
-
<a href="/strings/{{ $resolved }}" class="flex items-center">
-
{{ template "user/fragments/picHandle" $resolved }}
-
</a>
-
<span class="select-none [&:before]:content-['ยท']"></span>
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
<span class="select-none [&:before]:content-['ยท']"></span>
{{ with .Edited }}
···
{{ end }}
{{ define "stringCard" }}
+
{{ $resolved := resolve .Did.String }}
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
+
<div class="font-medium dark:text-white flex flex-wrap gap-1 items-center">
+
<a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a>
+
<span class="select-none">/</span>
+
<a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a>
</div>
{{ with .Description }}
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
{{ define "stringCardInfo" }}
{{ $stat := .Stats }}
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
<span class="select-none [&:before]:content-['ยท']"></span>
{{ with .Edited }}
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
···
{{ $event := index . 1 }}
{{ $follow := $event.Follow }}
{{ $profile := $event.Profile }}
-
{{ $stat := $event.FollowStats }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
-
<div class="flex items-center gap-4 flex-1">
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
-
</div>
-
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
-
<a href="/{{ $subjectHandle }}">
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
-
</a>
-
{{ with $profile }}
-
{{ with .Description }}
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
-
{{ end }}
-
{{ end }}
-
{{ with $stat }}
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
-
<div class="flex-shrink-0 w-fit ml-auto">
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
-
</div>
-
{{ end }}
-
</div>
{{ end }}
···
{{ $event := index . 1 }}
{{ $follow := $event.Follow }}
{{ $profile := $event.Profile }}
+
{{ $followStats := $event.FollowStats }}
+
{{ $followStatus := $event.FollowStatus }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $root.LoggedInUser
+
"UserDid" $follow.SubjectDid
+
"Profile" $profile
+
"FollowStatus" $followStatus
+
"FollowersCount" $followStats.Followers
+
"FollowingCount" $followStats.Following) }}
{{ end }}
+1
appview/pages/templates/user/completeSignup.html
···
content="complete your signup for tangled"
/>
<script src="/static/htmx.min.js"></script>
<link
rel="stylesheet"
href="/static/tw.css?{{ cssContentHash }}"
···
content="complete your signup for tangled"
/>
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link
rel="stylesheet"
href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Followers }}
-
{{ template "user/fragments/followCard" . }}
{{ else }}
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
{{ end }}
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Followers }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
{{ end }}
+8 -1
appview/pages/templates/user/following.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Following }}
-
{{ template "user/fragments/followCard" . }}
{{ else }}
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
{{ end }}
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Following }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
{{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
-
class="btn mt-2 flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
+
class="btn w-full flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
+
{{ i "user-round-plus" "w-4 h-4" }} follow
+
{{ else }}
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
+
{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
+20 -17
appview/pages/templates/user/fragments/followCard.html
···
{{ define "user/fragments/followCard" }}
{{ $userIdent := resolve .UserDid }}
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 max-h-full w-24 h-24">
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
</div>
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
-
<a href="/{{ $userIdent }}">
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
-
</a>
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
</div>
-
</div>
-
-
{{ if ne .FollowStatus.String "IsSelf" }}
-
<div class="max-w-24">
{{ template "user/fragments/follow" . }}
</div>
-
{{ end }}
</div>
</div>
-
{{ end }}
···
{{ define "user/fragments/followCard" }}
{{ $userIdent := resolve .UserDid }}
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 max-h-full w-24 h-24">
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
</div>
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
+
<a href="/{{ $userIdent }}">
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
+
</a>
+
{{ with .Profile }}
+
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
+
{{ end }}
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
+
<span class="select-none after:content-['ยท']"></span>
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
+
</div>
</div>
+
{{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }}
+
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
{{ template "user/fragments/follow" . }}
</div>
+
{{ end }}
+
</div>
</div>
</div>
+
{{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
···
<img
src="{{ tinyAvatar . }}"
alt=""
-
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
/>
-
{{ . | truncateAt30 }}
{{ end }}
···
<img
src="{{ tinyAvatar . }}"
alt=""
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
/>
+
{{ . | resolve | truncateAt30 }}
{{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
···
{{ define "user/fragments/picHandleLink" }}
-
{{ $resolved := resolve . }}
-
<a href="/{{ $resolved }}" class="flex items-center">
-
{{ template "user/fragments/picHandle" $resolved }}
</a>
{{ end }}
···
{{ define "user/fragments/picHandleLink" }}
+
<a href="/{{ resolve . }}" class="flex items-center gap-1">
+
{{ template "user/fragments/picHandle" . }}
</a>
{{ end }}
+10 -10
appview/pages/templates/user/fragments/repoCard.html
···
{{ with $repo }}
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
<div class="font-medium dark:text-white flex items-center justify-between">
-
<div class="flex items-center">
-
{{ if .Source }}
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
-
{{ else }}
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
-
{{ end }}
-
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
{{- end -}}
</div>
-
{{ if and $starButton $root.LoggedInUser }}
{{ template "repo/fragments/repoStar" $starData }}
{{ end }}
</div>
{{ with .Description }}
···
{{ with $repo }}
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
<div class="font-medium dark:text-white flex items-center justify-between">
+
<div class="flex items-center min-w-0 flex-1 mr-2">
+
{{ if .Source }}
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
+
{{ else }}
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
+
{{ end }}
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
{{- end -}}
</div>
{{ if and $starButton $root.LoggedInUser }}
+
<div class="shrink-0">
{{ template "repo/fragments/repoStar" $starData }}
+
</div>
{{ end }}
</div>
{{ with .Description }}
+2 -1
appview/pages/templates/user/login.html
···
<meta property="og:url" content="https://tangled.org/login" />
<meta property="og:description" content="login to for tangled" />
<script src="/static/htmx.min.js"></script>
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>login &middot; tangled</title>
</head>
···
placeholder="akshay.tngl.sh"
/>
<span class="text-sm text-gray-500 mt-1">
-
Use your <a href="https://atproto.com">ATProto</a>
handle to log in. If you're unsure, this is likely
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
</span>
···
<meta property="og:url" content="https://tangled.org/login" />
<meta property="og:description" content="login to for tangled" />
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>login &middot; tangled</title>
</head>
···
placeholder="akshay.tngl.sh"
/>
<span class="text-sm text-gray-500 mt-1">
+
Use your <a href="https://atproto.com">AT Protocol</a>
handle to log in. If you're unsure, this is likely
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
</span>
+173
appview/pages/templates/user/settings/notifications.html
···
···
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Settings</p>
+
</div>
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
+
<div class="col-span-1">
+
{{ template "user/settings/fragments/sidebar" . }}
+
</div>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
+
{{ template "notificationSettings" . }}
+
</div>
+
</section>
+
</div>
+
{{ end }}
+
+
{{ define "notificationSettings" }}
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
+
</p>
+
</div>
+
</div>
+
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
+
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Repository starred</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone stars your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New issues</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates an issue on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on an issue you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue closed</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When an issue on your repository is closed.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New pull requests</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates a pull request on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on a pull request you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request merged</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When your pull request is merged.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New followers</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone follows you.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Email notifications</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>Receive notifications via email in addition to in-app notifications.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
+
</label>
+
</div>
+
</div>
+
+
<div class="flex justify-end pt-2">
+
<button
+
type="submit"
+
class="btn-create flex items-center gap-2 group"
+
>
+
{{ i "save" "w-4 h-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
<div id="settings-notifications-success"></div>
+
+
<div id="settings-notifications-error" class="error"></div>
+
</form>
+
{{ end }}
+7 -1
appview/pages/templates/user/signup.html
···
<meta property="og:url" content="https://tangled.org/signup" />
<meta property="og:description" content="sign up for tangled" />
<script src="/static/htmx.min.js"></script>
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>sign up &middot; tangled</title>
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
···
invite code, desired username, and password in the next
page to complete your registration.
</span>
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
<span>join now</span>
</button>
</form>
<p class="text-sm text-gray-500">
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
</p>
<p id="signup-msg" class="error w-full"></p>
···
<meta property="og:url" content="https://tangled.org/signup" />
<meta property="og:description" content="sign up for tangled" />
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>sign up &middot; tangled</title>
+
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
···
invite code, desired username, and password in the next
page to complete your registration.
</span>
+
<div class="w-full mt-4 text-center">
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
+
</div>
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
<span>join now</span>
</button>
</form>
<p class="text-sm text-gray-500">
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
</p>
<p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
···
func FirstPage() Page {
return Page{
Offset: 0,
-
Limit: 10,
}
}
···
func FirstPage() Page {
return Page{
Offset: 0,
+
Limit: 30,
}
}
-164
appview/posthog/notifier.go
···
-
package posthog_service
-
-
import (
-
"context"
-
"log"
-
-
"github.com/posthog/posthog-go"
-
"tangled.org/core/appview/models"
-
"tangled.org/core/appview/notify"
-
)
-
-
type posthogNotifier struct {
-
client posthog.Client
-
notify.BaseNotifier
-
}
-
-
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
-
return &posthogNotifier{
-
client,
-
notify.BaseNotifier{},
-
}
-
}
-
-
var _ notify.Notifier = &posthogNotifier{}
-
-
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: repo.Did,
-
Event: "new_repo",
-
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: star.StarredByDid,
-
Event: "star",
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: star.StarredByDid,
-
Event: "unstar",
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: issue.Did,
-
Event: "new_issue",
-
Properties: posthog.Properties{
-
"repo_at": issue.RepoAt.String(),
-
"issue_id": issue.IssueId,
-
},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: pull.OwnerDid,
-
Event: "new_pull",
-
Properties: posthog.Properties{
-
"repo_at": pull.RepoAt,
-
"pull_id": pull.PullId,
-
},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: comment.OwnerDid,
-
Event: "new_pull_comment",
-
Properties: posthog.Properties{
-
"repo_at": comment.RepoAt,
-
"pull_id": comment.PullId,
-
},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: follow.UserDid,
-
Event: "follow",
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: follow.UserDid,
-
Event: "unfollow",
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: profile.Did,
-
Event: "edit_profile",
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: did,
-
Event: "delete_string",
-
Properties: posthog.Properties{"rkey": rkey},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: string.Did.String(),
-
Event: "edit_string",
-
Properties: posthog.Properties{"rkey": string.Rkey},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
-
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: string.Did.String(),
-
Event: "create_string",
-
Properties: posthog.Properties{"rkey": string.Rkey},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
···
+68 -2
appview/pulls/pulls.go
···
"github.com/bluekeyes/go-gitdiff/gitdiff"
comatproto "github.com/bluesky-social/indigo/api/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
···
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
}
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
LoggedInUser: user,
RepoInfo: repoInfo,
···
OrderedReactionKinds: models.OrderedReactionKinds,
Reactions: reactionCountMap,
UserReacted: userReactions,
})
}
···
m[p.Sha] = p
}
s.pages.RepoPulls(w, pages.RepoPullsParams{
LoggedInUser: s.oauth.GetUser(r),
RepoInfo: f.RepoInfo(user),
Pulls: pulls,
FilteringBy: state,
Stacks: stacks,
Pipelines: m,
···
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
if err != nil {
log.Println("failed to create resubmitted stack", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
return
}
// find the diff between the stacks, first, map them by changeId
origById := make(map[string]*models.Pull)
newById := make(map[string]*models.Pull)
for _, p := range origStack {
origById[p.ChangeId] = p
}
for _, p := range newStack {
newById[p.ChangeId] = p
}
···
// we still need to update the hash in submission.Patch and submission.SourceRev
if patchutil.Equal(newFiles, origFiles) &&
origHeader.Title == newHeader.Title &&
-
origHeader.Body == newHeader.Body {
unchanged[op.ChangeId] = struct{}{}
} else {
updated[op.ChangeId] = struct{}{}
···
record := op.AsRecord()
record.Patch = submission.Patch
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
···
return
}
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
}
···
return
}
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
}
···
// the stack is identified by a UUID
var stack models.Stack
parentChangeId := ""
for _, fp := range formatPatches {
// all patches must have a jj change-id
changeId, err := fp.ChangeId()
···
StackId: stackId,
ChangeId: changeId,
ParentChangeId: parentChangeId,
}
stack = append(stack, &pull)
parentChangeId = changeId
}
return stack, nil
···
"github.com/bluekeyes/go-gitdiff/gitdiff"
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
···
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
}
+
labelDefs, err := db.GetLabelDefinitions(
+
s.db,
+
db.FilterIn("at_uri", f.Repo.Labels),
+
db.FilterContains("scope", tangled.RepoPullNSID),
+
)
+
if err != nil {
+
log.Println("failed to fetch labels", err)
+
s.pages.Error503(w)
+
return
+
}
+
+
defs := make(map[string]*models.LabelDefinition)
+
for _, l := range labelDefs {
+
defs[l.AtUri().String()] = &l
+
}
+
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
LoggedInUser: user,
RepoInfo: repoInfo,
···
OrderedReactionKinds: models.OrderedReactionKinds,
Reactions: reactionCountMap,
UserReacted: userReactions,
+
+
LabelDefs: defs,
})
}
···
m[p.Sha] = p
}
+
labelDefs, err := db.GetLabelDefinitions(
+
s.db,
+
db.FilterIn("at_uri", f.Repo.Labels),
+
db.FilterContains("scope", tangled.RepoPullNSID),
+
)
+
if err != nil {
+
log.Println("failed to fetch labels", err)
+
s.pages.Error503(w)
+
return
+
}
+
+
defs := make(map[string]*models.LabelDefinition)
+
for _, l := range labelDefs {
+
defs[l.AtUri().String()] = &l
+
}
+
s.pages.RepoPulls(w, pages.RepoPullsParams{
LoggedInUser: s.oauth.GetUser(r),
RepoInfo: f.RepoInfo(user),
Pulls: pulls,
+
LabelDefs: defs,
FilteringBy: state,
Stacks: stacks,
Pipelines: m,
···
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
if err != nil {
log.Println("failed to create resubmitted stack", err)
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to merge pull request. Try again later.")
return
}
// find the diff between the stacks, first, map them by changeId
+
origById := make(map[string]*models.Pull)
newById := make(map[string]*models.Pull)
+
chIdToAtUri := make(map[string]*syntax.ATURI)
+
for _, p := range origStack {
origById[p.ChangeId] = p
+
+
// build map from change id to existing at uris (ignore error as it shouldnt be possible here)
+
pAtUri, _ := syntax.ParseATURI(fmt.Sprintf("at://%s/%s/%s", user.Did, tangled.RepoPullNSID, p.Rkey))
+
chIdToAtUri[p.ChangeId] = &pAtUri
}
for _, p := range newStack {
+
// if change id has already been given a PR use its at uri instead of the newly created (and thus incorrect)
+
// one made by newStack
+
if ppAt, ok := chIdToAtUri[p.ParentChangeId]; ok {
+
p.ParentAt = ppAt
+
}
+
newById[p.ChangeId] = p
}
···
// we still need to update the hash in submission.Patch and submission.SourceRev
if patchutil.Equal(newFiles, origFiles) &&
origHeader.Title == newHeader.Title &&
+
origHeader.Body == newHeader.Body &&
+
op.ParentChangeId == np.ParentChangeId {
unchanged[op.ChangeId] = struct{}{}
} else {
updated[op.ChangeId] = struct{}{}
···
record := op.AsRecord()
record.Patch = submission.Patch
+
record.StackInfo.Parent = (*string)(np.ParentAt)
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
···
return
}
+
// notify about the pull merge
+
for _, p := range pullsToMerge {
+
s.notifier.NewPullMerged(r.Context(), p)
+
}
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
}
···
return
}
+
for _, p := range pullsToClose {
+
s.notifier.NewPullClosed(r.Context(), p)
+
}
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
}
···
// the stack is identified by a UUID
var stack models.Stack
parentChangeId := ""
+
var parentAt *syntax.ATURI = nil
for _, fp := range formatPatches {
// all patches must have a jj change-id
changeId, err := fp.ChangeId()
···
StackId: stackId,
ChangeId: changeId,
+
ParentAt: parentAt,
ParentChangeId: parentChangeId,
}
stack = append(stack, &pull)
parentChangeId = changeId
+
// this is a bit of an ugly way to create the ATURI but its the best we can do with the data flow here
+
// (igore error as it shouldnt be possible here)
+
parsedParentAt, _ := syntax.ParseATURI(fmt.Sprintf("at://%s/%s/%s", user.Did, tangled.RepoPullNSID, pull.Rkey));
+
parentAt = &parsedParentAt
}
return stack, nil
+40 -14
appview/repo/artifact.go
···
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
···
})
}
-
// TODO: proper statuses here on early exit
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
-
tagParam := chi.URLParam(r, "tag")
-
filename := chi.URLParam(r, "file")
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
return
}
tag, err := rp.resolveTag(r.Context(), f, tagParam)
if err != nil {
···
return
}
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
return
-
}
-
artifacts, err := db.GetArtifact(
rp.db,
db.FilterEq("repo_at", f.RepoAt()),
···
)
if err != nil {
log.Println("failed to get artifacts", err)
return
}
if len(artifacts) != 1 {
-
log.Printf("too many or too little artifacts found")
return
}
artifact := artifacts[0]
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
if err != nil {
-
log.Println("failed to get blob from pds", err)
return
}
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
-
w.Write(getBlobResp)
}
// TODO: proper statuses here on early exit
···
"context"
"encoding/json"
"fmt"
+
"io"
"log"
"net/http"
"net/url"
···
})
}
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
return
}
+
+
tagParam := chi.URLParam(r, "tag")
+
filename := chi.URLParam(r, "file")
tag, err := rp.resolveTag(r.Context(), f, tagParam)
if err != nil {
···
return
}
artifacts, err := db.GetArtifact(
rp.db,
db.FilterEq("repo_at", f.RepoAt()),
···
)
if err != nil {
log.Println("failed to get artifacts", err)
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
return
}
+
if len(artifacts) != 1 {
+
log.Printf("too many or too few artifacts found")
+
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
artifact := artifacts[0]
+
ownerPds := f.OwnerId.PDSEndpoint()
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
+
q := url.Query()
+
q.Set("cid", artifact.BlobCid.String())
+
q.Set("did", artifact.Did)
+
url.RawQuery = q.Encode()
+
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
+
if err != nil {
+
log.Println("failed to create request", err)
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
+
return
+
}
+
req.Header.Set("Content-Type", "application/json")
+
+
resp, err := http.DefaultClient.Do(req)
if err != nil {
+
log.Println("failed to make request", err)
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
return
}
+
defer resp.Body.Close()
+
// copy status code and relevant headers from upstream response
+
w.WriteHeader(resp.StatusCode)
+
for key, values := range resp.Header {
+
for _, v := range values {
+
w.Header().Add(key, v)
+
}
+
}
+
+
// stream the body directly to the client
+
if _, err := io.Copy(w, resp.Body); err != nil {
+
log.Println("error streaming response to client:", err)
+
}
}
// TODO: proper statuses here on early exit
+17 -22
appview/repo/index.go
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
-
"tangled.org/core/appview/pages/markup"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/types"
···
})
}
// update appview's cache
-
err = db.InsertRepoLanguages(rp.db, langs)
if err != nil {
// non-fatal
log.Println("failed to cache lang results", err)
}
}
···
}
}()
-
// readme content
-
wg.Add(1)
-
go func() {
-
defer wg.Done()
-
for _, filename := range markup.ReadmeFilenames {
-
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
-
if err != nil {
-
continue
-
}
-
-
if blobResp == nil {
-
continue
-
}
-
-
readmeContent = blobResp.Content
-
readmeFileName = filename
-
break
-
}
-
}()
-
wg.Wait()
if errs != nil {
···
}
files = append(files, niceFile)
}
}
result := &types.RepoIndexResponse{
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/types"
···
})
}
+
tx, err := rp.db.Begin()
+
if err != nil {
+
return nil, err
+
}
+
defer tx.Rollback()
+
// update appview's cache
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
if err != nil {
// non-fatal
log.Println("failed to cache lang results", err)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return nil, err
}
}
···
}
}()
wg.Wait()
if errs != nil {
···
}
files = append(files, niceFile)
}
+
}
+
+
if treeResp != nil && treeResp.Readme != nil {
+
readmeFileName = treeResp.Readme.Filename
+
readmeContent = treeResp.Readme.Contents
}
result := &types.RepoIndexResponse{
+77 -51
appview/repo/repo.go
···
return
}
-
// readme content
-
var (
-
readmeContent string
-
readmeFileName string
-
)
-
-
for _, filename := range markup.ReadmeFilenames {
-
path := fmt.Sprintf("%s/%s", treePath, filename)
-
blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo)
-
if err != nil {
-
continue
-
}
-
-
if blobResp == nil {
-
continue
-
}
-
-
readmeContent = blobResp.Content
-
readmeFileName = path
-
break
-
}
-
// Convert XRPC response to internal types.RepoTreeResponse
files := make([]types.NiceTree, len(xrpcResp.Files))
for i, xrpcFile := range xrpcResp.Files {
···
if xrpcResp.Dotdot != nil {
result.DotDot = *xrpcResp.Dotdot
}
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
// so we can safely redirect to the "parent" (which is the same file).
···
BreadCrumbs: breadcrumbs,
TreePath: treePath,
RepoInfo: f.RepoInfo(user),
-
Readme: readmeContent,
-
ReadmeFileName: readmeFileName,
RepoTreeResponse: result,
})
}
···
return
}
errorId := "default-label-operation"
fail := func(msg string, err error) {
l.Error(msg, "err", err)
rp.pages.Notice(w, errorId, msg)
}
-
labelAt := r.FormValue("label")
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
if err != nil {
fail("Failed to subscribe to label.", err)
return
}
newRepo := f.Repo
-
newRepo.Labels = append(newRepo.Labels, labelAt)
repoRecord := newRepo.AsRecord()
client, err := rp.oauth.AuthorizedClient(r)
···
},
})
-
err = db.SubscribeLabel(rp.db, &models.RepoLabel{
-
RepoAt: f.RepoAt(),
-
LabelAt: syntax.ATURI(labelAt),
-
})
if err != nil {
fail("Failed to subscribe to label.", err)
return
}
// everything succeeded
rp.pages.HxRefresh(w)
···
return
}
errorId := "default-label-operation"
fail := func(msg string, err error) {
l.Error(msg, "err", err)
rp.pages.Notice(w, errorId, msg)
}
-
labelAt := r.FormValue("label")
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
if err != nil {
fail("Failed to unsubscribe to label.", err)
return
···
newRepo := f.Repo
var updated []string
for _, l := range newRepo.Labels {
-
if l != labelAt {
updated = append(updated, l)
}
}
···
err = db.UnsubscribeLabel(
rp.db,
db.FilterEq("repo_at", f.RepoAt()),
-
db.FilterEq("label_at", labelAt),
)
if err != nil {
fail("Failed to unsubscribe label.", err)
···
subscribedLabels[l] = struct{}{}
}
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Branches: result.Branches,
-
Labels: labels,
-
DefaultLabels: defaultLabels,
-
SubscribedLabels: subscribedLabels,
-
Tabs: settingsTabs,
-
Tab: "general",
})
}
···
}
// choose a name for a fork
-
forkName := f.Name
// this check is *only* to see if the forked repo name already exists
// in the user's account.
existingRepo, err := db.GetRepo(
rp.db,
db.FilterEq("did", user.Did),
-
db.FilterEq("name", f.Name),
)
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", err)
rp.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))
}
l = l.With("forkName", forkName)
···
Knot: targetKnot,
Rkey: rkey,
Source: sourceAt,
-
Description: existingRepo.Description,
Created: time.Now(),
}
record := repo.AsRecord()
···
return
}
// Convert XRPC response to internal types.RepoTreeResponse
files := make([]types.NiceTree, len(xrpcResp.Files))
for i, xrpcFile := range xrpcResp.Files {
···
if xrpcResp.Dotdot != nil {
result.DotDot = *xrpcResp.Dotdot
}
+
if xrpcResp.Readme != nil {
+
result.ReadmeFileName = xrpcResp.Readme.Filename
+
result.Readme = xrpcResp.Readme.Contents
+
}
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
// so we can safely redirect to the "parent" (which is the same file).
···
BreadCrumbs: breadcrumbs,
TreePath: treePath,
RepoInfo: f.RepoInfo(user),
RepoTreeResponse: result,
})
}
···
return
}
+
if err := r.ParseForm(); err != nil {
+
l.Error("invalid form", "err", err)
+
return
+
}
+
errorId := "default-label-operation"
fail := func(msg string, err error) {
l.Error(msg, "err", err)
rp.pages.Notice(w, errorId, msg)
}
+
labelAts := r.Form["label"]
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
if err != nil {
fail("Failed to subscribe to label.", err)
return
}
newRepo := f.Repo
+
newRepo.Labels = append(newRepo.Labels, labelAts...)
+
+
// dedup
+
slices.Sort(newRepo.Labels)
+
newRepo.Labels = slices.Compact(newRepo.Labels)
+
repoRecord := newRepo.AsRecord()
client, err := rp.oauth.AuthorizedClient(r)
···
},
})
+
tx, err := rp.db.Begin()
if err != nil {
fail("Failed to subscribe to label.", err)
return
}
+
defer tx.Rollback()
+
+
for _, l := range labelAts {
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
+
RepoAt: f.RepoAt(),
+
LabelAt: syntax.ATURI(l),
+
})
+
if err != nil {
+
fail("Failed to subscribe to label.", err)
+
return
+
}
+
}
+
+
if err := tx.Commit(); err != nil {
+
fail("Failed to subscribe to label.", err)
+
return
+
}
// everything succeeded
rp.pages.HxRefresh(w)
···
return
}
+
if err := r.ParseForm(); err != nil {
+
l.Error("invalid form", "err", err)
+
return
+
}
+
errorId := "default-label-operation"
fail := func(msg string, err error) {
l.Error(msg, "err", err)
rp.pages.Notice(w, errorId, msg)
}
+
labelAts := r.Form["label"]
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
if err != nil {
fail("Failed to unsubscribe to label.", err)
return
···
newRepo := f.Repo
var updated []string
for _, l := range newRepo.Labels {
+
if !slices.Contains(labelAts, l) {
updated = append(updated, l)
}
}
···
err = db.UnsubscribeLabel(
rp.db,
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterIn("label_at", labelAts),
)
if err != nil {
fail("Failed to unsubscribe label.", err)
···
subscribedLabels[l] = struct{}{}
}
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
+
// if all default labels are subbed, show the "unsubscribe all" button
+
shouldSubscribeAll := false
+
for _, dl := range defaultLabels {
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
+
// one of the default labels is not subscribed to
+
shouldSubscribeAll = true
+
break
+
}
+
}
+
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Branches: result.Branches,
+
Labels: labels,
+
DefaultLabels: defaultLabels,
+
SubscribedLabels: subscribedLabels,
+
ShouldSubscribeAll: shouldSubscribeAll,
+
Tabs: settingsTabs,
+
Tab: "general",
})
}
···
}
// choose a name for a fork
+
forkName := r.FormValue("repo_name")
+
if forkName == "" {
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
+
return
+
}
+
// this check is *only* to see if the forked repo name already exists
// in the user's account.
existingRepo, err := db.GetRepo(
rp.db,
db.FilterEq("did", user.Did),
+
db.FilterEq("name", forkName),
)
if err != nil {
+
if !errors.Is(err, sql.ErrNoRows) {
log.Println("error fetching existing repo from db", "err", err)
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
return
}
} else if existingRepo != nil {
+
// repo with this name already exists
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
+
return
}
l = l.With("forkName", forkName)
···
Knot: targetKnot,
Rkey: rkey,
Source: sourceAt,
+
Description: f.Repo.Description,
Created: time.Now(),
+
Labels: models.DefaultLabelDefs(),
}
record := repo.AsRecord()
+2 -3
appview/repo/router.go
···
r.Route("/tags", func(r chi.Router) {
r.Get("/", rp.RepoTags)
r.Route("/{tag}", func(r chi.Router) {
-
r.Use(middleware.AuthMiddleware(rp.oauth))
-
// require auth to download for now
r.Get("/download/{file}", rp.DownloadArtifact)
// require repo:push to upload or delete artifacts
···
// additionally: only the uploader can truly delete an artifact
// (record+blob will live on their pds)
r.Group(func(r chi.Router) {
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
r.Post("/upload", rp.AttachArtifact)
r.Delete("/{file}", rp.DeleteArtifact)
})
···
r.Route("/tags", func(r chi.Router) {
r.Get("/", rp.RepoTags)
r.Route("/{tag}", func(r chi.Router) {
r.Get("/download/{file}", rp.DownloadArtifact)
// require repo:push to upload or delete artifacts
···
// additionally: only the uploader can truly delete an artifact
// (record+blob will live on their pds)
r.Group(func(r chi.Router) {
+
r.Use(middleware.AuthMiddleware(rp.oauth))
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
r.Post("/upload", rp.AttachArtifact)
r.Delete("/{file}", rp.DeleteArtifact)
})
+51
appview/settings/settings.go
···
{"Name": "profile", "Icon": "user"},
{"Name": "keys", "Icon": "key"},
{"Name": "emails", "Icon": "mail"},
}
)
···
r.Post("/primary", s.emailsPrimary)
})
return r
}
···
Tabs: settingsTabs,
Tab: "profile",
})
}
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
···
{"Name": "profile", "Icon": "user"},
{"Name": "keys", "Icon": "key"},
{"Name": "emails", "Icon": "mail"},
+
{"Name": "notifications", "Icon": "bell"},
}
)
···
r.Post("/primary", s.emailsPrimary)
})
+
r.Route("/notifications", func(r chi.Router) {
+
r.Get("/", s.notificationsSettings)
+
r.Put("/", s.updateNotificationPreferences)
+
})
+
return r
}
···
Tabs: settingsTabs,
Tab: "profile",
})
+
}
+
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
+
user := s.OAuth.GetUser(r)
+
did := s.OAuth.GetDid(r)
+
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
+
if err != nil {
+
log.Printf("failed to get notification preferences: %s", err)
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
+
return
+
}
+
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
+
LoggedInUser: user,
+
Preferences: prefs,
+
Tabs: settingsTabs,
+
Tab: "notifications",
+
})
+
}
+
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
+
did := s.OAuth.GetDid(r)
+
+
prefs := &models.NotificationPreferences{
+
UserDid: did,
+
RepoStarred: r.FormValue("repo_starred") == "on",
+
IssueCreated: r.FormValue("issue_created") == "on",
+
IssueCommented: r.FormValue("issue_commented") == "on",
+
IssueClosed: r.FormValue("issue_closed") == "on",
+
PullCreated: r.FormValue("pull_created") == "on",
+
PullCommented: r.FormValue("pull_commented") == "on",
+
PullMerged: r.FormValue("pull_merged") == "on",
+
Followed: r.FormValue("followed") == "on",
+
EmailNotifications: r.FormValue("email_notifications") == "on",
+
}
+
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
+
if err != nil {
+
log.Printf("failed to update notification preferences: %s", err)
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
}
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
+65 -1
appview/signup/signup.go
···
import (
"bufio"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
···
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
-
s.pages.Signup(w)
case http.MethodPost:
if s.cf == nil {
http.Error(w, "signup is disabled", http.StatusFailedDependency)
}
emailId := r.FormValue("email")
noticeId := "signup-msg"
if !email.IsValidEmail(emailId) {
s.pages.Notice(w, noticeId, "Invalid email address.")
return
···
return
}
}
···
import (
"bufio"
+
"encoding/json"
+
"errors"
"fmt"
"log/slog"
"net/http"
+
"net/url"
"os"
"strings"
···
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
+
s.pages.Signup(w, pages.SignupParams{
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
+
})
case http.MethodPost:
if s.cf == nil {
http.Error(w, "signup is disabled", http.StatusFailedDependency)
+
return
}
emailId := r.FormValue("email")
+
cfToken := r.FormValue("cf-turnstile-response")
noticeId := "signup-msg"
+
+
if err := s.validateCaptcha(cfToken, r); err != nil {
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
+
return
+
}
+
if !email.IsValidEmail(emailId) {
s.pages.Notice(w, noticeId, "Invalid email address.")
return
···
return
}
}
+
+
type turnstileResponse struct {
+
Success bool `json:"success"`
+
ErrorCodes []string `json:"error-codes,omitempty"`
+
ChallengeTs string `json:"challenge_ts,omitempty"`
+
Hostname string `json:"hostname,omitempty"`
+
}
+
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
+
if cfToken == "" {
+
return errors.New("captcha token is empty")
+
}
+
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
+
return errors.New("turnstile secret key not configured")
+
}
+
+
data := url.Values{}
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
+
data.Set("response", cfToken)
+
+
// include the client IP if we have it
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
+
data.Set("remoteip", remoteIP)
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
+
}
+
} else {
+
data.Set("remoteip", r.RemoteAddr)
+
}
+
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
+
if err != nil {
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
+
}
+
defer resp.Body.Close()
+
+
var turnstileResp turnstileResponse
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
+
}
+
+
if !turnstileResp.Success {
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
+
return errors.New("turnstile validation failed")
+
}
+
+
return nil
+
}
+14 -1
appview/state/knotstream.go
···
})
}
-
return db.InsertRepoLanguages(d, langs)
}
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
})
}
+
tx, err := d.Begin()
+
if err != nil {
+
return err
+
}
+
defer tx.Rollback()
+
+
// update appview's cache
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
+
if err != nil {
+
fmt.Printf("failed; %s\n", err)
+
// non-fatal
+
}
+
+
return tx.Commit()
}
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+5 -13
appview/state/profile.go
···
s.pages.Error500(w)
return
}
-
var repoAts []string
for _, s := range stars {
-
repoAts = append(repoAts, string(s.RepoAt))
-
}
-
-
repos, err := db.GetRepos(
-
s.db,
-
0,
-
db.FilterIn("at_uri", repoAts),
-
)
-
if err != nil {
-
l.Error("failed to get repos", "err", err)
-
s.pages.Error500(w)
-
return
}
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
profile.Did = did
}
followCards[i] = pages.FollowCard{
UserDid: did,
FollowStatus: followStatus,
FollowersCount: followStats.Followers,
···
s.pages.Error500(w)
return
}
+
var repos []models.Repo
for _, s := range stars {
+
if s.Repo != nil {
+
repos = append(repos, *s.Repo)
+
}
}
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
profile.Did = did
}
followCards[i] = pages.FollowCard{
+
LoggedInUser: loggedInUser,
UserDid: did,
FollowStatus: followStatus,
FollowersCount: followStats.Followers,
+14 -3
appview/state/router.go
···
"tangled.org/core/appview/knots"
"tangled.org/core/appview/labels"
"tangled.org/core/appview/middleware"
oauthhandler "tangled.org/core/appview/oauth/handler"
"tangled.org/core/appview/pipelines"
"tangled.org/core/appview/pulls"
···
s.pages,
)
router.Get("/favicon.svg", s.Favicon)
router.Get("/favicon.ico", s.Favicon)
userRouter := s.UserRouter(&middleware)
standardRouter := s.StandardRouter(&middleware)
···
r.Get("/", s.HomeOrTimeline)
r.Get("/timeline", s.Timeline)
-
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
// special-case handler for serving tangled.org/core
r.Get("/core", s.Core())
···
r.Mount("/strings", s.StringsRouter(mw))
r.Mount("/knots", s.KnotsRouter())
r.Mount("/spindles", s.SpindlesRouter())
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
r.Get("/keys/{user}", s.Keys)
r.Get("/terms", s.TermsOfService)
r.Get("/privacy", s.PrivacyPolicy)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
s.pages.Error404(w)
···
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("go-get") == "1" {
w.Header().Set("Content-Type", "text/html")
-
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/tangled.org/core">`))
return
}
···
}
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
-
ls := labels.New(s.oauth, s.pages, s.db, s.validator)
return ls.Router(mw)
}
func (s *State) SignupRouter() http.Handler {
···
"tangled.org/core/appview/knots"
"tangled.org/core/appview/labels"
"tangled.org/core/appview/middleware"
+
"tangled.org/core/appview/notifications"
oauthhandler "tangled.org/core/appview/oauth/handler"
"tangled.org/core/appview/pipelines"
"tangled.org/core/appview/pulls"
···
s.pages,
)
+
router.Use(middleware.TryRefreshSession())
router.Get("/favicon.svg", s.Favicon)
router.Get("/favicon.ico", s.Favicon)
+
router.Get("/pwa-manifest.json", s.PWAManifest)
userRouter := s.UserRouter(&middleware)
standardRouter := s.StandardRouter(&middleware)
···
r.Get("/", s.HomeOrTimeline)
r.Get("/timeline", s.Timeline)
+
r.Get("/upgradeBanner", s.UpgradeBanner)
// special-case handler for serving tangled.org/core
r.Get("/core", s.Core())
···
r.Mount("/strings", s.StringsRouter(mw))
r.Mount("/knots", s.KnotsRouter())
r.Mount("/spindles", s.SpindlesRouter())
+
r.Mount("/notifications", s.NotificationsRouter(mw))
+
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
r.Get("/keys/{user}", s.Keys)
r.Get("/terms", s.TermsOfService)
r.Get("/privacy", s.PrivacyPolicy)
+
r.Get("/brand", s.Brand)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
s.pages.Error404(w)
···
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("go-get") == "1" {
w.Header().Set("Content-Type", "text/html")
+
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
return
}
···
}
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
+
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
return ls.Router(mw)
+
}
+
+
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
+
notifs := notifications.New(s.db, s.oauth, s.pages)
+
return notifs.Router(mw)
}
func (s *State) SignupRouter() http.Handler {
+76 -3
appview/state/state.go
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
-
posthogService "tangled.org/core/appview/posthog"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/validator"
xrpcclient "tangled.org/core/appview/xrpcclient"
···
cache := cache.New(config.Redis.Addr)
sess := session.New(cache)
oauth := oauth.NewOAuth(config, sess)
-
validator := validator.New(d, res)
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
if err != nil {
···
tangled.RepoIssueNSID,
tangled.RepoIssueCommentNSID,
tangled.LabelDefinitionNSID,
},
nil,
slog.Default(),
···
)
if err != nil {
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
}
ingester := appview.Ingester{
···
spindlestream.Start(ctx)
var notifiers []notify.Notifier
if !config.Core.Dev {
-
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
}
notifier := notify.NewMergedNotifier(notifiers...)
···
}
s.pages.Favicon(w)
}
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
···
})
}
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
if s.oauth.GetUser(r) != nil {
s.Timeline(w, r)
···
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
l := s.logger.With("handler", "UpgradeBanner")
l = l.With("did", user.Did)
l = l.With("handle", user.Handle)
···
Rkey: rkey,
Description: description,
Created: time.Now(),
}
record := repo.AsRecord()
···
})
return err
}
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
+
dbnotify "tangled.org/core/appview/notify/db"
+
phnotify "tangled.org/core/appview/notify/posthog"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/validator"
xrpcclient "tangled.org/core/appview/xrpcclient"
···
cache := cache.New(config.Redis.Addr)
sess := session.New(cache)
oauth := oauth.NewOAuth(config, sess)
+
validator := validator.New(d, res, enforcer)
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
if err != nil {
···
tangled.RepoIssueNSID,
tangled.RepoIssueCommentNSID,
tangled.LabelDefinitionNSID,
+
tangled.LabelOpNSID,
},
nil,
slog.Default(),
···
)
if err != nil {
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
+
}
+
+
if err := BackfillDefaultDefs(d, res); err != nil {
+
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
}
ingester := appview.Ingester{
···
spindlestream.Start(ctx)
var notifiers []notify.Notifier
+
+
// Always add the database notifier
+
notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
+
+
// Add other notifiers in production only
if !config.Core.Dev {
+
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
}
notifier := notify.NewMergedNotifier(notifiers...)
···
}
s.pages.Favicon(w)
+
}
+
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
+
const manifestJson = `{
+
"name": "tangled",
+
"description": "tightly-knit social coding.",
+
"icons": [
+
{
+
"src": "/favicon.svg",
+
"sizes": "144x144"
+
}
+
],
+
"start_url": "/",
+
"id": "org.tangled",
+
+
"display": "standalone",
+
"background_color": "#111827",
+
"theme_color": "#111827"
+
}`
+
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "application/json")
+
w.Write([]byte(manifestJson))
}
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
···
})
}
+
func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
s.pages.Brand(w, pages.BrandParams{
+
LoggedInUser: user,
+
})
+
}
+
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
if s.oauth.GetUser(r) != nil {
s.Timeline(w, r)
···
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
+
if user == nil {
+
return
+
}
+
l := s.logger.With("handler", "UpgradeBanner")
l = l.With("did", user.Did)
l = l.With("handle", user.Handle)
···
Rkey: rkey,
Description: description,
Created: time.Now(),
+
Labels: models.DefaultLabelDefs(),
}
record := repo.AsRecord()
···
})
return err
}
+
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
+
defaults := models.DefaultLabelDefs()
+
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
+
if err != nil {
+
return err
+
}
+
// already present
+
if len(defaultLabels) == len(defaults) {
+
return nil
+
}
+
+
labelDefs, err := models.FetchDefaultDefs(r)
+
if err != nil {
+
return err
+
}
+
+
// Insert each label definition to the database
+
for _, labelDef := range labelDefs {
+
_, err = db.AddLabelDefinition(e, &labelDef)
+
if err != nil {
+
return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
+
}
+
}
+
+
return nil
+
}
+15 -1
appview/validator/label.go
···
return nil
}
-
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
if labelDef == nil {
return fmt.Errorf("label definition is required")
}
if labelOp == nil {
return fmt.Errorf("label operation is required")
}
expectedKey := labelDef.AtUri().String()
···
return nil
}
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
if labelDef == nil {
return fmt.Errorf("label definition is required")
}
+
if repo == nil {
+
return fmt.Errorf("repo is required")
+
}
if labelOp == nil {
return fmt.Errorf("label operation is required")
+
}
+
+
// validate permissions: only collaborators can apply labels currently
+
//
+
// TODO: introduce a repo:triage permission
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
+
if err != nil {
+
return fmt.Errorf("failed to enforce permissions: %w", err)
+
}
+
if !ok {
+
return fmt.Errorf("unauhtorized label operation")
}
expectedKey := labelDef.AtUri().String()
+4 -1
appview/validator/validator.go
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/idresolver"
)
type Validator struct {
db *db.DB
sanitizer markup.Sanitizer
resolver *idresolver.Resolver
}
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
return &Validator{
db: db,
sanitizer: markup.NewSanitizer(),
resolver: res,
}
}
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/idresolver"
+
"tangled.org/core/rbac"
)
type Validator struct {
db *db.DB
sanitizer markup.Sanitizer
resolver *idresolver.Resolver
+
enforcer *rbac.Enforcer
}
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
return &Validator{
db: db,
sanitizer: markup.NewSanitizer(),
resolver: res,
+
enforcer: enforcer,
}
}
+1
cmd/gen.go
···
tangled.RepoIssueState{},
tangled.RepoPull{},
tangled.RepoPullComment{},
tangled.RepoPull_Source{},
tangled.RepoPullStatus{},
tangled.RepoPull_Target{},
···
tangled.RepoIssueState{},
tangled.RepoPull{},
tangled.RepoPullComment{},
+
tangled.RepoPull_StackInfo{},
tangled.RepoPull_Source{},
tangled.RepoPullStatus{},
tangled.RepoPull_Target{},
+1 -1
docs/spindle/pipeline.md
···
- `manual`: The workflow can be triggered manually.
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
```yaml
when:
···
- `manual`: The workflow can be triggered manually.
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
```yaml
when:
+1 -1
knotserver/config/config.go
···
Repo Repo `env:",prefix=KNOT_REPO_"`
Server Server `env:",prefix=KNOT_SERVER_"`
Git Git `env:",prefix=KNOT_GIT_"`
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
}
func Load(ctx context.Context) (*Config, error) {
···
Repo Repo `env:",prefix=KNOT_REPO_"`
Server Server `env:",prefix=KNOT_SERVER_"`
Git Git `env:",prefix=KNOT_GIT_"`
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
}
func Load(ctx context.Context) (*Config, error) {
-103
knotserver/git/git.go
···
h plumbing.Hash
}
-
type TagList struct {
-
refs []*TagReference
-
r *git.Repository
-
}
-
-
// TagReference is used to list both tag and non-annotated tags.
-
// Non-annotated tags should only contains a reference.
-
// Annotated tags should contain its reference and its tag information.
-
type TagReference struct {
-
ref *plumbing.Reference
-
tag *object.Tag
-
}
-
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
// to tar WriteHeader
type infoWrapper struct {
···
mode fs.FileMode
modTime time.Time
isDir bool
-
}
-
-
func (self *TagList) Len() int {
-
return len(self.refs)
-
}
-
-
func (self *TagList) Swap(i, j int) {
-
self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
-
}
-
-
// sorting tags in reverse chronological order
-
func (self *TagList) Less(i, j int) bool {
-
var dateI time.Time
-
var dateJ time.Time
-
-
if self.refs[i].tag != nil {
-
dateI = self.refs[i].tag.Tagger.When
-
} else {
-
c, err := self.r.CommitObject(self.refs[i].ref.Hash())
-
if err != nil {
-
dateI = time.Now()
-
} else {
-
dateI = c.Committer.When
-
}
-
}
-
-
if self.refs[j].tag != nil {
-
dateJ = self.refs[j].tag.Tagger.When
-
} else {
-
c, err := self.r.CommitObject(self.refs[j].ref.Hash())
-
if err != nil {
-
dateJ = time.Now()
-
} else {
-
dateJ = c.Committer.When
-
}
-
}
-
-
return dateI.After(dateJ)
}
func Open(path string, ref string) (*GitRepo, error) {
···
return g.r.CommitObject(h)
}
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
-
c, err := g.r.CommitObject(g.h)
-
if err != nil {
-
return nil, fmt.Errorf("last commit: %w", err)
-
}
-
return c, nil
-
}
-
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
c, err := g.r.CommitObject(g.h)
if err != nil {
···
}
return buf.Bytes(), nil
-
}
-
-
func (g *GitRepo) FileContent(path string) (string, error) {
-
c, err := g.r.CommitObject(g.h)
-
if err != nil {
-
return "", fmt.Errorf("commit object: %w", err)
-
}
-
-
tree, err := c.Tree()
-
if err != nil {
-
return "", fmt.Errorf("file tree: %w", err)
-
}
-
-
file, err := tree.File(path)
-
if err != nil {
-
return "", err
-
}
-
-
isbin, _ := file.IsBinary()
-
-
if !isbin {
-
return file.Contents()
-
} else {
-
return "", ErrBinaryFile
-
}
}
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
func (i *infoWrapper) Sys() any {
return nil
}
-
-
func (t *TagReference) Name() string {
-
return t.ref.Name().Short()
-
}
-
-
func (t *TagReference) Message() string {
-
if t.tag != nil {
-
return t.tag.Message
-
}
-
return ""
-
}
-
-
func (t *TagReference) TagObject() *object.Tag {
-
return t.tag
-
}
-
-
func (t *TagReference) Hash() plumbing.Hash {
-
return t.ref.Hash()
-
}
···
h plumbing.Hash
}
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
// to tar WriteHeader
type infoWrapper struct {
···
mode fs.FileMode
modTime time.Time
isDir bool
}
func Open(path string, ref string) (*GitRepo, error) {
···
return g.r.CommitObject(h)
}
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
c, err := g.r.CommitObject(g.h)
if err != nil {
···
}
return buf.Bytes(), nil
}
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
func (i *infoWrapper) Sys() any {
return nil
}
+1 -3
knotserver/git/tag.go
···
import (
"fmt"
-
"slices"
"strconv"
"strings"
"time"
···
outFormat.WriteString("")
outFormat.WriteString(recordSeparator)
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
if err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
···
tags = append(tags, tag)
}
-
slices.Reverse(tags)
return tags, nil
}
···
import (
"fmt"
"strconv"
"strings"
"time"
···
outFormat.WriteString("")
outFormat.WriteString(recordSeparator)
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
if err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
···
tags = append(tags, tag)
}
return tags, nil
}
-4
knotserver/http_util.go
···
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
-
-
func notFound(w http.ResponseWriter) {
-
writeError(w, "not found", http.StatusNotFound)
-
}
···
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
+1 -1
knotserver/ingester.go
···
return fmt.Errorf("failed to construct absolute repo path: %w", err)
}
-
gr, err := git.Open(repoPath, record.Source.Branch)
if err != nil {
return fmt.Errorf("failed to open git repository: %w", err)
}
···
return fmt.Errorf("failed to construct absolute repo path: %w", err)
}
+
gr, err := git.Open(repoPath, record.Source.Sha)
if err != nil {
return fmt.Errorf("failed to open git repository: %w", err)
}
+1 -1
knotserver/xrpc/repo_blob.go
···
contents, err := gr.RawContent(treePath)
if err != nil {
-
x.Logger.Error("file content", "error", err.Error())
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("FileNotFound"),
xrpcerr.WithMessage("file not found at the specified path"),
···
contents, err := gr.RawContent(treePath)
if err != nil {
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("FileNotFound"),
xrpcerr.WithMessage("file not found at the specified path"),
+24
knotserver/xrpc/repo_tree.go
···
"net/http"
"path/filepath"
"time"
"tangled.org/core/api/tangled"
"tangled.org/core/knotserver/git"
xrpcerr "tangled.org/core/xrpc/errors"
)
···
return
}
// convert NiceTree -> tangled.RepoTree_TreeEntry
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
for i, file := range files {
···
Parent: parentPtr,
Dotdot: dotdotPtr,
Files: treeEntries,
}
writeJson(w, response)
···
"net/http"
"path/filepath"
"time"
+
"unicode/utf8"
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/pages/markup"
"tangled.org/core/knotserver/git"
xrpcerr "tangled.org/core/xrpc/errors"
)
···
return
}
+
// if any of these files are a readme candidate, pass along its blob contents too
+
var readmeFileName string
+
var readmeContents string
+
for _, file := range files {
+
if markup.IsReadmeFile(file.Name) {
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
+
if err != nil {
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
+
}
+
+
if utf8.Valid(contents) {
+
readmeFileName = file.Name
+
readmeContents = string(contents)
+
break
+
}
+
}
+
}
+
// convert NiceTree -> tangled.RepoTree_TreeEntry
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
for i, file := range files {
···
Parent: parentPtr,
Dotdot: dotdotPtr,
Files: treeEntries,
+
Readme: &tangled.RepoTree_Readme{
+
Filename: readmeFileName,
+
Contents: readmeContents,
+
},
}
writeJson(w, response)
-158
legal/privacy.md
···
-
# Privacy Policy
-
-
**Last updated:** January 15, 2025
-
-
This Privacy Policy describes how Tangled ("we," "us," or "our")
-
collects, uses, and shares your personal information when you use our
-
platform and services (the "Service").
-
-
## 1. Information We Collect
-
-
### Account Information
-
-
When you create an account, we collect:
-
-
- Your chosen username
-
- Email address
-
- Profile information you choose to provide
-
- Authentication data
-
-
### Content and Activity
-
-
We store:
-
-
- Code repositories and associated metadata
-
- Issues, pull requests, and comments
-
- Activity logs and usage patterns
-
- Public keys for authentication
-
-
## 2. Data Location and Hosting
-
-
### EU Data Hosting
-
-
**All Tangled service data is hosted within the European Union.**
-
Specifically:
-
-
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
-
(*.tngl.sh) are located in Finland
-
- **Application Data:** All other service data is stored on EU-based
-
servers
-
- **Data Processing:** All data processing occurs within EU
-
jurisdiction
-
-
### External PDS Notice
-
-
**Important:** If your account is hosted on Bluesky's PDS or other
-
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
-
that data. The data protection, storage location, and privacy
-
practices for such accounts are governed by the respective PDS
-
provider's policies, not this Privacy Policy. We only control data
-
processing within our own services and infrastructure.
-
-
## 3. Third-Party Data Processors
-
-
We only share your data with the following third-party processors:
-
-
### Resend (Email Services)
-
-
- **Purpose:** Sending transactional emails (account verification,
-
notifications)
-
- **Data Shared:** Email address and necessary message content
-
-
### Cloudflare (Image Caching)
-
-
- **Purpose:** Caching and optimizing image delivery
-
- **Data Shared:** Public images and associated metadata for caching
-
purposes
-
-
### Posthog (Usage Metrics Tracking)
-
-
- **Purpose:** Tracking usage and platform metrics
-
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
-
information
-
-
## 4. How We Use Your Information
-
-
We use your information to:
-
-
- Provide and maintain the Service
-
- Process your transactions and requests
-
- Send you technical notices and support messages
-
- Improve and develop new features
-
- Ensure security and prevent fraud
-
- Comply with legal obligations
-
-
## 5. Data Sharing and Disclosure
-
-
We do not sell, trade, or rent your personal information. We may share
-
your information only in the following circumstances:
-
-
- With the third-party processors listed above
-
- When required by law or legal process
-
- To protect our rights, property, or safety, or that of our users
-
- In connection with a merger, acquisition, or sale of assets (with
-
appropriate protections)
-
-
## 6. Data Security
-
-
We implement appropriate technical and organizational measures to
-
protect your personal information against unauthorized access,
-
alteration, disclosure, or destruction. However, no method of
-
transmission over the Internet is 100% secure.
-
-
## 7. Data Retention
-
-
We retain your personal information for as long as necessary to provide
-
the Service and fulfill the purposes outlined in this Privacy Policy,
-
unless a longer retention period is required by law.
-
-
## 8. Your Rights
-
-
Under applicable data protection laws, you have the right to:
-
-
- Access your personal information
-
- Correct inaccurate information
-
- Request deletion of your information
-
- Object to processing of your information
-
- Data portability
-
- Withdraw consent (where applicable)
-
-
## 9. Cookies and Tracking
-
-
We use cookies and similar technologies to:
-
-
- Maintain your login session
-
- Remember your preferences
-
- Analyze usage patterns to improve the Service
-
-
You can control cookie settings through your browser preferences.
-
-
## 10. Children's Privacy
-
-
The Service is not intended for children under 16 years of age. We do
-
not knowingly collect personal information from children under 16. If
-
we become aware that we have collected such information, we will take
-
steps to delete it.
-
-
## 11. International Data Transfers
-
-
While all our primary data processing occurs within the EU, some of our
-
third-party processors may process data outside the EU. When this
-
occurs, we ensure appropriate safeguards are in place, such as Standard
-
Contractual Clauses or adequacy decisions.
-
-
## 12. Changes to This Privacy Policy
-
-
We may update this Privacy Policy from time to time. We will notify you
-
of any changes by posting the new Privacy Policy on this page and
-
updating the "Last updated" date.
-
-
## 13. Contact Information
-
-
If you have any questions about this Privacy Policy or wish to exercise
-
your rights, please contact us through our platform or via email.
-
-
---
-
-
This Privacy Policy complies with the EU General Data Protection
-
Regulation (GDPR) and other applicable data protection laws.
···
-109
legal/terms.md
···
-
# Terms of Service
-
-
**Last updated:** January 15, 2025
-
-
Welcome to Tangled. These Terms of Service ("Terms") govern your access
-
to and use of the Tangled platform and services (the "Service")
-
operated by us ("Tangled," "we," "us," or "our").
-
-
## 1. Acceptance of Terms
-
-
By accessing or using our Service, you agree to be bound by these Terms.
-
If you disagree with any part of these terms, then you may not access
-
the Service.
-
-
## 2. Account Registration
-
-
To use certain features of the Service, you must register for an
-
account. You agree to provide accurate, current, and complete
-
information during the registration process and to update such
-
information to keep it accurate, current, and complete.
-
-
## 3. Account Termination
-
-
> **Important Notice**
-
>
-
> **We reserve the right to terminate, suspend, or restrict access to
-
> your account at any time, for any reason, or for no reason at all, at
-
> our sole discretion.** This includes, but is not limited to,
-
> termination for violation of these Terms, inappropriate conduct, spam,
-
> abuse, or any other behavior we deem harmful to the Service or other
-
> users.
-
>
-
> Account termination may result in the loss of access to your
-
> repositories, data, and other content associated with your account. We
-
> are not obligated to provide advance notice of termination, though we
-
> may do so in our discretion.
-
-
## 4. Acceptable Use
-
-
You agree not to use the Service to:
-
-
- Violate any applicable laws or regulations
-
- Infringe upon the rights of others
-
- Upload, store, or share content that is illegal, harmful, threatening,
-
abusive, harassing, defamatory, vulgar, obscene, or otherwise
-
objectionable
-
- Engage in spam, phishing, or other deceptive practices
-
- Attempt to gain unauthorized access to the Service or other users'
-
accounts
-
- Interfere with or disrupt the Service or servers connected to the
-
Service
-
-
## 5. Content and Intellectual Property
-
-
You retain ownership of the content you upload to the Service. By
-
uploading content, you grant us a non-exclusive, worldwide, royalty-free
-
license to use, reproduce, modify, and distribute your content as
-
necessary to provide the Service.
-
-
## 6. Privacy
-
-
Your privacy is important to us. Please review our [Privacy
-
Policy](/privacy), which also governs your use of the Service.
-
-
## 7. Disclaimers
-
-
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
-
no warranties, expressed or implied, and hereby disclaim and negate all
-
other warranties including without limitation, implied warranties or
-
conditions of merchantability, fitness for a particular purpose, or
-
non-infringement of intellectual property or other violation of rights.
-
-
## 8. Limitation of Liability
-
-
In no event shall Tangled, nor its directors, employees, partners,
-
agents, suppliers, or affiliates, be liable for any indirect,
-
incidental, special, consequential, or punitive damages, including
-
without limitation, loss of profits, data, use, goodwill, or other
-
intangible losses, resulting from your use of the Service.
-
-
## 9. Indemnification
-
-
You agree to defend, indemnify, and hold harmless Tangled and its
-
affiliates, officers, directors, employees, and agents from and against
-
any and all claims, damages, obligations, losses, liabilities, costs,
-
or debt, and expenses (including attorney's fees).
-
-
## 10. Governing Law
-
-
These Terms shall be interpreted and governed by the laws of Finland,
-
without regard to its conflict of law provisions.
-
-
## 11. Changes to Terms
-
-
We reserve the right to modify or replace these Terms at any time. If a
-
revision is material, we will try to provide at least 30 days notice
-
prior to any new terms taking effect.
-
-
## 12. Contact Information
-
-
If you have any questions about these Terms of Service, please contact
-
us through our platform or via email.
-
-
---
-
-
These terms are effective as of the last updated date shown above and
-
will remain in effect except with respect to any changes in their
-
provisions in the future, which will be in effect immediately after
-
being posted on this page.
···
-29
lexicons/pulls/pull.json
···
"required": [
"target",
"title",
-
"patch",
"createdAt"
],
"properties": {
···
},
"body": {
"type": "string"
-
},
-
"patch": {
-
"type": "string"
-
},
-
"source": {
-
"type": "ref",
-
"ref": "#source"
},
"createdAt": {
"type": "string",
···
},
"branch": {
"type": "string"
-
}
-
}
-
},
-
"source": {
-
"type": "object",
-
"required": [
-
"branch",
-
"sha"
-
],
-
"properties": {
-
"branch": {
-
"type": "string"
-
},
-
"sha": {
-
"type": "string",
-
"minLength": 40,
-
"maxLength": 40
-
},
-
"repo": {
-
"type": "string",
-
"format": "at-uri"
}
}
}
···
"required": [
"target",
"title",
"createdAt"
],
"properties": {
···
},
"body": {
"type": "string"
},
"createdAt": {
"type": "string",
···
},
"branch": {
"type": "string"
}
}
}
+96
lexicons/pulls/round.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.pull.round",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"pull",
+
"patch",
+
"sourceInfo",
+
"createdAt"
+
],
+
"properties": {
+
"pull": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"prevRound": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef"
+
},
+
"patch": {
+
"type": "string",
+
"description": "A patch describing this change. Either gotten directly from the user (patch-based PR) or from the knot based on a commit from another repo. The source of the patch and it's potential details are described by sourceInfo"
+
},
+
"sourceInfo": {
+
"type": "union",
+
"refs": [
+
"#patchSourceInfo",
+
"#commitSourceInfo"
+
]
+
},
+
"stackInfo": {
+
"type": "ref",
+
"ref": "#stackInfo"
+
},
+
"comment": {
+
"type": "string"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
},
+
"patchSourceInfo": {
+
"type": "object",
+
"properties": {}
+
},
+
"commitSourceInfo": {
+
"type": "object",
+
"required": [
+
"repo",
+
"branch",
+
"sha"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "uri"
+
},
+
"branch": {
+
"type": "string"
+
},
+
"sha": {
+
"type": "string",
+
"minLength": 40,
+
"maxLength": 40
+
}
+
}
+
},
+
"stackInfo": {
+
"type": "object",
+
"required": [
+
"changeId"
+
],
+
"properties": {
+
"changeId": {
+
"type": "string",
+
"description": "Change ID of this commit/change."
+
},
+
"parent": {
+
"type": "string",
+
"description": "AT-URI of the PR for the parent commit/change in the change stack.",
+
"format": "at-uri"
+
}
+
}
+
}
+
}
+
}
+19
lexicons/repo/tree.json
···
"type": "string",
"description": "Parent directory path"
},
"files": {
"type": "array",
"items": {
···
"description": "Invalid request parameters"
}
]
},
"treeEntry": {
"type": "object",
···
"type": "string",
"description": "Parent directory path"
},
+
"readme": {
+
"type": "ref",
+
"ref": "#readme",
+
"description": "Readme for this file tree"
+
},
"files": {
"type": "array",
"items": {
···
"description": "Invalid request parameters"
}
]
+
},
+
"readme": {
+
"type": "object",
+
"required": ["filename", "contents"],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"description": "Name of the readme file"
+
},
+
"contents": {
+
"type": "string",
+
"description": "Contents of the readme file"
+
}
+
}
},
"treeEntry": {
"type": "object",
+1 -1
nix/pkgs/knot-unwrapped.nix
···
sqlite-lib,
src,
}: let
-
version = "1.9.0-alpha";
in
buildGoApplication {
pname = "knot";
···
sqlite-lib,
src,
}: let
+
version = "1.9.1-alpha";
in
buildGoApplication {
pname = "knot";
+7 -5
types/repo.go
···
}
type RepoTreeResponse struct {
-
Ref string `json:"ref,omitempty"`
-
Parent string `json:"parent,omitempty"`
-
Description string `json:"description,omitempty"`
-
DotDot string `json:"dotdot,omitempty"`
-
Files []NiceTree `json:"files,omitempty"`
}
type TagReference struct {
···
}
type RepoTreeResponse struct {
+
Ref string `json:"ref,omitempty"`
+
Parent string `json:"parent,omitempty"`
+
Description string `json:"description,omitempty"`
+
DotDot string `json:"dotdot,omitempty"`
+
Files []NiceTree `json:"files,omitempty"`
+
ReadmeFileName string `json:"readme_filename,omitempty"`
+
Readme string `json:"readme_contents,omitempty"`
}
type TagReference struct {