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

Compare changes

Choose any two refs to compare.

Changed files
+1516 -594
.tangled
workflows
api
appview
cmd
docs
spindle
knotserver
lexicons
pulls
repo
nix
types
+1 -1
appview/db/label.go
···
defs[l.AtUri().String()] = &l
}
-
return &models.LabelApplicationCtx{defs}, nil
+
return &models.LabelApplicationCtx{Defs: defs}, nil
}
+15 -15
appview/pages/funcmap.go
···
"relTimeFmt": humanize.Time,
"shortRelTimeFmt": func(t time.Time) string {
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
-
{time.Second, "now", time.Second},
-
{2 * time.Second, "1s %s", 1},
-
{time.Minute, "%ds %s", time.Second},
-
{2 * time.Minute, "1min %s", 1},
-
{time.Hour, "%dmin %s", time.Minute},
-
{2 * time.Hour, "1hr %s", 1},
-
{humanize.Day, "%dhrs %s", time.Hour},
-
{2 * humanize.Day, "1d %s", 1},
-
{20 * humanize.Day, "%dd %s", humanize.Day},
-
{8 * humanize.Week, "%dw %s", humanize.Week},
-
{humanize.Year, "%dmo %s", humanize.Month},
-
{18 * humanize.Month, "1y %s", 1},
-
{2 * humanize.Year, "2y %s", 1},
-
{humanize.LongTime, "%dy %s", humanize.Year},
-
{math.MaxInt64, "a long while %s", 1},
+
{D: time.Second, Format: "now", DivBy: time.Second},
+
{D: 2 * time.Second, Format: "1s %s", DivBy: 1},
+
{D: time.Minute, Format: "%ds %s", DivBy: time.Second},
+
{D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
+
{D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
+
{D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
+
{D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
+
{D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
+
{D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
+
{D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
+
{D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
+
{D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
+
{D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
+
{D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
+
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
})
},
"longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
···
+
package pages
+
+
import (
+
"html/template"
+
"tangled.org/core/appview/config"
+
"tangled.org/core/idresolver"
+
"testing"
+
)
+
+
func TestPages_funcMap(t *testing.T) {
+
tests := []struct {
+
name string // description of this test case
+
// Named input parameters for receiver constructor.
+
config *config.Config
+
res *idresolver.Resolver
+
want template.FuncMap
+
}{
+
// TODO: Add test cases.
+
}
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
p := NewPages(tt.config, tt.res)
+
got := p.funcMap()
+
// TODO: update the condition below to compare got with tt.want.
+
if true {
+
t.Errorf("funcMap() = %v, want %v", got, tt.want)
+
}
+
})
+
}
+
}
+1 -1
appview/reporesolver/resolver.go
···
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
if u != nil {
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
-
return repoinfo.RolesInRepo{r}
+
return repoinfo.RolesInRepo{Roles: r}
} else {
return repoinfo.RolesInRepo{}
}
+6
.tangled/workflows/test.yml
···
command: |
mkdir -p appview/pages/static; touch appview/pages/static/x
+
- name: run linter
+
environment:
+
CGO_ENABLED: 1
+
command: |
+
go vet -v ./...
+
- name: run all tests
environment:
CGO_ENABLED: 1
+1 -1
knotserver/ingester.go
···
return fmt.Errorf("failed to construct absolute repo path: %w", err)
}
-
gr, err := git.Open(repoPath, record.Source.Branch)
+
gr, err := git.Open(repoPath, record.Source.Sha)
if err != nil {
return fmt.Errorf("failed to open git repository: %w", err)
}
+11
appview/db/star.go
···
"errors"
"fmt"
"log"
+
"slices"
"strings"
"time"
···
stars = append(stars, s...)
}
+
slices.SortFunc(stars, func(a, b models.Star) int {
+
if a.Created.After(b.Created) {
+
return -1
+
}
+
if b.Created.After(a.Created) {
+
return 1
+
}
+
return 0
+
})
+
return stars, nil
}
+40 -14
appview/repo/artifact.go
···
"context"
"encoding/json"
"fmt"
+
"io"
"log"
"net/http"
"net/url"
···
})
}
-
// TODO: proper statuses here on early exit
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
-
tagParam := chi.URLParam(r, "tag")
-
filename := chi.URLParam(r, "file")
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
return
}
+
tagParam := chi.URLParam(r, "tag")
+
filename := chi.URLParam(r, "file")
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
if err != nil {
log.Println("failed to resolve tag", err)
···
return
}
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
return
-
}
-
artifacts, err := db.GetArtifact(
rp.db,
db.FilterEq("repo_at", f.RepoAt()),
···
)
if err != nil {
log.Println("failed to get artifacts", err)
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
return
}
+
if len(artifacts) != 1 {
-
log.Printf("too many or too little artifacts found")
+
log.Printf("too many or too few artifacts found")
+
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
artifact := artifacts[0]
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
+
ownerPds := f.OwnerId.PDSEndpoint()
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
+
q := url.Query()
+
q.Set("cid", artifact.BlobCid.String())
+
q.Set("did", artifact.Did)
+
url.RawQuery = q.Encode()
+
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
if err != nil {
-
log.Println("failed to get blob from pds", err)
+
log.Println("failed to create request", err)
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
+
req.Header.Set("Content-Type", "application/json")
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
-
w.Write(getBlobResp)
+
resp, err := http.DefaultClient.Do(req)
+
if err != nil {
+
log.Println("failed to make request", err)
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
+
return
+
}
+
defer resp.Body.Close()
+
+
// copy status code and relevant headers from upstream response
+
w.WriteHeader(resp.StatusCode)
+
for key, values := range resp.Header {
+
for _, v := range values {
+
w.Header().Add(key, v)
+
}
+
}
+
+
// stream the body directly to the client
+
if _, err := io.Copy(w, resp.Body); err != nil {
+
log.Println("error streaming response to client:", err)
+
}
}
// TODO: proper statuses here on early exit
+2 -3
appview/repo/router.go
···
r.Route("/tags", func(r chi.Router) {
r.Get("/", rp.RepoTags)
r.Route("/{tag}", func(r chi.Router) {
-
r.Use(middleware.AuthMiddleware(rp.oauth))
-
// require auth to download for now
r.Get("/download/{file}", rp.DownloadArtifact)
// require repo:push to upload or delete artifacts
···
// additionally: only the uploader can truly delete an artifact
// (record+blob will live on their pds)
r.Group(func(r chi.Router) {
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
+
r.Use(middleware.AuthMiddleware(rp.oauth))
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
r.Post("/upload", rp.AttachArtifact)
r.Delete("/{file}", rp.DeleteArtifact)
})
+36 -6
appview/pages/templates/repo/settings/general.html
···
{{ define "defaultLabelSettings" }}
<div class="flex flex-col gap-2">
-
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
-
<p class="text-gray-500 dark:text-gray-400">
-
Manage your issues and pulls by creating labels to categorize them. Only
-
repository owners may configure labels. You may choose to subscribe to
-
default labels, or create entirely custom labels.
-
</p>
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Manage your issues and pulls by creating labels to categorize them. Only
+
repository owners may configure labels. You may choose to subscribe to
+
default labels, or create entirely custom labels.
+
<p>
+
</div>
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
+
{{ $title := "Unubscribe from all labels" }}
+
{{ $icon := "x" }}
+
{{ $text := "unsubscribe all" }}
+
{{ $action := "unsubscribe" }}
+
{{ if $.ShouldSubscribeAll }}
+
{{ $title = "Subscribe to all labels" }}
+
{{ $icon = "check-check" }}
+
{{ $text = "subscribe all" }}
+
{{ $action = "subscribe" }}
+
{{ end }}
+
{{ range .DefaultLabels }}
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
+
{{ end }}
+
<button
+
type="submit"
+
title="{{$title}}"
+
class="btn flex items-center gap-2 group"
+
hx-swap="none"
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
+
{{ i $icon "size-4" }}
+
{{ $text }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</form>
+
</div>
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
{{ range .DefaultLabels }}
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
···
{{ $event := index . 1 }}
{{ $follow := $event.Follow }}
{{ $profile := $event.Profile }}
-
{{ $stat := $event.FollowStats }}
+
{{ $followStats := $event.FollowStats }}
+
{{ $followStatus := $event.FollowStatus }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
-
<div class="flex items-center gap-4 flex-1">
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
-
</div>
-
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
-
<a href="/{{ $subjectHandle }}">
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
-
</a>
-
{{ with $profile }}
-
{{ with .Description }}
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
-
{{ end }}
-
{{ end }}
-
{{ with $stat }}
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
-
<div class="flex-shrink-0 w-fit ml-auto">
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
-
</div>
-
{{ end }}
-
</div>
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $root.LoggedInUser
+
"UserDid" $follow.SubjectDid
+
"Profile" $profile
+
"FollowStatus" $followStatus
+
"FollowersCount" $followStats.Followers
+
"FollowingCount" $followStats.Following) }}
{{ end }}
+8 -1
appview/pages/templates/user/followers.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Followers }}
-
{{ template "user/fragments/followCard" . }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
{{ end }}
+8 -1
appview/pages/templates/user/following.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Following }}
-
{{ template "user/fragments/followCard" . }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
{{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
-
class="btn mt-2 flex gap-2 items-center group"
+
class="btn w-full flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
+
{{ i "user-round-plus" "w-4 h-4" }} follow
+
{{ else }}
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
+
{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
+10 -10
appview/pages/templates/user/fragments/repoCard.html
···
{{ with $repo }}
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
<div class="font-medium dark:text-white flex items-center justify-between">
-
<div class="flex items-center">
-
{{ if .Source }}
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
-
{{ else }}
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
-
{{ end }}
-
+
<div class="flex items-center min-w-0 flex-1 mr-2">
+
{{ if .Source }}
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
+
{{ else }}
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
+
{{ end }}
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
{{- end -}}
</div>
-
{{ if and $starButton $root.LoggedInUser }}
+
<div class="shrink-0">
{{ template "repo/fragments/repoStar" $starData }}
+
</div>
{{ end }}
</div>
{{ with .Description }}
+1
appview/models/repo.go
···
)
type Repo struct {
+
Id int64
Did string
Name string
Knot string
+58 -3
appview/posthog/notifier.go appview/notify/posthog/notifier.go
···
-
package posthog_service
+
package posthog
import (
"context"
···
}
}
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_closed",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: follow.UserDid,
···
}
}
-
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: string.Did.String(),
-
Event: "create_string",
+
Event: "new_string",
Properties: posthog.Properties{"rkey": string.Rkey},
})
if err != nil {
log.Println("failed to enqueue posthog event:", err)
}
}
+
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: comment.Did,
+
Event: "new_issue_comment",
+
Properties: posthog.Properties{
+
"issue_at": comment.IssueAt,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: issue.Did,
+
Event: "issue_closed",
+
Properties: posthog.Properties{
+
"repo_at": issue.RepoAt.String(),
+
"issue_id": issue.IssueId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_merged",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+36 -6
appview/db/repos.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.org/core/api/tangled"
"tangled.org/core/appview/models"
)
+
type Repo struct {
+
Id int64
+
Did string
+
Name string
+
Knot string
+
Rkey string
+
Created time.Time
+
Description string
+
Spindle string
+
+
// optionally, populate this when querying for reverse mappings
+
RepoStats *models.RepoStats
+
+
// optional
+
Source string
+
}
+
+
func (r Repo) RepoAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
+
}
+
+
func (r Repo) DidSlashRepo() string {
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
+
return p
+
}
+
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
repoMap := make(map[syntax.ATURI]*models.Repo)
···
repoQuery := fmt.Sprintf(
`select
+
id,
did,
name,
knot,
···
var description, source, spindle sql.NullString
err := rows.Scan(
+
&repo.Id,
&repo.Did,
&repo.Name,
&repo.Knot,
···
var repo models.Repo
var nullableDescription sql.NullString
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
var createdAt string
-
if err := row.Scan(&repo.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); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
var repos []models.Repo
rows, err := e.Query(
-
`select distinct 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.created, r.source
from repos r
left join collaborators c on r.at_uri = c.repo_at
where (r.did = ? or c.subject_did = ?)
···
var nullableDescription sql.NullString
var nullableSource sql.NullString
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
var nullableSource sql.NullString
row := e.QueryRow(
-
`select did, name, knot, rkey, description, created, source
+
`select id, did, name, knot, rkey, description, created, source
from repos
where did = ? and name = ? and source is not null and source != ''`,
did, name,
)
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
+51
appview/settings/settings.go
···
{"Name": "profile", "Icon": "user"},
{"Name": "keys", "Icon": "key"},
{"Name": "emails", "Icon": "mail"},
+
{"Name": "notifications", "Icon": "bell"},
}
)
···
r.Post("/primary", s.emailsPrimary)
})
+
r.Route("/notifications", func(r chi.Router) {
+
r.Get("/", s.notificationsSettings)
+
r.Put("/", s.updateNotificationPreferences)
+
})
+
return r
}
···
})
}
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
+
user := s.OAuth.GetUser(r)
+
did := s.OAuth.GetDid(r)
+
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
+
if err != nil {
+
log.Printf("failed to get notification preferences: %s", err)
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
+
return
+
}
+
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
+
LoggedInUser: user,
+
Preferences: prefs,
+
Tabs: settingsTabs,
+
Tab: "notifications",
+
})
+
}
+
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
+
did := s.OAuth.GetDid(r)
+
+
prefs := &models.NotificationPreferences{
+
UserDid: did,
+
RepoStarred: r.FormValue("repo_starred") == "on",
+
IssueCreated: r.FormValue("issue_created") == "on",
+
IssueCommented: r.FormValue("issue_commented") == "on",
+
IssueClosed: r.FormValue("issue_closed") == "on",
+
PullCreated: r.FormValue("pull_created") == "on",
+
PullCommented: r.FormValue("pull_commented") == "on",
+
PullMerged: r.FormValue("pull_merged") == "on",
+
Followed: r.FormValue("followed") == "on",
+
EmailNotifications: r.FormValue("email_notifications") == "on",
+
}
+
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
+
if err != nil {
+
log.Printf("failed to update notification preferences: %s", err)
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
+
}
+
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
user := s.OAuth.GetUser(r)
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
+4 -11
appview/pages/templates/errors/500.html
···
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
<div class="mb-6">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
</div>
</div>
···
500 &mdash; internal server error
</h1>
<p class="text-gray-600 dark:text-gray-300">
-
Something went wrong on our end. We've been notified and are working to fix the issue.
-
</p>
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
-
<div class="flex items-center gap-2">
-
{{ i "info" "w-4 h-4" }}
-
<span class="font-medium">we're on it!</span>
-
</div>
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
-
</div>
+
We encountered an error while processing your request. Please try again later.
+
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
<a href="/" class="btn no-underline hover:no-underline gap-2">
-
{{ i "home" "w-4 h-4" }}
+
{{ i "arrow-left" "w-4 h-4" }}
back to home
</a>
</div>
+173
appview/pages/templates/user/settings/notifications.html
···
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Settings</p>
+
</div>
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
+
<div class="col-span-1">
+
{{ template "user/settings/fragments/sidebar" . }}
+
</div>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
+
{{ template "notificationSettings" . }}
+
</div>
+
</section>
+
</div>
+
{{ end }}
+
+
{{ define "notificationSettings" }}
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
+
</p>
+
</div>
+
</div>
+
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
+
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Repository starred</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone stars your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New issues</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates an issue on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on an issue you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue closed</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When an issue on your repository is closed.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New pull requests</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates a pull request on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on a pull request you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request merged</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When your pull request is merged.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New followers</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone follows you.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Email notifications</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>Receive notifications via email in addition to in-app notifications.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
+
</label>
+
</div>
+
</div>
+
+
<div class="flex justify-end pt-2">
+
<button
+
type="submit"
+
class="btn-create flex items-center gap-2 group"
+
>
+
{{ i "save" "w-4 h-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
<div id="settings-notifications-success"></div>
+
+
<div id="settings-notifications-error" class="error"></div>
+
</form>
+
{{ end }}
+13 -4
appview/pages/templates/layouts/fragments/topbar.html
···
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
-
{{ template "fragments/logotypeSmall" }}
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
+
alpha
+
</span>
</a>
</div>
···
{{ define "newButton" }}
<details class="relative inline-block text-left nav-dropdown">
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
-
{{ i "plus" "w-4 h-4" }} new
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
</summary>
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
<a href="/repo/new" class="flex items-center gap-2">
···
class="cursor-pointer list-none flex items-center gap-1"
>
{{ $user := didOrHandle .Did .Handle }}
-
{{ template "user/fragments/picHandle" $user }}
+
<img
+
src="{{ tinyAvatar $user }}"
+
alt=""
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
+
/>
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
</summary>
<div
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+2 -2
appview/pages/templates/strings/put.html
···
{{ define "content" }}
<div class="px-6 py-2 mb-4">
{{ if eq .Action "new" }}
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
-
<p class="">Store and share code snippets with ease.</p>
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
{{ else }}
<p class="text-xl font-bold dark:text-white">Edit string</p>
{{ end }}
-33
knotserver/git/git.go
···
return g.r.CommitObject(h)
}
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
-
c, err := g.r.CommitObject(g.h)
-
if err != nil {
-
return nil, fmt.Errorf("last commit: %w", err)
-
}
-
return c, nil
-
}
-
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
c, err := g.r.CommitObject(g.h)
if err != nil {
···
return buf.Bytes(), nil
}
-
func (g *GitRepo) FileContent(path string) (string, error) {
-
c, err := g.r.CommitObject(g.h)
-
if err != nil {
-
return "", fmt.Errorf("commit object: %w", err)
-
}
-
-
tree, err := c.Tree()
-
if err != nil {
-
return "", fmt.Errorf("file tree: %w", err)
-
}
-
-
file, err := tree.File(path)
-
if err != nil {
-
return "", err
-
}
-
-
isbin, _ := file.IsBinary()
-
-
if !isbin {
-
return file.Contents()
-
} else {
-
return "", ErrBinaryFile
-
}
-
}
-
func (g *GitRepo) RawContent(path string) ([]byte, error) {
c, err := g.r.CommitObject(g.h)
if err != nil {
-4
knotserver/http_util.go
···
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
-
-
func notFound(w http.ResponseWriter) {
-
writeError(w, "not found", http.StatusNotFound)
-
}
+15 -1
appview/validator/label.go
···
return nil
}
-
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
if labelDef == nil {
return fmt.Errorf("label definition is required")
}
+
if repo == nil {
+
return fmt.Errorf("repo is required")
+
}
if labelOp == nil {
return fmt.Errorf("label operation is required")
}
+
// validate permissions: only collaborators can apply labels currently
+
//
+
// TODO: introduce a repo:triage permission
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
+
if err != nil {
+
return fmt.Errorf("failed to enforce permissions: %w", err)
+
}
+
if !ok {
+
return fmt.Errorf("unauhtorized label operation")
+
}
+
expectedKey := labelDef.AtUri().String()
if labelOp.OperandKey != expectedKey {
return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
+4 -1
appview/validator/validator.go
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/idresolver"
+
"tangled.org/core/rbac"
)
type Validator struct {
db *db.DB
sanitizer markup.Sanitizer
resolver *idresolver.Resolver
+
enforcer *rbac.Enforcer
}
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
return &Validator{
db: db,
sanitizer: markup.NewSanitizer(),
resolver: res,
+
enforcer: enforcer,
}
}
+3 -3
appview/labels/labels.go
···
}
}
-
// reduce the opset
-
labelOps = models.ReduceLabelOps(labelOps)
-
for i := range labelOps {
def := actx.Defs[labelOps[i].OperandKey]
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
···
}
}
+
// reduce the opset
+
labelOps = models.ReduceLabelOps(labelOps)
+
// next, apply all ops introduced in this request and filter out ones that are no-ops
validLabelOps := labelOps[:0]
for _, op := range labelOps {
+1 -3
knotserver/git/tag.go
···
import (
"fmt"
-
"slices"
"strconv"
"strings"
"time"
···
outFormat.WriteString("")
outFormat.WriteString(recordSeparator)
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
if err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
···
tags = append(tags, tag)
}
-
slices.Reverse(tags)
return tags, nil
}
+1 -1
knotserver/xrpc/repo_blob.go
···
contents, err := gr.RawContent(treePath)
if err != nil {
-
x.Logger.Error("file content", "error", err.Error())
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("FileNotFound"),
xrpcerr.WithMessage("file not found at the specified path"),
+10
api/tangled/repotree.go
···
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
// parent: The parent path in the tree
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
+
// readme: Readme for this file tree
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
// ref: The git reference used
Ref string `json:"ref" cborgen:"ref"`
}
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
+
type RepoTree_Readme struct {
+
// contents: Contents of the readme file
+
Contents string `json:"contents" cborgen:"contents"`
+
// filename: Name of the readme file
+
Filename string `json:"filename" cborgen:"filename"`
+
}
+
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
type RepoTree_TreeEntry struct {
// is_file: Whether this entry is a file
+15 -17
appview/pages/markup/format.go
···
package markup
-
import "strings"
+
import (
+
"regexp"
+
)
type Format string
···
)
var FileTypes map[Format][]string = map[Format][]string{
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
+
}
+
+
var FileTypePatterns = map[Format]*regexp.Regexp{
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
}
-
// ReadmeFilenames contains the list of common README filenames to search for,
-
// in order of preference. Only includes well-supported formats.
-
var ReadmeFilenames = []string{
-
"README.md", "readme.md",
-
"README",
-
"readme",
-
"README.markdown",
-
"readme.markdown",
-
"README.txt",
-
"readme.txt",
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
+
+
func IsReadmeFile(filename string) bool {
+
return ReadmePattern.MatchString(filename)
}
func GetFormat(filename string) Format {
-
for format, extensions := range FileTypes {
-
for _, extension := range extensions {
-
if strings.HasSuffix(filename, extension) {
-
return format
-
}
+
for format, pattern := range FileTypePatterns {
+
if pattern.MatchString(filename) {
+
return format
}
}
// default format
+2 -2
appview/pages/templates/repo/fragments/readme.html
···
{{ define "repo/fragments/readme" }}
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
{{- if .ReadmeFileName -}}
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
</div>
{{- end -}}
<section
-
class="p-6 overflow-auto {{ if not .Raw }}
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
prose dark:prose-invert dark:[&_pre]:bg-gray-900
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
dark:[&_pre]:border dark:[&_pre]:border-gray-700
+24
knotserver/xrpc/repo_tree.go
···
"net/http"
"path/filepath"
"time"
+
"unicode/utf8"
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/pages/markup"
"tangled.org/core/knotserver/git"
xrpcerr "tangled.org/core/xrpc/errors"
)
···
return
}
+
// if any of these files are a readme candidate, pass along its blob contents too
+
var readmeFileName string
+
var readmeContents string
+
for _, file := range files {
+
if markup.IsReadmeFile(file.Name) {
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
+
if err != nil {
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
+
}
+
+
if utf8.Valid(contents) {
+
readmeFileName = file.Name
+
readmeContents = string(contents)
+
break
+
}
+
}
+
}
+
// convert NiceTree -> tangled.RepoTree_TreeEntry
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
for i, file := range files {
···
Parent: parentPtr,
Dotdot: dotdotPtr,
Files: treeEntries,
+
Readme: &tangled.RepoTree_Readme{
+
Filename: readmeFileName,
+
Contents: readmeContents,
+
},
}
writeJson(w, response)
+19
lexicons/repo/tree.json
···
"type": "string",
"description": "Parent directory path"
},
+
"readme": {
+
"type": "ref",
+
"ref": "#readme",
+
"description": "Readme for this file tree"
+
},
"files": {
"type": "array",
"items": {
···
}
]
},
+
"readme": {
+
"type": "object",
+
"required": ["filename", "contents"],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"description": "Name of the readme file"
+
},
+
"contents": {
+
"type": "string",
+
"description": "Contents of the readme file"
+
}
+
}
+
},
"treeEntry": {
"type": "object",
"required": ["name", "mode", "size", "is_file", "is_subtree"],
+7 -5
types/repo.go
···
}
type RepoTreeResponse struct {
-
Ref string `json:"ref,omitempty"`
-
Parent string `json:"parent,omitempty"`
-
Description string `json:"description,omitempty"`
-
DotDot string `json:"dotdot,omitempty"`
-
Files []NiceTree `json:"files,omitempty"`
+
Ref string `json:"ref,omitempty"`
+
Parent string `json:"parent,omitempty"`
+
Description string `json:"description,omitempty"`
+
DotDot string `json:"dotdot,omitempty"`
+
Files []NiceTree `json:"files,omitempty"`
+
ReadmeFileName string `json:"readme_filename,omitempty"`
+
Readme string `json:"readme_contents,omitempty"`
}
type TagReference struct {
+224
appview/pages/templates/brand/brand.html
···
+
{{ define "title" }}brand{{ end }}
+
+
{{ define "content" }}
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Assets and guidelines for using Tangled's logo and brand elements.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="space-y-16">
+
+
<!-- Introduction Section -->
+
<section>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
+
follow the below guidelines when using Dolly and the logotype.
+
</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
+
</p>
+
</section>
+
+
<!-- Black Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Tangled logo - black version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
+
backgrounds and designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- White Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-black p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
+
alt="Tangled logo - white version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This version features white text and elements, ideal for dark backgrounds
+
and inverted designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- Mark Only Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Black mark on light background -->
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Dolly face - black version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- White mark on dark background -->
+
<div class="bg-black p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Dolly face - white version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
<strong class="font-semibold">Note</strong>: for situations where the background
+
is unknown, use the black version for ideal contrast in most environments.
+
</p>
+
</div>
+
</section>
+
+
<!-- Colored Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Red background -->
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel red background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
White logo mark on colored backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The white logo mark provides contrast on colored backgrounds.
+
Perfect for more fun design contexts.
+
</p>
+
</div>
+
</section>
+
+
<!-- Black Logo on Pastel Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Pink background -->
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel pink background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Dark logo mark on lighter, pastel backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The dark logo mark works beautifully on pastel backgrounds,
+
providing crisp contrast.
+
</p>
+
</div>
+
</section>
+
+
<!-- Recoloring Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Recolored Tangled logotype in gray/sand color"
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Custom coloring of the logotype is permitted.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
Recoloring the logotype is allowed as long as readability is maintained.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
+
</p>
+
</div>
+
</section>
+
+
<!-- Silhouette Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
+
alt="Dolly silhouette"
+
class="w-full max-w-32 mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
The silhouette can be used where a subtle brand presence is needed,
+
or as a background element. Works on any background color with proper contrast.
+
For example, we use this as the site's favicon.
+
</p>
+
</div>
+
</section>
+
+
</div>
+
</main>
+
</div>
+
{{ end }}
+13 -6
appview/pages/templates/legal/privacy.html
···
{{ define "title" }}privacy policy{{ end }}
{{ define "content" }}
-
<div class="max-w-4xl mx-auto px-4 py-8">
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
-
<div class="prose prose-gray dark:prose-invert max-w-none">
-
{{ .Content }}
-
</div>
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Learn how we collect, use, and protect your personal information.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="prose prose-gray dark:prose-invert max-w-none">
+
{{ .Content }}
</div>
+
</main>
</div>
-
{{ end }}
+
{{ end }}
+13 -6
appview/pages/templates/legal/terms.html
···
{{ define "title" }}terms of service{{ end }}
{{ define "content" }}
-
<div class="max-w-4xl mx-auto px-4 py-8">
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
-
<div class="prose prose-gray dark:prose-invert max-w-none">
-
{{ .Content }}
-
</div>
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
A few things you should know.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="prose prose-gray dark:prose-invert max-w-none">
+
{{ .Content }}
</div>
+
</main>
</div>
-
{{ end }}
+
{{ end }}
+4 -2
appview/config/config.go
···
}
type Cloudflare struct {
-
ApiToken string `env:"API_TOKEN"`
-
ZoneId string `env:"ZONE_ID"`
+
ApiToken string `env:"API_TOKEN"`
+
ZoneId string `env:"ZONE_ID"`
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
}
func (cfg RedisConfig) ToURL() string {
+13 -9
appview/db/email.go
···
return did, nil
}
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
-
if len(ems) == 0 {
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
+
if len(emails) == 0 {
return make(map[string]string), nil
}
···
verifiedFilter = 1
}
+
assoc := make(map[string]string)
+
// Create placeholders for the IN clause
-
placeholders := make([]string, len(ems))
-
args := make([]any, len(ems)+1)
+
placeholders := make([]string, 0, len(emails))
+
args := make([]any, 1, len(emails)+1)
args[0] = verifiedFilter
-
for i, em := range ems {
-
placeholders[i] = "?"
-
args[i+1] = em
+
for _, email := range emails {
+
if strings.HasPrefix(email, "did:") {
+
assoc[email] = email
+
continue
+
}
+
placeholders = append(placeholders, "?")
+
args = append(args, email)
}
query := `
···
}
defer rows.Close()
-
assoc := make(map[string]string)
-
for rows.Next() {
var email, did string
if err := rows.Scan(&email, &did); err != nil {
+7
appview/pages/templates/repo/fork.html
···
</div>
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
+
+
<fieldset class="space-y-3">
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
+
</fieldset>
+
<fieldset class="space-y-3">
<legend class="dark:text-white">Select a knot to fork into</legend>
<div class="space-y-2">
+11 -7
appview/repo/repo.go
···
// choose a name for a fork
-
forkName := f.Name
+
forkName := r.FormValue("repo_name")
+
if forkName == "" {
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
+
return
+
}
+
// this check is *only* to see if the forked repo name already exists
// in the user's account.
existingRepo, err := db.GetRepo(
rp.db,
db.FilterEq("did", user.Did),
-
db.FilterEq("name", f.Name),
+
db.FilterEq("name", forkName),
if err != nil {
-
if errors.Is(err, sql.ErrNoRows) {
-
// no existing repo with this name found, we can use the name as is
-
} else {
+
if !errors.Is(err, sql.ErrNoRows) {
log.Println("error fetching existing repo from db", "err", err)
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
return
} else if existingRepo != nil {
-
// repo with this name already exists, append random string
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
+
// repo with this name already exists
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
+
return
l = l.With("forkName", forkName)
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
···
{{ define "repo/fragments/labelPanel" }}
-
<div id="label-panel" class="flex flex-col gap-6">
+
<div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0">
{{ template "basicLabels" . }}
{{ template "kvLabels" . }}
</div>
+26
appview/pages/templates/repo/fragments/participants.html
···
+
{{ define "repo/fragments/participants" }}
+
{{ $all := . }}
+
{{ $ps := take $all 5 }}
+
<div class="px-6 md:px-0">
+
<div class="py-1 flex items-center text-sm">
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+
</div>
+
<div class="flex items-center -space-x-3 mt-2">
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
+
{{ range $i, $p := $ps }}
+
<img
+
src="{{ tinyAvatar . }}"
+
alt=""
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
+
/>
+
{{ end }}
+
+
{{ if gt (len $all) 5 }}
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
+
+{{ sub (len $all) 5 }}
+
</span>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+1 -27
appview/pages/templates/repo/issues/issue.html
···
"Defs" $.LabelDefs
"Subject" $.Issue.AtUri
"State" $.Issue.Labels) }}
-
{{ template "issueParticipants" . }}
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
</div>
</div>
{{ end }}
···
</div>
{{ end }}
-
{{ define "issueParticipants" }}
-
{{ $all := .Issue.Participants }}
-
{{ $ps := take $all 5 }}
-
<div>
-
<div class="py-1 flex items-center text-sm">
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
-
</div>
-
<div class="flex items-center -space-x-3 mt-2">
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
-
{{ range $i, $p := $ps }}
-
<img
-
src="{{ tinyAvatar . }}"
-
alt=""
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
-
/>
-
{{ end }}
-
-
{{ if gt (len $all) 5 }}
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
-
+{{ sub (len $all) 5 }}
-
</span>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
{{ define "repoAfter" }}
<div class="flex flex-col gap-4 mt-4">
+30 -12
appview/pages/templates/repo/pulls/pull.html
···
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
+
{{ define "repoContentLayout" }}
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
+
<div class="col-span-1 md:col-span-8">
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
+
{{ block "repoContent" . }}{{ end }}
+
</section>
+
{{ block "repoAfter" . }}{{ end }}
+
</div>
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
+
{{ template "repo/fragments/labelPanel"
+
(dict "RepoInfo" $.RepoInfo
+
"Defs" $.LabelDefs
+
"Subject" $.Pull.PullAt
+
"State" $.Pull.Labels) }}
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
+
</div>
+
</div>
+
{{ end }}
{{ define "repoContent" }}
{{ template "repo/pulls/fragments/pullHeader" . }}
···
{{ with $item }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
-
<div class="flex flex-wrap gap-2 items-center">
+
<div class="flex flex-wrap gap-2 items-stretch">
<!-- round number -->
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span class="gap-1 flex items-center">
{{ $owner := resolve $.Pull.OwnerDid }}
{{ $re := "re" }}
···
<span class="hidden md:inline">diff</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
-
{{ if not (eq .RoundNumber 0) }}
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
-
hx-boost="true"
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
-
<span class="hidden md:inline">interdiff</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</a>
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
+
{{ if ne $idx 0 }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
+
hx-boost="true"
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
+
<span class="hidden md:inline">interdiff</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
{{ end }}
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
</div>
</summary>
···
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
{{ range $cidx, $c := .Comments }}
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
{{ if gt $cidx 0 }}
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
{{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
···
<span class="before:content-['ยท']"></span>
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
{{ end }}
+
+
{{ $state := .Labels }}
+
{{ range $k, $d := $.LabelDefs }}
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
+
{{ end }}
+
{{ end }}
</div>
</div>
{{ if .StackId }}
+8 -48
appview/notify/db/db.go
···
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
var err error
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
if err != nil {
log.Printf("NewStar: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewStar: no repo found for %s", star.RepoAt)
-
return
-
}
-
repo := repos[0]
// don't notify yourself
if repo.Did == star.StarredByDid {
···
}
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssue: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssue: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
if repo.Did == issue.Did {
return
···
}
issue := issues[0]
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueComment: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
recipients := make(map[string]bool)
···
}
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPull: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPull: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
if repo.Did == pull.OwnerDid {
return
···
}
pull := pulls[0]
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
if err != nil {
log.Printf("NewPullComment: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullComment: no repo found for %s", comment.RepoAt)
-
return
-
}
-
repo := repos[0]
recipients := make(map[string]bool)
···
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueClosed: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == issue.Did {
···
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullMerged: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == pull.OwnerDid {
···
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullClosed: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == pull.OwnerDid {
+18 -13
appview/db/notifications.go
···
import (
"context"
"database/sql"
+
"errors"
"fmt"
+
"strings"
"time"
"tangled.org/core/appview/models"
···
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
}
-
func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
-
recipientFilter := FilterEq("recipient_did", userDID)
-
readFilter := FilterEq("read", 0)
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
-
query := fmt.Sprintf(`
-
SELECT COUNT(*)
-
FROM notifications
-
WHERE %s AND %s
-
`, recipientFilter.Condition(), readFilter.Condition())
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
args := append(recipientFilter.Arg(), readFilter.Arg()...)
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
+
var count int64
+
err := e.QueryRow(query, args...).Scan(&count)
-
var count int
-
err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
-
if err != nil {
-
return 0, fmt.Errorf("failed to get unread count: %w", err)
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
}
return count, nil
+29 -36
appview/notifications/notifications.go
···
package notifications
import (
+
"fmt"
"log"
"net/http"
"strconv"
···
r.Use(middleware.AuthMiddleware(n.oauth))
-
r.Get("/", n.notificationsPage)
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
r.Get("/count", n.getUnreadCount)
r.Post("/{id}/read", n.markRead)
···
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
userDid := n.oauth.GetDid(r)
-
limitStr := r.URL.Query().Get("limit")
-
offsetStr := r.URL.Query().Get("offset")
-
-
limit := 20 // default
-
if limitStr != "" {
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
-
limit = l
-
}
+
page, ok := r.Context().Value("page").(pagination.Page)
+
if !ok {
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
}
-
offset := 0 // default
-
if offsetStr != "" {
-
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
-
offset = o
-
}
+
total, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", userDid),
+
)
+
if err != nil {
+
log.Println("failed to get total notifications:", err)
+
n.pages.Error500(w)
+
return
}
-
page := pagination.Page{Limit: limit + 1, Offset: offset}
-
notifications, err := db.GetNotificationsWithEntities(n.db, page, db.FilterEq("recipient_did", userDid))
+
notifications, err := db.GetNotificationsWithEntities(
+
n.db,
+
page,
+
db.FilterEq("recipient_did", userDid),
+
)
if err != nil {
log.Println("failed to get notifications:", err)
n.pages.Error500(w)
return
}
-
hasMore := len(notifications) > limit
-
if hasMore {
-
notifications = notifications[:limit]
-
}
-
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
if err != nil {
log.Println("failed to mark notifications as read:", err)
···
return
}
-
params := pages.NotificationsParams{
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
LoggedInUser: user,
Notifications: notifications,
UnreadCount: unreadCount,
-
HasMore: hasMore,
-
NextOffset: offset + limit,
-
Limit: limit,
-
}
-
-
err = n.pages.Notifications(w, params)
-
if err != nil {
-
log.Println("failed to load notifs:", err)
-
n.pages.Error500(w)
-
return
-
}
+
Page: page,
+
Total: total,
+
}))
}
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
-
userDid := n.oauth.GetDid(r)
-
-
count, err := n.db.GetUnreadNotificationCount(r.Context(), userDid)
+
user := n.oauth.GetUser(r)
+
count, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", user.Did),
+
db.FilterEq("read", 0),
+
)
if err != nil {
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
return
+3 -4
appview/pages/pages.go
···
LoggedInUser *oauth.User
Notifications []*models.NotificationWithEntity
UnreadCount int
-
HasMore bool
-
NextOffset int
-
Limit int
+
Page pagination.Page
+
Total int64
}
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
···
}
type NotificationCountParams struct {
-
Count int
+
Count int64
}
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
+48 -15
appview/pages/templates/notifications/list.html
···
</div>
</div>
-
{{if .Notifications}}
-
<div class="flex flex-col gap-2" id="notifications-list">
-
{{range .Notifications}}
-
{{template "notifications/fragments/item" .}}
-
{{end}}
-
</div>
+
{{if .Notifications}}
+
<div class="flex flex-col gap-2" id="notifications-list">
+
{{range .Notifications}}
+
{{template "notifications/fragments/item" .}}
+
{{end}}
+
</div>
-
{{else}}
-
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
-
<div class="text-center py-12">
-
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
-
{{ i "bell-off" "w-16 h-16" }}
-
</div>
-
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
-
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
+
{{else}}
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<div class="text-center py-12">
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
+
{{ i "bell-off" "w-16 h-16" }}
</div>
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
</div>
-
{{end}}
+
</div>
+
{{end}}
+
+
{{ template "pagination" . }}
+
{{ end }}
+
+
{{ define "pagination" }}
+
<div class="flex justify-end mt-4 gap-2">
+
{{ if gt .Page.Offset 0 }}
+
{{ $prev := .Page.Previous }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
>
+
{{ i "chevron-left" "w-4 h-4" }}
+
previous
+
</a>
+
{{ else }}
+
<div></div>
+
{{ end }}
+
+
{{ $next := .Page.Next }}
+
{{ if lt $next.Offset .Total }}
+
{{ $next := .Page.Next }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
>
+
next
+
{{ i "chevron-right" "w-4 h-4" }}
+
</a>
+
{{ end }}
+
</div>
{{ end }}
+1 -1
appview/pagination/page.go
···
func FirstPage() Page {
return Page{
Offset: 0,
-
Limit: 10,
+
Limit: 30,
}
}
+27 -208
appview/pages/templates/notifications/fragments/item.html
···
{{define "notifications/fragments/item"}}
-
<div
-
class="
-
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
-
{{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}}
-
flex gap-2 items-center
-
"
-
>
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
+
<div
+
class="
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
+
flex gap-2 items-center
+
">
+
{{ template "notificationIcon" . }}
+
<div class="flex-1 w-full flex flex-col gap-1">
+
<span>{{ template "notificationHeader" . }}</span>
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
+
</div>
-
{{ template "notificationIcon" . }}
-
<div class="flex-1 w-full flex flex-col gap-1">
-
<span>{{ template "notificationHeader" . }}</span>
-
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
</div>
-
-
</div>
+
</a>
{{end}}
{{ define "notificationIcon" }}
···
{{ end }}
{{ end }}
-
{{define "issueNotification"}}
-
{{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="min-w-0 flex-1">
-
<!-- First line: icon + actor action -->
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
-
{{if eq .Type "issue_created"}}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "circle-dot" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "issue_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "message-circle" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "issue_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "ban" "w-4 h-4" }}
-
</span>
-
{{end}}
-
{{template "user/fragments/picHandle" .ActorDid}}
-
{{if eq .Type "issue_created"}}
-
<span class="text-gray-500 dark:text-gray-400">opened issue</span>
-
{{else if eq .Type "issue_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">commented on issue</span>
-
{{else if eq .Type "issue_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">closed issue</span>
-
{{end}}
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
-
<span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span>
-
<span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span>
-
<span>on</span>
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "pullNotification"}}
-
{{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
-
{{if eq .Type "pull_created"}}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "git-pull-request-create" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "message-circle" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_merged"}}
-
<span class="text-purple-600 dark:text-purple-500">
-
{{ i "git-merge" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_closed"}}
-
<span class="text-red-600 dark:text-red-500">
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
-
</span>
-
{{end}}
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
{{if eq .Type "pull_created"}}
-
<span class="text-gray-500 dark:text-gray-400">opened pull request</span>
-
{{else if eq .Type "pull_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">commented on pull request</span>
-
{{else if eq .Type "pull_merged"}}
-
<span class="text-gray-500 dark:text-gray-400">merged pull request</span>
-
{{else if eq .Type "pull_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">closed pull request</span>
-
{{end}}
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
-
<span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span>
-
<span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span>
-
<span>on</span>
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "repoNotification"}}
-
{{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="text-yellow-500 dark:text-yellow-400">
-
{{ i "star" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<!-- Single line for stars: actor action subject -->
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
<span class="text-gray-500 dark:text-gray-400">starred</span>
-
<span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "followNotification"}}
-
{{$url := printf "/%s" (resolve .ActorDid)}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="text-blue-600 dark:text-blue-400">
-
{{ i "user-plus" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
<span class="text-gray-500 dark:text-gray-400">followed you</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "genericNotification"}}
-
<a
-
href="#"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}">
-
{{ i "bell" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
<span>New notification</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
+
{{ define "notificationUrl" }}
+
{{ $url := "" }}
+
{{ if eq .Type "repo_starred" }}
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
+
{{ else if .Issue }}
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
+
{{ else if .Pull }}
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
+
{{ else if eq .Type "followed" }}
+
{{$url = printf "/%s" (resolve .ActorDid)}}
+
{{ else }}
+
{{ end }}
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
+
{{ $url }}
+
{{ end }}
+1 -1
nix/pkgs/knot-unwrapped.nix
···
sqlite-lib,
src,
}: let
-
version = "1.9.0-alpha";
+
version = "1.9.1-alpha";
in
buildGoApplication {
pname = "knot";
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
···
{{ define "repo/fragments/cloneDropdown" }}
{{ $knot := .RepoInfo.Knot }}
{{ if eq $knot "knot1.tangled.sh" }}
-
{{ $knot = "tangled.sh" }}
+
{{ $knot = "tangled.org" }}
{{ end }}
<details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
docs/spindle/pipeline.md
···
- `manual`: The workflow can be triggered manually.
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
```yaml
when:
+1 -1
knotserver/config/config.go
···
Repo Repo `env:",prefix=KNOT_REPO_"`
Server Server `env:",prefix=KNOT_SERVER_"`
Git Git `env:",prefix=KNOT_GIT_"`
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
}
func Load(ctx context.Context) (*Config, error) {
+3
appview/pages/templates/layouts/base.html
···
<link rel="preconnect" href="https://avatar.tangled.sh" />
<link rel="preconnect" href="https://camo.tangled.sh" />
+
<!-- pwa manifest -->
+
<link rel="manifest" href="/pwa-manifest.json" />
+
<!-- preload main font -->
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
+1
appview/pages/templates/user/completeSignup.html
···
content="complete your signup for tangled"
/>
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link
rel="stylesheet"
href="/static/tw.css?{{ cssContentHash }}"
+1
appview/pages/templates/user/signup.html
···
<meta property="og:url" content="https://tangled.org/signup" />
<meta property="og:description" content="sign up for tangled" />
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>sign up &middot; tangled</title>
+34
appview/db/language.go
···
package db
import (
+
"database/sql"
"fmt"
"strings"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
···
return nil
}
+
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
+
return err
+
}
+
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
+
err := DeleteRepoLanguages(
+
tx,
+
FilterEq("repo_at", repoAt),
+
FilterEq("ref", ref),
+
)
+
if err != nil {
+
return fmt.Errorf("failed to delete existing languages: %w", err)
+
}
+
+
return InsertRepoLanguages(tx, langs)
+
}
+14 -1
appview/state/knotstream.go
···
})
}
-
return db.InsertRepoLanguages(d, langs)
+
tx, err := d.Begin()
+
if err != nil {
+
return err
+
}
+
defer tx.Rollback()
+
+
// update appview's cache
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
+
if err != nil {
+
fmt.Printf("failed; %s\n", err)
+
// non-fatal
+
}
+
+
return tx.Commit()
}
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+202 -1
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
+
fieldCount := 8
if t.Body == nil {
fieldCount--
···
fieldCount--
+
if t.StackInfo == nil {
+
fieldCount--
+
}
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
···
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
+
+
// t.StackInfo (tangled.RepoPull_StackInfo) (struct)
+
if t.StackInfo != nil {
+
+
if len("stackInfo") > 1000000 {
+
return xerrors.Errorf("Value in field \"stackInfo\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stackInfo"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("stackInfo")); err != nil {
+
return err
+
}
+
+
if err := t.StackInfo.MarshalCBOR(cw); err != nil {
+
return err
+
}
+
}
return nil
···
t.CreatedAt = string(sval)
+
// t.StackInfo (tangled.RepoPull_StackInfo) (struct)
+
case "stackInfo":
+
+
{
+
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.StackInfo = new(RepoPull_StackInfo)
+
if err := t.StackInfo.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.StackInfo pointer: %w", err)
+
}
+
}
+
+
}
default:
// Field doesn't exist on this type, so ignore it
···
return nil
+
func (t *RepoPull_StackInfo) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
fieldCount := 2
+
+
if t.Parent == nil {
+
fieldCount--
+
}
+
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
return err
+
}
+
+
// t.Parent (string) (string)
+
if t.Parent != nil {
+
+
if len("parent") > 1000000 {
+
return xerrors.Errorf("Value in field \"parent\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("parent"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("parent")); err != nil {
+
return err
+
}
+
+
if t.Parent == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Parent) > 1000000 {
+
return xerrors.Errorf("Value in field t.Parent was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Parent))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Parent)); err != nil {
+
return err
+
}
+
}
+
}
+
+
// t.ChangeId (string) (string)
+
if len("changeId") > 1000000 {
+
return xerrors.Errorf("Value in field \"changeId\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("changeId"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("changeId")); err != nil {
+
return err
+
}
+
+
if len(t.ChangeId) > 1000000 {
+
return xerrors.Errorf("Value in field t.ChangeId was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ChangeId))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.ChangeId)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *RepoPull_StackInfo) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = RepoPull_StackInfo{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("RepoPull_StackInfo: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 8)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.Parent (string) (string)
+
case "parent":
+
+
{
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Parent = (*string)(&sval)
+
}
+
}
+
// t.ChangeId (string) (string)
+
case "changeId":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.ChangeId = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
func (t *RepoPull_Source) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
+16 -7
api/tangled/repopull.go
···
} //
// RECORDTYPE: RepoPull
type RepoPull struct {
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
Patch string `json:"patch" cborgen:"patch"`
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
-
Title string `json:"title" cborgen:"title"`
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Patch string `json:"patch" cborgen:"patch"`
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
+
StackInfo *RepoPull_StackInfo `json:"stackInfo,omitempty" cborgen:"stackInfo,omitempty"`
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
+
Title string `json:"title" cborgen:"title"`
}
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
···
Sha string `json:"sha" cborgen:"sha"`
}
+
// RepoPull_StackInfo is a "stackInfo" in the sh.tangled.repo.pull schema.
+
type RepoPull_StackInfo struct {
+
// changeId: Change ID of this commit/change.
+
ChangeId string `json:"changeId" cborgen:"changeId"`
+
// parent: AT-URI of the PR for the parent commit/change in the change stack.
+
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
+
}
+
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
type RepoPull_Target struct {
Branch string `json:"branch" cborgen:"branch"`
+7
appview/db/db.go
···
})
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
runMigration(conn, "add-parent-at-for-stacks-to-pulls", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table pulls add column parent_at text;
+
`)
+
return err
+
})
+
return &DB{db}, nil
+5
appview/models/pull.go
···
// stacking
StackId string // nullable string
ChangeId string // nullable string
+
ParentAt *syntax.ATURI
ParentChangeId string // nullable string
// meta
···
},
Patch: p.LatestPatch(),
Source: source,
+
StackInfo: &tangled.RepoPull_StackInfo{
+
ChangeId: p.ChangeId,
+
Parent: (*string)(p.ParentAt),
+
},
}
return record
}
+24 -2
appview/pulls/pulls.go
···
"github.com/bluekeyes/go-gitdiff/gitdiff"
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
···
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
if err != nil {
log.Println("failed to create resubmitted stack", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
s.pages.Notice(w, "pull-resubmit-error", "Failed to merge pull request. Try again later.")
return
// find the diff between the stacks, first, map them by changeId
+
origById := make(map[string]*models.Pull)
newById := make(map[string]*models.Pull)
+
chIdToAtUri := make(map[string]*syntax.ATURI)
+
for _, p := range origStack {
origById[p.ChangeId] = p
+
+
// build map from change id to existing at uris (ignore error as it shouldnt be possible here)
+
pAtUri, _ := syntax.ParseATURI(fmt.Sprintf("at://%s/%s/%s", user.Did, tangled.RepoPullNSID, p.Rkey))
+
chIdToAtUri[p.ChangeId] = &pAtUri
for _, p := range newStack {
+
// if change id has already been given a PR use its at uri instead of the newly created (and thus incorrect)
+
// one made by newStack
+
if ppAt, ok := chIdToAtUri[p.ParentChangeId]; ok {
+
p.ParentAt = ppAt
+
}
+
newById[p.ChangeId] = p
···
// we still need to update the hash in submission.Patch and submission.SourceRev
if patchutil.Equal(newFiles, origFiles) &&
origHeader.Title == newHeader.Title &&
-
origHeader.Body == newHeader.Body {
+
origHeader.Body == newHeader.Body &&
+
op.ParentChangeId == np.ParentChangeId {
unchanged[op.ChangeId] = struct{}{}
} else {
updated[op.ChangeId] = struct{}{}
···
record := op.AsRecord()
record.Patch = submission.Patch
+
record.StackInfo.Parent = (*string)(np.ParentAt)
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
···
// the stack is identified by a UUID
var stack models.Stack
parentChangeId := ""
+
var parentAt *syntax.ATURI = nil
for _, fp := range formatPatches {
// all patches must have a jj change-id
changeId, err := fp.ChangeId()
···
StackId: stackId,
ChangeId: changeId,
+
ParentAt: parentAt,
ParentChangeId: parentChangeId,
stack = append(stack, &pull)
parentChangeId = changeId
+
// this is a bit of an ugly way to create the ATURI but its the best we can do with the data flow here
+
// (igore error as it shouldnt be possible here)
+
parsedParentAt, _ := syntax.ParseATURI(fmt.Sprintf("at://%s/%s/%s", user.Did, tangled.RepoPullNSID, pull.Rkey));
+
parentAt = &parsedParentAt
return stack, nil
+1
cmd/gen.go
···
tangled.RepoIssueState{},
tangled.RepoPull{},
tangled.RepoPullComment{},
+
tangled.RepoPull_StackInfo{},
tangled.RepoPull_Source{},
tangled.RepoPullStatus{},
tangled.RepoPull_Target{},
+96
lexicons/pulls/round.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.pull.round",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"pull",
+
"patch",
+
"sourceInfo",
+
"createdAt"
+
],
+
"properties": {
+
"pull": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"prevRound": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef"
+
},
+
"patch": {
+
"type": "string",
+
"description": "A patch describing this change. Either gotten directly from the user (patch-based PR) or from the knot based on a commit from another repo. The source of the patch and it's potential details are described by sourceInfo"
+
},
+
"sourceInfo": {
+
"type": "union",
+
"refs": [
+
"#patchSourceInfo",
+
"#commitSourceInfo"
+
]
+
},
+
"stackInfo": {
+
"type": "ref",
+
"ref": "#stackInfo"
+
},
+
"comment": {
+
"type": "string"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
},
+
"patchSourceInfo": {
+
"type": "object",
+
"properties": {}
+
},
+
"commitSourceInfo": {
+
"type": "object",
+
"required": [
+
"repo",
+
"branch",
+
"sha"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "uri"
+
},
+
"branch": {
+
"type": "string"
+
},
+
"sha": {
+
"type": "string",
+
"minLength": 40,
+
"maxLength": 40
+
}
+
}
+
},
+
"stackInfo": {
+
"type": "object",
+
"required": [
+
"changeId"
+
],
+
"properties": {
+
"changeId": {
+
"type": "string",
+
"description": "Change ID of this commit/change."
+
},
+
"parent": {
+
"type": "string",
+
"description": "AT-URI of the PR for the parent commit/change in the change stack.",
+
"format": "at-uri"
+
}
+
}
+
}
+
}
+
}