forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+5681 -2826
.air
api
appview
config
db
indexer
issues
knots
middleware
models
notify
oauth
ogcard
pages
pipelines
pulls
repo
reporesolver
settings
spindles
state
strings
validator
docs
guard
idresolver
knotserver
lexicons
nix
spindle
types
workflow
+8 -6
.air/appview.toml
···
-
[build]
-
cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go"
-
bin = ";set -o allexport && source .env && set +o allexport; .bin/app"
root = "."
+
tmp_dir = "out"
-
exclude_regex = [".*_templ.go"]
-
include_ext = ["go", "templ", "html", "css"]
-
exclude_dir = ["target", "atrium", "nix"]
+
[build]
+
cmd = "go build -o out/appview.out cmd/appview/main.go"
+
bin = "out/appview.out"
+
+
include_ext = ["go"]
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
+
stop_on_error = true
+11
.air/knot.toml
···
+
root = "."
+
tmp_dir = "out"
+
+
[build]
+
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o out/knot.out cmd/knot/main.go'
+
bin = "out/knot.out"
+
args_bin = ["server"]
+
+
include_ext = ["go"]
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
+
stop_on_error = true
-7
.air/knotserver.toml
···
-
[build]
-
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/'
-
bin = ".bin/knot server"
-
root = "."
-
-
exclude_regex = [""]
-
include_ext = ["go", "templ"]
+10
.air/spindle.toml
···
+
root = "."
+
tmp_dir = "out"
+
+
[build]
+
cmd = "go build -o out/spindle.out cmd/spindle/main.go"
+
bin = "out/spindle.out"
+
+
include_ext = ["go"]
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
+
stop_on_error = true
+13
.editorconfig
···
+
root = true
+
+
[*.html]
+
indent_size = 2
+
+
[*.json]
+
indent_size = 2
+
+
[*.nix]
+
indent_size = 2
+
+
[*.yml]
+
indent_size = 2
+3 -1
api/tangled/actorprofile.go
···
Location *string `json:"location,omitempty" cborgen:"location,omitempty"`
// pinnedRepositories: Any ATURI, it is up to appviews to validate these fields.
PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"`
-
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
+
// pronouns: Preferred gender pronouns.
+
Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"`
+
Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
}
+196 -2
api/tangled/cbor_gen.go
···
}
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
+
fieldCount := 8
if t.Description == nil {
fieldCount--
···
}
if t.PinnedRepositories == nil {
+
fieldCount--
+
}
+
+
if t.Pronouns == nil {
fieldCount--
}
···
return err
}
if _, err := cw.WriteString(string(*t.Location)); err != nil {
+
return err
+
}
+
}
+
}
+
+
// t.Pronouns (string) (string)
+
if t.Pronouns != nil {
+
+
if len("pronouns") > 1000000 {
+
return xerrors.Errorf("Value in field \"pronouns\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("pronouns")); err != nil {
+
return err
+
}
+
+
if t.Pronouns == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Pronouns) > 1000000 {
+
return xerrors.Errorf("Value in field t.Pronouns was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Pronouns)); err != nil {
return err
}
}
···
}
t.Location = (*string)(&sval)
+
}
+
}
+
// t.Pronouns (string) (string)
+
case "pronouns":
+
+
{
+
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.Pronouns = (*string)(&sval)
}
}
// t.Description (string) (string)
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 8
+
fieldCount := 10
if t.Description == nil {
fieldCount--
···
if t.Spindle == nil {
+
fieldCount--
+
}
+
+
if t.Topics == nil {
+
fieldCount--
+
}
+
+
if t.Website == nil {
fieldCount--
···
+
// t.Topics ([]string) (slice)
+
if t.Topics != nil {
+
+
if len("topics") > 1000000 {
+
return xerrors.Errorf("Value in field \"topics\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("topics")); err != nil {
+
return err
+
}
+
+
if len(t.Topics) > 8192 {
+
return xerrors.Errorf("Slice value in field t.Topics was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil {
+
return err
+
}
+
for _, v := range t.Topics {
+
if len(v) > 1000000 {
+
return xerrors.Errorf("Value in field v was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(v)); err != nil {
+
return err
+
}
+
+
}
+
}
+
// t.Spindle (string) (string)
if t.Spindle != nil {
···
+
// t.Website (string) (string)
+
if t.Website != nil {
+
+
if len("website") > 1000000 {
+
return xerrors.Errorf("Value in field \"website\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("website")); err != nil {
+
return err
+
}
+
+
if t.Website == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Website) > 1000000 {
+
return xerrors.Errorf("Value in field t.Website was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Website)); err != nil {
+
return err
+
}
+
}
+
}
+
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
t.Source = (*string)(&sval)
+
// t.Topics ([]string) (slice)
+
case "topics":
+
+
maj, extra, err = cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
+
if extra > 8192 {
+
return fmt.Errorf("t.Topics: array too large (%d)", extra)
+
}
+
+
if maj != cbg.MajArray {
+
return fmt.Errorf("expected cbor array")
+
}
+
+
if extra > 0 {
+
t.Topics = make([]string, extra)
+
}
+
+
for i := 0; i < int(extra); i++ {
+
{
+
var maj byte
+
var extra uint64
+
var err error
+
_ = maj
+
_ = extra
+
_ = err
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Topics[i] = string(sval)
+
}
+
+
}
+
}
// t.Spindle (string) (string)
case "spindle":
···
t.Spindle = (*string)(&sval)
+
}
+
}
+
// t.Website (string) (string)
+
case "website":
+
+
{
+
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.Website = (*string)(&sval)
// t.CreatedAt (string) (string)
+13 -1
api/tangled/repoblob.go
···
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
type RepoBlob_Output struct {
// content: File content (base64 encoded for binary files)
-
Content string `json:"content" cborgen:"content"`
+
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
// encoding: Content encoding
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
// isBinary: Whether the file is binary
···
Ref string `json:"ref" cborgen:"ref"`
// size: File size in bytes
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
+
// submodule: Submodule information if path is a submodule
+
Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"`
}
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
Name string `json:"name" cborgen:"name"`
// when: Author timestamp
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema.
+
type RepoBlob_Submodule struct {
+
// branch: Branch to track in the submodule
+
Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"`
+
// name: Submodule name
+
Name string `json:"name" cborgen:"name"`
+
// url: Submodule repository URL
+
Url string `json:"url" cborgen:"url"`
}
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-4
api/tangled/repotree.go
···
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
type RepoTree_TreeEntry struct {
-
// is_file: Whether this entry is a file
-
Is_file bool `json:"is_file" cborgen:"is_file"`
-
// is_subtree: Whether this entry is a directory/subtree
-
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
// mode: File mode
Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
···
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
// spindle: CI runner to send jobs to and receive results from
Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"`
+
// topics: Topics related to the repo
+
Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"`
+
// website: Any URI related to the repo
+
Website *string `json:"website,omitempty" cborgen:"website,omitempty"`
}
+11
appview/config/config.go
···
ClientKid string `env:"CLIENT_KID"`
}
+
type PlcConfig struct {
+
PLCURL string `env:"URL, default=https://plc.directory"`
+
}
+
type JetstreamConfig struct {
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
}
···
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
}
+
type LabelConfig struct {
+
DefaultLabelDefs []string `env:"DEFAULTS, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"` // delimiter=,
+
GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"`
+
}
+
func (cfg RedisConfig) ToURL() string {
u := &url.URL{
Scheme: "redis",
···
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
+
Plc PlcConfig `env:",prefix=TANGLED_PLC_"`
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
+
Label LabelConfig `env:",prefix=TANGLED_LABEL_"`
}
func LoadConfig(ctx context.Context) (*Config, error) {
+61 -2
appview/db/db.go
···
-- 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);
`)
if err != nil {
return nil, err
···
runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table pull_submissions add column combined text;
+
`)
+
return err
+
})
+
+
runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table profile add column pronouns text;
+
`)
+
return err
+
})
+
+
runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table repos add column website text;
+
alter table repos add column topics text;
+
`)
+
return err
+
})
+
+
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table notification_preferences add column user_mentioned integer not null default 1;
+
`)
+
return err
+
})
+
+
// remove the foreign key constraints from stars.
+
runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table stars_new (
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text not null,
+
+
subject_at text not null,
+
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
unique(did, rkey),
+
unique(did, subject_at)
+
);
+
+
insert into stars_new (
+
id,
+
did,
+
rkey,
+
subject_at,
+
created
+
)
+
select
+
id,
+
starred_by_did,
+
rkey,
+
repo_at,
+
created
+
from stars;
+
+
drop table stars;
+
alter table stars_new rename to stars;
+
+
create index if not exists idx_stars_created on stars(created);
+
create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
`)
return err
})
+15 -5
appview/db/notifications.go
···
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,
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics,
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
···
var issue models.Issue
var pull models.Pull
var rId, iId, pId sql.NullInt64
-
var rDid, rName, rDescription sql.NullString
+
var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString
var iDid sql.NullString
var iIssueId sql.NullInt64
var iTitle sql.NullString
···
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,
+
&rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr,
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
)
···
}
if rDescription.Valid {
repo.Description = rDescription.String
+
}
+
if rWebsite.Valid {
+
repo.Website = rWebsite.String
+
}
+
if rTopicStr.Valid {
+
repo.Topics = strings.Fields(rTopicStr.String)
}
nwe.Repo = &repo
}
···
pull_created,
pull_commented,
followed,
+
user_mentioned,
pull_merged,
issue_closed,
email_notifications
···
&prefs.PullCreated,
&prefs.PullCommented,
&prefs.Followed,
+
&prefs.UserMentioned,
&prefs.PullMerged,
&prefs.IssueClosed,
&prefs.EmailNotifications,
···
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+
pull_commented, followed, user_mentioned, pull_merged, issue_closed,
+
email_notifications)
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
result, err := d.DB.ExecContext(ctx, query,
···
prefs.PullCreated,
prefs.PullCommented,
prefs.Followed,
+
prefs.UserMentioned,
prefs.PullMerged,
prefs.IssueClosed,
prefs.EmailNotifications,
+4 -2
appview/db/pipeline.go
···
// this is a mega query, but the most useful one:
// get N pipelines, for each one get the latest status of its N workflows
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
+
func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
join
triggers t ON p.trigger_id = t.id
%s
-
`, whereClause)
+
order by p.created desc
+
limit %d
+
`, whereClause, limit)
rows, err := e.Query(query, args...)
if err != nil {
+26 -6
appview/db/profile.go
···
did,
description,
include_bluesky,
-
location
+
location,
+
pronouns
)
-
values (?, ?, ?, ?)`,
+
values (?, ?, ?, ?, ?)`,
profile.Did,
profile.Description,
includeBskyValue,
profile.Location,
+
profile.Pronouns,
)
if err != nil {
···
did,
description,
include_bluesky,
-
location
+
location,
+
pronouns
from
profile
%s`,
···
for rows.Next() {
var profile models.Profile
var includeBluesky int
+
var pronouns sql.Null[string]
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
if err != nil {
return nil, err
}
if includeBluesky != 0 {
profile.IncludeBluesky = true
+
}
+
+
if pronouns.Valid {
+
profile.Pronouns = pronouns.V
}
profileMap[profile.Did] = &profile
···
func GetProfile(e Execer, did string) (*models.Profile, error) {
var profile models.Profile
+
var pronouns sql.Null[string]
+
profile.Did = did
includeBluesky := 0
+
err := e.QueryRow(
-
`select description, include_bluesky, location from profile where did = ?`,
+
`select description, include_bluesky, location, pronouns from profile where did = ?`,
did,
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
if err == sql.ErrNoRows {
profile := models.Profile{}
profile.Did = did
···
if includeBluesky != 0 {
profile.IncludeBluesky = true
+
}
+
+
if pronouns.Valid {
+
profile.Pronouns = pronouns.V
}
rows, err := e.Query(`select link from profile_links where did = ?`, did)
···
// ensure description is not too long
if len(profile.Location) > 40 {
return fmt.Errorf("Entered location is too long.")
+
}
+
+
// ensure pronouns are not too long
+
if len(profile.Pronouns) > 40 {
+
return fmt.Errorf("Entered pronouns are too long.")
}
// ensure links are in order
+4 -4
appview/db/pulls.go
···
_, err = tx.Exec(`
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
values (?, ?, ?, ?, ?)
-
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
+
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
return err
}
···
if err != nil {
return "", err
}
-
return pull.PullAt(), err
+
return pull.AtUri(), err
}
func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) {
···
pull.ParentChangeId = parentChangeId.String
}
-
pulls[pull.PullAt()] = &pull
+
pulls[pull.AtUri()] = &pull
}
var pullAts []syntax.ATURI
for _, p := range pulls {
-
pullAts = append(pullAts, p.PullAt())
+
pullAts = append(pullAts, p.AtUri())
}
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
if err != nil {
+53 -15
appview/db/repos.go
···
rkey,
created,
description,
+
website,
+
topics,
source,
spindle
from
···
for rows.Next() {
var repo models.Repo
var createdAt string
-
var description, source, spindle sql.NullString
+
var description, website, topicStr, source, spindle sql.NullString
err := rows.Scan(
&repo.Id,
···
&repo.Rkey,
&createdAt,
&description,
+
&website,
+
&topicStr,
&source,
&spindle,
)
···
}
if description.Valid {
repo.Description = description.String
+
}
+
if website.Valid {
+
repo.Website = website.String
+
}
+
if topicStr.Valid {
+
repo.Topics = strings.Fields(topicStr.String)
}
if source.Valid {
repo.Source = source.String
···
starCountQuery := fmt.Sprintf(
`select
-
repo_at, count(1)
+
subject_at, count(1)
from stars
-
where repo_at in (%s)
-
group by repo_at`,
+
where subject_at in (%s)
+
group by subject_at`,
inClause,
)
rows, err = e.Query(starCountQuery, args...)
···
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
var repo models.Repo
var nullableDescription sql.NullString
+
var nullableWebsite sql.NullString
+
var nullableTopicStr sql.NullString
-
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics 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 {
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
if nullableDescription.Valid {
repo.Description = nullableDescription.String
-
} else {
-
repo.Description = ""
+
}
+
if nullableWebsite.Valid {
+
repo.Website = nullableWebsite.String
+
}
+
if nullableTopicStr.Valid {
+
repo.Topics = strings.Fields(nullableTopicStr.String)
}
return &repo, nil
}
+
func PutRepo(tx *sql.Tx, repo models.Repo) error {
+
_, err := tx.Exec(
+
`update repos
+
set knot = ?, description = ?, website = ?, topics = ?
+
where did = ? and rkey = ?
+
`,
+
repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey,
+
)
+
return err
+
}
+
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,
+
(did, name, knot, rkey, at_uri, description, website, topics, source)
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source,
)
if err != nil {
return fmt.Errorf("failed to insert repo: %w", err)
···
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
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, 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 repo models.Repo
var createdAt string
var nullableDescription sql.NullString
+
var nullableWebsite sql.NullString
var nullableSource sql.NullString
-
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
var repo models.Repo
var createdAt string
var nullableDescription sql.NullString
+
var nullableWebsite sql.NullString
+
var nullableTopicStr sql.NullString
var nullableSource sql.NullString
row := e.QueryRow(
-
`select id, did, name, knot, rkey, description, created, source
+
`select id, did, name, knot, rkey, description, website, topics, 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)
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
if nullableDescription.Valid {
repo.Description = nullableDescription.String
+
}
+
+
if nullableWebsite.Valid {
+
repo.Website = nullableWebsite.String
+
}
+
+
if nullableTopicStr.Valid {
+
repo.Topics = strings.Fields(nullableTopicStr.String)
}
if nullableSource.Valid {
+39 -99
appview/db/star.go
···
)
func AddStar(e Execer, star *models.Star) error {
-
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
+
query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)`
_, err := e.Exec(
query,
-
star.StarredByDid,
+
star.Did,
star.RepoAt.String(),
star.Rkey,
)
···
}
// Get a star record
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
+
func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) {
query := `
-
select starred_by_did, repo_at, created, rkey
+
select did, subject_at, created, rkey
from stars
-
where starred_by_did = ? and repo_at = ?`
-
row := e.QueryRow(query, starredByDid, repoAt)
+
where did = ? and subject_at = ?`
+
row := e.QueryRow(query, did, subjectAt)
var star models.Star
var created string
-
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
+
err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
if err != nil {
return nil, err
}
···
}
// Remove a star
-
func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error {
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt)
+
func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error {
+
_, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt)
return err
}
// Remove a star
-
func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
+
func DeleteStarByRkey(e Execer, did string, rkey string) error {
+
_, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey)
return err
}
-
func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
+
func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) {
stars := 0
err := e.QueryRow(
-
`select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars)
+
`select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars)
if err != nil {
return 0, err
}
···
}
query := fmt.Sprintf(`
-
SELECT repo_at
+
SELECT subject_at
FROM stars
-
WHERE starred_by_did = ? AND repo_at IN (%s)
+
WHERE did = ? AND subject_at IN (%s)
`, strings.Join(placeholders, ","))
rows, err := e.Query(query, args...)
···
return result, nil
}
-
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
-
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
+
func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool {
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt})
if err != nil {
return false
}
-
return statuses[repoAt.String()]
+
return statuses[subjectAt.String()]
}
// GetStarStatuses returns a map of repo URIs to star status for a given user
-
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
-
return getStarStatuses(e, userDid, repoAts)
+
func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) {
+
return getStarStatuses(e, userDid, subjectAts)
}
-
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
+
+
// GetRepoStars return a list of stars each holding target repository.
+
// If there isn't known repo with starred at-uri, those stars will be ignored.
+
func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
}
repoQuery := fmt.Sprintf(
-
`select starred_by_did, repo_at, created, rkey
+
`select did, subject_at, created, rkey
from stars
%s
order by created desc
···
for rows.Next() {
var star models.Star
var created string
-
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
+
err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
if err != nil {
return nil, err
}
···
return nil, err
}
+
var repoStars []models.RepoStar
for _, r := range repos {
if stars, ok := starMap[string(r.RepoAt())]; ok {
-
for i := range stars {
-
stars[i].Repo = &r
+
for _, star := range stars {
+
repoStars = append(repoStars, models.RepoStar{
+
Star: star,
+
Repo: &r,
+
})
}
}
}
-
var stars []models.Star
-
for _, s := range starMap {
-
stars = append(stars, s...)
-
}
-
-
slices.SortFunc(stars, func(a, b models.Star) int {
+
slices.SortFunc(repoStars, func(a, b models.RepoStar) int {
if a.Created.After(b.Created) {
return -1
}
···
return 0
})
-
return stars, nil
+
return repoStars, nil
}
func CountStars(e Execer, filters ...filter) (int64, error) {
···
return count, nil
}
-
func GetAllStars(e Execer, limit int) ([]models.Star, error) {
-
var stars []models.Star
-
-
rows, err := e.Query(`
-
select
-
s.starred_by_did,
-
s.repo_at,
-
s.rkey,
-
s.created,
-
r.did,
-
r.name,
-
r.knot,
-
r.rkey,
-
r.created
-
from stars s
-
join repos r on s.repo_at = r.at_uri
-
`)
-
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var star models.Star
-
var repo models.Repo
-
var starCreatedAt, repoCreatedAt string
-
-
if err := rows.Scan(
-
&star.StarredByDid,
-
&star.RepoAt,
-
&star.Rkey,
-
&starCreatedAt,
-
&repo.Did,
-
&repo.Name,
-
&repo.Knot,
-
&repo.Rkey,
-
&repoCreatedAt,
-
); err != nil {
-
return nil, err
-
}
-
-
star.Created, err = time.Parse(time.RFC3339, starCreatedAt)
-
if err != nil {
-
star.Created = time.Now()
-
}
-
repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt)
-
if err != nil {
-
repo.Created = time.Now()
-
}
-
star.Repo = &repo
-
-
stars = append(stars, star)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return stars, nil
-
}
-
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
// first, get the top repo URIs by star count from the last week
query := `
with recent_starred_repos as (
-
select distinct repo_at
+
select distinct subject_at
from stars
where created >= datetime('now', '-7 days')
),
repo_star_counts as (
select
-
s.repo_at,
+
s.subject_at,
count(*) as stars_gained_last_week
from stars s
-
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
+
join recent_starred_repos rsr on s.subject_at = rsr.subject_at
where s.created >= datetime('now', '-7 days')
-
group by s.repo_at
+
group by s.subject_at
)
-
select rsc.repo_at
+
select rsc.subject_at
from repo_star_counts rsc
order by rsc.stars_gained_last_week desc
limit 8
+3 -13
appview/db/timeline.go
···
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
filters := make([]filter, 0)
if userIsFollowing != nil {
-
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
+
filters = append(filters, FilterIn("did", userIsFollowing))
}
-
stars, err := GetStars(e, limit, filters...)
+
stars, err := GetRepoStars(e, limit, filters...)
if err != nil {
return nil, err
}
-
// filter star records without a repo
-
n := 0
-
for _, s := range stars {
-
if s.Repo != nil {
-
stars[n] = s
-
n++
-
}
-
}
-
stars = stars[:n]
-
var repos []models.Repo
for _, s := range stars {
repos = append(repos, *s.Repo)
···
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
events = append(events, models.TimelineEvent{
-
Star: &s,
+
RepoStar: &s,
EventAt: s.Created,
IsStarred: isStarred,
StarCount: starCount,
+3 -1
appview/indexer/issues/indexer.go
···
log.Fatalln("failed to populate issue indexer", err)
}
}
-
l.Info("Initialized the issue indexer")
+
+
count, _ := ix.indexer.DocCount()
+
l.Info("Initialized the issue indexer", "docCount", count)
}
func generateIssueIndexMapping() (mapping.IndexMapping, error) {
+4 -3
appview/indexer/notifier.go
···
import (
"context"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
"tangled.org/core/log"
···
var _ notify.Notifier = &Indexer{}
-
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) {
+
func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
l.Debug("indexing new issue")
err := ix.Issues.Index(ctx, *issue)
···
}
}
-
func (ix *Indexer) NewIssueState(ctx context.Context, issue *models.Issue) {
+
func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue)
l.Debug("updating an issue")
err := ix.Issues.Index(ctx, *issue)
···
}
}
-
func (ix *Indexer) NewPullState(ctx context.Context, pull *models.Pull) {
+
func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull)
l.Debug("updating a pr")
err := ix.Pulls.Index(ctx, pull)
+3 -1
appview/indexer/pulls/indexer.go
···
log.Fatalln("failed to populate pull indexer", err)
}
}
-
l.Info("Initialized the pull indexer")
+
+
count, _ := ix.indexer.DocCount()
+
l.Info("Initialized the pull indexer", "docCount", count)
}
func generatePullIndexMapping() (mapping.IndexMapping, error) {
+9 -3
appview/ingester.go
···
return err
}
err = db.AddStar(i.Db, &models.Star{
-
StarredByDid: did,
-
RepoAt: subjectUri,
-
Rkey: e.Commit.RKey,
+
Did: did,
+
RepoAt: subjectUri,
+
Rkey: e.Commit.RKey,
})
case jmodels.CommitOperationDelete:
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
···
includeBluesky := record.Bluesky
+
pronouns := ""
+
if record.Pronouns != nil {
+
pronouns = *record.Pronouns
+
}
+
location := ""
if record.Location != nil {
location = *record.Location
···
Links: links,
Stats: stats,
PinnedRepos: pinned,
+
Pronouns: pronouns,
}
ddb, ok := i.Db.Execer.(*db.DB)
+50 -20
appview/issues/issues.go
···
"tangled.org/core/appview/notify"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
+
"tangled.org/core/appview/pages/markup"
"tangled.org/core/appview/pagination"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/validator"
···
issue.Open = false
// notify about the issue closure
-
rp.notifier.NewIssueState(r.Context(), issue)
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
···
issue.Open = true
// notify about the issue reopen
-
rp.notifier.NewIssueState(r.Context(), issue)
+
rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue)
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
···
// notify about the new comment
comment.Id = commentId
-
rp.notifier.NewIssueComment(r.Context(), &comment)
+
+
rawMentions := markup.FindUserMentions(comment.Body)
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
+
var mentions []syntax.DID
+
for _, ident := range idents {
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
+
mentions = append(mentions, ident.DID)
+
}
+
}
+
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
}
···
keyword := params.Get("q")
-
var ids []int64
+
var issues []models.Issue
searchOpts := models.IssueSearchOptions{
Keyword: keyword,
RepoAt: f.RepoAt().String(),
···
l.Error("failed to search for issues", "err", err)
return
}
-
ids = res.Hits
-
l.Debug("searched issues with indexer", "count", len(ids))
-
} else {
-
ids, err = db.GetIssueIDs(rp.db, searchOpts)
+
l.Debug("searched issues with indexer", "count", len(res.Hits))
+
+
issues, err = db.GetIssues(
+
rp.db,
+
db.FilterIn("id", res.Hits),
+
)
if err != nil {
-
l.Error("failed to search for issues", "err", err)
+
l.Error("failed to get issues", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
return
}
-
l.Debug("indexed all issues from the db", "count", len(ids))
-
}
-
issues, err := db.GetIssues(
-
rp.db,
-
db.FilterIn("id", ids),
-
)
-
if err != nil {
-
l.Error("failed to get issues", "err", err)
-
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
-
return
+
} else {
+
openInt := 0
+
if isOpen {
+
openInt = 1
+
}
+
issues, err = db.GetIssuesPaginated(
+
rp.db,
+
page,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("open", openInt),
+
)
+
if err != nil {
+
l.Error("failed to get issues", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
+
return
+
}
}
labelDefs, err := db.GetLabelDefinitions(
···
// everything is successful, do not rollback the atproto record
atUri = ""
-
rp.notifier.NewIssue(r.Context(), issue)
+
+
rawMentions := markup.FindUserMentions(issue.Body)
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
+
var mentions []syntax.DID
+
for _, ident := range idents {
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
+
mentions = append(mentions, ident.DID)
+
}
+
}
+
rp.notifier.NewIssue(r.Context(), issue, mentions)
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
}
+5 -5
appview/issues/opengraph.go
···
var statusBgColor color.RGBA
if issue.Open {
-
statusIcon = "static/icons/circle-dot.svg"
+
statusIcon = "circle-dot"
statusText = "open"
statusBgColor = color.RGBA{34, 139, 34, 255} // green
} else {
-
statusIcon = "static/icons/circle-dot.svg"
+
statusIcon = "ban"
statusText = "closed"
statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
}
···
badgeIconSize := 36
// Draw icon with status color (no background)
-
err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
+
err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
if err != nil {
log.Printf("failed to draw status icon: %v", err)
}
···
currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
// Draw comment count
-
err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+
err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw comment icon: %v", err)
}
···
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
if err != nil {
log.Printf("dolly silhouette not available (this is ok): %v", err)
}
+9
appview/knots/knots.go
···
"log/slog"
"net/http"
"slices"
+
"strings"
"time"
"github.com/go-chi/chi/v5"
···
}
domain := r.FormValue("domain")
+
// Strip protocol, trailing slashes, and whitespace
+
// Rkey cannot contain slashes
+
domain = strings.TrimSpace(domain)
+
domain = strings.TrimPrefix(domain, "https://")
+
domain = strings.TrimPrefix(domain, "http://")
+
domain = strings.TrimSuffix(domain, "/")
if domain == "" {
k.Pages.Notice(w, noticeId, "Incomplete form.")
return
···
}
member := r.FormValue("member")
+
member = strings.TrimPrefix(member, "@")
if member == "" {
l.Error("empty member")
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
}
member := r.FormValue("member")
+
member = strings.TrimPrefix(member, "@")
if member == "" {
l.Error("empty member")
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+4 -2
appview/middleware/middleware.go
···
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
didOrHandle := chi.URLParam(req, "user")
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
+
if slices.Contains(excluded, didOrHandle) {
next.ServeHTTP(w, req)
return
}
-
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
if err != nil {
···
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
repoName := chi.URLParam(req, "repo")
+
repoName = strings.TrimSuffix(repoName, ".git")
+
id, ok := req.Context().Value("resolvedId").(identity.Identity)
if !ok {
log.Println("malformed middleware")
+25 -43
appview/models/label.go
···
"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"
)
···
return result
}
-
var (
-
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
-
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
-
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
-
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
-
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
-
)
+
func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) {
+
var labelDefs []LabelDefinition
+
ctx := context.Background()
-
func DefaultLabelDefs() []string {
-
return []string{
-
LabelWontfix,
-
LabelDuplicate,
-
LabelAssignee,
-
LabelGoodFirstIssue,
-
LabelDocumentation,
-
}
-
}
+
for _, dl := range aturis {
+
atUri, err := syntax.ParseATURI(dl)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err)
+
}
+
if atUri.Collection() != tangled.LabelDefinitionNSID {
+
return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri)
+
}
-
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,
-
}
+
owner, err := r.ResolveIdent(ctx, atUri.Authority().String())
+
if err != nil {
+
return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err)
+
}
-
var labelDefs []LabelDefinition
+
xrpcc := xrpc.Client{
+
Host: owner.PDSEndpoint(),
+
}
-
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,
+
ctx,
+
&xrpcc,
"",
-
parsedUri.Collection().String(),
-
parsedUri.Authority().String(),
-
parsedUri.RecordKey().String(),
+
atUri.Collection().String(),
+
atUri.Authority().String(),
+
atUri.RecordKey().String(),
)
if err != nil {
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
···
}
labelDef, err := LabelDefinitionFromRecord(
-
parsedUri.Authority().String(),
-
parsedUri.RecordKey().String(),
+
atUri.Authority().String(),
+
atUri.RecordKey().String(),
labelRecord,
)
if err != nil {
+7
appview/models/notifications.go
···
NotificationTypeIssueReopen NotificationType = "issue_reopen"
NotificationTypePullClosed NotificationType = "pull_closed"
NotificationTypePullReopen NotificationType = "pull_reopen"
+
NotificationTypeUserMentioned NotificationType = "user_mentioned"
)
type Notification struct {
···
return "git-pull-request-create"
case NotificationTypeFollowed:
return "user-plus"
+
case NotificationTypeUserMentioned:
+
return "at-sign"
default:
return ""
}
···
PullCreated bool
PullCommented bool
Followed bool
+
UserMentioned bool
PullMerged bool
IssueClosed bool
EmailNotifications bool
···
return prefs.PullCreated // same pref for now
case NotificationTypeFollowed:
return prefs.Followed
+
case NotificationTypeUserMentioned:
+
return prefs.UserMentioned
default:
return false
}
···
PullCreated: true,
PullCommented: true,
Followed: true,
+
UserMentioned: true,
PullMerged: true,
IssueClosed: true,
EmailNotifications: false,
+1
appview/models/profile.go
···
Links [5]string
Stats [2]VanityStat
PinnedRepos [6]syntax.ATURI
+
Pronouns string
}
func (p Profile) IsLinksEmpty() bool {
+1 -1
appview/models/pull.go
···
return p.LatestSubmission().SourceRev
}
-
func (p *Pull) PullAt() syntax.ATURI {
+
func (p *Pull) AtUri() syntax.ATURI {
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
}
+61 -1
appview/models/repo.go
···
import (
"fmt"
+
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
Rkey string
Created time.Time
Description string
+
Website string
+
Topics []string
Spindle string
Labels []string
···
}
func (r *Repo) AsRecord() tangled.Repo {
-
var source, spindle, description *string
+
var source, spindle, description, website *string
if r.Source != "" {
source = &r.Source
···
description = &r.Description
}
+
if r.Website != "" {
+
website = &r.Website
+
}
+
return tangled.Repo{
Knot: r.Knot,
Name: r.Name,
Description: description,
+
Website: website,
+
Topics: r.Topics,
CreatedAt: r.Created.Format(time.RFC3339),
Source: source,
Spindle: spindle,
···
func (r Repo) DidSlashRepo() string {
p, _ := securejoin.SecureJoin(r.Did, r.Name)
return p
+
}
+
+
func (r Repo) TopicStr() string {
+
return strings.Join(r.Topics, " ")
}
type RepoStats struct {
···
Repo *Repo
Issues []Issue
}
+
+
type BlobContentType int
+
+
const (
+
BlobContentTypeCode BlobContentType = iota
+
BlobContentTypeMarkup
+
BlobContentTypeImage
+
BlobContentTypeSvg
+
BlobContentTypeVideo
+
BlobContentTypeSubmodule
+
)
+
+
func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode }
+
func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup }
+
func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage }
+
func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg }
+
func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo }
+
func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule }
+
+
type BlobView struct {
+
HasTextView bool // can show as code/text
+
HasRenderedView bool // can show rendered (markup/image/video/submodule)
+
HasRawView bool // can download raw (everything except submodule)
+
+
// current display mode
+
ShowingRendered bool // currently in rendered mode
+
ShowingText bool // currently in text/code mode
+
+
// content type flags
+
ContentType BlobContentType
+
+
// Content data
+
Contents string
+
ContentSrc string // URL for media files
+
Lines int
+
SizeHint uint64
+
}
+
+
// if both views are available, then show a toggle between them
+
func (b BlobView) ShowToggle() bool {
+
return b.HasTextView && b.HasRenderedView
+
}
+
+
func (b BlobView) IsUnsupported() bool {
+
// no view available, only raw
+
return !(b.HasRenderedView || b.HasTextView)
+
}
+14 -5
appview/models/star.go
···
)
type Star struct {
-
StarredByDid string
-
RepoAt syntax.ATURI
-
Created time.Time
-
Rkey string
+
Did string
+
RepoAt syntax.ATURI
+
Created time.Time
+
Rkey string
+
}
-
// optionally, populate this when querying for reverse mappings
+
// RepoStar is used for reverse mapping to repos
+
type RepoStar struct {
+
Star
Repo *Repo
}
+
+
// StringStar is used for reverse mapping to strings
+
type StringStar struct {
+
Star
+
String *String
+
}
+1 -1
appview/models/string.go
···
Edited *time.Time
}
-
func (s *String) StringAt() syntax.ATURI {
+
func (s *String) AtUri() syntax.ATURI {
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
}
+1 -1
appview/models/timeline.go
···
type TimelineEvent struct {
*Repo
*Follow
-
*Star
+
*RepoStar
EventAt time.Time
+55 -17
appview/notify/db/db.go
···
"slices"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
"tangled.org/core/idresolver"
+
)
+
+
const (
+
maxMentions = 5
)
type databaseNotifier struct {
···
}
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
+
if star.RepoAt.Collection().String() != tangled.RepoNSID {
+
// skip string stars for now
+
return
+
}
var err error
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
if err != nil {
···
return
}
-
actorDid := syntax.DID(star.StarredByDid)
+
actorDid := syntax.DID(star.Did)
recipients := []syntax.DID{syntax.DID(repo.Did)}
eventType := models.NotificationTypeRepoStarred
entityType := "repo"
···
// no-op
}
-
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
// build the recipients list
// - owner of the repo
···
}
actorDid := syntax.DID(issue.Did)
-
eventType := models.NotificationTypeIssueCreated
entityType := "issue"
entityId := issue.AtUri().String()
repoId := &issue.Repo.Id
···
n.notifyEvent(
actorDid,
recipients,
-
eventType,
+
models.NotificationTypeIssueCreated,
+
entityType,
+
entityId,
+
repoId,
+
issueId,
+
pullId,
+
)
+
n.notifyEvent(
+
actorDid,
+
mentions,
+
models.NotificationTypeUserMentioned,
entityType,
entityId,
repoId,
···
)
}
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
if err != nil {
log.Printf("NewIssueComment: failed to get issues: %v", err)
···
}
actorDid := syntax.DID(comment.Did)
-
eventType := models.NotificationTypeIssueCommented
entityType := "issue"
entityId := issue.AtUri().String()
repoId := &issue.Repo.Id
···
n.notifyEvent(
actorDid,
recipients,
-
eventType,
+
models.NotificationTypeIssueCommented,
+
entityType,
+
entityId,
+
repoId,
+
issueId,
+
pullId,
+
)
+
n.notifyEvent(
+
actorDid,
+
mentions,
+
models.NotificationTypeUserMentioned,
entityType,
entityId,
repoId,
···
actorDid := syntax.DID(pull.OwnerDid)
eventType := models.NotificationTypePullCreated
entityType := "pull"
-
entityId := pull.PullAt().String()
+
entityId := pull.AtUri().String()
repoId := &repo.Id
var issueId *int64
p := int64(pull.ID)
···
)
}
-
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
pull, err := db.GetPull(n.db,
syntax.ATURI(comment.RepoAt),
comment.PullId,
···
actorDid := syntax.DID(comment.OwnerDid)
eventType := models.NotificationTypePullCommented
entityType := "pull"
-
entityId := pull.PullAt().String()
+
entityId := pull.AtUri().String()
repoId := &repo.Id
var issueId *int64
p := int64(pull.ID)
···
issueId,
pullId,
)
+
n.notifyEvent(
+
actorDid,
+
mentions,
+
models.NotificationTypeUserMentioned,
+
entityType,
+
entityId,
+
repoId,
+
issueId,
+
pullId,
+
)
}
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
···
// no-op
}
-
func (n *databaseNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
+
func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
// build up the recipients list:
// - repo owner
// - repo collaborators
···
recipients = append(recipients, syntax.DID(p))
}
-
actorDid := syntax.DID(issue.Repo.Did)
entityType := "pull"
entityId := issue.AtUri().String()
repoId := &issue.Repo.Id
···
}
n.notifyEvent(
-
actorDid,
+
actor,
recipients,
eventType,
entityType,
···
)
}
-
func (n *databaseNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
+
func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
// Get repo details
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
···
recipients = append(recipients, syntax.DID(p))
}
-
actorDid := syntax.DID(repo.Did)
entityType := "pull"
-
entityId := pull.PullAt().String()
+
entityId := pull.AtUri().String()
repoId := &repo.Id
var issueId *int64
var eventType models.NotificationType
···
pullId := &p
n.notifyEvent(
-
actorDid,
+
actor,
recipients,
eventType,
entityType,
···
issueId *int64,
pullId *int64,
) {
+
if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions {
+
recipients = recipients[:maxMentions]
+
}
recipientSet := make(map[syntax.DID]struct{})
for _, did := range recipients {
// everybody except actor themselves
+11 -10
appview/notify/merged_notifier.go
···
"reflect"
"sync"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
"tangled.org/core/log"
)
···
m.fanout("DeleteStar", ctx, star)
}
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
-
m.fanout("NewIssue", ctx, issue)
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
+
m.fanout("NewIssue", ctx, issue, mentions)
}
-
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
-
m.fanout("NewIssueComment", ctx, comment)
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
+
m.fanout("NewIssueComment", ctx, comment, mentions)
}
-
func (m *mergedNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
-
m.fanout("NewIssueState", ctx, issue)
+
func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
+
m.fanout("NewIssueState", ctx, actor, issue)
}
func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {
···
m.fanout("NewPull", ctx, pull)
}
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
-
m.fanout("NewPullComment", ctx, comment)
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
+
m.fanout("NewPullComment", ctx, comment, mentions)
}
-
func (m *mergedNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
-
m.fanout("NewPullState", ctx, pull)
+
func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+
m.fanout("NewPullState", ctx, actor, pull)
}
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
+15 -12
appview/notify/notifier.go
···
import (
"context"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
···
NewStar(ctx context.Context, star *models.Star)
DeleteStar(ctx context.Context, star *models.Star)
-
NewIssue(ctx context.Context, issue *models.Issue)
-
NewIssueComment(ctx context.Context, comment *models.IssueComment)
-
NewIssueState(ctx context.Context, issue *models.Issue)
+
NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID)
+
NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID)
+
NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue)
DeleteIssue(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)
-
NewPullState(ctx context.Context, pull *models.Pull)
+
NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID)
+
NewPullState(ctx context.Context, actor syntax.DID, 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) NewIssueState(ctx context.Context, issue *models.Issue) {}
-
func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {}
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
+
}
+
func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {}
+
func (m *BaseNotifier) DeleteIssue(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) NewPullState(ctx context.Context, pull *models.Pull) {}
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) {
+
}
+
func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {}
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+15 -9
appview/notify/posthog/notifier.go
···
"context"
"log"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/posthog/posthog-go"
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
···
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: star.StarredByDid,
+
DistinctId: star.Did,
Event: "star",
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
})
···
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: star.StarredByDid,
+
DistinctId: star.Did,
Event: "unstar",
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
})
···
}
}
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: issue.Did,
Event: "new_issue",
Properties: posthog.Properties{
"repo_at": issue.RepoAt.String(),
"issue_id": issue.IssueId,
+
"mentions": mentions,
},
})
if err != nil {
···
}
}
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: comment.OwnerDid,
Event: "new_pull_comment",
Properties: posthog.Properties{
-
"repo_at": comment.RepoAt,
-
"pull_id": comment.PullId,
+
"repo_at": comment.RepoAt,
+
"pull_id": comment.PullId,
+
"mentions": mentions,
},
})
if err != nil {
···
}
}
-
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: comment.Did,
Event: "new_issue_comment",
Properties: posthog.Properties{
"issue_at": comment.IssueAt,
+
"mentions": mentions,
},
})
if err != nil {
···
}
}
-
func (n *posthogNotifier) NewIssueState(ctx context.Context, issue *models.Issue) {
+
func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {
var event string
if issue.Open {
event = "issue_reopen"
···
Event: event,
Properties: posthog.Properties{
"repo_at": issue.RepoAt.String(),
+
"actor": actor,
"issue_id": issue.IssueId,
},
})
···
}
}
-
func (n *posthogNotifier) NewPullState(ctx context.Context, pull *models.Pull) {
+
func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
var event string
switch pull.State {
case models.PullClosed:
···
Properties: posthog.Properties{
"repo_at": pull.RepoAt,
"pull_id": pull.PullId,
+
"actor": actor,
},
})
if err != nil {
+4
appview/oauth/oauth.go
···
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
clientApp.Dir = res.Directory()
+
// allow non-public transports in dev mode
+
if config.Core.Dev {
+
clientApp.Resolver.Client.Transport = http.DefaultTransport
+
}
clientName := config.Core.AppviewName
+59 -10
appview/ogcard/card.go
···
import (
"bytes"
"fmt"
+
"html/template"
"image"
"image/color"
"io"
···
return width, nil
}
-
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
-
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
-
svgData, err := pages.Files.ReadFile(svgPath)
-
if err != nil {
-
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
-
}
-
+
func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) {
// Convert color to hex string for SVG
rgba, isRGBA := iconColor.(color.RGBA)
if !isRGBA {
···
// Parse SVG
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
if err != nil {
-
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
+
return nil, fmt.Errorf("failed to parse SVG: %w", err)
}
+
return icon, nil
+
}
+
+
func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) {
+
svgData, err := pages.Files.ReadFile(svgPath)
+
if err != nil {
+
return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
+
}
+
+
icon, err := BuildSVGIconFromData(svgData, iconColor)
+
if err != nil {
+
return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err)
+
}
+
+
return icon, nil
+
}
+
+
func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) {
+
return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
+
}
+
+
func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error {
+
icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor)
+
if err != nil {
+
return err
+
}
+
+
c.DrawSVGIcon(icon, x, y, size)
+
+
return nil
+
}
+
+
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
+
tpl, err := template.New("dolly").
+
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
+
if err != nil {
+
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
+
}
+
+
var svgData bytes.Buffer
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
+
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
+
}
+
+
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
+
if err != nil {
+
return err
+
}
+
+
c.DrawSVGIcon(icon, x, y, size)
+
+
return nil
+
}
+
+
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
+
func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) {
// Set the icon size
w, h := float64(size), float64(size)
icon.SetTarget(0, 0, w, h)
···
}
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
-
-
return nil
}
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
+64 -5
appview/pages/funcmap.go
···
package pages
import (
+
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
···
"strings"
"time"
+
"github.com/alecthomas/chroma/v2"
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+
"github.com/alecthomas/chroma/v2/lexers"
+
"github.com/alecthomas/chroma/v2/styles"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/dustin/go-humanize"
"github.com/go-enry/go-enry/v2"
+
"github.com/yuin/goldmark"
"tangled.org/core/appview/filetree"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/crypto"
···
"contains": func(s string, target string) bool {
return strings.Contains(s, target)
},
+
"stripPort": func(hostname string) string {
+
if strings.Contains(hostname, ":") {
+
return strings.Split(hostname, ":")[0]
+
}
+
return hostname
+
},
"mapContains": func(m any, key any) bool {
mapValue := reflect.ValueOf(m)
if mapValue.Kind() != reflect.Map {
···
return "handle.invalid"
}
-
return "@" + identity.Handle.String()
+
return identity.Handle.String()
},
"truncateAt30": func(s string) string {
if len(s) <= 30 {
···
},
"splitOn": func(s, sep string) []string {
return strings.Split(s, sep)
+
},
+
"string": func(v any) string {
+
return fmt.Sprint(v)
},
"int64": func(a int) int64 {
return int64(a)
···
return b
},
"didOrHandle": func(did, handle string) string {
-
if handle != "" {
-
return fmt.Sprintf("@%s", handle)
+
if handle != "" && handle != syntax.HandleInvalid.String() {
+
return handle
} else {
return did
}
···
},
"description": func(text string) template.HTML {
p.rctx.RendererType = markup.RendererTypeDefault
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
+
sanitized := p.rctx.SanitizeDescription(htmlString)
+
return template.HTML(sanitized)
+
},
+
"readme": func(text string) template.HTML {
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
htmlString := p.rctx.RenderMarkdown(text)
-
sanitized := p.rctx.SanitizeDescription(htmlString)
+
sanitized := p.rctx.SanitizeDefault(htmlString)
return template.HTML(sanitized)
},
+
"code": func(content, path string) string {
+
var style *chroma.Style = styles.Get("catpuccin-latte")
+
formatter := chromahtml.New(
+
chromahtml.InlineCode(false),
+
chromahtml.WithLineNumbers(true),
+
chromahtml.WithLinkableLineNumbers(true, "L"),
+
chromahtml.Standalone(false),
+
chromahtml.WithClasses(true),
+
)
+
+
lexer := lexers.Get(filepath.Base(path))
+
if lexer == nil {
+
lexer = lexers.Fallback
+
}
+
+
iterator, err := lexer.Tokenise(nil, content)
+
if err != nil {
+
p.logger.Error("chroma tokenize", "err", "err")
+
return ""
+
}
+
+
var code bytes.Buffer
+
err = formatter.Format(&code, style, iterator)
+
if err != nil {
+
p.logger.Error("chroma format", "err", "err")
+
return ""
+
}
+
+
return code.String()
+
},
+
"trimUriScheme": func(text string) string {
+
text = strings.TrimPrefix(text, "https://")
+
text = strings.TrimPrefix(text, "http://")
+
return text
+
},
"isNil": func(t any) bool {
// returns false for other "zero" values
return t == nil
···
u, _ := url.PathUnescape(s)
return u
},
-
+
"safeUrl": func(s string) template.URL {
+
return template.URL(s)
+
},
"tinyAvatar": func(handle string) string {
return p.AvatarUrl(handle, "tiny")
},
+111
appview/pages/markup/extension/atlink.go
···
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
+
+
package extension
+
+
import (
+
"regexp"
+
+
"github.com/yuin/goldmark"
+
"github.com/yuin/goldmark/ast"
+
"github.com/yuin/goldmark/parser"
+
"github.com/yuin/goldmark/renderer"
+
"github.com/yuin/goldmark/renderer/html"
+
"github.com/yuin/goldmark/text"
+
"github.com/yuin/goldmark/util"
+
)
+
+
// An AtNode struct represents an AtNode
+
type AtNode struct {
+
Handle string
+
ast.BaseInline
+
}
+
+
var _ ast.Node = &AtNode{}
+
+
// Dump implements Node.Dump.
+
func (n *AtNode) Dump(source []byte, level int) {
+
ast.DumpHelper(n, source, level, nil, nil)
+
}
+
+
// KindAt is a NodeKind of the At node.
+
var KindAt = ast.NewNodeKind("At")
+
+
// Kind implements Node.Kind.
+
func (n *AtNode) Kind() ast.NodeKind {
+
return KindAt
+
}
+
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
+
+
type atParser struct{}
+
+
// NewAtParser return a new InlineParser that parses
+
// at expressions.
+
func NewAtParser() parser.InlineParser {
+
return &atParser{}
+
}
+
+
func (s *atParser) Trigger() []byte {
+
return []byte{'@'}
+
}
+
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
+
line, segment := block.PeekLine()
+
m := atRegexp.FindSubmatchIndex(line)
+
if m == nil {
+
return nil
+
}
+
atSegment := text.NewSegment(segment.Start, segment.Start+m[1])
+
block.Advance(m[1])
+
node := &AtNode{}
+
node.AppendChild(node, ast.NewTextSegment(atSegment))
+
node.Handle = string(atSegment.Value(block.Source())[1:])
+
return node
+
}
+
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
+
// renders At nodes.
+
type atHtmlRenderer struct {
+
html.Config
+
}
+
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+
r := &atHtmlRenderer{
+
Config: html.NewConfig(),
+
}
+
for _, opt := range opts {
+
opt.SetHTMLOption(&r.Config)
+
}
+
return r
+
}
+
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+
reg.Register(KindAt, r.renderAt)
+
}
+
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+
if entering {
+
w.WriteString(`<a href="/@`)
+
w.WriteString(n.(*AtNode).Handle)
+
w.WriteString(`" class="mention font-bold">`)
+
} else {
+
w.WriteString("</a>")
+
}
+
return ast.WalkContinue, nil
+
}
+
+
type atExt struct{}
+
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
+
var AtExt = &atExt{}
+
+
func (e *atExt) Extend(m goldmark.Markdown) {
+
m.Parser().AddOptions(parser.WithInlineParsers(
+
util.Prioritized(NewAtParser(), 500),
+
))
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
+
util.Prioritized(NewAtHTMLRenderer(), 500),
+
))
+
}
+35 -2
appview/pages/markup/markdown.go
···
htmlparse "golang.org/x/net/html"
"tangled.org/core/api/tangled"
+
textension "tangled.org/core/appview/pages/markup/extension"
"tangled.org/core/appview/pages/repoinfo"
)
···
Files fs.FS
}
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
+
func NewMarkdown() goldmark.Markdown {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
···
),
treeblood.MathML(),
callout.CalloutExtention,
+
textension.AtExt,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
+
return md
+
}
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
+
return rctx.RenderMarkdownWith(source, NewMarkdown())
+
}
+
+
func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
if rctx != nil {
var transformers []util.PrioritizedValue
···
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
parsedURL := &url.URL{
Scheme: scheme,
···
}
return path.Join(rctx.CurrentDir, dst)
+
}
+
+
// FindUserMentions returns Set of user handles from given markup soruce.
+
// It doesn't guarntee unique DIDs
+
func FindUserMentions(source string) []string {
+
var (
+
mentions []string
+
mentionsSet = make(map[string]struct{})
+
md = NewMarkdown()
+
sourceBytes = []byte(source)
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
+
)
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+
if entering && n.Kind() == textension.KindAt {
+
handle := n.(*textension.AtNode).Handle
+
mentionsSet[handle] = struct{}{}
+
return ast.WalkSkipChildren, nil
+
}
+
return ast.WalkContinue, nil
+
})
+
for handle := range mentionsSet {
+
mentions = append(mentions, handle)
+
}
+
return mentions
}
func isAbsoluteUrl(link string) bool {
+3
appview/pages/markup/sanitizer.go
···
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
+
// at-mentions
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a")
+
// centering content
policy.AllowElements("center")
+18 -116
appview/pages/pages.go
···
package pages
import (
-
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
···
"tangled.org/core/patchutil"
"tangled.org/core/types"
-
"github.com/alecthomas/chroma/v2"
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
-
"github.com/alecthomas/chroma/v2/lexers"
-
"github.com/alecthomas/chroma/v2/styles"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/go-git/go-git/v5/plumbing"
···
return p.executePlain("user/fragments/editPins", w, params)
}
-
type RepoStarFragmentParams struct {
+
type StarBtnFragmentParams struct {
IsStarred bool
-
RepoAt syntax.ATURI
-
Stats models.RepoStats
-
}
-
-
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
-
return p.executePlain("repo/fragments/repoStar", w, params)
-
}
-
-
type RepoDescriptionParams struct {
-
RepoInfo repoinfo.RepoInfo
+
SubjectAt syntax.ATURI
+
StarCount int
}
-
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
-
return p.executePlain("repo/fragments/editRepoDescription", w, params)
-
}
-
-
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
-
return p.executePlain("repo/fragments/repoDescription", w, params)
+
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
+
return p.executePlain("fragments/starBtn", w, params)
}
type RepoIndexParams struct {
···
func (r RepoTreeParams) TreeStats() RepoTreeStats {
numFolders, numFiles := 0, 0
for _, f := range r.Files {
-
if !f.IsFile {
+
if !f.IsFile() {
numFolders += 1
-
} else if f.IsFile {
+
} else if f.IsFile() {
numFiles += 1
}
}
···
}
type RepoBlobParams struct {
-
LoggedInUser *oauth.User
-
RepoInfo repoinfo.RepoInfo
-
Active string
-
Unsupported bool
-
IsImage bool
-
IsVideo bool
-
ContentSrc string
-
BreadCrumbs [][]string
-
ShowRendered bool
-
RenderToggle bool
-
RenderedContents template.HTML
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
BreadCrumbs [][]string
+
BlobView models.BlobView
*tangled.RepoBlob_Output
-
// Computed fields for template compatibility
-
Contents string
-
Lines int
-
SizeHint uint64
-
IsBinary bool
}
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
-
var style *chroma.Style = styles.Get("catpuccin-latte")
-
-
if params.ShowRendered {
-
switch markup.GetFormat(params.Path) {
-
case markup.FormatMarkdown:
-
p.rctx.RepoInfo = params.RepoInfo
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
-
htmlString := p.rctx.RenderMarkdown(params.Contents)
-
sanitized := p.rctx.SanitizeDefault(htmlString)
-
params.RenderedContents = template.HTML(sanitized)
-
}
-
}
-
-
c := params.Contents
-
formatter := chromahtml.New(
-
chromahtml.InlineCode(false),
-
chromahtml.WithLineNumbers(true),
-
chromahtml.WithLinkableLineNumbers(true, "L"),
-
chromahtml.Standalone(false),
-
chromahtml.WithClasses(true),
-
)
-
-
lexer := lexers.Get(filepath.Base(params.Path))
-
if lexer == nil {
-
lexer = lexers.Fallback
+
switch params.BlobView.ContentType {
+
case models.BlobContentTypeMarkup:
+
p.rctx.RepoInfo = params.RepoInfo
}
-
iterator, err := lexer.Tokenise(nil, c)
-
if err != nil {
-
return fmt.Errorf("chroma tokenize: %w", err)
-
}
-
-
var code bytes.Buffer
-
err = formatter.Format(&code, style, iterator)
-
if err != nil {
-
return fmt.Errorf("chroma format: %w", err)
-
}
-
-
params.Contents = code.String()
params.Active = "overview"
return p.executeRepo("repo/blob", w, params)
}
···
ShowRendered bool
RenderToggle bool
RenderedContents template.HTML
-
String models.String
+
String *models.String
Stats models.StringStats
+
IsStarred bool
+
StarCount int
Owner identity.Identity
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
-
var style *chroma.Style = styles.Get("catpuccin-latte")
-
-
if params.ShowRendered {
-
switch markup.GetFormat(params.String.Filename) {
-
case markup.FormatMarkdown:
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
-
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
-
sanitized := p.rctx.SanitizeDefault(htmlString)
-
params.RenderedContents = template.HTML(sanitized)
-
}
-
}
-
-
c := params.String.Contents
-
formatter := chromahtml.New(
-
chromahtml.InlineCode(false),
-
chromahtml.WithLineNumbers(true),
-
chromahtml.WithLinkableLineNumbers(true, "L"),
-
chromahtml.Standalone(false),
-
chromahtml.WithClasses(true),
-
)
-
-
lexer := lexers.Get(filepath.Base(params.String.Filename))
-
if lexer == nil {
-
lexer = lexers.Fallback
-
}
-
-
iterator, err := lexer.Tokenise(nil, c)
-
if err != nil {
-
return fmt.Errorf("chroma tokenize: %w", err)
-
}
-
-
var code bytes.Buffer
-
err = formatter.Format(&code, style, iterator)
-
if err != nil {
-
return fmt.Errorf("chroma format: %w", err)
-
}
-
-
params.String.Contents = code.String()
return p.execute("strings/string", w, params)
+7 -7
appview/pages/repoinfo/repoinfo.go
···
package repoinfo
import (
-
"fmt"
"path"
"slices"
-
"strings"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
"tangled.org/core/appview/state/userutil"
)
-
func (r RepoInfo) OwnerWithAt() string {
+
func (r RepoInfo) Owner() string {
if r.OwnerHandle != "" {
-
return fmt.Sprintf("@%s", r.OwnerHandle)
+
return r.OwnerHandle
} else {
return r.OwnerDid
}
}
func (r RepoInfo) FullName() string {
-
return path.Join(r.OwnerWithAt(), r.Name)
+
return path.Join(r.Owner(), r.Name)
}
func (r RepoInfo) OwnerWithoutAt() string {
-
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
-
return after
+
if r.OwnerHandle != "" {
+
return r.OwnerHandle
} else {
return userutil.FlattenDid(r.OwnerDid)
}
···
OwnerDid string
OwnerHandle string
Description string
+
Website string
+
Topics []string
Knot string
Spindle string
RepoAt syntax.ATURI
+82 -54
appview/pages/templates/fragments/dolly/logo.html
···
{{ define "fragments/dolly/logo" }}
-
<svg
-
version="1.1"
-
id="svg1"
-
class="{{.}}"
-
width="25"
-
height="25"
-
viewBox="0 0 25 25"
-
sodipodi:docname="tangled_dolly_face_only.png"
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-
xmlns:xlink="http://www.w3.org/1999/xlink"
-
xmlns="http://www.w3.org/2000/svg"
-
xmlns:svg="http://www.w3.org/2000/svg">
-
<title>Dolly</title>
-
<defs
-
id="defs1" />
-
<sodipodi:namedview
-
id="namedview1"
-
pagecolor="#ffffff"
-
bordercolor="#000000"
-
borderopacity="0.25"
-
inkscape:showpageshadow="2"
-
inkscape:pageopacity="0.0"
-
inkscape:pagecheckerboard="true"
-
inkscape:deskcolor="#d5d5d5">
-
<inkscape:page
-
x="0"
-
y="0"
-
width="25"
-
height="25"
-
id="page2"
-
margin="0"
-
bleed="0" />
-
</sodipodi:namedview>
-
<g
-
inkscape:groupmode="layer"
-
inkscape:label="Image"
-
id="g1">
-
<image
-
width="252.48"
-
height="248.96001"
-
preserveAspectRatio="none"
-
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;"
-
id="image1"
-
x="-233.6257"
-
y="10.383364"
-
style="display:none" />
-
<path
-
fill="currentColor"
-
style="stroke-width:0.111183"
-
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
-
id="path4" />
-
</g>
-
</svg>
+
<svg
+
version="1.1"
+
id="svg1"
+
class="{{ . }}"
+
width="25"
+
height="25"
+
viewBox="0 0 25 25"
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
+
inkscape:export-filename="tangled_logotype_black_on_trans.svg"
+
inkscape:export-xdpi="96"
+
inkscape:export-ydpi="96"
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+
xmlns="http://www.w3.org/2000/svg"
+
xmlns:svg="http://www.w3.org/2000/svg"
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+
xmlns:cc="http://creativecommons.org/ns#">
+
<sodipodi:namedview
+
id="namedview1"
+
pagecolor="#ffffff"
+
bordercolor="#000000"
+
borderopacity="0.25"
+
inkscape:showpageshadow="2"
+
inkscape:pageopacity="0.0"
+
inkscape:pagecheckerboard="true"
+
inkscape:deskcolor="#d5d5d5"
+
inkscape:zoom="45.254834"
+
inkscape:cx="3.1377863"
+
inkscape:cy="8.9382717"
+
inkscape:window-width="3840"
+
inkscape:window-height="2160"
+
inkscape:window-x="0"
+
inkscape:window-y="0"
+
inkscape:window-maximized="0"
+
inkscape:current-layer="g1"
+
borderlayer="true">
+
<inkscape:page
+
x="0"
+
y="0"
+
width="25"
+
height="25"
+
id="page2"
+
margin="0"
+
bleed="0" />
+
</sodipodi:namedview>
+
<g
+
inkscape:groupmode="layer"
+
inkscape:label="Image"
+
id="g1"
+
transform="translate(-0.42924038,-0.87777209)">
+
<path
+
fill="currentColor"
+
style="stroke-width:0.111183;"
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
+
id="path4"
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" />
+
</g>
+
<metadata
+
id="metadata1">
+
<rdf:RDF>
+
<cc:Work
+
rdf:about="">
+
<cc:license
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
+
</cc:Work>
+
<cc:License
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
+
<cc:permits
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
+
<cc:permits
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
+
<cc:requires
+
rdf:resource="http://creativecommons.org/ns#Notice" />
+
<cc:requires
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
+
<cc:permits
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
+
</cc:License>
+
</rdf:RDF>
+
</metadata>
+
</svg>
{{ end }}
+60 -22
appview/pages/templates/fragments/dolly/silhouette.html
···
<svg
version="1.1"
id="svg1"
-
width="32"
-
height="32"
+
width="25"
+
height="25"
viewBox="0 0 25 25"
-
sodipodi:docname="tangled_dolly_silhouette.png"
+
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
+
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
+
inkscape:export-xdpi="96"
+
inkscape:export-ydpi="96"
+
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
-
xmlns:svg="http://www.w3.org/2000/svg">
-
<style>
-
.dolly {
-
color: #000000;
-
}
+
xmlns:svg="http://www.w3.org/2000/svg"
+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+
xmlns:cc="http://creativecommons.org/ns#">
+
<style>
+
.dolly {
+
color: #000000;
+
}
-
@media (prefers-color-scheme: dark) {
-
.dolly {
-
color: #ffffff;
-
}
-
}
-
</style>
-
<title>Dolly</title>
-
<defs
-
id="defs1" />
+
@media (prefers-color-scheme: dark) {
+
.dolly {
+
color: #ffffff;
+
}
+
}
+
</style>
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
···
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
-
inkscape:deskcolor="#d1d1d1">
+
inkscape:deskcolor="#d5d5d5"
+
inkscape:zoom="64"
+
inkscape:cx="4.96875"
+
inkscape:cy="13.429688"
+
inkscape:window-width="3840"
+
inkscape:window-height="2160"
+
inkscape:window-x="0"
+
inkscape:window-y="0"
+
inkscape:window-maximized="0"
+
inkscape:current-layer="g1"
+
borderlayer="true">
<inkscape:page
x="0"
y="0"
···
<g
inkscape:groupmode="layer"
inkscape:label="Image"
-
id="g1">
+
id="g1"
+
transform="translate(-0.42924038,-0.87777209)">
<path
class="dolly"
fill="currentColor"
-
style="stroke-width:1.12248"
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
-
id="path1" />
+
style="stroke-width:0.111183"
+
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
+
id="path7"
+
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
</g>
+
<metadata
+
id="metadata1">
+
<rdf:RDF>
+
<cc:Work
+
rdf:about="">
+
<cc:license
+
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
+
</cc:Work>
+
<cc:License
+
rdf:about="http://creativecommons.org/licenses/by/4.0/">
+
<cc:permits
+
rdf:resource="http://creativecommons.org/ns#Reproduction" />
+
<cc:permits
+
rdf:resource="http://creativecommons.org/ns#Distribution" />
+
<cc:requires
+
rdf:resource="http://creativecommons.org/ns#Notice" />
+
<cc:requires
+
rdf:resource="http://creativecommons.org/ns#Attribution" />
+
<cc:permits
+
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
+
</cc:License>
+
</rdf:RDF>
+
</metadata>
</svg>
{{ end }}
-44
appview/pages/templates/fragments/dolly/silhouette.svg
···
-
<svg
-
version="1.1"
-
id="svg1"
-
width="32"
-
height="32"
-
viewBox="0 0 25 25"
-
sodipodi:docname="tangled_dolly_silhouette.png"
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-
xmlns="http://www.w3.org/2000/svg"
-
xmlns:svg="http://www.w3.org/2000/svg">
-
<title>Dolly</title>
-
<defs
-
id="defs1" />
-
<sodipodi:namedview
-
id="namedview1"
-
pagecolor="#ffffff"
-
bordercolor="#000000"
-
borderopacity="0.25"
-
inkscape:showpageshadow="2"
-
inkscape:pageopacity="0.0"
-
inkscape:pagecheckerboard="true"
-
inkscape:deskcolor="#d1d1d1">
-
<inkscape:page
-
x="0"
-
y="0"
-
width="25"
-
height="25"
-
id="page2"
-
margin="0"
-
bleed="0" />
-
</sodipodi:namedview>
-
<g
-
inkscape:groupmode="layer"
-
inkscape:label="Image"
-
id="g1">
-
<path
-
class="dolly"
-
fill="currentColor"
-
style="stroke-width:1.12248"
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
-
id="path1" />
-
</g>
-
</svg>
+28
appview/pages/templates/fragments/starBtn.html
···
+
{{ define "fragments/starBtn" }}
+
<button
+
id="starBtn"
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
+
data-star-subject-at="{{ .SubjectAt }}"
+
{{ if .IsStarred }}
+
hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
+
{{ else }}
+
hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
+
{{ end }}
+
+
hx-trigger="click"
+
hx-target="this"
+
hx-swap="outerHTML"
+
hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'
+
hx-disabled-elt="#starBtn"
+
>
+
{{ if .IsStarred }}
+
{{ i "star" "w-4 h-4 fill-current" }}
+
{{ else }}
+
{{ i "star" "w-4 h-4" }}
+
{{ end }}
+
<span class="text-sm">
+
{{ .StarCount }}
+
</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+25
appview/pages/templates/fragments/tabSelector.html
···
+
{{ define "fragments/tabSelector" }}
+
{{ $name := .Name }}
+
{{ $all := .Values }}
+
{{ $active := .Active }}
+
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
+
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
+
{{ range $index, $value := $all }}
+
{{ $isActive := eq $value.Key $active }}
+
<a href="?{{ $name }}={{ $value.Key }}"
+
class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
+
{{ if $value.Icon }}
+
{{ i $value.Icon "size-4" }}
+
{{ end }}
+
+
{{ with $value.Meta }}
+
{{ . }}
+
{{ end }}
+
+
{{ $value.Value }}
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+
+17 -9
appview/pages/templates/knots/fragments/addMemberModal.html
···
<div
id="add-member-{{ .Id }}"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
class="
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
{{ block "addKnotMemberPopover" . }} {{ end }}
</div>
{{ end }}
···
ADD MEMBER
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
-
<input
-
type="text"
-
id="member-did-{{ .Id }}"
-
name="member"
-
required
-
placeholder="@foo.bsky.social"
-
/>
+
<actor-typeahead>
+
<input
+
autocapitalize="none"
+
autocorrect="off"
+
autocomplete="off"
+
type="text"
+
id="member-did-{{ .Id }}"
+
name="member"
+
required
+
placeholder="user.tngl.sh"
+
class="w-full"
+
/>
+
</actor-typeahead>
<div class="flex gap-2 pt-2">
<button
type="button"
···
</div>
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
</form>
-
{{ end }}
+
{{ end }}
+1
appview/pages/templates/layouts/base.html
···
<script defer src="/static/htmx.min.js"></script>
<script defer src="/static/htmx-ext-ws.min.js"></script>
+
<script defer src="/static/actor-typeahead.js" type="module"></script>
<!-- preconnect to image cdn -->
<link rel="preconnect" href="https://avatar.tangled.sh" />
+57 -26
appview/pages/templates/layouts/repobase.html
···
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
{{ define "content" }}
-
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
-
{{ if .RepoInfo.Source }}
-
<p class="text-sm">
-
<div class="flex items-center">
-
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
-
forked from
-
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
-
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
-
</div>
-
</p>
-
{{ end }}
-
<div class="text-lg flex items-center justify-between">
-
<div>
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
-
<span class="select-none">/</span>
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
+
<section id="repo-header" class="mb-4 p-2 dark:text-white">
+
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
+
<!-- left items -->
+
<div class="flex flex-col gap-2">
+
<!-- repo owner / repo name -->
+
<div class="flex items-center gap-2 flex-wrap">
+
{{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }}
+
<span class="select-none">/</span>
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
+
</div>
+
+
{{ if .RepoInfo.Source }}
+
{{ $sourceOwner := resolve .RepoInfo.Source.Did }}
+
<div class="flex items-center gap-1 text-sm flex-wrap">
+
{{ i "git-fork" "w-3 h-3 shrink-0" }}
+
<span>forked from</span>
+
<a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">
+
{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}
+
</a>
+
</div>
+
{{ end }}
+
+
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
+
{{ if .RepoInfo.Description }}
+
{{ .RepoInfo.Description | description }}
+
{{ else }}
+
<span class="italic">this repo has no description</span>
+
{{ end }}
+
+
{{ with .RepoInfo.Website }}
+
<span class="flex items-center gap-1">
+
<span class="flex-shrink-0">{{ i "globe" "size-4" }}</span>
+
<a href="{{ . }}">{{ . | trimUriScheme }}</a>
+
</span>
+
{{ end }}
+
+
{{ if .RepoInfo.Topics }}
+
<div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300">
+
{{ range .RepoInfo.Topics }}
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
</span>
</div>
-
<div class="flex items-center gap-2 z-auto">
-
<a
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
-
href="/{{ .RepoInfo.FullName }}/feed.atom"
-
>
-
{{ i "rss" "size-4" }}
-
</a>
-
{{ template "repo/fragments/repoStar" .RepoInfo }}
+
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
+
{{ template "fragments/starBtn"
+
(dict "SubjectAt" .RepoInfo.RepoAt
+
"IsStarred" .RepoInfo.IsStarred
+
"StarCount" .RepoInfo.Stats.StarCount) }}
<a
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
hx-boost="true"
···
fork
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
+
<a
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
+
href="/{{ .RepoInfo.FullName }}/feed.atom">
+
{{ i "rss" "size-4" }}
+
<span class="md:hidden">atom</span>
+
</a>
</div>
</div>
-
{{ template "repo/fragments/repoDescription" . }}
</section>
<section class="w-full flex flex-col" >
···
</div>
</nav>
{{ block "repoContentLayout" . }}
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white">
{{ block "repoContent" . }}{{ end }}
</section>
{{ block "repoAfter" . }}{{ end }}
+2
appview/pages/templates/notifications/fragments/item.html
···
reopened a pull request
{{ else if eq .Type "followed" }}
followed you
+
{{ else if eq .Type "user_mentioned" }}
+
mentioned you
{{ else }}
{{ end }}
{{ end }}
+64 -39
appview/pages/templates/repo/blob.html
···
{{ end }}
{{ define "repoContent" }}
-
{{ $lines := split .Contents }}
-
{{ $tot_lines := len $lines }}
-
{{ $tot_chars := len (printf "%d" $tot_lines) }}
-
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
{{ $linkstyle := "no-underline hover:underline" }}
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
</div>
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<span>{{ .Lines }} lines</span>
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<span>{{ byteFmt .SizeHint }}</span>
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
-
{{ if .RenderToggle }}
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<a
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
-
hx-boost="true"
-
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
+
+
{{ if .BlobView.ShowingText }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<span>{{ .Lines }} lines</span>
+
{{ end }}
+
+
{{ if .BlobView.SizeHint }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<span>{{ byteFmt .BlobView.SizeHint }}</span>
+
{{ end }}
+
+
{{ if .BlobView.HasRawView }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
+
{{ end }}
+
+
{{ if .BlobView.ShowToggle }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true">
+
view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }}
+
</a>
{{ end }}
</div>
</div>
</div>
-
{{ if and .IsBinary .Unsupported }}
-
<p class="text-center text-gray-400 dark:text-gray-500">
-
Previews are not supported for this file type.
-
</p>
-
{{ else if .IsBinary }}
-
<div class="text-center">
-
{{ if .IsImage }}
-
<img src="{{ .ContentSrc }}"
-
alt="{{ .Path }}"
-
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
-
{{ else if .IsVideo }}
-
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
-
<source src="{{ .ContentSrc }}">
-
Your browser does not support the video tag.
-
</video>
-
{{ end }}
-
</div>
-
{{ else }}
-
<div class="overflow-auto relative">
-
{{ if .ShowRendered }}
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
+
{{ if .BlobView.IsUnsupported }}
+
<p class="text-center text-gray-400 dark:text-gray-500">
+
Previews are not supported for this file type.
+
</p>
+
{{ else if .BlobView.ContentType.IsSubmodule }}
+
<p class="text-center text-gray-400 dark:text-gray-500">
+
This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>.
+
</p>
+
{{ else if .BlobView.ContentType.IsImage }}
+
<div class="text-center">
+
<img src="{{ .BlobView.ContentSrc }}"
+
alt="{{ .Path }}"
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
+
</div>
+
{{ else if .BlobView.ContentType.IsVideo }}
+
<div class="text-center">
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
+
<source src="{{ .BlobView.ContentSrc }}">
+
Your browser does not support the video tag.
+
</video>
+
</div>
+
{{ else if .BlobView.ContentType.IsSvg }}
+
<div class="overflow-auto relative">
+
{{ if .BlobView.ShowingRendered }}
+
<div class="text-center">
+
<img src="{{ .BlobView.ContentSrc }}"
+
alt="{{ .Path }}"
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
+
</div>
+
{{ else }}
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
+
{{ end }}
+
</div>
+
{{ else if .BlobView.ContentType.IsMarkup }}
+
<div class="overflow-auto relative">
+
{{ if .BlobView.ShowingRendered }}
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div>
{{ else }}
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
{{ end }}
-
</div>
+
</div>
+
{{ else if .BlobView.ContentType.IsCode }}
+
<div class="overflow-auto relative">
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
+
</div>
{{ end }}
{{ template "fragments/multiline-select" }}
{{ end }}
+1 -1
appview/pages/templates/repo/compare/compare.html
···
{{ end }}
{{ define "mainLayout" }}
-
<div class="px-1 col-span-full flex flex-col gap-4">
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
{{ block "contentLayout" . }}
{{ block "content" . }}{{ end }}
{{ end }}
+1 -1
appview/pages/templates/repo/empty.html
···
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
-
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
<p><span class="{{$bullet}}">4</span>Push!</p>
</div>
</div>
+1
appview/pages/templates/repo/fork.html
···
value="{{ . }}"
class="mr-2"
id="domain-{{ . }}"
+
{{if eq (len $.Knots) 1}}checked{{end}}
/>
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
</div>
+4 -4
appview/pages/templates/repo/fragments/cloneDropdown.html
···
<code
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
onclick="window.getSelection().selectAllChildren(this)"
-
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
-
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
+
data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}"
+
>https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code>
<button
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
<code
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
onclick="window.getSelection().selectAllChildren(this)"
-
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
-
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
+
data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
+
>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
<button
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+20 -18
appview/pages/templates/repo/fragments/diffOpts.html
···
{{ if .Split }}
{{ $active = "split" }}
{{ end }}
-
{{ $values := list "unified" "split" }}
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
+
+
{{ $unified :=
+
(dict
+
"Key" "unified"
+
"Value" "unified"
+
"Icon" "square-split-vertical"
+
"Meta" "") }}
+
{{ $split :=
+
(dict
+
"Key" "split"
+
"Value" "split"
+
"Icon" "square-split-horizontal"
+
"Meta" "") }}
+
{{ $values := list $unified $split }}
+
+
{{ template "fragments/tabSelector"
+
(dict
+
"Name" "diff"
+
"Values" $values
+
"Active" $active) }}
</section>
{{ end }}
-
{{ define "tabSelector" }}
-
{{ $name := .Name }}
-
{{ $all := .Values }}
-
{{ $active := .Active }}
-
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
-
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
-
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
-
{{ range $index, $value := $all }}
-
{{ $isActive := eq $value $active }}
-
<a href="?{{ $name }}={{ $value }}"
-
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
-
{{ $value }}
-
</a>
-
{{ end }}
-
</div>
-
{{ end }}
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
-
{{ define "repo/fragments/editRepoDescription" }}
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
-
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
-
{{ i "check" "w-3 h-3" }} save
-
</button>
-
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
-
{{ i "x" "w-3 h-3" }} cancel
-
</button>
-
</form>
-
{{ end }}
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
···
+
{{ define "repo/fragments/externalLinkPanel" }}
+
<div id="at-uri-panel" class="px-2 md:px-0">
+
<div class="flex justify-between items-center gap-2">
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span>
+
<div class="flex items-center gap-2">
+
<button
+
onclick="copyToClipboard(this)"
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
+
title="Copy to clipboard">
+
{{ i "copy" "w-4 h-4" }}
+
</button>
+
<a
+
href="https://pdsls.dev/{{.}}"
+
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
+
title="View in PDSls">
+
{{ i "arrow-up-right" "w-4 h-4" }}
+
</a>
+
</div>
+
</div>
+
<span
+
class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600"
+
onclick="window.getSelection().selectAllChildren(this)"
+
title="{{.}}"
+
data-aturi="{{ . | string | safeUrl }}"
+
>{{.}}</span>
+
+
+
</div>
+
+
<script>
+
function copyToClipboard(button) {
+
const container = document.getElementById("at-uri-panel");
+
const urlSpan = container?.querySelector('[data-aturi]');
+
const text = urlSpan?.getAttribute('data-aturi');
+
console.log("copying to clipboard", text)
+
if (!text) return;
+
+
navigator.clipboard.writeText(text).then(() => {
+
const originalContent = button.innerHTML;
+
button.innerHTML = `{{ i "check" "w-4 h-4" }}`;
+
setTimeout(() => {
+
button.innerHTML = originalContent;
+
}, 2000);
+
});
+
}
+
</script>
+
{{ end }}
+
-15
appview/pages/templates/repo/fragments/repoDescription.html
···
-
{{ define "repo/fragments/repoDescription" }}
-
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
-
{{ if .RepoInfo.Description }}
-
{{ .RepoInfo.Description | description }}
-
{{ else }}
-
<span class="italic">this repo has no description</span>
-
{{ end }}
-
-
{{ if .RepoInfo.Roles.IsOwner }}
-
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
-
{{ i "pencil" "w-3 h-3" }}
-
</button>
-
{{ end }}
-
</span>
-
{{ end }}
-26
appview/pages/templates/repo/fragments/repoStar.html
···
-
{{ define "repo/fragments/repoStar" }}
-
<button
-
id="starBtn"
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
-
{{ if .IsStarred }}
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ else }}
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ end }}
-
-
hx-trigger="click"
-
hx-target="this"
-
hx-swap="outerHTML"
-
hx-disabled-elt="#starBtn"
-
>
-
{{ if .IsStarred }}
-
{{ i "star" "w-4 h-4 fill-current" }}
-
{{ else }}
-
{{ i "star" "w-4 h-4" }}
-
{{ end }}
-
<span class="text-sm">
-
{{ .Stats.StarCount }}
-
</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+8 -1
appview/pages/templates/repo/index.html
···
{{ end }}
{{ define "repoLanguages" }}
-
<details class="group -m-6 mb-4">
+
<details class="group -my-4 -m-6 mb-4">
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
{{ range $value := .Languages }}
<div
···
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
+
{{ if .IsSubmodule }}
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
+
{{ $icon = "folder-input" }}
+
{{ $iconStyle = "size-4" }}
+
{{ end }}
+
{{ if .IsFile }}
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
{{ $icon = "file" }}
{{ $iconStyle = "size-4" }}
{{ end }}
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
{{ i $icon $iconStyle "flex-shrink-0" }}
+1 -1
appview/pages/templates/repo/issues/fragments/issueListing.html
···
class="no-underline hover:underline"
>
{{ .Title | description }}
-
<span class="text-gray-500">#{{ .IssueId }}</span>
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
</a>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+1
appview/pages/templates/repo/issues/issue.html
···
"Subject" $.Issue.AtUri
"State" $.Issue.Labels) }}
{{ template "repo/fragments/participants" $.Issue.Participants }}
+
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
</div>
</div>
{{ end }}
+50 -27
appview/pages/templates/repo/issues/issues.html
···
{{ end }}
{{ define "repoContent" }}
-
<div class="flex justify-between items-center gap-4">
-
<div class="flex gap-4">
-
<a
-
href="?state=open"
-
class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
+
{{ $active := "closed" }}
+
{{ if .FilteringByOpen }}
+
{{ $active = "open" }}
+
{{ end }}
+
+
{{ $open :=
+
(dict
+
"Key" "open"
+
"Value" "open"
+
"Icon" "circle-dot"
+
"Meta" (string .RepoInfo.Stats.IssueCount.Open)) }}
+
{{ $closed :=
+
(dict
+
"Key" "closed"
+
"Value" "closed"
+
"Icon" "ban"
+
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
+
{{ $values := list $open $closed }}
+
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
+
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
+
<div class="flex-1 flex relative">
+
<input
+
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
+
type="text"
+
name="q"
+
value="{{ .FilterQuery }}"
+
placeholder=" "
>
-
{{ i "circle-dot" "w-4 h-4" }}
-
<span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span>
-
</a>
-
<a
-
href="?state=closed"
-
class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
+
<a
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
>
-
{{ i "ban" "w-4 h-4" }}
-
<span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span>
-
</a>
-
<form class="flex gap-4" method="GET">
-
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
-
<input class="" type="text" name="q" value="{{ .FilterQuery }}">
-
<button class="btn" type="submit">
-
search
-
</button>
+
{{ i "x" "w-4 h-4" }}
+
</a>
+
</div>
+
<button
+
type="submit"
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
+
>
+
{{ i "search" "w-4 h-4" }}
+
</button>
</form>
-
</div>
-
<a
+
<div class="sm:row-start-1">
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+
</div>
+
<a
href="/{{ .RepoInfo.FullName }}/issues/new"
-
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
-
>
+
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
+
>
{{ i "circle-plus" "w-4 h-4" }}
<span>new</span>
-
</a>
-
</div>
-
<div class="error" id="issues"></div>
+
</a>
+
</div>
+
<div class="error" id="issues"></div>
{{ end }}
{{ define "repoAfter" }}
+1
appview/pages/templates/repo/new.html
···
class="mr-2"
id="domain-{{ . }}"
required
+
{{if eq (len $.Knots) 1}}checked{{end}}
/>
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
</div>
+3 -3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
<div id="lines" hx-swap-oob="beforeend">
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
-
<div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div>
-
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
+
<div class="group-open:hidden flex items-center gap-1">{{ i "chevron-right" "w-4 h-4" }} {{ template "stepHeader" . }}</div>
+
<div class="hidden group-open:flex items-center gap-1">{{ i "chevron-down" "w-4 h-4" }} {{ template "stepHeader" . }}</div>
</summary>
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
</details>
···
{{ end }}
{{ define "stepHeader" }}
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
+
{{ .Name }}
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
{{ end }}
+15 -3
appview/pages/templates/repo/pipelines/pipelines.html
···
{{ range .Pipelines }}
{{ block "pipeline" (list $ .) }} {{ end }}
{{ else }}
-
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
-
No pipelines run for this repository.
-
</p>
+
<div class="py-6 w-fit flex flex-col gap-4 mx-auto">
+
<p>
+
No pipelines have been run for this repository yet. To get started:
+
</p>
+
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
+
<p>
+
<span class="{{ $bullet }}">1</span>First, choose a spindle in your
+
<a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>.
+
</p>
+
<p>
+
<span class="{{ $bullet }}">2</span>Configure your CI/CD
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
+
</p>
+
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
+
</div>
{{ end }}
</div>
</div>
+81 -83
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
{{ $isLastRound := eq $roundNumber $lastIdx }}
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
{{ $isUpToDate := .ResubmitCheck.No }}
-
<div class="relative w-fit">
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
-
<button
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
-
hx-target="#actions-{{$roundNumber}}"
-
hx-swap="outerHtml"
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
-
{{ i "message-square-plus" "w-4 h-4" }}
-
<span>comment</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ if .BranchDeleteStatus }}
-
<button
-
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
-
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
-
{{ i "git-branch" "w-4 h-4" }}
-
<span>delete branch</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
-
{{ $disabled := "" }}
-
{{ if $isConflicted }}
-
{{ $disabled = "disabled" }}
-
{{ end }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
-
hx-swap="none"
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
-
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
-
{{ i "git-merge" "w-4 h-4" }}
-
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative">
+
<button
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
+
{{ i "message-square-plus" "w-4 h-4" }}
+
<span>comment</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ if .BranchDeleteStatus }}
+
<button
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
+
{{ i "git-branch" "w-4 h-4" }}
+
<span>delete branch</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isConflicted }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
+
{{ i "git-merge" "w-4 h-4" }}
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
-
{{ $disabled := "" }}
-
{{ if $isUpToDate }}
-
{{ $disabled = "disabled" }}
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isUpToDate }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button id="resubmitBtn"
+
{{ if not .Pull.IsPatchBased }}
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
{{ else }}
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
{{ end }}
-
<button id="resubmitBtn"
-
{{ if not .Pull.IsPatchBased }}
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
{{ else }}
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
hx-target="#actions-{{$roundNumber}}"
-
hx-swap="outerHtml"
-
{{ end }}
-
hx-disabled-elt="#resubmitBtn"
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
+
hx-disabled-elt="#resubmitBtn"
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
-
{{ if $disabled }}
-
title="Update this branch to resubmit this pull request"
-
{{ else }}
-
title="Resubmit this pull request"
-
{{ end }}
-
>
-
{{ i "rotate-ccw" "w-4 h-4" }}
-
<span>resubmit</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+
{{ if $disabled }}
+
title="Update this branch to resubmit this pull request"
+
{{ else }}
+
title="Resubmit this pull request"
+
{{ end }}
+
>
+
{{ i "rotate-ccw" "w-4 h-4" }}
+
<span>resubmit</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2 group">
-
{{ i "ban" "w-4 h-4" }}
-
<span>close</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2 group">
+
{{ i "ban" "w-4 h-4" }}
+
<span>close</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2 group">
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
-
<span>reopen</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
</div>
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2 group">
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
+
<span>reopen</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
</div>
{{ end }}
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
"Kind" $kind
"Count" $reactionData.Count
"IsReacted" (index $.UserReacted $kind)
-
"ThreadAt" $.Pull.PullAt
+
"ThreadAt" $.Pull.AtUri
"Users" $reactionData.Users)
}}
{{ end }}
+2 -1
appview/pages/templates/repo/pulls/pull.html
···
{{ template "repo/fragments/labelPanel"
(dict "RepoInfo" $.RepoInfo
"Defs" $.LabelDefs
-
"Subject" $.Pull.PullAt
+
"Subject" $.Pull.AtUri
"State" $.Pull.Labels) }}
{{ template "repo/fragments/participants" $.Pull.Participants }}
+
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
</div>
</div>
{{ end }}
+60 -38
appview/pages/templates/repo/pulls/pulls.html
···
{{ end }}
{{ define "repoContent" }}
-
<div class="flex justify-between items-center">
-
<div class="flex gap-4">
-
<a
-
href="?state=open"
-
class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
-
>
-
{{ i "git-pull-request" "w-4 h-4" }}
-
<span>{{ .RepoInfo.Stats.PullCount.Open }} open</span>
-
</a>
-
<a
-
href="?state=merged"
-
class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
-
>
-
{{ i "git-merge" "w-4 h-4" }}
-
<span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span>
-
</a>
-
<a
-
href="?state=closed"
-
class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}"
-
>
-
{{ i "ban" "w-4 h-4" }}
-
<span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span>
-
</a>
-
<form class="flex gap-4" method="GET">
-
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
-
<input class="" type="text" name="q" value="{{ .FilterQuery }}">
-
<button class="btn" type="submit">
-
search
-
</button>
-
</form>
-
</div>
+
{{ $active := "closed" }}
+
{{ if .FilteringBy.IsOpen }}
+
{{ $active = "open" }}
+
{{ else if .FilteringBy.IsMerged }}
+
{{ $active = "merged" }}
+
{{ end }}
+
{{ $open :=
+
(dict
+
"Key" "open"
+
"Value" "open"
+
"Icon" "git-pull-request"
+
"Meta" (string .RepoInfo.Stats.PullCount.Open)) }}
+
{{ $merged :=
+
(dict
+
"Key" "merged"
+
"Value" "merged"
+
"Icon" "git-merge"
+
"Meta" (string .RepoInfo.Stats.PullCount.Merged)) }}
+
{{ $closed :=
+
(dict
+
"Key" "closed"
+
"Value" "closed"
+
"Icon" "ban"
+
"Meta" (string .RepoInfo.Stats.PullCount.Closed)) }}
+
{{ $values := list $open $merged $closed }}
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
+
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
+
<div class="flex-1 flex relative">
+
<input
+
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
+
type="text"
+
name="q"
+
value="{{ .FilterQuery }}"
+
placeholder=" "
+
>
<a
-
href="/{{ .RepoInfo.FullName }}/pulls/new"
-
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
+
href="?state={{ .FilteringBy.String }}"
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
>
-
{{ i "git-pull-request-create" "w-4 h-4" }}
-
<span>new</span>
+
{{ i "x" "w-4 h-4" }}
</a>
+
</div>
+
<button
+
type="submit"
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
+
>
+
{{ i "search" "w-4 h-4" }}
+
</button>
+
</form>
+
<div class="sm:row-start-1">
+
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
</div>
-
<div class="error" id="pulls"></div>
+
<a
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
+
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
+
>
+
{{ i "git-pull-request-create" "w-4 h-4" }}
+
<span>new</span>
+
</a>
+
</div>
+
<div class="error" id="pulls"></div>
{{ end }}
{{ define "repoAfter" }}
···
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
</div>
</summary>
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
+
{{ block "stackedPullList" (list $otherPulls $) }} {{ end }}
</details>
{{ end }}
{{ end }}
···
</div>
{{ end }}
-
{{ define "pullList" }}
+
{{ define "stackedPullList" }}
{{ $list := index . 0 }}
{{ $root := index . 1 }}
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
+17 -10
appview/pages/templates/repo/settings/access.html
···
<div
id="add-collaborator-modal"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
class="
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
+
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
{{ template "addCollaboratorModal" . }}
</div>
{{ end }}
···
ADD COLLABORATOR
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
-
<input
-
autocapitalize="none"
-
autocorrect="off"
-
type="text"
-
id="add-collaborator"
-
name="collaborator"
-
required
-
placeholder="@foo.bsky.social"
-
/>
+
<actor-typeahead>
+
<input
+
autocapitalize="none"
+
autocorrect="off"
+
autocomplete="off"
+
type="text"
+
id="add-collaborator"
+
name="collaborator"
+
required
+
placeholder="user.tngl.sh"
+
class="w-full"
+
/>
+
</actor-typeahead>
<div class="flex gap-2 pt-2">
<button
type="button"
+47
appview/pages/templates/repo/settings/general.html
···
{{ template "repo/settings/fragments/sidebar" . }}
</div>
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
+
{{ template "baseSettings" . }}
{{ template "branchSettings" . }}
{{ template "defaultLabelSettings" . }}
{{ template "customLabelSettings" . }}
···
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
</div>
</section>
+
{{ end }}
+
+
{{ define "baseSettings" }}
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none">
+
<fieldset
+
class=""
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
+
>
+
<h2 class="text-sm pb-2 uppercase font-bold">Description</h2>
+
<textarea
+
rows="3"
+
class="w-full mb-2"
+
id="base-form-description"
+
name="description"
+
>{{ .RepoInfo.Description }}</textarea>
+
<h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2>
+
<input
+
type="text"
+
class="w-full mb-2"
+
id="base-form-website"
+
name="website"
+
value="{{ .RepoInfo.Website }}"
+
>
+
<h2 class="text-sm pb-2 uppercase font-bold">Topics</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
List of topics separated by spaces.
+
</p>
+
<textarea
+
rows="2"
+
class="w-full my-2"
+
id="base-form-topics"
+
name="topics"
+
>{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea>
+
<div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></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>
+
</fieldset>
+
</form>
{{ end }}
{{ define "branchSettings" }}
+8
appview/pages/templates/repo/tree.html
···
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
+
{{ if .IsSubmodule }}
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
+
{{ $icon = "folder-input" }}
+
{{ $iconStyle = "size-4" }}
+
{{ end }}
+
{{ if .IsFile }}
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
{{ $icon = "file" }}
{{ $iconStyle = "size-4" }}
{{ end }}
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
{{ i $icon $iconStyle "flex-shrink-0" }}
+16 -10
appview/pages/templates/spindles/fragments/addMemberModal.html
···
<div
id="add-member-{{ .Instance }}"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
class="
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
{{ block "addSpindleMemberPopover" . }} {{ end }}
</div>
{{ end }}
···
ADD MEMBER
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
-
<input
-
autocapitalize="none"
-
autocorrect="off"
-
type="text"
-
id="member-did-{{ .Id }}"
-
name="member"
-
required
-
placeholder="@foo.bsky.social"
-
/>
+
<actor-typeahead>
+
<input
+
autocapitalize="none"
+
autocorrect="off"
+
autocomplete="off"
+
type="text"
+
id="member-did-{{ .Id }}"
+
name="member"
+
required
+
placeholder="user.tngl.sh"
+
class="w-full"
+
/>
+
</actor-typeahead>
<div class="flex gap-2 pt-2">
<button
type="button"
+10 -6
appview/pages/templates/strings/string.html
···
<span class="select-none">/</span>
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
</div>
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
-
<div class="flex gap-2 text-base">
+
<div class="flex gap-2 text-base">
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
hx-boost="true"
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
···
<span class="hidden md:inline">delete</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
-
</div>
-
{{ end }}
+
{{ end }}
+
{{ template "fragments/starBtn"
+
(dict "SubjectAt" .String.AtUri
+
"IsStarred" .IsStarred
+
"StarCount" .StarCount) }}
+
</div>
</div>
<span>
{{ with .String.Description }}
···
</div>
<div class="overflow-x-auto overflow-y-hidden relative">
{{ if .ShowRendered }}
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
{{ else }}
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div>
{{ end }}
</div>
{{ template "fragments/multiline-select" }}
+1 -2
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
<a href="/goodfirstissues" class="no-underline hover:no-underline">
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
<div class="flex-1 flex flex-col gap-2">
-
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
<p>
-
Make your first contribution to an open-source project this October.
+
Make your first contribution to an open-source project.
<em>good-first-issue</em> helps new contributors find easy ways to
start contributing to open-source projects.
</p>
+2 -2
appview/pages/templates/timeline/fragments/hero.html
···
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
<p class="text-lg">
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
+
Tangled is a decentralized Git hosting and collaboration platform.
</p>
<p class="text-lg">
-
we envision a place where developers have complete ownership of their
+
We envision a place where developers have complete ownership of their
code, open source communities can freely self-govern and most
importantly, coding can be social and fun again.
</p>
+4 -4
appview/pages/templates/timeline/fragments/timeline.html
···
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
</div>
{{ with $repo }}
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
{{ end }}
{{ end }}
{{ define "timeline/fragments/starEvent" }}
{{ $root := index . 0 }}
{{ $event := index . 1 }}
-
{{ $star := $event.Star }}
+
{{ $star := $event.RepoStar }}
{{ with $star }}
-
{{ $starrerHandle := resolve .StarredByDid }}
+
{{ $starrerHandle := resolve .Did }}
{{ $repoOwnerHandle := resolve .Repo.Did }}
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
</div>
{{ with .Repo }}
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
{{ end }}
{{ end }}
{{ end }}
+17
appview/pages/templates/user/fragments/editBio.html
···
</div>
<div class="flex flex-col gap-1">
+
<label class="m-0 p-0" for="pronouns">pronouns</label>
+
<div class="flex items-center gap-2 w-full">
+
{{ $pronouns := "" }}
+
{{ if and .Profile .Profile.Pronouns }}
+
{{ $pronouns = .Profile.Pronouns }}
+
{{ end }}
+
<input
+
type="text"
+
class="py-1 px-1 w-full"
+
name="pronouns"
+
placeholder="they/them"
+
value="{{ $pronouns }}"
+
>
+
</div>
+
</div>
+
+
<div class="flex flex-col gap-1">
<label class="m-0 p-0" for="location">location</label>
<div class="flex items-center gap-2 w-full">
{{ $location := "" }}
+19 -6
appview/pages/templates/user/fragments/profileCard.html
···
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ $userIdent }}
</p>
-
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
+
{{ with .Profile }}
+
{{ if .Pronouns }}
+
<p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p>
+
{{ end }}
+
{{ end }}
</div>
<div class="md:hidden">
···
{{ end }}
</div>
{{ end }}
-
{{ if ne .FollowStatus.String "IsSelf" }}
-
{{ template "user/fragments/follow" . }}
-
{{ else }}
+
+
<div class="flex mt-2 items-center gap-2">
+
{{ if ne .FollowStatus.String "IsSelf" }}
+
{{ template "user/fragments/follow" . }}
+
{{ else }}
<button id="editBtn"
-
class="btn mt-2 w-full flex items-center gap-2 group"
+
class="btn w-full flex items-center gap-2 group"
hx-target="#profile-bio"
hx-get="/profile/edit-bio"
hx-swap="innerHTML">
···
edit
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
-
{{ end }}
+
{{ end }}
+
+
<a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
+
href="/{{ $userIdent }}/feed.atom">
+
{{ i "rss" "size-4" }}
+
</a>
+
</div>
+
</div>
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
</div>
+2 -1
appview/pages/templates/user/fragments/repoCard.html
···
{{ define "user/fragments/repoCard" }}
+
{{/* root, repo, fullName [,starButton [,starData]] */}}
{{ $root := index . 0 }}
{{ $repo := index . 1 }}
{{ $fullName := index . 2 }}
···
</div>
{{ if and $starButton $root.LoggedInUser }}
<div class="shrink-0">
-
{{ template "repo/fragments/repoStar" $starData }}
+
{{ template "fragments/starBtn" $starData }}
</div>
{{ end }}
</div>
+14
appview/pages/templates/user/settings/notifications.html
···
<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">Mentions</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone mentions you.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}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>
+3
appview/pipelines/pipelines.go
···
ps, err := db.GetPipelineStatuses(
p.db,
+
30,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
+
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
+
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
+7 -7
appview/pulls/opengraph.go
···
var statusColor color.RGBA
if pull.State.IsOpen() {
-
statusIcon = "static/icons/git-pull-request.svg"
+
statusIcon = "git-pull-request"
statusText = "open"
statusColor = color.RGBA{34, 139, 34, 255} // green
} else if pull.State.IsMerged() {
-
statusIcon = "static/icons/git-merge.svg"
+
statusIcon = "git-merge"
statusText = "merged"
statusColor = color.RGBA{138, 43, 226, 255} // purple
} else {
-
statusIcon = "static/icons/git-pull-request-closed.svg"
+
statusIcon = "git-pull-request-closed"
statusText = "closed"
statusColor = color.RGBA{128, 128, 128, 255} // gray
}
···
statusIconSize := 36
// Draw icon with status color
-
err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
+
err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor)
if err != nil {
log.Printf("failed to draw status icon: %v", err)
}
···
currentX := statsX + statusIconSize + 12 + statusTextWidth + 40
// Draw comment count
-
err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+
err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw comment icon: %v", err)
}
···
currentX += commentTextWidth + 40
// Draw files changed
-
err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+
err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw file diff icon: %v", err)
}
···
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
if err != nil {
log.Printf("dolly silhouette not available (this is ok): %v", err)
}
+23 -9
appview/pulls/pulls.go
···
"tangled.org/core/types"
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
···
ps, err := db.GetPipelineStatuses(
s.db,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
m[p.Sha] = p
}
-
reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt())
+
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
if err != nil {
log.Println("failed to get pull reactions")
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
···
userReactions := map[models.ReactionKind]bool{}
if user != nil {
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
}
labelDefs, err := db.GetLabelDefinitions(
···
repoInfo := f.RepoInfo(user)
ps, err := db.GetPipelineStatuses(
s.db,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
}
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "PullComment")
user := s.oauth.GetUser(r)
f, err := s.repoResolver.Resolve(r)
if err != nil {
···
Rkey: tid.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoPullComment{
-
Pull: pull.PullAt().String(),
+
Pull: pull.AtUri().String(),
Body: body,
CreatedAt: createdAt,
},
···
return
}
-
s.notifier.NewPullComment(r.Context(), comment)
+
rawMentions := markup.FindUserMentions(comment.Body)
+
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
+
var mentions []syntax.DID
+
for _, ident := range idents {
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
+
mentions = append(mentions, ident.DID)
+
}
+
}
+
s.notifier.NewPullComment(r.Context(), comment, mentions)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
return
···
defer tx.Rollback()
-
pullAt := pull.PullAt()
+
pullAt := pull.AtUri()
newRoundNumber := len(pull.Submissions)
newPatch := patch
newSourceRev := sourceRev
···
// resubmit the new pull
-
pullAt := op.PullAt()
+
pullAt := op.AtUri()
newRoundNumber := len(op.Submissions)
newPatch := np.LatestPatch()
combinedPatch := np.LatestSubmission().Combined
···
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
f, err := s.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to resolve repo:", err)
···
// notify about the pull merge
for _, p := range pullsToMerge {
-
s.notifier.NewPullState(r.Context(), p)
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
···
for _, p := range pullsToClose {
-
s.notifier.NewPullState(r.Context(), p)
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
···
for _, p := range pullsToReopen {
-
s.notifier.NewPullState(r.Context(), p)
+
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+49
appview/repo/archive.go
···
+
package repo
+
+
import (
+
"fmt"
+
"net/http"
+
"net/url"
+
"strings"
+
+
"tangled.org/core/api/tangled"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-chi/chi/v5"
+
"github.com/go-git/go-git/v5/plumbing"
+
)
+
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "DownloadArchive")
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
// Set headers for file download, just pass along whatever the knot specifies
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+
w.Header().Set("Content-Type", "application/gzip")
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
+
// Write the archive data directly
+
w.Write(archiveBytes)
+
}
+291
appview/repo/blob.go
···
+
package repo
+
+
import (
+
"encoding/base64"
+
"fmt"
+
"io"
+
"net/http"
+
"net/url"
+
"path/filepath"
+
"slices"
+
"strings"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/config"
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/pages"
+
"tangled.org/core/appview/pages/markup"
+
"tangled.org/core/appview/reporesolver"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-chi/chi/v5"
+
)
+
+
// the content can be one of the following:
+
//
+
// - code : text | | raw
+
// - markup : text | rendered | raw
+
// - svg : text | rendered | raw
+
// - png : | rendered | raw
+
// - video : | rendered | raw
+
// - submodule : | rendered |
+
// - rest : | |
+
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoBlob")
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
+
filePath := chi.URLParam(r, "*")
+
filePath, _ = url.PathUnescape(filePath)
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
// Use XRPC response directly instead of converting to internal types
+
var breadcrumbs [][]string
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
+
if filePath != "" {
+
for idx, elem := range strings.Split(filePath, "/") {
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
+
}
+
}
+
+
// Create the blob view
+
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
+
+
user := rp.oauth.GetUser(r)
+
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
BreadCrumbs: breadcrumbs,
+
BlobView: blobView,
+
RepoBlob_Output: resp,
+
})
+
}
+
+
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoBlobRaw")
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
+
filePath := chi.URLParam(r, "*")
+
filePath, _ = url.PathUnescape(filePath)
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
baseURL := &url.URL{
+
Scheme: scheme,
+
Host: f.Knot,
+
Path: "/xrpc/sh.tangled.repo.blob",
+
}
+
query := baseURL.Query()
+
query.Set("repo", repo)
+
query.Set("ref", ref)
+
query.Set("path", filePath)
+
query.Set("raw", "true")
+
baseURL.RawQuery = query.Encode()
+
blobURL := baseURL.String()
+
req, err := http.NewRequest("GET", blobURL, nil)
+
if err != nil {
+
l.Error("failed to create request", "err", err)
+
return
+
}
+
+
// forward the If-None-Match header
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
+
req.Header.Set("If-None-Match", clientETag)
+
}
+
client := &http.Client{}
+
+
resp, err := client.Do(req)
+
if err != nil {
+
l.Error("failed to reach knotserver", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
+
defer resp.Body.Close()
+
+
// forward 304 not modified
+
if resp.StatusCode == http.StatusNotModified {
+
w.WriteHeader(http.StatusNotModified)
+
return
+
}
+
+
if resp.StatusCode != http.StatusOK {
+
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
+
w.WriteHeader(resp.StatusCode)
+
_, _ = io.Copy(w, resp.Body)
+
return
+
}
+
+
contentType := resp.Header.Get("Content-Type")
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
l.Error("error reading response body from knotserver", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
+
// serve all textual content as text/plain
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
w.Write(body)
+
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
+
// serve images and videos with their original content type
+
w.Header().Set("Content-Type", contentType)
+
w.Write(body)
+
} else {
+
w.WriteHeader(http.StatusUnsupportedMediaType)
+
w.Write([]byte("unsupported content type"))
+
return
+
}
+
}
+
+
// NewBlobView creates a BlobView from the XRPC response
+
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
+
view := models.BlobView{
+
Contents: "",
+
Lines: 0,
+
}
+
+
// Set size
+
if resp.Size != nil {
+
view.SizeHint = uint64(*resp.Size)
+
} else if resp.Content != nil {
+
view.SizeHint = uint64(len(*resp.Content))
+
}
+
+
if resp.Submodule != nil {
+
view.ContentType = models.BlobContentTypeSubmodule
+
view.HasRenderedView = true
+
view.ContentSrc = resp.Submodule.Url
+
return view
+
}
+
+
// Determine if binary
+
if resp.IsBinary != nil && *resp.IsBinary {
+
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
+
ext := strings.ToLower(filepath.Ext(resp.Path))
+
+
switch ext {
+
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
+
view.ContentType = models.BlobContentTypeImage
+
view.HasRawView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = true
+
+
case ".svg":
+
view.ContentType = models.BlobContentTypeSvg
+
view.HasRawView = true
+
view.HasTextView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = queryParams.Get("code") != "true"
+
if resp.Content != nil {
+
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
+
view.Contents = string(bytes)
+
view.Lines = strings.Count(view.Contents, "\n") + 1
+
}
+
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
+
view.ContentType = models.BlobContentTypeVideo
+
view.HasRawView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = true
+
}
+
+
return view
+
}
+
+
// otherwise, we are dealing with text content
+
view.HasRawView = true
+
view.HasTextView = true
+
+
if resp.Content != nil {
+
view.Contents = *resp.Content
+
view.Lines = strings.Count(view.Contents, "\n") + 1
+
}
+
+
// with text, we may be dealing with markdown
+
format := markup.GetFormat(resp.Path)
+
if format == markup.FormatMarkdown {
+
view.ContentType = models.BlobContentTypeMarkup
+
view.HasRenderedView = true
+
view.ShowingRendered = queryParams.Get("code") != "true"
+
}
+
+
return view
+
}
+
+
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
+
scheme := "http"
+
if !config.Core.Dev {
+
scheme = "https"
+
}
+
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
baseURL := &url.URL{
+
Scheme: scheme,
+
Host: f.Knot,
+
Path: "/xrpc/sh.tangled.repo.blob",
+
}
+
query := baseURL.Query()
+
query.Set("repo", repoName)
+
query.Set("ref", ref)
+
query.Set("path", filePath)
+
query.Set("raw", "true")
+
baseURL.RawQuery = query.Encode()
+
blobURL := baseURL.String()
+
+
if !config.Core.Dev {
+
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
+
}
+
return blobURL
+
}
+
+
func isTextualMimeType(mimeType string) bool {
+
textualTypes := []string{
+
"application/json",
+
"application/xml",
+
"application/yaml",
+
"application/x-yaml",
+
"application/toml",
+
"application/javascript",
+
"application/ecmascript",
+
"message/",
+
}
+
return slices.Contains(textualTypes, mimeType)
+
}
+95
appview/repo/branches.go
···
+
package repo
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/oauth"
+
"tangled.org/core/appview/pages"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
"tangled.org/core/types"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
)
+
+
func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoBranches")
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
var result types.RepoBranchesResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
l.Error("failed to decode XRPC response", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
sortBranches(result.Branches)
+
user := rp.oauth.GetUser(r)
+
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
RepoBranchesResponse: result,
+
})
+
}
+
+
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "DeleteBranch")
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
noticeId := "delete-branch-error"
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
rp.pages.Notice(w, noticeId, msg)
+
}
+
branch := r.FormValue("branch")
+
if branch == "" {
+
fail("No branch provided.", nil)
+
return
+
}
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
+
if err != nil {
+
fail("Failed to connect to knotserver", nil)
+
return
+
}
+
err = tangled.RepoDeleteBranch(
+
r.Context(),
+
client,
+
&tangled.RepoDeleteBranch_Input{
+
Branch: branch,
+
Repo: f.RepoAt().String(),
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
+
return
+
}
+
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
+
rp.pages.HxRefresh(w)
+
}
+218
appview/repo/compare.go
···
+
package repo
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/url"
+
"strings"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/pages"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
"tangled.org/core/patchutil"
+
"tangled.org/core/types"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-chi/chi/v5"
+
)
+
+
func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoCompareNew")
+
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var branchResult types.RepoBranchesResponse
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
+
l.Error("failed to decode XRPC branches response", "err", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
branches := branchResult.Branches
+
+
sortBranches(branches)
+
+
var defaultBranch string
+
for _, b := range branches {
+
if b.IsDefault {
+
defaultBranch = b.Name
+
}
+
}
+
+
base := defaultBranch
+
head := defaultBranch
+
+
params := r.URL.Query()
+
queryBase := params.Get("base")
+
queryHead := params.Get("head")
+
if queryBase != "" {
+
base = queryBase
+
}
+
if queryHead != "" {
+
head = queryHead
+
}
+
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var tags types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
+
l.Error("failed to decode XRPC tags response", "err", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
+
repoinfo := f.RepoInfo(user)
+
+
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
+
LoggedInUser: user,
+
RepoInfo: repoinfo,
+
Branches: branches,
+
Tags: tags.Tags,
+
Base: base,
+
Head: head,
+
})
+
}
+
+
func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoCompare")
+
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
var diffOpts types.DiffOpts
+
if d := r.URL.Query().Get("diff"); d == "split" {
+
diffOpts.Split = true
+
}
+
+
// if user is navigating to one of
+
// /compare/{base}...{head}
+
// /compare/{base}/{head}
+
var base, head string
+
rest := chi.URLParam(r, "*")
+
+
var parts []string
+
if strings.Contains(rest, "...") {
+
parts = strings.SplitN(rest, "...", 2)
+
} else if strings.Contains(rest, "/") {
+
parts = strings.SplitN(rest, "/", 2)
+
}
+
+
if len(parts) == 2 {
+
base = parts[0]
+
head = parts[1]
+
}
+
+
base, _ = url.PathUnescape(base)
+
head, _ = url.PathUnescape(head)
+
+
if base == "" || head == "" {
+
l.Error("invalid comparison")
+
rp.pages.Error404(w)
+
return
+
}
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var branches types.RepoBranchesResponse
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
+
l.Error("failed to decode XRPC branches response", "err", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var tags types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
+
l.Error("failed to decode XRPC tags response", "err", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var formatPatch types.RepoFormatPatchResponse
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
+
l.Error("failed to decode XRPC compare response", "err", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
+
var diff types.NiceDiff
+
if formatPatch.CombinedPatchRaw != "" {
+
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
+
} else {
+
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
+
}
+
+
repoinfo := f.RepoInfo(user)
+
+
rp.pages.RepoCompare(w, pages.RepoCompareParams{
+
LoggedInUser: user,
+
RepoInfo: repoinfo,
+
Branches: branches.Branches,
+
Tags: tags.Tags,
+
Base: base,
+
Head: head,
+
Diff: &diff,
+
DiffOpts: diffOpts,
+
})
+
+
}
+1 -1
appview/repo/feed.go
···
return fmt.Sprintf("%s in %s", base, repoName)
}
-
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
+
func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to fully resolve repo:", err)
+5 -6
appview/repo/index.go
···
"github.com/go-enry/go-enry/v2"
)
-
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
+
func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) {
l := rp.logger.With("handler", "RepoIndex")
ref := chi.URLParam(r, "ref")
···
if treeResp != nil && treeResp.Files != nil {
for _, file := range treeResp.Files {
niceFile := types.NiceTree{
-
IsFile: file.Is_file,
-
IsSubtree: file.Is_subtree,
-
Name: file.Name,
-
Mode: file.Mode,
-
Size: file.Size,
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
}
+
if file.Last_commit != nil {
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
niceFile.LastCommit = &types.LastCommitInfo{
+223
appview/repo/log.go
···
+
package repo
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/url"
+
"strconv"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/commitverify"
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/pages"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
"tangled.org/core/types"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-chi/chi/v5"
+
"github.com/go-git/go-git/v5/plumbing"
+
)
+
+
func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoLog")
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to fully resolve repo", "err", err)
+
return
+
}
+
+
page := 1
+
if r.URL.Query().Get("page") != "" {
+
page, err = strconv.Atoi(r.URL.Query().Get("page"))
+
if err != nil {
+
page = 1
+
}
+
}
+
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
limit := int64(60)
+
cursor := ""
+
if page > 1 {
+
// Convert page number to cursor (offset)
+
offset := (page - 1) * int(limit)
+
cursor = strconv.Itoa(offset)
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var xrpcResp types.RepoLogResponse
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
+
l.Error("failed to decode XRPC response", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
tagMap := make(map[string][]string)
+
if tagBytes != nil {
+
var tagResp types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
+
for _, tag := range tagResp.Tags {
+
hash := tag.Hash
+
if tag.Tag != nil {
+
hash = tag.Tag.Target.String()
+
}
+
tagMap[hash] = append(tagMap[hash], tag.Name)
+
}
+
}
+
}
+
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
if branchBytes != nil {
+
var branchResp types.RepoBranchesResponse
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
+
for _, branch := range branchResp.Branches {
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
+
}
+
}
+
}
+
+
user := rp.oauth.GetUser(r)
+
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
+
if err != nil {
+
l.Error("failed to fetch email to did mapping", "err", err)
+
}
+
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
+
if err != nil {
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
+
}
+
+
repoInfo := f.RepoInfo(user)
+
+
var shas []string
+
for _, c := range xrpcResp.Commits {
+
shas = append(shas, c.Hash.String())
+
}
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
+
if err != nil {
+
l.Error("failed to getPipelineStatuses", "err", err)
+
// non-fatal
+
}
+
+
rp.pages.RepoLog(w, pages.RepoLogParams{
+
LoggedInUser: user,
+
TagMap: tagMap,
+
RepoInfo: repoInfo,
+
RepoLogResponse: xrpcResp,
+
EmailToDid: emailToDidMap,
+
VerifiedCommits: vc,
+
Pipelines: pipelines,
+
})
+
}
+
+
func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoCommit")
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to fully resolve repo", "err", err)
+
return
+
}
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
+
var diffOpts types.DiffOpts
+
if d := r.URL.Query().Get("diff"); d == "split" {
+
diffOpts.Split = true
+
}
+
+
if !plumbing.IsHash(ref) {
+
rp.pages.Error404(w)
+
return
+
}
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var result types.RepoCommitResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
l.Error("failed to decode XRPC response", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
+
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
+
if err != nil {
+
l.Error("failed to get email to did mapping", "err", err)
+
}
+
+
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
+
if err != nil {
+
l.Error("failed to GetVerifiedCommits", "err", err)
+
}
+
+
user := rp.oauth.GetUser(r)
+
repoInfo := f.RepoInfo(user)
+
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
+
if err != nil {
+
l.Error("failed to getPipelineStatuses", "err", err)
+
// non-fatal
+
}
+
var pipeline *models.Pipeline
+
if p, ok := pipelines[result.Diff.Commit.This]; ok {
+
pipeline = &p
+
}
+
+
rp.pages.RepoCommit(w, pages.RepoCommitParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
RepoCommitResponse: result,
+
EmailToDid: emailToDidMap,
+
VerifiedCommit: vc,
+
Pipeline: pipeline,
+
DiffOpts: diffOpts,
+
})
+
}
+5 -5
appview/repo/opengraph.go
···
// Draw star icon, count, and label
// Align icon baseline with text baseline
iconBaselineOffset := int(textSize) / 2
-
err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+
err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw star icon: %v", err)
}
···
// Draw issues icon, count, and label
issueStartX := currentX
-
err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+
err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw circle-dot icon: %v", err)
}
···
// Draw pull request icon, count, and label
prStartX := currentX
-
err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+
err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw git-pull-request icon: %v", err)
}
···
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
-
err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
+
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
if err != nil {
log.Printf("dolly silhouette not available (this is ok): %v", err)
}
···
return nil
}
-
func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
+
func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) {
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
+2 -1378
appview/repo/repo.go
···
import (
"context"
"database/sql"
-
"encoding/json"
"errors"
"fmt"
-
"io"
"log/slog"
"net/http"
"net/url"
-
"path/filepath"
"slices"
-
"strconv"
"strings"
"time"
"tangled.org/core/api/tangled"
-
"tangled.org/core/appview/commitverify"
"tangled.org/core/appview/config"
"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"
-
"tangled.org/core/appview/pages/markup"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/validator"
xrpcclient "tangled.org/core/appview/xrpcclient"
"tangled.org/core/eventconsumer"
"tangled.org/core/idresolver"
-
"tangled.org/core/patchutil"
"tangled.org/core/rbac"
"tangled.org/core/tid"
-
"tangled.org/core/types"
"tangled.org/core/xrpc/serviceauth"
comatproto "github.com/bluesky-social/indigo/api/atproto"
atpclient "github.com/bluesky-social/indigo/atproto/client"
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
-
"github.com/go-git/go-git/v5/plumbing"
)
type Repo struct {
···
}
}
-
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "DownloadArchive")
-
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.archive", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
// Set headers for file download, just pass along whatever the knot specifies
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
-
w.Header().Set("Content-Type", "application/gzip")
-
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
-
-
// Write the archive data directly
-
w.Write(archiveBytes)
-
}
-
-
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoLog")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to fully resolve repo", "err", err)
-
return
-
}
-
-
page := 1
-
if r.URL.Query().Get("page") != "" {
-
page, err = strconv.Atoi(r.URL.Query().Get("page"))
-
if err != nil {
-
page = 1
-
}
-
}
-
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
limit := int64(60)
-
cursor := ""
-
if page > 1 {
-
// Convert page number to cursor (offset)
-
offset := (page - 1) * int(limit)
-
cursor = strconv.Itoa(offset)
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.log", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var xrpcResp types.RepoLogResponse
-
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
-
l.Error("failed to decode XRPC response", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
tagMap := make(map[string][]string)
-
if tagBytes != nil {
-
var tagResp types.RepoTagsResponse
-
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
-
for _, tag := range tagResp.Tags {
-
hash := tag.Hash
-
if tag.Tag != nil {
-
hash = tag.Tag.Target.String()
-
}
-
tagMap[hash] = append(tagMap[hash], tag.Name)
-
}
-
}
-
}
-
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
if branchBytes != nil {
-
var branchResp types.RepoBranchesResponse
-
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
-
for _, branch := range branchResp.Branches {
-
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
-
}
-
}
-
}
-
-
user := rp.oauth.GetUser(r)
-
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
-
if err != nil {
-
l.Error("failed to fetch email to did mapping", "err", err)
-
}
-
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
-
if err != nil {
-
l.Error("failed to GetVerifiedObjectCommits", "err", err)
-
}
-
-
repoInfo := f.RepoInfo(user)
-
-
var shas []string
-
for _, c := range xrpcResp.Commits {
-
shas = append(shas, c.Hash.String())
-
}
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
-
if err != nil {
-
l.Error("failed to getPipelineStatuses", "err", err)
-
// non-fatal
-
}
-
-
rp.pages.RepoLog(w, pages.RepoLogParams{
-
LoggedInUser: user,
-
TagMap: tagMap,
-
RepoInfo: repoInfo,
-
RepoLogResponse: xrpcResp,
-
EmailToDid: emailToDidMap,
-
VerifiedCommits: vc,
-
Pipelines: pipelines,
-
})
-
}
-
-
func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoDescriptionEdit")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
w.WriteHeader(http.StatusBadRequest)
-
return
-
}
-
-
user := rp.oauth.GetUser(r)
-
rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
-
RepoInfo: f.RepoInfo(user),
-
})
-
}
-
-
func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoDescription")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
w.WriteHeader(http.StatusBadRequest)
-
return
-
}
-
-
repoAt := f.RepoAt()
-
rkey := repoAt.RecordKey().String()
-
if rkey == "" {
-
l.Error("invalid aturi for repo", "err", err)
-
w.WriteHeader(http.StatusInternalServerError)
-
return
-
}
-
-
user := rp.oauth.GetUser(r)
-
-
switch r.Method {
-
case http.MethodGet:
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
-
RepoInfo: f.RepoInfo(user),
-
})
-
return
-
case http.MethodPut:
-
newDescription := r.FormValue("description")
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
l.Error("failed to get client")
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
-
return
-
}
-
-
// optimistic update
-
err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
-
if err != nil {
-
l.Error("failed to perform update-description query", "err", err)
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
-
return
-
}
-
-
newRepo := f.Repo
-
newRepo.Description = newDescription
-
record := newRepo.AsRecord()
-
-
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
-
//
-
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
-
if err != nil {
-
// failed to get record
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
-
return
-
}
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoNSID,
-
Repo: newRepo.Did,
-
Rkey: newRepo.Rkey,
-
SwapRecord: ex.Cid,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &record,
-
},
-
})
-
-
if err != nil {
-
l.Error("failed to perferom update-description query", "err", err)
-
// failed to get record
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
-
return
-
}
-
-
newRepoInfo := f.RepoInfo(user)
-
newRepoInfo.Description = newDescription
-
-
rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
-
RepoInfo: newRepoInfo,
-
})
-
return
-
}
-
}
-
-
func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoCommit")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to fully resolve repo", "err", err)
-
return
-
}
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
var diffOpts types.DiffOpts
-
if d := r.URL.Query().Get("diff"); d == "split" {
-
diffOpts.Split = true
-
}
-
-
if !plumbing.IsHash(ref) {
-
rp.pages.Error404(w)
-
return
-
}
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.diff", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var result types.RepoCommitResponse
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
-
l.Error("failed to decode XRPC response", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
-
emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
-
if err != nil {
-
l.Error("failed to get email to did mapping", "err", err)
-
}
-
-
vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
-
if err != nil {
-
l.Error("failed to GetVerifiedCommits", "err", err)
-
}
-
-
user := rp.oauth.GetUser(r)
-
repoInfo := f.RepoInfo(user)
-
pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
-
if err != nil {
-
l.Error("failed to getPipelineStatuses", "err", err)
-
// non-fatal
-
}
-
var pipeline *models.Pipeline
-
if p, ok := pipelines[result.Diff.Commit.This]; ok {
-
pipeline = &p
-
}
-
-
rp.pages.RepoCommit(w, pages.RepoCommitParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
RepoCommitResponse: result,
-
EmailToDid: emailToDidMap,
-
VerifiedCommit: vc,
-
Pipeline: pipeline,
-
DiffOpts: diffOpts,
-
})
-
}
-
-
func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoTree")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to fully resolve repo", "err", err)
-
return
-
}
-
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
// if the tree path has a trailing slash, let's strip it
-
// so we don't 404
-
treePath := chi.URLParam(r, "*")
-
treePath, _ = url.PathUnescape(treePath)
-
treePath = strings.TrimSuffix(treePath, "/")
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
// Convert XRPC response to internal types.RepoTreeResponse
-
files := make([]types.NiceTree, len(xrpcResp.Files))
-
for i, xrpcFile := range xrpcResp.Files {
-
file := types.NiceTree{
-
Name: xrpcFile.Name,
-
Mode: xrpcFile.Mode,
-
Size: int64(xrpcFile.Size),
-
IsFile: xrpcFile.Is_file,
-
IsSubtree: xrpcFile.Is_subtree,
-
}
-
-
// Convert last commit info if present
-
if xrpcFile.Last_commit != nil {
-
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
-
file.LastCommit = &types.LastCommitInfo{
-
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
-
Message: xrpcFile.Last_commit.Message,
-
When: commitWhen,
-
}
-
}
-
-
files[i] = file
-
}
-
-
result := types.RepoTreeResponse{
-
Ref: xrpcResp.Ref,
-
Files: files,
-
}
-
-
if xrpcResp.Parent != nil {
-
result.Parent = *xrpcResp.Parent
-
}
-
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).
-
if len(result.Files) == 0 && result.Parent == treePath {
-
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
-
http.Redirect(w, r, redirectTo, http.StatusFound)
-
return
-
}
-
-
user := rp.oauth.GetUser(r)
-
-
var breadcrumbs [][]string
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
-
if treePath != "" {
-
for idx, elem := range strings.Split(treePath, "/") {
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
-
}
-
}
-
-
sortFiles(result.Files)
-
-
rp.pages.RepoTree(w, pages.RepoTreeParams{
-
LoggedInUser: user,
-
BreadCrumbs: breadcrumbs,
-
TreePath: treePath,
-
RepoInfo: f.RepoInfo(user),
-
RepoTreeResponse: result,
-
})
-
}
-
-
func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoTags")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var result types.RepoTagsResponse
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
-
l.Error("failed to decode XRPC response", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
-
if err != nil {
-
l.Error("failed grab artifacts", "err", err)
-
return
-
}
-
-
// convert artifacts to map for easy UI building
-
artifactMap := make(map[plumbing.Hash][]models.Artifact)
-
for _, a := range artifacts {
-
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
-
}
-
-
var danglingArtifacts []models.Artifact
-
for _, a := range artifacts {
-
found := false
-
for _, t := range result.Tags {
-
if t.Tag != nil {
-
if t.Tag.Hash == a.Tag {
-
found = true
-
}
-
}
-
}
-
-
if !found {
-
danglingArtifacts = append(danglingArtifacts, a)
-
}
-
}
-
-
user := rp.oauth.GetUser(r)
-
rp.pages.RepoTags(w, pages.RepoTagsParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
RepoTagsResponse: result,
-
ArtifactMap: artifactMap,
-
DanglingArtifacts: danglingArtifacts,
-
})
-
}
-
-
func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoBranches")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var result types.RepoBranchesResponse
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
-
l.Error("failed to decode XRPC response", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
-
sortBranches(result.Branches)
-
-
user := rp.oauth.GetUser(r)
-
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
RepoBranchesResponse: result,
-
})
-
}
-
-
func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "DeleteBranch")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
noticeId := "delete-branch-error"
-
fail := func(msg string, err error) {
-
l.Error(msg, "err", err)
-
rp.pages.Notice(w, noticeId, msg)
-
}
-
-
branch := r.FormValue("branch")
-
if branch == "" {
-
fail("No branch provided.", nil)
-
return
-
}
-
-
client, err := rp.oauth.ServiceClient(
-
r,
-
oauth.WithService(f.Knot),
-
oauth.WithLxm(tangled.RepoDeleteBranchNSID),
-
oauth.WithDev(rp.config.Core.Dev),
-
)
-
if err != nil {
-
fail("Failed to connect to knotserver", nil)
-
return
-
}
-
-
err = tangled.RepoDeleteBranch(
-
r.Context(),
-
client,
-
&tangled.RepoDeleteBranch_Input{
-
Branch: branch,
-
Repo: f.RepoAt().String(),
-
},
-
)
-
if err := xrpcclient.HandleXrpcErr(err); err != nil {
-
fail(fmt.Sprintf("Failed to delete branch: %s", err), err)
-
return
-
}
-
l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt())
-
-
rp.pages.HxRefresh(w)
-
}
-
-
func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoBlob")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
filePath := chi.URLParam(r, "*")
-
filePath, _ = url.PathUnescape(filePath)
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
-
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
// Use XRPC response directly instead of converting to internal types
-
-
var breadcrumbs [][]string
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
-
if filePath != "" {
-
for idx, elem := range strings.Split(filePath, "/") {
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
-
}
-
}
-
-
showRendered := false
-
renderToggle := false
-
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
-
renderToggle = true
-
showRendered = r.URL.Query().Get("code") != "true"
-
}
-
-
var unsupported bool
-
var isImage bool
-
var isVideo bool
-
var contentSrc string
-
-
if resp.IsBinary != nil && *resp.IsBinary {
-
ext := strings.ToLower(filepath.Ext(resp.Path))
-
switch ext {
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
-
isImage = true
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
-
isVideo = true
-
default:
-
unsupported = true
-
}
-
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
-
baseURL := &url.URL{
-
Scheme: scheme,
-
Host: f.Knot,
-
Path: "/xrpc/sh.tangled.repo.blob",
-
}
-
query := baseURL.Query()
-
query.Set("repo", repoName)
-
query.Set("ref", ref)
-
query.Set("path", filePath)
-
query.Set("raw", "true")
-
baseURL.RawQuery = query.Encode()
-
blobURL := baseURL.String()
-
-
contentSrc = blobURL
-
if !rp.config.Core.Dev {
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
-
}
-
}
-
-
lines := 0
-
if resp.IsBinary == nil || !*resp.IsBinary {
-
lines = strings.Count(resp.Content, "\n") + 1
-
}
-
-
var sizeHint uint64
-
if resp.Size != nil {
-
sizeHint = uint64(*resp.Size)
-
} else {
-
sizeHint = uint64(len(resp.Content))
-
}
-
-
user := rp.oauth.GetUser(r)
-
-
// Determine if content is binary (dereference pointer)
-
isBinary := false
-
if resp.IsBinary != nil {
-
isBinary = *resp.IsBinary
-
}
-
-
rp.pages.RepoBlob(w, pages.RepoBlobParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
BreadCrumbs: breadcrumbs,
-
ShowRendered: showRendered,
-
RenderToggle: renderToggle,
-
Unsupported: unsupported,
-
IsImage: isImage,
-
IsVideo: isVideo,
-
ContentSrc: contentSrc,
-
RepoBlob_Output: resp,
-
Contents: resp.Content,
-
Lines: lines,
-
SizeHint: sizeHint,
-
IsBinary: isBinary,
-
})
-
}
-
-
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoBlobRaw")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
w.WriteHeader(http.StatusBadRequest)
-
return
-
}
-
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
filePath := chi.URLParam(r, "*")
-
filePath, _ = url.PathUnescape(filePath)
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
-
baseURL := &url.URL{
-
Scheme: scheme,
-
Host: f.Knot,
-
Path: "/xrpc/sh.tangled.repo.blob",
-
}
-
query := baseURL.Query()
-
query.Set("repo", repo)
-
query.Set("ref", ref)
-
query.Set("path", filePath)
-
query.Set("raw", "true")
-
baseURL.RawQuery = query.Encode()
-
blobURL := baseURL.String()
-
-
req, err := http.NewRequest("GET", blobURL, nil)
-
if err != nil {
-
l.Error("failed to create request", "err", err)
-
return
-
}
-
-
// forward the If-None-Match header
-
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
-
req.Header.Set("If-None-Match", clientETag)
-
}
-
-
client := &http.Client{}
-
resp, err := client.Do(req)
-
if err != nil {
-
l.Error("failed to reach knotserver", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
defer resp.Body.Close()
-
-
// forward 304 not modified
-
if resp.StatusCode == http.StatusNotModified {
-
w.WriteHeader(http.StatusNotModified)
-
return
-
}
-
-
if resp.StatusCode != http.StatusOK {
-
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
-
w.WriteHeader(resp.StatusCode)
-
_, _ = io.Copy(w, resp.Body)
-
return
-
}
-
-
contentType := resp.Header.Get("Content-Type")
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
l.Error("error reading response body from knotserver", "err", err)
-
w.WriteHeader(http.StatusInternalServerError)
-
return
-
}
-
-
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
-
// serve all textual content as text/plain
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
-
w.Write(body)
-
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
-
// serve images and videos with their original content type
-
w.Header().Set("Content-Type", contentType)
-
w.Write(body)
-
} else {
-
w.WriteHeader(http.StatusUnsupportedMediaType)
-
w.Write([]byte("unsupported content type"))
-
return
-
}
-
}
-
-
// isTextualMimeType returns true if the MIME type represents textual content
-
// that should be served as text/plain
-
func isTextualMimeType(mimeType string) bool {
-
textualTypes := []string{
-
"application/json",
-
"application/xml",
-
"application/yaml",
-
"application/x-yaml",
-
"application/toml",
-
"application/javascript",
-
"application/ecmascript",
-
"message/",
-
}
-
-
return slices.Contains(textualTypes, mimeType)
-
}
-
// modify the spindle configured for this repo
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
···
rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
}
-
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "SetDefaultBranch")
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
noticeId := "operation-error"
-
branch := r.FormValue("branch")
-
if branch == "" {
-
http.Error(w, "malformed form", http.StatusBadRequest)
-
return
-
}
-
-
client, err := rp.oauth.ServiceClient(
-
r,
-
oauth.WithService(f.Knot),
-
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
-
oauth.WithDev(rp.config.Core.Dev),
-
)
-
if err != nil {
-
l.Error("failed to connect to knot server", "err", err)
-
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
-
return
-
}
-
-
xe := tangled.RepoSetDefaultBranch(
-
r.Context(),
-
client,
-
&tangled.RepoSetDefaultBranch_Input{
-
Repo: f.RepoAt().String(),
-
DefaultBranch: branch,
-
},
-
)
-
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
-
l.Error("xrpc failed", "err", xe)
-
rp.pages.Notice(w, noticeId, err.Error())
-
return
-
}
-
-
rp.pages.HxRefresh(w)
-
}
-
-
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
-
user := rp.oauth.GetUser(r)
-
l := rp.logger.With("handler", "Secrets")
-
l = l.With("did", user.Did)
-
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
if f.Spindle == "" {
-
l.Error("empty spindle cannot add/rm secret", "err", err)
-
return
-
}
-
-
lxm := tangled.RepoAddSecretNSID
-
if r.Method == http.MethodDelete {
-
lxm = tangled.RepoRemoveSecretNSID
-
}
-
-
spindleClient, err := rp.oauth.ServiceClient(
-
r,
-
oauth.WithService(f.Spindle),
-
oauth.WithLxm(lxm),
-
oauth.WithExp(60),
-
oauth.WithDev(rp.config.Core.Dev),
-
)
-
if err != nil {
-
l.Error("failed to create spindle client", "err", err)
-
return
-
}
-
-
key := r.FormValue("key")
-
if key == "" {
-
w.WriteHeader(http.StatusBadRequest)
-
return
-
}
-
-
switch r.Method {
-
case http.MethodPut:
-
errorId := "add-secret-error"
-
-
value := r.FormValue("value")
-
if value == "" {
-
w.WriteHeader(http.StatusBadRequest)
-
return
-
}
-
-
err = tangled.RepoAddSecret(
-
r.Context(),
-
spindleClient,
-
&tangled.RepoAddSecret_Input{
-
Repo: f.RepoAt().String(),
-
Key: key,
-
Value: value,
-
},
-
)
-
if err != nil {
-
l.Error("Failed to add secret.", "err", err)
-
rp.pages.Notice(w, errorId, "Failed to add secret.")
-
return
-
}
-
-
case http.MethodDelete:
-
errorId := "operation-error"
-
-
err = tangled.RepoRemoveSecret(
-
r.Context(),
-
spindleClient,
-
&tangled.RepoRemoveSecret_Input{
-
Repo: f.RepoAt().String(),
-
Key: key,
-
},
-
)
-
if err != nil {
-
l.Error("Failed to delete secret.", "err", err)
-
rp.pages.Notice(w, errorId, "Failed to delete secret.")
-
return
-
}
-
}
-
-
rp.pages.HxRefresh(w)
-
}
-
-
type tab = map[string]any
-
-
var (
-
// would be great to have ordered maps right about now
-
settingsTabs []tab = []tab{
-
{"Name": "general", "Icon": "sliders-horizontal"},
-
{"Name": "access", "Icon": "users"},
-
{"Name": "pipelines", "Icon": "layers-2"},
-
}
-
)
-
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
-
tabVal := r.URL.Query().Get("tab")
-
if tabVal == "" {
-
tabVal = "general"
-
}
-
-
switch tabVal {
-
case "general":
-
rp.generalSettings(w, r)
-
-
case "access":
-
rp.accessSettings(w, r)
-
-
case "pipelines":
-
rp.pipelineSettings(w, r)
-
}
-
}
-
-
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "generalSettings")
-
-
f, err := rp.repoResolver.Resolve(r)
-
user := rp.oauth.GetUser(r)
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var result types.RepoBranchesResponse
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
-
l.Error("failed to decode XRPC response", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
-
if err != nil {
-
l.Error("failed to fetch labels", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
-
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
-
if err != nil {
-
l.Error("failed to fetch labels", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
// remove default labels from the labels list, if present
-
defaultLabelMap := make(map[string]bool)
-
for _, dl := range defaultLabels {
-
defaultLabelMap[dl.AtUri().String()] = true
-
}
-
n := 0
-
for _, l := range labels {
-
if !defaultLabelMap[l.AtUri().String()] {
-
labels[n] = l
-
n++
-
}
-
}
-
labels = labels[:n]
-
-
subscribedLabels := make(map[string]struct{})
-
for _, l := range f.Repo.Labels {
-
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",
-
})
-
}
-
-
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "accessSettings")
-
-
f, err := rp.repoResolver.Resolve(r)
-
user := rp.oauth.GetUser(r)
-
-
repoCollaborators, err := f.Collaborators(r.Context())
-
if err != nil {
-
l.Error("failed to get collaborators", "err", err)
-
}
-
-
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Tabs: settingsTabs,
-
Tab: "access",
-
Collaborators: repoCollaborators,
-
})
-
}
-
-
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "pipelineSettings")
-
-
f, err := rp.repoResolver.Resolve(r)
-
user := rp.oauth.GetUser(r)
-
-
// all spindles that the repo owner is a member of
-
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
-
if err != nil {
-
l.Error("failed to fetch spindles", "err", err)
-
return
-
}
-
-
var secrets []*tangled.RepoListSecrets_Secret
-
if f.Spindle != "" {
-
if spindleClient, err := rp.oauth.ServiceClient(
-
r,
-
oauth.WithService(f.Spindle),
-
oauth.WithLxm(tangled.RepoListSecretsNSID),
-
oauth.WithExp(60),
-
oauth.WithDev(rp.config.Core.Dev),
-
); err != nil {
-
l.Error("failed to create spindle client", "err", err)
-
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
-
l.Error("failed to fetch secrets", "err", err)
-
} else {
-
secrets = resp.Secrets
-
}
-
}
-
-
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
-
return strings.Compare(a.Key, b.Key)
-
})
-
-
var dids []string
-
for _, s := range secrets {
-
dids = append(dids, s.CreatedBy)
-
}
-
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
-
-
// convert to a more manageable form
-
var niceSecret []map[string]any
-
for id, s := range secrets {
-
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
-
niceSecret = append(niceSecret, map[string]any{
-
"Id": id,
-
"Key": s.Key,
-
"CreatedAt": when,
-
"CreatedBy": resolvedIdents[id].Handle.String(),
-
})
-
}
-
-
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Tabs: settingsTabs,
-
Tab: "pipelines",
-
Spindles: spindles,
-
CurrentSpindle: f.Spindle,
-
Secrets: niceSecret,
-
})
-
}
-
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
l := rp.logger.With("handler", "SyncRepoFork")
···
Source: sourceAt,
Description: f.Repo.Description,
Created: time.Now(),
-
Labels: models.DefaultLabelDefs(),
+
Labels: rp.config.Label.DefaultLabelDefs,
record := repo.AsRecord()
···
aturi = ""
rp.notifier.NewRepo(r.Context(), repo)
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName))
···
})
return err
-
-
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoCompareNew")
-
-
user := rp.oauth.GetUser(r)
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var branchResult types.RepoBranchesResponse
-
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
-
l.Error("failed to decode XRPC branches response", "err", err)
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
return
-
}
-
branches := branchResult.Branches
-
-
sortBranches(branches)
-
-
var defaultBranch string
-
for _, b := range branches {
-
if b.IsDefault {
-
defaultBranch = b.Name
-
}
-
}
-
-
base := defaultBranch
-
head := defaultBranch
-
-
params := r.URL.Query()
-
queryBase := params.Get("base")
-
queryHead := params.Get("head")
-
if queryBase != "" {
-
base = queryBase
-
}
-
if queryHead != "" {
-
head = queryHead
-
}
-
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var tags types.RepoTagsResponse
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
-
l.Error("failed to decode XRPC tags response", "err", err)
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
return
-
}
-
-
repoinfo := f.RepoInfo(user)
-
-
rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
-
LoggedInUser: user,
-
RepoInfo: repoinfo,
-
Branches: branches,
-
Tags: tags.Tags,
-
Base: base,
-
Head: head,
-
})
-
}
-
-
func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
-
l := rp.logger.With("handler", "RepoCompare")
-
-
user := rp.oauth.GetUser(r)
-
f, err := rp.repoResolver.Resolve(r)
-
if err != nil {
-
l.Error("failed to get repo and knot", "err", err)
-
return
-
}
-
-
var diffOpts types.DiffOpts
-
if d := r.URL.Query().Get("diff"); d == "split" {
-
diffOpts.Split = true
-
}
-
-
// if user is navigating to one of
-
// /compare/{base}/{head}
-
// /compare/{base}...{head}
-
base := chi.URLParam(r, "base")
-
head := chi.URLParam(r, "head")
-
if base == "" && head == "" {
-
rest := chi.URLParam(r, "*") // master...feature/xyz
-
parts := strings.SplitN(rest, "...", 2)
-
if len(parts) == 2 {
-
base = parts[0]
-
head = parts[1]
-
}
-
}
-
-
base, _ = url.PathUnescape(base)
-
head, _ = url.PathUnescape(head)
-
-
if base == "" || head == "" {
-
l.Error("invalid comparison")
-
rp.pages.Error404(w)
-
return
-
}
-
-
scheme := "http"
-
if !rp.config.Core.Dev {
-
scheme = "https"
-
}
-
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
xrpcc := &indigoxrpc.Client{
-
Host: host,
-
}
-
-
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
-
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var branches types.RepoBranchesResponse
-
if err := json.Unmarshal(branchBytes, &branches); err != nil {
-
l.Error("failed to decode XRPC branches response", "err", err)
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
return
-
}
-
-
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var tags types.RepoTagsResponse
-
if err := json.Unmarshal(tagBytes, &tags); err != nil {
-
l.Error("failed to decode XRPC tags response", "err", err)
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
return
-
}
-
-
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
-
l.Error("failed to call XRPC repo.compare", "err", xrpcerr)
-
rp.pages.Error503(w)
-
return
-
}
-
-
var formatPatch types.RepoFormatPatchResponse
-
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
-
l.Error("failed to decode XRPC compare response", "err", err)
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
return
-
}
-
-
var diff types.NiceDiff
-
if formatPatch.CombinedPatchRaw != "" {
-
diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base)
-
} else {
-
diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base)
-
}
-
-
repoinfo := f.RepoInfo(user)
-
-
rp.pages.RepoCompare(w, pages.RepoCompareParams{
-
LoggedInUser: user,
-
RepoInfo: repoinfo,
-
Branches: branches.Branches,
-
Tags: tags.Tags,
-
Base: base,
-
Head: head,
-
Diff: &diff,
-
DiffOpts: diffOpts,
-
})
-
-
}
+3 -16
appview/repo/repo_util.go
···
package repo
import (
-
"crypto/rand"
-
"math/big"
"slices"
"sort"
"strings"
···
func sortFiles(files []types.NiceTree) {
sort.Slice(files, func(i, j int) bool {
-
iIsFile := files[i].IsFile
-
jIsFile := files[j].IsFile
+
iIsFile := files[i].IsFile()
+
jIsFile := files[j].IsFile()
if iIsFile != jIsFile {
return !iIsFile
}
···
return
}
-
func randomString(n int) string {
-
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-
result := make([]byte, n)
-
-
for i := 0; i < n; i++ {
-
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
-
result[i] = letters[n.Int64()]
-
}
-
-
return string(result)
-
}
-
// grab pipelines from DB and munge that into a hashmap with commit sha as key
//
// golang is so blessed that it requires 35 lines of imperative code for this
···
ps, err := db.GetPipelineStatuses(
d,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
+14 -20
appview/repo/router.go
···
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
-
r.Get("/", rp.RepoIndex)
-
r.Get("/opengraph", rp.RepoOpenGraphSummary)
-
r.Get("/feed.atom", rp.RepoAtomFeed)
-
r.Get("/commits/{ref}", rp.RepoLog)
+
r.Get("/", rp.Index)
+
r.Get("/opengraph", rp.Opengraph)
+
r.Get("/feed.atom", rp.AtomFeed)
+
r.Get("/commits/{ref}", rp.Log)
r.Route("/tree/{ref}", func(r chi.Router) {
-
r.Get("/", rp.RepoIndex)
-
r.Get("/*", rp.RepoTree)
+
r.Get("/", rp.Index)
+
r.Get("/*", rp.Tree)
})
-
r.Get("/commit/{ref}", rp.RepoCommit)
-
r.Get("/branches", rp.RepoBranches)
+
r.Get("/commit/{ref}", rp.Commit)
+
r.Get("/branches", rp.Branches)
r.Delete("/branches", rp.DeleteBranch)
r.Route("/tags", func(r chi.Router) {
-
r.Get("/", rp.RepoTags)
+
r.Get("/", rp.Tags)
r.Route("/{tag}", func(r chi.Router) {
r.Get("/download/{file}", rp.DownloadArtifact)
···
})
})
})
-
r.Get("/blob/{ref}/*", rp.RepoBlob)
+
r.Get("/blob/{ref}/*", rp.Blob)
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
// intentionally doesn't use /* as this isn't
···
})
r.Route("/compare", func(r chi.Router) {
-
r.Get("/", rp.RepoCompareNew) // start an new comparison
+
r.Get("/", rp.CompareNew) // start an new comparison
// we have to wildcard here since we want to support GitHub's compare syntax
// /compare/{ref1}...{ref2}
// for example:
// /compare/master...some/feature
// /compare/master...example.com:another/feature <- this is a fork
-
r.Get("/{base}/{head}", rp.RepoCompare)
-
r.Get("/*", rp.RepoCompare)
+
r.Get("/*", rp.Compare)
})
// label panel in issues/pulls/discussions/tasks
···
// settings routes, needs auth
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(rp.oauth))
-
// repo description can only be edited by owner
-
r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) {
-
r.Put("/", rp.RepoDescription)
-
r.Get("/", rp.RepoDescription)
-
r.Get("/edit", rp.RepoDescriptionEdit)
-
})
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
-
r.Get("/", rp.RepoSettings)
+
r.Get("/", rp.Settings)
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings)
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
+442
appview/repo/settings.go
···
+
package repo
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"slices"
+
"strings"
+
"time"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/oauth"
+
"tangled.org/core/appview/pages"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
"tangled.org/core/types"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
)
+
+
type tab = map[string]any
+
+
var (
+
// would be great to have ordered maps right about now
+
settingsTabs []tab = []tab{
+
{"Name": "general", "Icon": "sliders-horizontal"},
+
{"Name": "access", "Icon": "users"},
+
{"Name": "pipelines", "Icon": "layers-2"},
+
}
+
)
+
+
func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "SetDefaultBranch")
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
noticeId := "operation-error"
+
branch := r.FormValue("branch")
+
if branch == "" {
+
http.Error(w, "malformed form", http.StatusBadRequest)
+
return
+
}
+
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
+
if err != nil {
+
l.Error("failed to connect to knot server", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
+
return
+
}
+
+
xe := tangled.RepoSetDefaultBranch(
+
r.Context(),
+
client,
+
&tangled.RepoSetDefaultBranch_Input{
+
Repo: f.RepoAt().String(),
+
DefaultBranch: branch,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
+
l.Error("xrpc failed", "err", xe)
+
rp.pages.Notice(w, noticeId, err.Error())
+
return
+
}
+
+
rp.pages.HxRefresh(w)
+
}
+
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
l := rp.logger.With("handler", "Secrets")
+
l = l.With("did", user.Did)
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
if f.Spindle == "" {
+
l.Error("empty spindle cannot add/rm secret", "err", err)
+
return
+
}
+
+
lxm := tangled.RepoAddSecretNSID
+
if r.Method == http.MethodDelete {
+
lxm = tangled.RepoRemoveSecretNSID
+
}
+
+
spindleClient, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Spindle),
+
oauth.WithLxm(lxm),
+
oauth.WithExp(60),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
+
if err != nil {
+
l.Error("failed to create spindle client", "err", err)
+
return
+
}
+
+
key := r.FormValue("key")
+
if key == "" {
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodPut:
+
errorId := "add-secret-error"
+
+
value := r.FormValue("value")
+
if value == "" {
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
err = tangled.RepoAddSecret(
+
r.Context(),
+
spindleClient,
+
&tangled.RepoAddSecret_Input{
+
Repo: f.RepoAt().String(),
+
Key: key,
+
Value: value,
+
},
+
)
+
if err != nil {
+
l.Error("Failed to add secret.", "err", err)
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
+
return
+
}
+
+
case http.MethodDelete:
+
errorId := "operation-error"
+
+
err = tangled.RepoRemoveSecret(
+
r.Context(),
+
spindleClient,
+
&tangled.RepoRemoveSecret_Input{
+
Repo: f.RepoAt().String(),
+
Key: key,
+
},
+
)
+
if err != nil {
+
l.Error("Failed to delete secret.", "err", err)
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
+
return
+
}
+
}
+
+
rp.pages.HxRefresh(w)
+
}
+
+
func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) {
+
tabVal := r.URL.Query().Get("tab")
+
if tabVal == "" {
+
tabVal = "general"
+
}
+
+
switch tabVal {
+
case "general":
+
rp.generalSettings(w, r)
+
+
case "access":
+
rp.accessSettings(w, r)
+
+
case "pipelines":
+
rp.pipelineSettings(w, r)
+
}
+
}
+
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "generalSettings")
+
+
f, err := rp.repoResolver.Resolve(r)
+
user := rp.oauth.GetUser(r)
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
var result types.RepoBranchesResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
l.Error("failed to decode XRPC response", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs))
+
if err != nil {
+
l.Error("failed to fetch labels", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
+
if err != nil {
+
l.Error("failed to fetch labels", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
// remove default labels from the labels list, if present
+
defaultLabelMap := make(map[string]bool)
+
for _, dl := range defaultLabels {
+
defaultLabelMap[dl.AtUri().String()] = true
+
}
+
n := 0
+
for _, l := range labels {
+
if !defaultLabelMap[l.AtUri().String()] {
+
labels[n] = l
+
n++
+
}
+
}
+
labels = labels[:n]
+
+
subscribedLabels := make(map[string]struct{})
+
for _, l := range f.Repo.Labels {
+
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",
+
})
+
}
+
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "accessSettings")
+
+
f, err := rp.repoResolver.Resolve(r)
+
user := rp.oauth.GetUser(r)
+
+
repoCollaborators, err := f.Collaborators(r.Context())
+
if err != nil {
+
l.Error("failed to get collaborators", "err", err)
+
}
+
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Tabs: settingsTabs,
+
Tab: "access",
+
Collaborators: repoCollaborators,
+
})
+
}
+
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "pipelineSettings")
+
+
f, err := rp.repoResolver.Resolve(r)
+
user := rp.oauth.GetUser(r)
+
+
// all spindles that the repo owner is a member of
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
+
if err != nil {
+
l.Error("failed to fetch spindles", "err", err)
+
return
+
}
+
+
var secrets []*tangled.RepoListSecrets_Secret
+
if f.Spindle != "" {
+
if spindleClient, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Spindle),
+
oauth.WithLxm(tangled.RepoListSecretsNSID),
+
oauth.WithExp(60),
+
oauth.WithDev(rp.config.Core.Dev),
+
); err != nil {
+
l.Error("failed to create spindle client", "err", err)
+
} else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
+
l.Error("failed to fetch secrets", "err", err)
+
} else {
+
secrets = resp.Secrets
+
}
+
}
+
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
+
return strings.Compare(a.Key, b.Key)
+
})
+
+
var dids []string
+
for _, s := range secrets {
+
dids = append(dids, s.CreatedBy)
+
}
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
+
+
// convert to a more manageable form
+
var niceSecret []map[string]any
+
for id, s := range secrets {
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
+
niceSecret = append(niceSecret, map[string]any{
+
"Id": id,
+
"Key": s.Key,
+
"CreatedAt": when,
+
"CreatedBy": resolvedIdents[id].Handle.String(),
+
})
+
}
+
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Tabs: settingsTabs,
+
Tab: "pipelines",
+
Spindles: spindles,
+
CurrentSpindle: f.Spindle,
+
Secrets: niceSecret,
+
})
+
}
+
+
func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "EditBaseSettings")
+
+
noticeId := "repo-base-settings-error"
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
l.Error("failed to get client")
+
rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.")
+
return
+
}
+
+
var (
+
description = r.FormValue("description")
+
website = r.FormValue("website")
+
topicStr = r.FormValue("topics")
+
)
+
+
err = rp.validator.ValidateURI(website)
+
if website != "" && err != nil {
+
l.Error("invalid uri", "err", err)
+
rp.pages.Notice(w, noticeId, err.Error())
+
return
+
}
+
+
topics, err := rp.validator.ValidateRepoTopicStr(topicStr)
+
if err != nil {
+
l.Error("invalid topics", "err", err)
+
rp.pages.Notice(w, noticeId, err.Error())
+
return
+
}
+
l.Debug("got", "topicsStr", topicStr, "topics", topics)
+
+
newRepo := f.Repo
+
newRepo.Description = description
+
newRepo.Website = website
+
newRepo.Topics = topics
+
record := newRepo.AsRecord()
+
+
tx, err := rp.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
l.Error("failed to begin transaction", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.PutRepo(tx, newRepo)
+
if err != nil {
+
l.Error("failed to update repository", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to save repository information.")
+
return
+
}
+
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
+
if err != nil {
+
// failed to get record
+
l.Error("failed to get repo record", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.")
+
return
+
}
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoNSID,
+
Repo: newRepo.Did,
+
Rkey: newRepo.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
+
if err != nil {
+
l.Error("failed to perferom update-repo query", "err", err)
+
// failed to get record
+
rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.")
+
return
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
l.Error("failed to commit", "err", err)
+
}
+
+
rp.pages.HxRefresh(w)
+
}
+79
appview/repo/tags.go
···
+
package repo
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/pages"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
"tangled.org/core/types"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-git/go-git/v5/plumbing"
+
)
+
+
func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoTags")
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
var result types.RepoTagsResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
l.Error("failed to decode XRPC response", "err", err)
+
rp.pages.Error503(w)
+
return
+
}
+
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
+
if err != nil {
+
l.Error("failed grab artifacts", "err", err)
+
return
+
}
+
// convert artifacts to map for easy UI building
+
artifactMap := make(map[plumbing.Hash][]models.Artifact)
+
for _, a := range artifacts {
+
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
+
}
+
var danglingArtifacts []models.Artifact
+
for _, a := range artifacts {
+
found := false
+
for _, t := range result.Tags {
+
if t.Tag != nil {
+
if t.Tag.Hash == a.Tag {
+
found = true
+
}
+
}
+
}
+
if !found {
+
danglingArtifacts = append(danglingArtifacts, a)
+
}
+
}
+
user := rp.oauth.GetUser(r)
+
rp.pages.RepoTags(w, pages.RepoTagsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
RepoTagsResponse: result,
+
ArtifactMap: artifactMap,
+
DanglingArtifacts: danglingArtifacts,
+
})
+
}
+106
appview/repo/tree.go
···
+
package repo
+
+
import (
+
"fmt"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/pages"
+
xrpcclient "tangled.org/core/appview/xrpcclient"
+
"tangled.org/core/types"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-chi/chi/v5"
+
"github.com/go-git/go-git/v5/plumbing"
+
)
+
+
func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoTree")
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to fully resolve repo", "err", err)
+
return
+
}
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
// if the tree path has a trailing slash, let's strip it
+
// so we don't 404
+
treePath := chi.URLParam(r, "*")
+
treePath, _ = url.PathUnescape(treePath)
+
treePath = strings.TrimSuffix(treePath, "/")
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
l.Error("failed to call XRPC repo.tree", "err", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
// Convert XRPC response to internal types.RepoTreeResponse
+
files := make([]types.NiceTree, len(xrpcResp.Files))
+
for i, xrpcFile := range xrpcResp.Files {
+
file := types.NiceTree{
+
Name: xrpcFile.Name,
+
Mode: xrpcFile.Mode,
+
Size: int64(xrpcFile.Size),
+
}
+
// Convert last commit info if present
+
if xrpcFile.Last_commit != nil {
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
+
file.LastCommit = &types.LastCommitInfo{
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
+
Message: xrpcFile.Last_commit.Message,
+
When: commitWhen,
+
}
+
}
+
files[i] = file
+
}
+
result := types.RepoTreeResponse{
+
Ref: xrpcResp.Ref,
+
Files: files,
+
}
+
if xrpcResp.Parent != nil {
+
result.Parent = *xrpcResp.Parent
+
}
+
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).
+
if len(result.Files) == 0 && result.Parent == treePath {
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
+
http.Redirect(w, r, redirectTo, http.StatusFound)
+
return
+
}
+
user := rp.oauth.GetUser(r)
+
var breadcrumbs [][]string
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
+
if treePath != "" {
+
for idx, elem := range strings.Split(treePath, "/") {
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
+
}
+
}
+
sortFiles(result.Files)
+
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
+
LoggedInUser: user,
+
BreadCrumbs: breadcrumbs,
+
TreePath: treePath,
+
RepoInfo: f.RepoInfo(user),
+
RepoTreeResponse: result,
+
})
+
}
+2
appview/reporesolver/resolver.go
···
Rkey: f.Repo.Rkey,
RepoAt: repoAt,
Description: f.Description,
+
Website: f.Website,
+
Topics: f.Topics,
IsStarred: isStarred,
Knot: knot,
Spindle: f.Spindle,
+1
appview/settings/settings.go
···
PullCommented: r.FormValue("pull_commented") == "on",
PullMerged: r.FormValue("pull_merged") == "on",
Followed: r.FormValue("followed") == "on",
+
UserMentioned: r.FormValue("user_mentioned") == "on",
EmailNotifications: r.FormValue("email_notifications") == "on",
}
+9
appview/spindles/spindles.go
···
"log/slog"
"net/http"
"slices"
+
"strings"
"time"
"github.com/go-chi/chi/v5"
···
}
instance := r.FormValue("instance")
+
// Strip protocol, trailing slashes, and whitespace
+
// Rkey cannot contain slashes
+
instance = strings.TrimSpace(instance)
+
instance = strings.TrimPrefix(instance, "https://")
+
instance = strings.TrimPrefix(instance, "http://")
+
instance = strings.TrimSuffix(instance, "/")
if instance == "" {
s.Pages.Notice(w, noticeId, "Incomplete form.")
return
···
}
member := r.FormValue("member")
+
member = strings.TrimPrefix(member, "@")
if member == "" {
l.Error("empty member")
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
···
}
member := r.FormValue("member")
+
member = strings.TrimPrefix(member, "@")
if member == "" {
l.Error("empty member")
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+10 -4
appview/state/gfi.go
···
package state
import (
-
"fmt"
"log"
"net/http"
"sort"
"github.com/bluesky-social/indigo/atproto/syntax"
-
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
···
page := pagination.FromContext(r.Context())
-
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
+
goodFirstIssueLabel := s.config.Label.GoodFirstIssue
+
+
gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel))
+
if err != nil {
+
log.Println("failed to get gfi label def", err)
+
s.pages.Error500(w)
+
return
+
}
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
if err != nil {
···
RepoGroups: []*models.RepoGroup{},
LabelDefs: make(map[string]*models.LabelDefinition),
Page: page,
+
GfiLabel: gfiLabelDef,
})
return
}
···
RepoGroups: paginatedGroups,
LabelDefs: labelDefsMap,
Page: page,
-
GfiLabel: labelDefsMap[goodFirstIssueLabel],
+
GfiLabel: gfiLabelDef,
})
}
+1
appview/state/login.go
···
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
if err != nil {
+
l.Error("failed to start auth", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+5 -5
appview/state/profile.go
···
return nil, fmt.Errorf("failed to get string count: %w", err)
}
-
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
+
starredCount, err := db.CountStars(s.db, db.FilterEq("did", did))
if err != nil {
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
}
···
}
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
-
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
+
stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid))
if err != nil {
l.Error("failed to get stars", "err", err)
s.pages.Error500(w)
···
}
var repos []models.Repo
for _, s := range stars {
-
if s.Repo != nil {
-
repos = append(repos, *s.Repo)
-
}
+
repos = append(repos, *s.Repo)
}
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
profile.Description = r.FormValue("description")
profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
profile.Location = r.FormValue("location")
+
profile.Pronouns = r.FormValue("pronouns")
var links [5]string
for i := range 5 {
···
Location: &profile.Location,
PinnedRepositories: pinnedRepoStrings,
Stats: vanityStats[:],
+
Pronouns: &profile.Pronouns,
}},
SwapRecord: cid,
})
+36 -31
appview/state/router.go
···
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
pat := chi.URLParam(r, "*")
-
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
-
userRouter.ServeHTTP(w, r)
-
} else {
-
// Check if the first path element is a valid handle without '@' or a flattened DID
-
pathParts := strings.SplitN(pat, "/", 2)
-
if len(pathParts) > 0 {
-
if userutil.IsHandleNoAt(pathParts[0]) {
-
// Redirect to the same path but with '@' prefixed to the handle
-
redirectPath := "@" + pat
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
-
return
-
} else if userutil.IsFlattenedDid(pathParts[0]) {
-
// Redirect to the unflattened DID version
-
unflattenedDid := userutil.UnflattenDid(pathParts[0])
-
var redirectPath string
-
if len(pathParts) > 1 {
-
redirectPath = unflattenedDid + "/" + pathParts[1]
-
} else {
-
redirectPath = unflattenedDid
-
}
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
-
return
-
}
+
pathParts := strings.SplitN(pat, "/", 2)
+
+
if len(pathParts) > 0 {
+
firstPart := pathParts[0]
+
+
// if using a DID or handle, just continue as per usual
+
if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) {
+
userRouter.ServeHTTP(w, r)
+
return
+
}
+
+
// if using a flattened DID (like you would in go modules), unflatten
+
if userutil.IsFlattenedDid(firstPart) {
+
unflattenedDid := userutil.UnflattenDid(firstPart)
+
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
+
+
redirectURL := *r.URL
+
redirectURL.Path = "/" + redirectPath
+
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
+
return
+
}
+
+
// if using a handle with @, rewrite to work without @
+
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
+
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
+
+
redirectURL := *r.URL
+
redirectURL.Path = "/" + redirectPath
+
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
+
return
}
-
standardRouter.ServeHTTP(w, r)
+
}
+
+
standardRouter.ServeHTTP(w, r)
})
return router
···
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
r.Get("/", s.Profile)
r.Get("/feed.atom", s.AtomFeedPage)
-
-
// redirect /@handle/repo.git -> /@handle/repo
-
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
-
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
-
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
-
})
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
r.Use(mw.GoImport())
···
// r.Post("/import", s.ImportRepo)
})
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
r.Post("/", s.Follow)
+9 -13
appview/state/star.go
···
log.Println("created atproto record: ", resp.Uri)
star := &models.Star{
-
StarredByDid: currentUser.Did,
-
RepoAt: subjectUri,
-
Rkey: rkey,
+
Did: currentUser.Did,
+
RepoAt: subjectUri,
+
Rkey: rkey,
}
err = db.AddStar(s.db, star)
···
s.notifier.NewStar(r.Context(), star)
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
IsStarred: true,
-
RepoAt: subjectUri,
-
Stats: models.RepoStats{
-
StarCount: starCount,
-
},
+
SubjectAt: subjectUri,
+
StarCount: starCount,
})
return
···
s.notifier.DeleteStar(r.Context(), star)
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
IsStarred: false,
-
RepoAt: subjectUri,
-
Stats: models.RepoStats{
-
StarCount: starCount,
-
},
+
SubjectAt: subjectUri,
+
StarCount: starCount,
})
return
+11 -11
appview/state/state.go
···
return nil, fmt.Errorf("failed to create enforcer: %w", err)
}
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
+
res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
if err != nil {
logger.Error("failed to create redis resolver", "err", err)
-
res = idresolver.DefaultResolver()
+
res = idresolver.DefaultResolver(config.Plc.PLCURL)
}
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
···
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
}
-
if err := BackfillDefaultDefs(d, res); err != nil {
+
if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
}
···
return
}
-
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
if err != nil {
// non-fatal
}
···
pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
if err != nil {
-
w.WriteHeader(http.StatusNotFound)
+
s.logger.Error("failed to get public keys", "err", err)
+
http.Error(w, "failed to get public keys", http.StatusInternalServerError)
return
}
if len(pubKeys) == 0 {
-
w.WriteHeader(http.StatusNotFound)
+
w.WriteHeader(http.StatusNoContent)
return
}
···
Rkey: rkey,
Description: description,
Created: time.Now(),
-
Labels: models.DefaultLabelDefs(),
+
Labels: s.config.Label.DefaultLabelDefs,
}
record := repo.AsRecord()
···
aturi = ""
s.notifier.NewRepo(r.Context(), repo)
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName))
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
}
}
···
return err
}
-
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
-
defaults := models.DefaultLabelDefs()
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
if err != nil {
return err
···
return nil
}
-
labelDefs, err := models.FetchDefaultDefs(r)
+
labelDefs, err := models.FetchLabelDefs(r, defaults)
if err != nil {
return err
}
+6 -6
appview/state/userutil/userutil.go
···
didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`)
)
-
func IsHandleNoAt(s string) bool {
+
func IsHandle(s string) bool {
// ref: https://atproto.com/specs/handle
return handleRegex.MatchString(s)
+
}
+
+
// IsDid checks if the given string is a standard DID.
+
func IsDid(s string) bool {
+
return didRegex.MatchString(s)
}
func UnflattenDid(s string) string {
···
return strings.Replace(s, ":", "-", 2)
}
return s
-
}
-
-
// IsDid checks if the given string is a standard DID.
-
func IsDid(s string) bool {
-
return didRegex.MatchString(s)
}
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+14 -2
appview/strings/strings.go
···
showRendered = r.URL.Query().Get("code") != "true"
}
+
starCount, err := db.GetStarCount(s.Db, string.AtUri())
+
if err != nil {
+
l.Error("failed to get star count", "err", err)
+
}
+
user := s.OAuth.GetUser(r)
+
isStarred := false
+
if user != nil {
+
isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
+
}
+
s.Pages.SingleString(w, pages.SingleStringParams{
-
LoggedInUser: s.OAuth.GetUser(r),
+
LoggedInUser: user,
RenderToggle: renderToggle,
ShowRendered: showRendered,
-
String: string,
+
String: &string,
Stats: string.Stats(),
+
IsStarred: isStarred,
+
StarCount: starCount,
Owner: id,
})
}
+53
appview/validator/repo_topics.go
···
+
package validator
+
+
import (
+
"fmt"
+
"maps"
+
"regexp"
+
"slices"
+
"strings"
+
)
+
+
const (
+
maxTopicLen = 50
+
maxTopics = 20
+
)
+
+
var (
+
topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`)
+
)
+
+
// ValidateRepoTopicStr parses and validates whitespace-separated topic string.
+
//
+
// Rules:
+
// - topics are separated by whitespace
+
// - each topic may contain lowercase letters, digits, and hyphens only
+
// - each topic must be <= 50 characters long
+
// - no more than 20 topics allowed
+
// - duplicates are removed
+
func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) {
+
topicsStr = strings.TrimSpace(topicsStr)
+
if topicsStr == "" {
+
return nil, nil
+
}
+
parts := strings.Fields(topicsStr)
+
if len(parts) > maxTopics {
+
return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics)
+
}
+
+
topicSet := make(map[string]struct{})
+
+
for _, t := range parts {
+
if _, exists := topicSet[t]; exists {
+
continue
+
}
+
if len(t) > maxTopicLen {
+
return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics)
+
}
+
if !topicRE.MatchString(t) {
+
return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t)
+
}
+
topicSet[t] = struct{}{}
+
}
+
return slices.Collect(maps.Keys(topicSet)), nil
+
}
+17
appview/validator/uri.go
···
+
package validator
+
+
import (
+
"fmt"
+
"net/url"
+
)
+
+
func (v *Validator) ValidateURI(uri string) error {
+
parsed, err := url.Parse(uri)
+
if err != nil {
+
return fmt.Errorf("invalid uri format")
+
}
+
if parsed.Scheme == "" {
+
return fmt.Errorf("uri scheme missing")
+
}
+
return nil
+
}
+3 -3
docs/hacking.md
···
did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR
# the secret key from above
-
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
+
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
# run redis in at a new shell to store oauth sessions
redis-server
···
If for any reason you wish to disable either one of the
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
-
`services.tangled-spindle.enable` (or
-
`services.tangled-knot.enable`) to `false`.
+
`services.tangled.spindle.enable` (or
+
`services.tangled.knot.enable`) to `false`.
+1 -1
docs/migrations.md
···
latest revision, and change your config block like so:
```diff
-
services.tangled-knot = {
+
services.tangled.knot = {
enable = true;
server = {
- secretFile = /path/to/secret;
+19 -1
docs/spindle/pipeline.md
···
- `push`: The workflow should run every time a commit is pushed to the repository.
- `pull_request`: The workflow should run every time a pull request is made or updated.
- `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.
+
- `branch`: 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. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
+
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
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:
···
branch: ["main", "develop"]
- event: ["pull_request"]
branch: ["main"]
+
```
+
+
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
+
+
```yaml
+
when:
+
- event: ["push"]
+
tag: ["v*"]
+
```
+
+
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
+
+
```yaml
+
when:
+
- event: ["push"]
+
branch: ["main", "release-*"]
+
tag: ["v*", "stable"]
```
## Engine
+17
flake.lock
···
{
"nodes": {
+
"actor-typeahead-src": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1762835797,
+
"narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=",
+
"ref": "refs/heads/main",
+
"rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b",
+
"revCount": 6,
+
"type": "git",
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
+
},
+
"original": {
+
"type": "git",
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
+
}
+
},
"flake-compat": {
"flake": false,
"locked": {
···
},
"root": {
"inputs": {
+
"actor-typeahead-src": "actor-typeahead-src",
"flake-compat": "flake-compat",
"gomod2nix": "gomod2nix",
"htmx-src": "htmx-src",
+12 -10
flake.nix
···
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
flake = false;
};
+
actor-typeahead-src = {
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
+
flake = false;
+
};
ibm-plex-mono-src = {
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
flake = false;
···
inter-fonts-src,
sqlite-lib-src,
ibm-plex-mono-src,
+
actor-typeahead-src,
...
}: let
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
};
appview = self.callPackage ./nix/pkgs/appview.nix {};
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
air-watcher = name: arg:
pkgs.writeShellScriptBin "run"
''
-
${pkgs.air}/bin/air -c /dev/null \
-
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
-
-build.bin "./out/${name}.out" \
-
-build.args_bin "${arg}" \
-
-build.stop_on_error "true" \
-
-build.include_ext "go"
+
export PATH=${pkgs.go}/bin:$PATH
+
${pkgs.air}/bin/air -c ./.air/${name}.toml \
+
-build.args_bin "${arg}"
'';
tailwind-watcher =
pkgs.writeShellScriptBin "run"
···
}: {
imports = [./nix/modules/appview.nix];
-
services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview;
};
nixosModules.knot = {
lib,
···
}: {
imports = [./nix/modules/knot.nix];
-
services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot;
};
nixosModules.spindle = {
lib,
···
}: {
imports = [./nix/modules/spindle.nix];
-
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
};
};
}
+4 -12
go.mod
···
github.com/alecthomas/assert/v2 v2.11.0
github.com/alecthomas/chroma/v2 v2.15.0
github.com/avast/retry-go/v4 v4.6.1
+
github.com/blevesearch/bleve/v2 v2.5.3
github.com/bluekeyes/go-gitdiff v0.8.1
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
+
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/carlmjohnson/versioninfo v0.22.5
github.com/casbin/casbin/v2 v2.103.0
+
github.com/charmbracelet/log v0.4.2
github.com/cloudflare/cloudflare-go v0.115.0
github.com/cyphar/filepath-securejoin v0.4.1
github.com/dgraph-io/ristretto v0.2.0
···
github.com/hiddeco/sshsig v0.2.0
github.com/hpcloud/tail v1.0.0
github.com/ipfs/go-cid v0.5.0
-
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/mattn/go-sqlite3 v1.14.24
github.com/microcosm-cc/bluemonday v1.0.27
github.com/openbao/openbao/api/v2 v2.3.0
···
github.com/wyatt915/goldmark-treeblood v0.0.1
github.com/yuin/goldmark v1.7.13
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
golang.org/x/crypto v0.40.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
golang.org/x/image v0.31.0
···
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
-
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect
···
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
-
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
-
github.com/charmbracelet/log v0.4.2 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
···
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
···
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
-
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
-
github.com/lestrrat-go/httpcc v1.0.1 // indirect
-
github.com/lestrrat-go/httprc v1.0.6 // indirect
-
github.com/lestrrat-go/iter v1.0.2 // indirect
-
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
···
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
-
github.com/segmentio/asm v1.2.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
···
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wyatt915/treeblood v0.1.16 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
+2 -17
go.sum
···
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
-
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
+
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
-
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
-
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
-
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
-
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
-
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
-
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
-
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
-
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
-
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
-
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
-
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
-
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
-
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
+36 -61
guard/guard.go
···
"os/exec"
"strings"
-
"github.com/bluesky-social/indigo/atproto/identity"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/urfave/cli/v3"
-
"tangled.org/core/idresolver"
"tangled.org/core/log"
)
···
"command", sshCommand,
"client", clientIP)
+
// TODO: greet user with their resolved handle instead of did
if sshCommand == "" {
l.Info("access denied: no interactive shells", "user", incomingUser)
fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
···
}
gitCommand := cmdParts[0]
-
-
// did:foo/repo-name or
-
// handle/repo-name or
-
// any of the above with a leading slash (/)
-
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
-
l.Info("command components", "components", components)
-
-
if len(components) != 2 {
-
l.Error("invalid repo format", "components", components)
-
fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
-
os.Exit(-1)
-
}
-
-
didOrHandle := components[0]
-
identity := resolveIdentity(ctx, l, didOrHandle)
-
did := identity.DID.String()
-
repoName := components[1]
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
+
repoPath := cmdParts[1]
validCommands := map[string]bool{
"git-receive-pack": true,
···
return fmt.Errorf("access denied: invalid git command")
}
-
if gitCommand != "git-upload-pack" {
-
if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) {
-
l.Error("access denied: user not allowed",
-
"did", incomingUser,
-
"reponame", qualifiedRepoName)
-
fmt.Fprintln(os.Stderr, "access denied: user not allowed")
-
os.Exit(-1)
-
}
+
// qualify repo path from internal server which holds the knot config
+
qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
+
if err != nil {
+
l.Error("failed to run guard", "err", err)
+
fmt.Fprintln(os.Stderr, err)
+
os.Exit(1)
}
-
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
+
fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
l.Info("processing command",
"user", incomingUser,
"command", gitCommand,
-
"repo", repoName,
+
"repo", repoPath,
"fullPath", fullPath,
"client", clientIP)
···
gitCmd.Stdin = os.Stdin
gitCmd.Env = append(os.Environ(),
fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
-
fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
)
if err := gitCmd.Run(); err != nil {
···
l.Info("command completed",
"user", incomingUser,
"command", gitCommand,
-
"repo", repoName,
+
"repo", repoPath,
"success", true)
return nil
}
-
func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
-
resolver := idresolver.DefaultResolver()
-
ident, err := resolver.ResolveIdent(ctx, didOrHandle)
+
// runs guardAndQualifyRepo logic
+
func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
+
u, _ := url.Parse(endpoint + "/guard")
+
q := u.Query()
+
q.Add("user", incomingUser)
+
q.Add("repo", repo)
+
q.Add("gitCmd", gitCommand)
+
u.RawQuery = q.Encode()
+
+
resp, err := http.Get(u.String())
if err != nil {
-
l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
-
fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
-
os.Exit(1)
-
}
-
if ident.Handle.IsInvalidHandle() {
-
l.Error("Error resolving handle", "invalid handle", didOrHandle)
-
fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
-
os.Exit(1)
+
return "", err
}
-
return ident
-
}
+
defer resp.Body.Close()
-
func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
-
u, _ := url.Parse(endpoint + "/push-allowed")
-
q := u.Query()
-
q.Add("user", user)
-
q.Add("repo", qualifiedRepoName)
-
u.RawQuery = q.Encode()
+
l.Info("Running guard", "url", u.String(), "status", resp.Status)
-
req, err := http.Get(u.String())
+
body, err := io.ReadAll(resp.Body)
if err != nil {
-
l.Error("Error verifying permissions", "error", err)
-
fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
-
os.Exit(1)
+
return "", err
}
-
-
l.Info("Checking push permission",
-
"url", u.String(),
-
"status", req.Status)
+
text := string(body)
-
return req.StatusCode == http.StatusNoContent
+
switch resp.StatusCode {
+
case http.StatusOK:
+
return text, nil
+
case http.StatusForbidden:
+
l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
+
return text, errors.New("access denied: user not allowed")
+
default:
+
return "", errors.New(text)
+
}
}
+17 -8
idresolver/resolver.go
···
directory identity.Directory
}
-
func BaseDirectory() identity.Directory {
+
func BaseDirectory(plcUrl string) identity.Directory {
base := identity.BaseDirectory{
-
PLCURL: identity.DefaultPLCURL,
+
PLCURL: plcUrl,
HTTPClient: http.Client{
Timeout: time.Second * 10,
Transport: &http.Transport{
···
return &base
}
-
func RedisDirectory(url string) (identity.Directory, error) {
+
func RedisDirectory(url, plcUrl string) (identity.Directory, error) {
hitTTL := time.Hour * 24
errTTL := time.Second * 30
invalidHandleTTL := time.Minute * 5
-
return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000)
+
return redisdir.NewRedisDirectory(
+
BaseDirectory(plcUrl),
+
url,
+
hitTTL,
+
errTTL,
+
invalidHandleTTL,
+
10000,
+
)
}
-
func DefaultResolver() *Resolver {
+
func DefaultResolver(plcUrl string) *Resolver {
+
base := BaseDirectory(plcUrl)
+
cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5)
return &Resolver{
-
directory: identity.DefaultDirectory(),
+
directory: &cached,
}
}
-
func RedisResolver(redisUrl string) (*Resolver, error) {
-
directory, err := RedisDirectory(redisUrl)
+
func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) {
+
directory, err := RedisDirectory(redisUrl, plcUrl)
if err != nil {
return nil, err
}
+38
input.css
···
@apply no-underline;
}
+
.prose a.mention {
+
@apply no-underline hover:underline;
+
}
+
.prose li {
@apply my-0 py-0;
}
···
details[data-callout] > summary::-webkit-details-marker {
display: none;
}
+
}
@layer utilities {
.error {
···
text-decoration: underline;
}
}
+
+
actor-typeahead {
+
--color-background: #ffffff;
+
--color-border: #d1d5db;
+
--color-shadow: #000000;
+
--color-hover: #f9fafb;
+
--color-avatar-fallback: #e5e7eb;
+
--radius: 0.0;
+
--padding-menu: 0.0rem;
+
z-index: 1000;
+
}
+
+
actor-typeahead::part(handle) {
+
color: #111827;
+
}
+
+
actor-typeahead::part(menu) {
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+
}
+
+
@media (prefers-color-scheme: dark) {
+
actor-typeahead {
+
--color-background: #1f2937;
+
--color-border: #4b5563;
+
--color-shadow: #000000;
+
--color-hover: #374151;
+
--color-avatar-fallback: #4b5563;
+
}
+
+
actor-typeahead::part(handle) {
+
color: #f9fafb;
+
}
+
}
+1
knotserver/config/config.go
···
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
DBPath string `env:"DB_PATH, default=knotserver.db"`
Hostname string `env:"HOSTNAME, required"`
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
Owner string `env:"OWNER, required"`
LogDids bool `env:"LOG_DIDS, default=true"`
+60 -2
knotserver/git/git.go
···
import (
"archive/tar"
"bytes"
+
"errors"
"fmt"
"io"
"io/fs"
···
"time"
"github.com/go-git/go-git/v5"
+
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
var (
-
ErrBinaryFile = fmt.Errorf("binary file")
-
ErrNotBinaryFile = fmt.Errorf("not binary file")
+
ErrBinaryFile = errors.New("binary file")
+
ErrNotBinaryFile = errors.New("not binary file")
+
ErrMissingGitModules = errors.New("no .gitmodules file found")
+
ErrInvalidGitModules = errors.New("invalid .gitmodules file")
+
ErrNotSubmodule = errors.New("path is not a submodule")
)
type GitRepo struct {
···
defer reader.Close()
return io.ReadAll(reader)
+
}
+
+
// read and parse .gitmodules
+
func (g *GitRepo) Submodules() (*config.Modules, error) {
+
c, err := g.r.CommitObject(g.h)
+
if err != nil {
+
return nil, fmt.Errorf("commit object: %w", err)
+
}
+
+
tree, err := c.Tree()
+
if err != nil {
+
return nil, fmt.Errorf("tree: %w", err)
+
}
+
+
// read .gitmodules file
+
modulesEntry, err := tree.FindEntry(".gitmodules")
+
if err != nil {
+
return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
+
}
+
+
modulesFile, err := tree.TreeEntryFile(modulesEntry)
+
if err != nil {
+
return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
+
}
+
+
content, err := modulesFile.Contents()
+
if err != nil {
+
return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
+
}
+
+
// parse .gitmodules
+
modules := config.NewModules()
+
if err = modules.Unmarshal([]byte(content)); err != nil {
+
return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
+
}
+
+
return modules, nil
+
}
+
+
func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
+
modules, err := g.Submodules()
+
if err != nil {
+
return nil, err
+
}
+
+
for _, submodule := range modules.Submodules {
+
if submodule.Path == path {
+
return submodule, nil
+
}
+
}
+
+
// path is not a submodule
+
return nil, ErrNotSubmodule
}
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4 -13
knotserver/git/tree.go
···
"path"
"time"
+
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"tangled.org/core/types"
)
···
}
for _, e := range subtree.Entries {
-
mode, _ := e.Mode.ToOSFileMode()
sz, _ := subtree.Size(e.Name)
-
fpath := path.Join(parent, e.Name)
var lastCommit *types.LastCommitInfo
···
nts = append(nts, types.NiceTree{
Name: e.Name,
-
Mode: mode.String(),
-
IsFile: e.Mode.IsFile(),
+
Mode: e.Mode.String(),
Size: sz,
LastCommit: lastCommit,
})
···
default:
}
-
mode, err := e.Mode.ToOSFileMode()
-
if err != nil {
-
// TODO: log this
-
continue
-
}
-
if e.Mode.IsFile() {
-
err = cb(e, currentTree, root)
-
if errors.Is(err, TerminateWalk) {
+
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
return err
}
}
// e is a directory
-
if mode.IsDir() {
+
if e.Mode == filemode.Dir {
subtree, err := currentTree.Tree(e.Name)
if err != nil {
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+4 -8
knotserver/ingester.go
···
"github.com/bluesky-social/jetstream/pkg/models"
securejoin "github.com/cyphar/filepath-securejoin"
"tangled.org/core/api/tangled"
-
"tangled.org/core/idresolver"
"tangled.org/core/knotserver/db"
"tangled.org/core/knotserver/git"
"tangled.org/core/log"
···
}
// resolve this aturi to extract the repo record
-
resolver := idresolver.DefaultResolver()
-
ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
+
ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
if err != nil || ident.Handle.IsInvalidHandle() {
return fmt.Errorf("failed to resolve handle: %w", err)
}
···
var pipeline workflow.RawPipeline
for _, e := range workflowDir {
-
if !e.IsFile {
+
if !e.IsFile() {
continue
}
···
return err
}
-
resolver := idresolver.DefaultResolver()
-
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
+
subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject)
if err != nil || subjectId.Handle.IsInvalidHandle() {
return err
}
// TODO: fix this for good, we need to fetch the record here unfortunately
// resolve this aturi to extract the repo record
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
+
owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String())
if err != nil || owner.Handle.IsInvalidHandle() {
return fmt.Errorf("failed to resolve handle: %w", err)
}
+146 -49
knotserver/internal.go
···
)
type InternalHandle struct {
-
db *db.DB
-
c *config.Config
-
e *rbac.Enforcer
-
l *slog.Logger
-
n *notifier.Notifier
+
db *db.DB
+
c *config.Config
+
e *rbac.Enforcer
+
l *slog.Logger
+
n *notifier.Notifier
+
res *idresolver.Resolver
}
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
···
writeJSON(w, data)
}
+
// response in text/plain format
+
// the body will be qualified repository path on success/push-denied
+
// or an error message when process failed
+
func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
+
l := h.l.With("handler", "PostReceiveHook")
+
+
var (
+
incomingUser = r.URL.Query().Get("user")
+
repo = r.URL.Query().Get("repo")
+
gitCommand = r.URL.Query().Get("gitCmd")
+
)
+
+
if incomingUser == "" || repo == "" || gitCommand == "" {
+
w.WriteHeader(http.StatusBadRequest)
+
l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
+
fmt.Fprintln(w, "invalid internal request")
+
return
+
}
+
+
// did:foo/repo-name or
+
// handle/repo-name or
+
// any of the above with a leading slash (/)
+
components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
+
l.Info("command components", "components", components)
+
+
if len(components) != 2 {
+
w.WriteHeader(http.StatusBadRequest)
+
l.Error("invalid repo format", "components", components)
+
fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
+
return
+
}
+
repoOwner := components[0]
+
repoName := components[1]
+
+
resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
+
+
repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner)
+
if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
+
l.Error("Error resolving handle", "handle", repoOwner, "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
fmt.Fprintf(w, "error resolving handle: invalid handle\n")
+
return
+
}
+
repoOwnerDid := repoOwnerIdent.DID.String()
+
+
qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
+
+
if gitCommand == "git-receive-pack" {
+
ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
+
if err != nil || !ok {
+
w.WriteHeader(http.StatusForbidden)
+
fmt.Fprint(w, repo)
+
return
+
}
+
}
+
+
w.WriteHeader(http.StatusOK)
+
fmt.Fprint(w, qualifiedRepo)
+
}
+
type PushOptions struct {
skipCi bool
verboseCi bool
···
// non-fatal
}
-
if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() {
-
msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context())
-
if err != nil {
-
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
-
// non-fatal
-
} else {
-
for msgLine := range msg {
-
resp.Messages = append(resp.Messages, msg[msgLine])
-
}
-
}
+
err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
+
if err != nil {
+
l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
+
// non-fatal
}
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
···
writeJSON(w, resp)
}
-
func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) {
-
l := h.l.With("handler", "replyCompare")
-
userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner)
-
user := repoOwner
-
if err != nil {
-
l.Error("Failed to fetch user identity", "err", err)
-
// non-fatal
-
} else {
-
user = userIdent.Handle.String()
-
}
-
gr, err := git.PlainOpen(gitRelativeDir)
-
if err != nil {
-
l.Error("Failed to open git repository", "err", err)
-
return []string{}, err
-
}
-
defaultBranch, err := gr.FindMainBranch()
-
if err != nil {
-
l.Error("Failed to fetch default branch", "err", err)
-
return []string{}, err
-
}
-
if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() {
-
return []string{}, nil
-
}
-
ZWS := "\u200B"
-
var msg []string
-
msg = append(msg, ZWS)
-
msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
-
msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
-
msg = append(msg, ZWS)
-
return msg, nil
-
}
-
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
if err != nil {
···
return errors.Join(errs, h.db.InsertEvent(event, h.n))
}
-
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
+
func (h *InternalHandle) triggerPipeline(
+
clientMsgs *[]string,
+
line git.PostReceiveLine,
+
gitUserDid string,
+
repoDid string,
+
repoName string,
+
pushOptions PushOptions,
+
) error {
if pushOptions.skipCi {
return nil
}
···
var pipeline workflow.RawPipeline
for _, e := range workflowDir {
-
if !e.IsFile {
+
if !e.IsFile() {
continue
}
···
return h.db.InsertEvent(event, h.n)
}
+
func (h *InternalHandle) emitCompareLink(
+
clientMsgs *[]string,
+
line git.PostReceiveLine,
+
repoDid string,
+
repoName string,
+
) error {
+
// this is a second push to a branch, don't reply with the link again
+
if !line.OldSha.IsZero() {
+
return nil
+
}
+
+
// the ref was not updated to a new hash, don't reply with the link
+
//
+
// NOTE: do we need this?
+
if line.NewSha.String() == line.OldSha.String() {
+
return nil
+
}
+
+
pushedRef := plumbing.ReferenceName(line.Ref)
+
+
userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
+
user := repoDid
+
if err == nil {
+
user = userIdent.Handle.String()
+
}
+
+
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
+
if err != nil {
+
return err
+
}
+
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
+
if err != nil {
+
return err
+
}
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
return err
+
}
+
+
defaultBranch, err := gr.FindMainBranch()
+
if err != nil {
+
return err
+
}
+
+
// pushing to default branch
+
if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
+
return nil
+
}
+
+
// pushing a tag, don't prompt the user the open a PR
+
if pushedRef.IsTag() {
+
return nil
+
}
+
+
ZWS := "\u200B"
+
*clientMsgs = append(*clientMsgs, ZWS)
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
+
*clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
+
*clientMsgs = append(*clientMsgs, ZWS)
+
return nil
+
}
+
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
r := chi.NewRouter()
l := log.FromContext(ctx)
l = log.SubLogger(l, "internal")
+
res := idresolver.DefaultResolver(c.Server.PlcUrl)
h := InternalHandle{
db,
···
e,
l,
n,
+
res,
}
r.Get("/push-allowed", h.PushAllowed)
r.Get("/keys", h.InternalKeys)
+
r.Get("/guard", h.Guard)
r.Post("/hooks/post-receive", h.PostReceiveHook)
r.Mount("/debug", middleware.Profiler())
+1 -1
knotserver/router.go
···
l: log.FromContext(ctx),
jc: jc,
n: n,
-
resolver: idresolver.DefaultResolver(),
+
resolver: idresolver.DefaultResolver(c.Server.PlcUrl),
}
err := e.AddKnot(rbac.ThisServer)
+21 -2
knotserver/xrpc/repo_blob.go
···
return
}
+
// first check if this path is a submodule
+
submodule, err := gr.Submodule(treePath)
+
if err != nil {
+
// this is okay, continue and try to treat it as a regular file
+
} else {
+
response := tangled.RepoBlob_Output{
+
Ref: ref,
+
Path: treePath,
+
Submodule: &tangled.RepoBlob_Submodule{
+
Name: submodule.Name,
+
Url: submodule.URL,
+
Branch: &submodule.Branch,
+
},
+
}
+
writeJson(w, response)
+
return
+
}
+
contents, err := gr.RawContent(treePath)
if err != nil {
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
var encoding string
isBinary := !isTextual(mimeType)
+
size := int64(len(contents))
if isBinary {
content = base64.StdEncoding.EncodeToString(contents)
···
response := tangled.RepoBlob_Output{
Ref: ref,
Path: treePath,
-
Content: content,
+
Content: &content,
Encoding: &encoding,
-
Size: &[]int64{int64(len(contents))}[0],
+
Size: &size,
IsBinary: &isBinary,
}
+3 -5
knotserver/xrpc/repo_tree.go
···
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
for i, file := range files {
entry := &tangled.RepoTree_TreeEntry{
-
Name: file.Name,
-
Mode: file.Mode,
-
Size: file.Size,
-
Is_file: file.IsFile,
-
Is_subtree: file.IsSubtree,
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
}
if file.LastCommit != nil {
+5
lexicons/actor/profile.json
···
"type": "string",
"format": "at-uri"
}
+
},
+
"pronouns": {
+
"type": "string",
+
"description": "Preferred gender pronouns.",
+
"maxLength": 40
}
}
}
+49 -5
lexicons/repo/blob.json
···
"type": "query",
"parameters": {
"type": "params",
-
"required": ["repo", "ref", "path"],
+
"required": [
+
"repo",
+
"ref",
+
"path"
+
],
"properties": {
"repo": {
"type": "string",
···
"encoding": "application/json",
"schema": {
"type": "object",
-
"required": ["ref", "path", "content"],
+
"required": [
+
"ref",
+
"path"
+
],
"properties": {
"ref": {
"type": "string",
···
"encoding": {
"type": "string",
"description": "Content encoding",
-
"enum": ["utf-8", "base64"]
+
"enum": [
+
"utf-8",
+
"base64"
+
]
},
"size": {
"type": "integer",
···
"mimeType": {
"type": "string",
"description": "MIME type of the file"
+
},
+
"submodule": {
+
"type": "ref",
+
"ref": "#submodule",
+
"description": "Submodule information if path is a submodule"
},
"lastCommit": {
"type": "ref",
···
},
"lastCommit": {
"type": "object",
-
"required": ["hash", "message", "when"],
+
"required": [
+
"hash",
+
"message",
+
"when"
+
],
"properties": {
"hash": {
"type": "string",
···
},
"signature": {
"type": "object",
-
"required": ["name", "email", "when"],
+
"required": [
+
"name",
+
"email",
+
"when"
+
],
"properties": {
"name": {
"type": "string",
···
"type": "string",
"format": "datetime",
"description": "Author timestamp"
+
}
+
}
+
},
+
"submodule": {
+
"type": "object",
+
"required": [
+
"name",
+
"url"
+
],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Submodule name"
+
},
+
"url": {
+
"type": "string",
+
"description": "Submodule repository URL"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Branch to track in the submodule"
}
}
}
+15
lexicons/repo/repo.json
···
"minGraphemes": 1,
"maxGraphemes": 140
},
+
"website": {
+
"type": "string",
+
"format": "uri",
+
"description": "Any URI related to the repo"
+
},
+
"topics": {
+
"type": "array",
+
"description": "Topics related to the repo",
+
"items": {
+
"type": "string",
+
"minLength": 1,
+
"maxLength": 50
+
},
+
"maxLength": 50
+
},
"source": {
"type": "string",
"format": "uri",
+1 -9
lexicons/repo/tree.json
···
},
"treeEntry": {
"type": "object",
-
"required": ["name", "mode", "size", "is_file", "is_subtree"],
+
"required": ["name", "mode", "size"],
"properties": {
"name": {
"type": "string",
···
"size": {
"type": "integer",
"description": "File size in bytes"
-
},
-
"is_file": {
-
"type": "boolean",
-
"description": "Whether this entry is a file"
-
},
-
"is_subtree": {
-
"type": "boolean",
-
"description": "Whether this entry is a directory/subtree"
},
"last_commit": {
"type": "ref",
+2 -2
nix/gomod2nix.toml
···
version = "v0.0.0-20241210005130-ea96859b93d1"
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
[mod."github.com/bmatcuk/doublestar/v4"]
-
version = "v4.7.1"
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
+
version = "v4.9.1"
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
[mod."github.com/carlmjohnson/versioninfo"]
version = "v0.22.5"
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
+285 -18
nix/modules/appview.nix
···
lib,
...
}: let
-
cfg = config.services.tangled-appview;
+
cfg = config.services.tangled.appview;
in
with lib; {
options = {
-
services.tangled-appview = {
+
services.tangled.appview = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable tangled appview";
};
+
package = mkOption {
type = types.package;
description = "Package to use for the appview";
};
+
+
# core configuration
port = mkOption {
-
type = types.int;
+
type = types.port;
default = 3000;
description = "Port to run the appview on";
};
-
cookie_secret = mkOption {
+
+
listenAddr = mkOption {
+
type = types.str;
+
default = "0.0.0.0:${toString cfg.port}";
+
description = "Listen address for the appview service";
+
};
+
+
dbPath = mkOption {
type = types.str;
-
default = "00000000000000000000000000000000";
-
description = "Cookie secret";
+
default = "/var/lib/appview/appview.db";
+
description = "Path to the SQLite database file";
+
};
+
+
appviewHost = mkOption {
+
type = types.str;
+
default = "https://tangled.org";
+
example = "https://example.com";
+
description = "Public host URL for the appview instance";
+
};
+
+
appviewName = mkOption {
+
type = types.str;
+
default = "Tangled";
+
description = "Display name for the appview instance";
+
};
+
+
dev = mkOption {
+
type = types.bool;
+
default = false;
+
description = "Enable development mode";
+
};
+
+
disallowedNicknamesFile = mkOption {
+
type = types.nullOr types.path;
+
default = null;
+
description = "Path to file containing disallowed nicknames";
+
};
+
+
# redis configuration
+
redis = {
+
addr = mkOption {
+
type = types.str;
+
default = "localhost:6379";
+
description = "Redis server address";
+
};
+
+
db = mkOption {
+
type = types.int;
+
default = 0;
+
description = "Redis database number";
+
};
+
};
+
+
# jetstream configuration
+
jetstream = {
+
endpoint = mkOption {
+
type = types.str;
+
default = "wss://jetstream1.us-east.bsky.network/subscribe";
+
description = "Jetstream WebSocket endpoint";
+
};
+
};
+
+
# knotstream consumer configuration
+
knotstream = {
+
retryInterval = mkOption {
+
type = types.str;
+
default = "60s";
+
description = "Initial retry interval for knotstream consumer";
+
};
+
+
maxRetryInterval = mkOption {
+
type = types.str;
+
default = "120m";
+
description = "Maximum retry interval for knotstream consumer";
+
};
+
+
connectionTimeout = mkOption {
+
type = types.str;
+
default = "5s";
+
description = "Connection timeout for knotstream consumer";
+
};
+
+
workerCount = mkOption {
+
type = types.int;
+
default = 64;
+
description = "Number of workers for knotstream consumer";
+
};
+
+
queueSize = mkOption {
+
type = types.int;
+
default = 100;
+
description = "Queue size for knotstream consumer";
+
};
+
};
+
+
# spindlestream consumer configuration
+
spindlestream = {
+
retryInterval = mkOption {
+
type = types.str;
+
default = "60s";
+
description = "Initial retry interval for spindlestream consumer";
+
};
+
+
maxRetryInterval = mkOption {
+
type = types.str;
+
default = "120m";
+
description = "Maximum retry interval for spindlestream consumer";
+
};
+
+
connectionTimeout = mkOption {
+
type = types.str;
+
default = "5s";
+
description = "Connection timeout for spindlestream consumer";
+
};
+
+
workerCount = mkOption {
+
type = types.int;
+
default = 64;
+
description = "Number of workers for spindlestream consumer";
+
};
+
+
queueSize = mkOption {
+
type = types.int;
+
default = 100;
+
description = "Queue size for spindlestream consumer";
+
};
+
};
+
+
# resend configuration
+
resend = {
+
sentFrom = mkOption {
+
type = types.str;
+
default = "noreply@notifs.tangled.sh";
+
description = "Email address to send notifications from";
+
};
+
};
+
+
# posthog configuration
+
posthog = {
+
endpoint = mkOption {
+
type = types.str;
+
default = "https://eu.i.posthog.com";
+
description = "PostHog API endpoint";
+
};
+
};
+
+
# camo configuration
+
camo = {
+
host = mkOption {
+
type = types.str;
+
default = "https://camo.tangled.sh";
+
description = "Camo proxy host URL";
+
};
};
+
+
# avatar configuration
+
avatar = {
+
host = mkOption {
+
type = types.str;
+
default = "https://avatar.tangled.sh";
+
description = "Avatar service host URL";
+
};
+
};
+
+
plc = {
+
url = mkOption {
+
type = types.str;
+
default = "https://plc.directory";
+
description = "PLC directory URL";
+
};
+
};
+
+
pds = {
+
host = mkOption {
+
type = types.str;
+
default = "https://tngl.sh";
+
description = "PDS host URL";
+
};
+
};
+
+
label = {
+
defaults = mkOption {
+
type = types.listOf types.str;
+
default = [
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
+
];
+
description = "Default label definitions";
+
};
+
+
goodFirstIssue = mkOption {
+
type = types.str;
+
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
+
description = "Good first issue label definition";
+
};
+
};
+
environmentFile = mkOption {
type = with types; nullOr path;
default = null;
-
example = "/etc/tangled-appview.env";
+
example = "/etc/appview.env";
description = ''
Additional environment file as defined in {manpage}`systemd.exec(5)`.
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
-
passed to the service without makeing them world readable in the
-
nix store.
-
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
+
{env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
+
{env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
+
{env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
+
{env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
+
{env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
+
and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
+
without making them world readable in the nix store.
'';
};
};
};
config = mkIf cfg.enable {
-
systemd.services.tangled-appview = {
+
services.redis.servers.appview = {
+
enable = true;
+
port = 6379;
+
};
+
+
systemd.services.appview = {
description = "tangled appview service";
wantedBy = ["multi-user.target"];
+
after = ["redis-appview.service" "network-online.target"];
+
requires = ["redis-appview.service"];
+
wants = ["network-online.target"];
serviceConfig = {
-
ListenStream = "0.0.0.0:${toString cfg.port}";
+
Type = "simple";
ExecStart = "${cfg.package}/bin/appview";
Restart = "always";
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
+
RestartSec = "10s";
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
+
+
# state directory
+
StateDirectory = "appview";
+
WorkingDirectory = "/var/lib/appview";
+
+
# security hardening
+
NoNewPrivileges = true;
+
PrivateTmp = true;
+
ProtectSystem = "strict";
+
ProtectHome = true;
+
ReadWritePaths = ["/var/lib/appview"];
};
-
environment = {
-
TANGLED_DB_PATH = "appview.db";
-
TANGLED_COOKIE_SECRET = cfg.cookie_secret;
-
};
+
environment =
+
{
+
TANGLED_DB_PATH = cfg.dbPath;
+
TANGLED_LISTEN_ADDR = cfg.listenAddr;
+
TANGLED_APPVIEW_HOST = cfg.appviewHost;
+
TANGLED_APPVIEW_NAME = cfg.appviewName;
+
TANGLED_DEV =
+
if cfg.dev
+
then "true"
+
else "false";
+
}
+
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
+
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
+
}
+
// {
+
TANGLED_REDIS_ADDR = cfg.redis.addr;
+
TANGLED_REDIS_DB = toString cfg.redis.db;
+
+
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
+
+
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
+
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
+
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
+
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
+
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
+
+
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
+
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
+
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
+
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
+
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
+
+
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
+
+
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
+
+
TANGLED_CAMO_HOST = cfg.camo.host;
+
+
TANGLED_AVATAR_HOST = cfg.avatar.host;
+
+
TANGLED_PLC_URL = cfg.plc.url;
+
+
TANGLED_PDS_HOST = cfg.pds.host;
+
+
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
+
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
+
};
};
};
}
+74 -4
nix/modules/knot.nix
···
lib,
...
}: let
-
cfg = config.services.tangled-knot;
+
cfg = config.services.tangled.knot;
in
with lib; {
options = {
-
services.tangled-knot = {
+
services.tangled.knot = {
enable = mkOption {
type = types.bool;
default = false;
···
description = "Path where repositories are scanned from";
};
+
readme = mkOption {
+
type = types.listOf types.str;
+
default = [
+
"README.md"
+
"readme.md"
+
"README"
+
"readme"
+
"README.markdown"
+
"readme.markdown"
+
"README.txt"
+
"readme.txt"
+
"README.rst"
+
"readme.rst"
+
"README.org"
+
"readme.org"
+
"README.asciidoc"
+
"readme.asciidoc"
+
];
+
description = "List of README filenames to look for (in priority order)";
+
};
+
mainBranch = mkOption {
type = types.str;
default = "main";
description = "Default branch name for repositories";
+
};
+
};
+
+
git = {
+
userName = mkOption {
+
type = types.str;
+
default = "Tangled";
+
description = "Git user name used as committer";
+
};
+
+
userEmail = mkOption {
+
type = types.str;
+
default = "noreply@tangled.org";
+
description = "Git user email used as committer";
};
};
···
description = "Hostname for the server (required)";
};
+
plcUrl = mkOption {
+
type = types.str;
+
default = "https://plc.directory";
+
description = "atproto PLC directory";
+
};
+
+
jetstreamEndpoint = mkOption {
+
type = types.str;
+
default = "wss://jetstream1.us-west.bsky.network/subscribe";
+
description = "Jetstream endpoint to subscribe to";
+
};
+
+
logDids = mkOption {
+
type = types.bool;
+
default = true;
+
description = "Enable logging of DIDs";
+
};
+
dev = mkOption {
type = types.bool;
default = false;
···
mkdir -p "${cfg.stateDir}/.config/git"
cat > "${cfg.stateDir}/.config/git/config" << EOF
[user]
-
name = Git User
-
email = git@example.com
+
name = ${cfg.git.userName}
+
email = ${cfg.git.userEmail}
[receive]
advertisePushOptions = true
+
[uploadpack]
+
allowFilter = true
EOF
${setMotd}
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
WorkingDirectory = cfg.stateDir;
Environment = [
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
+
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
+
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
"KNOT_SERVER_OWNER=${cfg.server.owner}"
+
"KNOT_SERVER_LOG_DIDS=${
+
if cfg.server.logDids
+
then "true"
+
else "false"
+
}"
+
"KNOT_SERVER_DEV=${
+
if cfg.server.dev
+
then "true"
+
else "false"
+
}"
];
ExecStart = "${cfg.package}/bin/knot server";
Restart = "always";
+10 -3
nix/modules/spindle.nix
···
lib,
...
}: let
-
cfg = config.services.tangled-spindle;
+
cfg = config.services.tangled.spindle;
in
with lib; {
options = {
-
services.tangled-spindle = {
+
services.tangled.spindle = {
enable = mkOption {
type = types.bool;
default = false;
···
type = types.str;
example = "my.spindle.com";
description = "Hostname for the server (required)";
+
};
+
+
plcUrl = mkOption {
+
type = types.str;
+
default = "https://plc.directory";
+
description = "atproto PLC directory";
};
jetstreamEndpoint = mkOption {
···
"SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
"SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}"
"SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}"
-
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
+
"SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}"
+
"SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+2
nix/pkgs/appview-static-files.nix
···
lucide-src,
inter-fonts-src,
ibm-plex-mono-src,
+
actor-typeahead-src,
sqlite-lib,
tailwindcss,
src,
···
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
# for whatever reason (produces broken css), so we are doing this instead
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+1 -1
nix/pkgs/knot-unwrapped.nix
···
sqlite-lib,
src,
}: let
-
version = "1.9.1-alpha";
+
version = "1.11.0-alpha";
in
buildGoApplication {
pname = "knot";
+21 -8
nix/vm.nix
···
if var == ""
then throw "\$${name} must be defined, see docs/hacking.md for more details"
else var;
+
envVarOr = name: default: let
+
var = builtins.getEnv name;
+
in
+
if var != ""
+
then var
+
else default;
+
+
plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory";
+
jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe";
in
nixpkgs.lib.nixosSystem {
inherit system;
···
time.timeZone = "Europe/London";
services.getty.autologinUser = "root";
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
-
services.tangled-knot = {
+
services.tangled.knot = {
enable = true;
motd = "Welcome to the development knot!\n";
server = {
owner = envVar "TANGLED_VM_KNOT_OWNER";
-
hostname = "localhost:6000";
+
hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000";
+
plcUrl = plcUrl;
+
jetstreamEndpoint = jetstream;
listenAddr = "0.0.0.0:6000";
};
};
-
services.tangled-spindle = {
+
services.tangled.spindle = {
enable = true;
server = {
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
-
hostname = "localhost:6555";
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
+
plcUrl = plcUrl;
+
jetstreamEndpoint = jetstream;
listenAddr = "0.0.0.0:6555";
dev = true;
queueSize = 100;
···
users = {
# So we don't have to deal with permission clashing between
# blank disk VMs and existing state
-
users.${config.services.tangled-knot.gitUser}.uid = 666;
-
groups.${config.services.tangled-knot.gitUser}.gid = 666;
+
users.${config.services.tangled.knot.gitUser}.uid = 666;
+
groups.${config.services.tangled.knot.gitUser}.gid = 666;
# TODO: separate spindle user
};
···
serviceConfig.PermissionsStartOnly = true;
};
in {
-
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
-
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir;
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath);
};
})
];
+1
spindle/config/config.go
···
DBPath string `env:"DB_PATH, default=spindle.db"`
Hostname string `env:"HOSTNAME, required"`
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
Dev bool `env:"DEV, default=false"`
Owner string `env:"OWNER, required"`
Secrets Secrets `env:",prefix=SECRETS_"`
+1 -1
spindle/engines/nixery/engine.go
···
setup := &setupSteps{}
setup.addStep(nixConfStep())
-
setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
+
setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
// this step could be empty
if s := dependencyStep(dwf.Dependencies); s != nil {
setup.addStep(*s)
-73
spindle/engines/nixery/setup_steps.go
···
import (
"fmt"
-
"path"
"strings"
-
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/workflow"
)
func nixConfStep() Step {
···
command: setupCmd,
name: "Configure Nix",
}
-
}
-
-
// cloneOptsAsSteps processes clone options and adds corresponding steps
-
// to the beginning of the workflow's step list if cloning is not skipped.
-
//
-
// the steps to do here are:
-
// - git init
-
// - git remote add origin <url>
-
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
-
// - git checkout FETCH_HEAD
-
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
-
if twf.Clone.Skip {
-
return Step{}
-
}
-
-
var commands []string
-
-
// initialize git repo in workspace
-
commands = append(commands, "git init")
-
-
// add repo as git remote
-
scheme := "https://"
-
if dev {
-
scheme = "http://"
-
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
-
}
-
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
-
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
-
-
// run git fetch
-
{
-
var fetchArgs []string
-
-
// default clone depth is 1
-
depth := 1
-
if twf.Clone.Depth > 1 {
-
depth = int(twf.Clone.Depth)
-
}
-
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
-
-
// optionally recurse submodules
-
if twf.Clone.Submodules {
-
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
-
}
-
-
// set remote to fetch from
-
fetchArgs = append(fetchArgs, "origin")
-
-
// set revision to checkout
-
switch workflow.TriggerKind(tr.Kind) {
-
case workflow.TriggerKindManual:
-
// TODO: unimplemented
-
case workflow.TriggerKindPush:
-
fetchArgs = append(fetchArgs, tr.Push.NewSha)
-
case workflow.TriggerKindPullRequest:
-
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
-
}
-
-
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
-
}
-
-
// run git checkout
-
commands = append(commands, "git checkout FETCH_HEAD")
-
-
cloneStep := Step{
-
command: strings.Join(commands, "\n"),
-
name: "Clone repository into workspace",
-
}
-
return cloneStep
}
// dependencyStep processes dependencies defined in the workflow.
+3 -7
spindle/ingester.go
···
"tangled.org/core/api/tangled"
"tangled.org/core/eventconsumer"
-
"tangled.org/core/idresolver"
"tangled.org/core/rbac"
"tangled.org/core/spindle/db"
···
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
var err error
did := e.Did
-
resolver := idresolver.DefaultResolver()
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
···
}
// add collaborators to rbac
-
owner, err := resolver.ResolveIdent(ctx, did)
+
owner, err := s.res.ResolveIdent(ctx, did)
if err != nil || owner.Handle.IsInvalidHandle() {
return err
}
···
return err
}
-
resolver := idresolver.DefaultResolver()
-
-
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
+
subjectId, err := s.res.ResolveIdent(ctx, record.Subject)
if err != nil || subjectId.Handle.IsInvalidHandle() {
return err
}
···
// TODO: get rid of this entirely
// resolve this aturi to extract the repo record
-
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
+
owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String())
if err != nil || owner.Handle.IsInvalidHandle() {
return fmt.Errorf("failed to resolve handle: %w", err)
}
+151
spindle/models/clone.go
···
+
package models
+
+
import (
+
"fmt"
+
"strings"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/workflow"
+
)
+
+
type CloneStep struct {
+
name string
+
kind StepKind
+
commands []string
+
}
+
+
func (s CloneStep) Name() string {
+
return s.name
+
}
+
+
func (s CloneStep) Commands() []string {
+
return s.commands
+
}
+
+
func (s CloneStep) Command() string {
+
return strings.Join(s.commands, "\n")
+
}
+
+
func (s CloneStep) Kind() StepKind {
+
return s.kind
+
}
+
+
// BuildCloneStep generates git clone commands.
+
// The caller must ensure the current working directory is set to the desired
+
// workspace directory before executing these commands.
+
//
+
// The generated commands are:
+
// - git init
+
// - git remote add origin <url>
+
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
+
// - git checkout FETCH_HEAD
+
//
+
// Supports all trigger types (push, PR, manual) and clone options.
+
func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) CloneStep {
+
if twf.Clone != nil && twf.Clone.Skip {
+
return CloneStep{}
+
}
+
+
commitSHA, err := extractCommitSHA(tr)
+
if err != nil {
+
return CloneStep{
+
kind: StepKindSystem,
+
name: "Clone repository into workspace (error)",
+
commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())},
+
}
+
}
+
+
repoURL := buildRepoURL(tr, dev)
+
+
var cloneOpts tangled.Pipeline_CloneOpts
+
if twf.Clone != nil {
+
cloneOpts = *twf.Clone
+
}
+
fetchArgs := buildFetchArgs(cloneOpts, commitSHA)
+
+
return CloneStep{
+
kind: StepKindSystem,
+
name: "Clone repository into workspace",
+
commands: []string{
+
"git init",
+
fmt.Sprintf("git remote add origin %s", repoURL),
+
fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")),
+
"git checkout FETCH_HEAD",
+
},
+
}
+
}
+
+
// extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type
+
func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) {
+
switch workflow.TriggerKind(tr.Kind) {
+
case workflow.TriggerKindPush:
+
if tr.Push == nil {
+
return "", fmt.Errorf("push trigger metadata is nil")
+
}
+
return tr.Push.NewSha, nil
+
+
case workflow.TriggerKindPullRequest:
+
if tr.PullRequest == nil {
+
return "", fmt.Errorf("pull request trigger metadata is nil")
+
}
+
return tr.PullRequest.SourceSha, nil
+
+
case workflow.TriggerKindManual:
+
// Manual triggers don't have an explicit SHA in the metadata
+
// For now, return empty string - could be enhanced to fetch from default branch
+
// TODO: Implement manual trigger SHA resolution (fetch default branch HEAD)
+
return "", nil
+
+
default:
+
return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind)
+
}
+
}
+
+
// buildRepoURL constructs the repository URL from trigger metadata
+
func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string {
+
if tr.Repo == nil {
+
return ""
+
}
+
+
// Determine protocol
+
scheme := "https://"
+
if devMode {
+
scheme = "http://"
+
}
+
+
// Get host from knot
+
host := tr.Repo.Knot
+
+
// In dev mode, replace localhost with host.docker.internal for Docker networking
+
if devMode && strings.Contains(host, "localhost") {
+
host = strings.ReplaceAll(host, "localhost", "host.docker.internal")
+
}
+
+
// Build URL: {scheme}{knot}/{did}/{repo}
+
return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo)
+
}
+
+
// buildFetchArgs constructs the arguments for git fetch based on clone options
+
func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string {
+
args := []string{}
+
+
// Set fetch depth (default to 1 for shallow clone)
+
depth := clone.Depth
+
if depth == 0 {
+
depth = 1
+
}
+
args = append(args, fmt.Sprintf("--depth=%d", depth))
+
+
// Add submodules if requested
+
if clone.Submodules {
+
args = append(args, "--recurse-submodules=yes")
+
}
+
+
// Add remote and SHA
+
args = append(args, "origin")
+
if sha != "" {
+
args = append(args, sha)
+
}
+
+
return args
+
}
+371
spindle/models/clone_test.go
···
+
package models
+
+
import (
+
"strings"
+
"testing"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/workflow"
+
)
+
+
func TestBuildCloneStep_PushTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Submodules: false,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
OldSha: "def456",
+
Ref: "refs/heads/main",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
if step.Kind() != StepKindSystem {
+
t.Errorf("Expected StepKindSystem, got %v", step.Kind())
+
}
+
+
if step.Name() != "Clone repository into workspace" {
+
t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name())
+
}
+
+
commands := step.Commands()
+
if len(commands) != 4 {
+
t.Errorf("Expected 4 commands, got %d", len(commands))
+
}
+
+
// Verify commands contain expected git operations
+
allCmds := strings.Join(commands, " ")
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
if !strings.Contains(allCmds, "git remote add origin") {
+
t.Error("Commands should contain 'git remote add origin'")
+
}
+
if !strings.Contains(allCmds, "git fetch") {
+
t.Error("Commands should contain 'git fetch'")
+
}
+
if !strings.Contains(allCmds, "abc123") {
+
t.Error("Commands should contain commit SHA")
+
}
+
if !strings.Contains(allCmds, "git checkout FETCH_HEAD") {
+
t.Error("Commands should contain 'git checkout FETCH_HEAD'")
+
}
+
if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") {
+
t.Error("Commands should contain expected repo URL")
+
}
+
}
+
+
func TestBuildCloneStep_PullRequestTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPullRequest),
+
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
+
SourceSha: "pr-sha-789",
+
SourceBranch: "feature-branch",
+
TargetBranch: "main",
+
Action: "opened",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "pr-sha-789") {
+
t.Error("Commands should contain PR commit SHA")
+
}
+
}
+
+
func TestBuildCloneStep_ManualTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindManual),
+
Manual: &tangled.Pipeline_ManualTriggerData{
+
Inputs: nil,
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA
+
allCmds := strings.Join(step.Commands(), " ")
+
// Should still have basic git commands
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
if !strings.Contains(allCmds, "git fetch") {
+
t.Error("Commands should contain 'git fetch'")
+
}
+
}
+
+
func TestBuildCloneStep_SkipFlag(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Skip: true,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Empty step when skip is true
+
if step.Name() != "" {
+
t.Error("Expected empty step name when Skip is true")
+
}
+
if len(step.Commands()) != 0 {
+
t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands()))
+
}
+
}
+
+
func TestBuildCloneStep_DevMode(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "localhost:3000",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, true)
+
+
// In dev mode, should use http:// and replace localhost with host.docker.internal
+
allCmds := strings.Join(step.Commands(), " ")
+
expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo"
+
if !strings.Contains(allCmds, expectedURL) {
+
t.Errorf("Expected dev mode URL '%s' in commands", expectedURL)
+
}
+
}
+
+
func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 10,
+
Submodules: true,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=10") {
+
t.Error("Commands should contain '--depth=10'")
+
}
+
+
if !strings.Contains(allCmds, "--recurse-submodules=yes") {
+
t.Error("Commands should contain '--recurse-submodules=yes'")
+
}
+
}
+
+
func TestBuildCloneStep_DefaultDepth(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 0, // Default should be 1
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=1") {
+
t.Error("Commands should default to '--depth=1'")
+
}
+
}
+
+
func TestBuildCloneStep_NilPushData(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: nil, // Nil push data should create error step
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name when push data is nil")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "Failed to get clone info") {
+
t.Error("Commands should contain error message")
+
}
+
if !strings.Contains(allCmds, "exit 1") {
+
t.Error("Commands should exit with error")
+
}
+
}
+
+
func TestBuildCloneStep_NilPRData(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPullRequest),
+
PullRequest: nil, // Nil PR data should create error step
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name when pull request data is nil")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "Failed to get clone info") {
+
t.Error("Commands should contain error message")
+
}
+
}
+
+
func TestBuildCloneStep_UnknownTriggerKind(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: "unknown_trigger",
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name for unknown trigger kind")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "unknown trigger kind") {
+
t.Error("Commands should contain error message about unknown trigger kind")
+
}
+
}
+
+
func TestBuildCloneStep_NilCloneOpts(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: nil, // Nil clone options should use defaults
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should still work with default options
+
if step.Kind() != StepKindSystem {
+
t.Errorf("Expected StepKindSystem, got %v", step.Kind())
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=1") {
+
t.Error("Commands should default to '--depth=1' when Clone is nil")
+
}
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
}
+15 -7
spindle/secrets/openbao.go
···
)
type OpenBaoManager struct {
-
client *vault.Client
-
mountPath string
-
logger *slog.Logger
+
client *vault.Client
+
mountPath string
+
logger *slog.Logger
+
connectionTimeout time.Duration
}
type OpenBaoManagerOpt func(*OpenBaoManager)
···
}
}
+
func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt {
+
return func(v *OpenBaoManager) {
+
v.connectionTimeout = timeout
+
}
+
}
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
// The proxy handles all authentication automatically via Auto-Auth
···
}
manager := &OpenBaoManager{
-
client: client,
-
mountPath: "spindle", // default KV v2 mount path
-
logger: logger,
+
client: client,
+
mountPath: "spindle", // default KV v2 mount path
+
logger: logger,
+
connectionTimeout: 10 * time.Second, // default connection timeout
}
for _, opt := range opts {
···
// testConnection verifies that we can connect to the proxy
func (v *OpenBaoManager) testConnection() error {
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout)
defer cancel()
// try token self-lookup as a quick way to verify proxy works
+5 -2
spindle/secrets/openbao_test.go
···
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
+
// Use shorter timeout for tests to avoid long waits
+
opts := append(tt.opts, WithConnectionTimeout(1*time.Second))
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...)
if tt.expectError {
assert.Error(t, err)
···
// All these will fail because no real proxy is running
// but we can test that the configuration is properly accepted
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
+
// Use shorter timeout for tests to avoid long waits
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second))
assert.Error(t, err) // Expected because no real proxy
assert.Nil(t, manager)
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+86 -41
spindle/server.go
···
vault secrets.Manager
}
-
func Run(ctx context.Context) error {
+
// New creates a new Spindle server with the provided configuration and engines.
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
logger := log.FromContext(ctx)
-
-
cfg, err := config.Load(ctx)
-
if err != nil {
-
return fmt.Errorf("failed to load config: %w", err)
-
}
d, err := db.Make(cfg.Server.DBPath)
if err != nil {
-
return fmt.Errorf("failed to setup db: %w", err)
+
return nil, fmt.Errorf("failed to setup db: %w", err)
}
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
if err != nil {
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
}
e.E.EnableAutoSave(true)
···
switch cfg.Server.Secrets.Provider {
case "openbao":
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
}
vault, err = secrets.NewOpenBaoManager(
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
)
if err != nil {
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
}
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
case "sqlite", "":
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
if err != nil {
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
}
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
default:
-
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
-
}
-
-
nixeryEng, err := nixery.New(ctx, cfg)
-
if err != nil {
-
return err
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
}
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
}
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
if err != nil {
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
}
jc.AddDid(cfg.Server.Owner)
// Check if the spindle knows about any Dids;
dids, err := d.GetAllDids()
if err != nil {
-
return fmt.Errorf("failed to get all dids: %w", err)
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
}
for _, d := range dids {
jc.AddDid(d)
}
-
resolver := idresolver.DefaultResolver()
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
-
spindle := Spindle{
+
spindle := &Spindle{
jc: jc,
e: e,
db: d,
l: logger,
n: &n,
-
engs: map[string]models.Engine{"nixery": nixeryEng},
+
engs: engines,
jq: jq,
cfg: cfg,
res: resolver,
···
err = e.AddSpindle(rbacDomain)
if err != nil {
-
return fmt.Errorf("failed to set rbac domain: %w", err)
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
}
err = spindle.configureOwner()
if err != nil {
-
return err
+
return nil, err
}
logger.Info("owner set", "did", cfg.Server.Owner)
-
-
// starts a job queue runner in the background
-
jq.Start()
-
defer jq.Stop()
-
-
// Stop vault token renewal if it implements Stopper
-
if stopper, ok := vault.(secrets.Stopper); ok {
-
defer stopper.Stop()
-
}
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
if err != nil {
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
}
err = jc.StartJetstream(ctx, spindle.ingest())
if err != nil {
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
}
// for each incoming sh.tangled.pipeline, we execute
···
ccfg.CursorStore = cursorStore
knownKnots, err := d.Knots()
if err != nil {
-
return err
+
return nil, err
}
for _, knot := range knownKnots {
logger.Info("adding source start", "knot", knot)
···
}
spindle.ks = eventconsumer.NewConsumer(*ccfg)
+
return spindle, nil
+
}
+
+
// DB returns the database instance.
+
func (s *Spindle) DB() *db.DB {
+
return s.db
+
}
+
+
// Queue returns the job queue instance.
+
func (s *Spindle) Queue() *queue.Queue {
+
return s.jq
+
}
+
+
// Engines returns the map of available engines.
+
func (s *Spindle) Engines() map[string]models.Engine {
+
return s.engs
+
}
+
+
// Vault returns the secrets manager instance.
+
func (s *Spindle) Vault() secrets.Manager {
+
return s.vault
+
}
+
+
// Notifier returns the notifier instance.
+
func (s *Spindle) Notifier() *notifier.Notifier {
+
return s.n
+
}
+
+
// Enforcer returns the RBAC enforcer instance.
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
+
return s.e
+
}
+
+
// Start starts the Spindle server (blocking).
+
func (s *Spindle) Start(ctx context.Context) error {
+
// starts a job queue runner in the background
+
s.jq.Start()
+
defer s.jq.Stop()
+
+
// Stop vault token renewal if it implements Stopper
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
+
defer stopper.Stop()
+
}
+
go func() {
-
logger.Info("starting knot event consumer")
-
spindle.ks.Start(ctx)
+
s.l.Info("starting knot event consumer")
+
s.ks.Start(ctx)
}()
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
+
}
+
+
func Run(ctx context.Context) error {
+
cfg, err := config.Load(ctx)
+
if err != nil {
+
return fmt.Errorf("failed to load config: %w", err)
+
}
+
+
nixeryEng, err := nixery.New(ctx, cfg)
+
if err != nil {
+
return err
+
}
+
+
s, err := New(ctx, cfg, map[string]models.Engine{
+
"nixery": nixeryEng,
+
})
+
if err != nil {
+
return err
+
}
-
return nil
+
return s.Start(ctx)
}
func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
···
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
return fmt.Errorf("failed to write to websocket: %w", err)
}
+
case <-time.After(30 * time.Second):
+
// send a keep-alive
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
+
return fmt.Errorf("failed to write control: %w", err)
+
}
}
}
}
+22 -1
types/repo.go
···
package types
import (
+
"encoding/json"
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/go-git/go-git/v5/plumbing/object"
)
···
type Branch struct {
Reference `json:"reference"`
Commit *object.Commit `json:"commit,omitempty"`
-
IsDefault bool `json:"is_deafult,omitempty"`
+
IsDefault bool `json:"is_default,omitempty"`
+
}
+
+
func (b *Branch) UnmarshalJSON(data []byte) error {
+
aux := &struct {
+
Reference `json:"reference"`
+
Commit *object.Commit `json:"commit,omitempty"`
+
IsDefault bool `json:"is_default,omitempty"`
+
MispelledIsDefault bool `json:"is_deafult,omitempty"` // mispelled name
+
}{}
+
+
if err := json.Unmarshal(data, aux); err != nil {
+
return err
+
}
+
+
b.Reference = aux.Reference
+
b.Commit = aux.Commit
+
b.IsDefault = aux.IsDefault || aux.MispelledIsDefault // whichever was set
+
+
return nil
}
type RepoTagsResponse struct {
+88 -5
types/tree.go
···
package types
import (
+
"fmt"
+
"os"
"time"
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/go-git/go-git/v5/plumbing/filemode"
)
// A nicer git tree representation.
type NiceTree struct {
// Relative path
-
Name string `json:"name"`
-
Mode string `json:"mode"`
-
Size int64 `json:"size"`
-
IsFile bool `json:"is_file"`
-
IsSubtree bool `json:"is_subtree"`
+
Name string `json:"name"`
+
Mode string `json:"mode"`
+
Size int64 `json:"size"`
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
+
}
+
+
func (t *NiceTree) FileMode() (filemode.FileMode, error) {
+
if numericMode, err := filemode.New(t.Mode); err == nil {
+
return numericMode, nil
+
}
+
+
// TODO: this is here for backwards compat, can be removed in future versions
+
osMode, err := parseModeString(t.Mode)
+
if err != nil {
+
return filemode.Empty, nil
+
}
+
+
conv, err := filemode.NewFromOSFileMode(osMode)
+
if err != nil {
+
return filemode.Empty, nil
+
}
+
+
return conv, nil
+
}
+
+
// ParseFileModeString parses a file mode string like "-rw-r--r--"
+
// and returns an os.FileMode
+
func parseModeString(modeStr string) (os.FileMode, error) {
+
if len(modeStr) != 10 {
+
return 0, fmt.Errorf("invalid mode string length: expected 10, got %d", len(modeStr))
+
}
+
+
var mode os.FileMode
+
+
// Parse file type (first character)
+
switch modeStr[0] {
+
case 'd':
+
mode |= os.ModeDir
+
case 'l':
+
mode |= os.ModeSymlink
+
case '-':
+
// regular file
+
default:
+
return 0, fmt.Errorf("unknown file type: %c", modeStr[0])
+
}
+
+
// parse permissions for owner, group, and other
+
perms := modeStr[1:]
+
shifts := []int{6, 3, 0} // bit shifts for owner, group, other
+
+
for i := range 3 {
+
offset := i * 3
+
shift := shifts[i]
+
+
if perms[offset] == 'r' {
+
mode |= os.FileMode(4 << shift)
+
}
+
if perms[offset+1] == 'w' {
+
mode |= os.FileMode(2 << shift)
+
}
+
if perms[offset+2] == 'x' {
+
mode |= os.FileMode(1 << shift)
+
}
+
}
+
+
return mode, nil
+
}
+
+
func (t *NiceTree) IsFile() bool {
+
m, err := t.FileMode()
+
+
if err != nil {
+
return false
+
}
+
+
return m.IsFile()
+
}
+
+
func (t *NiceTree) IsSubmodule() bool {
+
m, err := t.FileMode()
+
+
if err != nil {
+
return false
+
}
+
+
return m == filemode.Submodule
}
type LastCommitInfo struct {
+9 -1
workflow/compile.go
···
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
cw := &tangled.Pipeline_Workflow{}
-
if !w.Match(compiler.Trigger) {
+
matched, err := w.Match(compiler.Trigger)
+
if err != nil {
+
compiler.Diagnostics.AddError(
+
w.Name,
+
fmt.Errorf("failed to execute workflow: %w", err),
+
)
+
return nil
+
}
+
if !matched {
compiler.Diagnostics.AddWarning(
w.Name,
WorkflowSkipped,
+125
workflow/compile_test.go
···
assert.Len(t, c.Diagnostics.Errors, 1)
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
}
+
+
func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) {
+
wf := Workflow{
+
Name: ".tangled/workflows/branch_and_tag.yml",
+
When: []Constraint{
+
{
+
Event: []string{"push"},
+
Branch: []string{"main", "develop"},
+
Tag: []string{"v*"},
+
},
+
},
+
Engine: "nixery",
+
}
+
+
tests := []struct {
+
name string
+
trigger tangled.Pipeline_TriggerMetadata
+
shouldMatch bool
+
expectedCount int
+
}{
+
{
+
name: "matches main branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/main",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "matches develop branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/develop",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "matches v* tag pattern",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/tags/v1.0.0",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "matches v* tag pattern with different version",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/tags/v2.5.3",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "does not match master branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/master",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: false,
+
expectedCount: 0,
+
},
+
{
+
name: "does not match non-v tag",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/tags/release-1.0",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: false,
+
expectedCount: 0,
+
},
+
{
+
name: "does not match feature branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/feature/new-feature",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: false,
+
expectedCount: 0,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
c := Compiler{Trigger: tt.trigger}
+
cp := c.Compile([]Workflow{wf})
+
+
assert.Len(t, cp.Workflows, tt.expectedCount)
+
if tt.shouldMatch {
+
assert.Equal(t, wf.Name, cp.Workflows[0].Name)
+
}
+
})
+
}
+
}
+61 -19
workflow/def.go
···
"tangled.org/core/api/tangled"
+
"github.com/bmatcuk/doublestar/v4"
"github.com/go-git/go-git/v5/plumbing"
"gopkg.in/yaml.v3"
)
···
Constraint struct {
Event StringList `yaml:"event"`
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
+
Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
+
Tag StringList `yaml:"tag"` // optional; only applies to push events
}
CloneOpts struct {
···
return strings.ReplaceAll(string(t), "_", " ")
}
+
// matchesPattern checks if a name matches any of the given patterns.
+
// Patterns can be exact matches or glob patterns using * and **.
+
// * matches any sequence of non-separator characters
+
// ** matches any sequence of characters including separators
+
func matchesPattern(name string, patterns []string) (bool, error) {
+
for _, pattern := range patterns {
+
matched, err := doublestar.Match(pattern, name)
+
if err != nil {
+
return false, err
+
}
+
if matched {
+
return true, nil
+
}
+
}
+
return false, nil
+
}
+
func FromFile(name string, contents []byte) (Workflow, error) {
var wf Workflow
···
}
// if any of the constraints on a workflow is true, return true
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
// manual triggers always run the workflow
if trigger.Manual != nil {
-
return true
+
return true, nil
}
// if not manual, run through the constraint list and see if any one matches
for _, c := range w.When {
-
if c.Match(trigger) {
-
return true
+
matched, err := c.Match(trigger)
+
if err != nil {
+
return false, err
+
}
+
if matched {
+
return true, nil
}
}
// no constraints, always run this workflow
if len(w.When) == 0 {
-
return true
+
return true, nil
}
-
return false
+
return false, nil
}
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
match := true
// manual triggers always pass this constraint
if trigger.Manual != nil {
-
return true
+
return true, nil
}
// apply event constraints
···
// apply branch constraints for PRs
if trigger.PullRequest != nil {
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
+
if err != nil {
+
return false, err
+
}
+
match = match && matched
}
// apply ref constraints for pushes
if trigger.Push != nil {
-
match = match && c.MatchRef(trigger.Push.Ref)
+
matched, err := c.MatchRef(trigger.Push.Ref)
+
if err != nil {
+
return false, err
+
}
+
match = match && matched
}
-
return match
-
}
-
-
func (c *Constraint) MatchBranch(branch string) bool {
-
return slices.Contains(c.Branch, branch)
+
return match, nil
}
-
func (c *Constraint) MatchRef(ref string) bool {
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
refName := plumbing.ReferenceName(ref)
+
shortName := refName.Short()
+
if refName.IsBranch() {
-
return slices.Contains(c.Branch, refName.Short())
+
return c.MatchBranch(shortName)
}
-
return false
+
+
if refName.IsTag() {
+
return c.MatchTag(shortName)
+
}
+
+
return false, nil
+
}
+
+
func (c *Constraint) MatchBranch(branch string) (bool, error) {
+
return matchesPattern(branch, c.Branch)
+
}
+
+
func (c *Constraint) MatchTag(tag string) (bool, error) {
+
return matchesPattern(tag, c.Tag)
}
func (c *Constraint) MatchEvent(event string) bool {
+284 -1
workflow/def_test.go
···
"github.com/stretchr/testify/assert"
)
-
func TestUnmarshalWorkflow(t *testing.T) {
+
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
yamlData := `
when:
- event: ["push", "pull_request"]
···
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}
+
+
func TestUnmarshalWorkflowWithTags(t *testing.T) {
+
yamlData := `
+
when:
+
- event: ["push"]
+
tag: ["v*", "release-*"]`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err, "YAML should unmarshal without error")
+
+
assert.Len(t, wf.When, 1, "Should have one constraint")
+
assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
+
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
+
}
+
+
func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
+
yamlData := `
+
when:
+
- event: ["push"]
+
branch: ["main", "develop"]
+
tag: ["v*"]`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err, "YAML should unmarshal without error")
+
+
assert.Len(t, wf.When, 1, "Should have one constraint")
+
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
+
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
+
}
+
+
func TestMatchesPattern(t *testing.T) {
+
tests := []struct {
+
name string
+
input string
+
patterns []string
+
expected bool
+
}{
+
{"exact match", "main", []string{"main"}, true},
+
{"exact match in list", "develop", []string{"main", "develop"}, true},
+
{"no match", "feature", []string{"main", "develop"}, false},
+
{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
+
{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
+
{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
+
{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
+
{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
+
{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
+
{"double star no match", "feature/test", []string{"release/**"}, false},
+
{"no patterns matches nothing", "anything", []string{}, false},
+
{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
+
{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
+
{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := matchesPattern(tt.input, tt.patterns)
+
assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
+
})
+
}
+
}
+
+
func TestConstraintMatchRef_Branches(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
ref string
+
expected bool
+
}{
+
{
+
name: "exact branch match",
+
constraint: Constraint{Branch: []string{"main"}},
+
ref: "refs/heads/main",
+
expected: true,
+
},
+
{
+
name: "branch glob match",
+
constraint: Constraint{Branch: []string{"feature-*"}},
+
ref: "refs/heads/feature-123",
+
expected: true,
+
},
+
{
+
name: "branch no match",
+
constraint: Constraint{Branch: []string{"main"}},
+
ref: "refs/heads/develop",
+
expected: false,
+
},
+
{
+
name: "no constraints matches nothing",
+
constraint: Constraint{},
+
ref: "refs/heads/anything",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchRef(tt.ref)
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
+
})
+
}
+
}
+
+
func TestConstraintMatchRef_Tags(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
ref string
+
expected bool
+
}{
+
{
+
name: "exact tag match",
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
+
ref: "refs/tags/v1.0.0",
+
expected: true,
+
},
+
{
+
name: "tag glob match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
ref: "refs/tags/v1.2.3",
+
expected: true,
+
},
+
{
+
name: "tag glob with pattern",
+
constraint: Constraint{Tag: []string{"release-*"}},
+
ref: "refs/tags/release-2024",
+
expected: true,
+
},
+
{
+
name: "tag no match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
ref: "refs/tags/release-1.0",
+
expected: false,
+
},
+
{
+
name: "tag not matched when only branch constraint",
+
constraint: Constraint{Branch: []string{"main"}},
+
ref: "refs/tags/v1.0.0",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchRef(tt.ref)
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
+
})
+
}
+
}
+
+
func TestConstraintMatchRef_Combined(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
ref string
+
expected bool
+
}{
+
{
+
name: "matches branch in combined constraint",
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
+
ref: "refs/heads/main",
+
expected: true,
+
},
+
{
+
name: "matches tag in combined constraint",
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
+
ref: "refs/tags/v1.0.0",
+
expected: true,
+
},
+
{
+
name: "no match in combined constraint",
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
+
ref: "refs/heads/develop",
+
expected: false,
+
},
+
{
+
name: "glob patterns in combined constraint - branch",
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
+
ref: "refs/heads/release-2024",
+
expected: true,
+
},
+
{
+
name: "glob patterns in combined constraint - tag",
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
+
ref: "refs/tags/v2.0.0",
+
expected: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchRef(tt.ref)
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
+
})
+
}
+
}
+
+
func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
branch string
+
expected bool
+
}{
+
{
+
name: "exact match",
+
constraint: Constraint{Branch: []string{"main"}},
+
branch: "main",
+
expected: true,
+
},
+
{
+
name: "glob match",
+
constraint: Constraint{Branch: []string{"feature-*"}},
+
branch: "feature-123",
+
expected: true,
+
},
+
{
+
name: "no match",
+
constraint: Constraint{Branch: []string{"main"}},
+
branch: "develop",
+
expected: false,
+
},
+
{
+
name: "multiple patterns with match",
+
constraint: Constraint{Branch: []string{"main", "release-*"}},
+
branch: "release-1.0",
+
expected: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchBranch(tt.branch)
+
assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
+
})
+
}
+
}
+
+
func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
tag string
+
expected bool
+
}{
+
{
+
name: "exact match",
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
+
tag: "v1.0.0",
+
expected: true,
+
},
+
{
+
name: "glob match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
tag: "v2.3.4",
+
expected: true,
+
},
+
{
+
name: "no match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
tag: "release-1.0",
+
expected: false,
+
},
+
{
+
name: "multiple patterns with match",
+
constraint: Constraint{Tag: []string{"v*", "release-*"}},
+
tag: "release-2024",
+
expected: true,
+
},
+
{
+
name: "empty tag list matches nothing",
+
constraint: Constraint{Tag: []string{}},
+
tag: "v1.0.0",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchTag(tt.tag)
+
assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
+
})
+
}
+
}