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

Compare changes

Choose any two refs to compare.

+1 -1
.air/knot.toml
···
[build]
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o out/knot.out cmd/knot/main.go'
bin = "out/knot.out"
-
args_bin = "server"
+
args_bin = ["server"]
include_ext = ["go"]
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
+39 -2
appview/db/db.go
···
-- indexes for better performance
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
-
create index if not exists idx_stars_created on stars(created);
-
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
`)
if err != nil {
return nil, err
···
runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table notification_preferences add column user_mentioned integer not null default 1;
+
`)
+
return err
+
})
+
+
// remove the foreign key constraints from stars.
+
runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table stars_new (
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text not null,
+
+
subject_at text not null,
+
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
unique(did, rkey),
+
unique(did, subject_at)
+
);
+
+
insert into stars_new (
+
id,
+
did,
+
rkey,
+
subject_at,
+
created
+
)
+
select
+
id,
+
starred_by_did,
+
rkey,
+
repo_at,
+
created
+
from stars;
+
+
drop table stars;
+
alter table stars_new rename to stars;
+
+
create index if not exists idx_stars_created on stars(created);
+
create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
`)
return err
})
+4 -2
appview/db/pipeline.go
···
// this is a mega query, but the most useful one:
// get N pipelines, for each one get the latest status of its N workflows
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
+
func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
join
triggers t ON p.trigger_id = t.id
%s
-
`, whereClause)
+
order by p.created desc
+
limit %d
+
`, whereClause, limit)
rows, err := e.Query(query, args...)
if err != nil {
+3 -3
appview/db/repos.go
···
starCountQuery := fmt.Sprintf(
`select
-
repo_at, count(1)
+
subject_at, count(1)
from stars
-
where repo_at in (%s)
-
group by repo_at`,
+
where subject_at in (%s)
+
group by subject_at`,
inClause,
)
rows, err = e.Query(starCountQuery, args...)
+39 -99
appview/db/star.go
···
)
func AddStar(e Execer, star *models.Star) error {
-
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
+
query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)`
_, err := e.Exec(
query,
-
star.StarredByDid,
+
star.Did,
star.RepoAt.String(),
star.Rkey,
)
···
}
// Get a star record
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
+
func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) {
query := `
-
select starred_by_did, repo_at, created, rkey
+
select did, subject_at, created, rkey
from stars
-
where starred_by_did = ? and repo_at = ?`
-
row := e.QueryRow(query, starredByDid, repoAt)
+
where did = ? and subject_at = ?`
+
row := e.QueryRow(query, did, subjectAt)
var star models.Star
var created string
-
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
+
err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
if err != nil {
return nil, err
}
···
}
// Remove a star
-
func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error {
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt)
+
func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error {
+
_, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt)
return err
}
// Remove a star
-
func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error {
-
_, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey)
+
func DeleteStarByRkey(e Execer, did string, rkey string) error {
+
_, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey)
return err
}
-
func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) {
+
func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) {
stars := 0
err := e.QueryRow(
-
`select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars)
+
`select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars)
if err != nil {
return 0, err
}
···
}
query := fmt.Sprintf(`
-
SELECT repo_at
+
SELECT subject_at
FROM stars
-
WHERE starred_by_did = ? AND repo_at IN (%s)
+
WHERE did = ? AND subject_at IN (%s)
`, strings.Join(placeholders, ","))
rows, err := e.Query(query, args...)
···
return result, nil
}
-
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
-
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
+
func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool {
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt})
if err != nil {
return false
}
-
return statuses[repoAt.String()]
+
return statuses[subjectAt.String()]
}
// GetStarStatuses returns a map of repo URIs to star status for a given user
-
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
-
return getStarStatuses(e, userDid, repoAts)
+
func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) {
+
return getStarStatuses(e, userDid, subjectAts)
}
-
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
+
+
// GetRepoStars return a list of stars each holding target repository.
+
// If there isn't known repo with starred at-uri, those stars will be ignored.
+
func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
}
repoQuery := fmt.Sprintf(
-
`select starred_by_did, repo_at, created, rkey
+
`select did, subject_at, created, rkey
from stars
%s
order by created desc
···
for rows.Next() {
var star models.Star
var created string
-
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
+
err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey)
if err != nil {
return nil, err
}
···
return nil, err
}
+
var repoStars []models.RepoStar
for _, r := range repos {
if stars, ok := starMap[string(r.RepoAt())]; ok {
-
for i := range stars {
-
stars[i].Repo = &r
+
for _, star := range stars {
+
repoStars = append(repoStars, models.RepoStar{
+
Star: star,
+
Repo: &r,
+
})
}
}
}
-
var stars []models.Star
-
for _, s := range starMap {
-
stars = append(stars, s...)
-
}
-
-
slices.SortFunc(stars, func(a, b models.Star) int {
+
slices.SortFunc(repoStars, func(a, b models.RepoStar) int {
if a.Created.After(b.Created) {
return -1
}
···
return 0
})
-
return stars, nil
+
return repoStars, nil
}
func CountStars(e Execer, filters ...filter) (int64, error) {
···
return count, nil
}
-
func GetAllStars(e Execer, limit int) ([]models.Star, error) {
-
var stars []models.Star
-
-
rows, err := e.Query(`
-
select
-
s.starred_by_did,
-
s.repo_at,
-
s.rkey,
-
s.created,
-
r.did,
-
r.name,
-
r.knot,
-
r.rkey,
-
r.created
-
from stars s
-
join repos r on s.repo_at = r.at_uri
-
`)
-
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var star models.Star
-
var repo models.Repo
-
var starCreatedAt, repoCreatedAt string
-
-
if err := rows.Scan(
-
&star.StarredByDid,
-
&star.RepoAt,
-
&star.Rkey,
-
&starCreatedAt,
-
&repo.Did,
-
&repo.Name,
-
&repo.Knot,
-
&repo.Rkey,
-
&repoCreatedAt,
-
); err != nil {
-
return nil, err
-
}
-
-
star.Created, err = time.Parse(time.RFC3339, starCreatedAt)
-
if err != nil {
-
star.Created = time.Now()
-
}
-
repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt)
-
if err != nil {
-
repo.Created = time.Now()
-
}
-
star.Repo = &repo
-
-
stars = append(stars, star)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return stars, nil
-
}
-
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
// first, get the top repo URIs by star count from the last week
query := `
with recent_starred_repos as (
-
select distinct repo_at
+
select distinct subject_at
from stars
where created >= datetime('now', '-7 days')
),
repo_star_counts as (
select
-
s.repo_at,
+
s.subject_at,
count(*) as stars_gained_last_week
from stars s
-
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
+
join recent_starred_repos rsr on s.subject_at = rsr.subject_at
where s.created >= datetime('now', '-7 days')
-
group by s.repo_at
+
group by s.subject_at
)
-
select rsc.repo_at
+
select rsc.subject_at
from repo_star_counts rsc
order by rsc.stars_gained_last_week desc
limit 8
+3 -13
appview/db/timeline.go
···
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
filters := make([]filter, 0)
if userIsFollowing != nil {
-
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
+
filters = append(filters, FilterIn("did", userIsFollowing))
}
-
stars, err := GetStars(e, limit, filters...)
+
stars, err := GetRepoStars(e, limit, filters...)
if err != nil {
return nil, err
}
-
// filter star records without a repo
-
n := 0
-
for _, s := range stars {
-
if s.Repo != nil {
-
stars[n] = s
-
n++
-
}
-
}
-
stars = stars[:n]
-
var repos []models.Repo
for _, s := range stars {
repos = append(repos, *s.Repo)
···
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
events = append(events, models.TimelineEvent{
-
Star: &s,
+
RepoStar: &s,
EventAt: s.Created,
IsStarred: isStarred,
StarCount: starCount,
+3 -3
appview/ingester.go
···
return err
}
err = db.AddStar(i.Db, &models.Star{
-
StarredByDid: did,
-
RepoAt: subjectUri,
-
Rkey: e.Commit.RKey,
+
Did: did,
+
RepoAt: subjectUri,
+
Rkey: e.Commit.RKey,
})
case jmodels.CommitOperationDelete:
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
+14 -5
appview/models/star.go
···
)
type Star struct {
-
StarredByDid string
-
RepoAt syntax.ATURI
-
Created time.Time
-
Rkey string
+
Did string
+
RepoAt syntax.ATURI
+
Created time.Time
+
Rkey string
+
}
-
// optionally, populate this when querying for reverse mappings
+
// RepoStar is used for reverse mapping to repos
+
type RepoStar struct {
+
Star
Repo *Repo
}
+
+
// StringStar is used for reverse mapping to strings
+
type StringStar struct {
+
Star
+
String *String
+
}
+1 -1
appview/models/string.go
···
Edited *time.Time
}
-
func (s *String) StringAt() syntax.ATURI {
+
func (s *String) AtUri() syntax.ATURI {
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
}
+1 -1
appview/models/timeline.go
···
type TimelineEvent struct {
*Repo
*Follow
-
*Star
+
*RepoStar
EventAt time.Time
+6 -1
appview/notify/db/db.go
···
"slices"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
···
}
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
+
if star.RepoAt.Collection().String() != tangled.RepoNSID {
+
// skip string stars for now
+
return
+
}
var err error
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
if err != nil {
···
return
}
-
actorDid := syntax.DID(star.StarredByDid)
+
actorDid := syntax.DID(star.Did)
recipients := []syntax.DID{syntax.DID(repo.Did)}
eventType := models.NotificationTypeRepoStarred
entityType := "repo"
+2 -2
appview/notify/posthog/notifier.go
···
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: star.StarredByDid,
+
DistinctId: star.Did,
Event: "star",
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
})
···
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: star.StarredByDid,
+
DistinctId: star.Did,
Event: "unstar",
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
})
+2 -2
appview/oauth/handler.go
···
r.Get("/oauth/client-metadata.json", o.clientMetadata)
r.Get("/oauth/jwks.json", o.jwks)
-
r.Get("/oauth/callback", o.Callback)
+
r.Get("/oauth/callback", o.callback)
return r
}
···
}
}
-
func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) {
+
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := o.Logger.With("query", r.URL.Query())
+15 -2
appview/oauth/oauth.go
···
exp int64
lxm string
dev bool
+
timeout time.Duration
}
type ServiceClientOpt func(*ServiceClientOpts)
+
+
func DefaultServiceClientOpts() ServiceClientOpts {
+
return ServiceClientOpts{
+
timeout: time.Second * 5,
+
}
+
}
func WithService(service string) ServiceClientOpt {
return func(s *ServiceClientOpts) {
···
}
}
+
func WithTimeout(timeout time.Duration) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
s.timeout = timeout
+
}
+
}
+
func (s *ServiceClientOpts) Audience() string {
return fmt.Sprintf("did:web:%s", s.service)
}
···
}
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
-
opts := ServiceClientOpts{}
+
opts := DefaultServiceClientOpts()
for _, o := range os {
o(&opts)
}
···
},
Host: opts.Host(),
Client: &http.Client{
-
Timeout: time.Second * 5,
+
Timeout: opts.timeout,
},
}, nil
}
-10
appview/oauth/session.go
···
-
package oauth
-
-
import (
-
"net/http"
-
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
-
)
-
-
func (o *OAuth) SaveSession2(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) {
-
}
+1 -1
appview/pages/markup/extension/atlink.go
···
if entering {
w.WriteString(`<a href="/@`)
w.WriteString(n.(*AtNode).Handle)
-
w.WriteString(`" class="mention">`)
+
w.WriteString(`" class="mention font-bold">`)
} else {
w.WriteString("</a>")
}
+1 -1
appview/pages/markup/markdown.go
···
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
parsedURL := &url.URL{
Scheme: scheme,
+8 -6
appview/pages/pages.go
···
return p.executePlain("user/fragments/editPins", w, params)
}
-
type RepoStarFragmentParams struct {
+
type StarBtnFragmentParams struct {
IsStarred bool
-
RepoAt syntax.ATURI
-
Stats models.RepoStats
+
SubjectAt syntax.ATURI
+
StarCount int
}
-
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
-
return p.executePlain("repo/fragments/repoStar", w, params)
+
func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
+
return p.executePlain("fragments/starBtn", w, params)
}
type RepoIndexParams struct {
···
ShowRendered bool
RenderToggle bool
RenderedContents template.HTML
-
String models.String
+
String *models.String
Stats models.StringStats
+
IsStarred bool
+
StarCount int
Owner identity.Identity
+28
appview/pages/templates/fragments/starBtn.html
···
+
{{ define "fragments/starBtn" }}
+
<button
+
id="starBtn"
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
+
data-star-subject-at="{{ .SubjectAt }}"
+
{{ if .IsStarred }}
+
hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
+
{{ else }}
+
hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}"
+
{{ end }}
+
+
hx-trigger="click"
+
hx-target="this"
+
hx-swap="outerHTML"
+
hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'
+
hx-disabled-elt="#starBtn"
+
>
+
{{ if .IsStarred }}
+
{{ i "star" "w-4 h-4 fill-current" }}
+
{{ else }}
+
{{ i "star" "w-4 h-4" }}
+
{{ end }}
+
<span class="text-sm">
+
{{ .StarCount }}
+
</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+4 -1
appview/pages/templates/layouts/repobase.html
···
</div>
<div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto">
-
{{ template "repo/fragments/repoStar" .RepoInfo }}
+
{{ template "fragments/starBtn"
+
(dict "SubjectAt" .RepoInfo.RepoAt
+
"IsStarred" .RepoInfo.IsStarred
+
"StarCount" .RepoInfo.Stats.StarCount) }}
<a
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
hx-boost="true"
+3 -1
appview/pages/templates/repo/blob.html
···
{{ end }}
</div>
{{ else if .BlobView.ContentType.IsCode }}
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
+
<div class="overflow-auto relative">
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
+
</div>
{{ end }}
{{ template "fragments/multiline-select" }}
{{ end }}
+1 -1
appview/pages/templates/repo/compare/compare.html
···
{{ end }}
{{ define "mainLayout" }}
-
<div class="px-1 col-span-full flex flex-col gap-4">
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
{{ block "contentLayout" . }}
{{ block "content" . }}{{ end }}
{{ end }}
+1
appview/pages/templates/repo/fork.html
···
value="{{ . }}"
class="mr-2"
id="domain-{{ . }}"
+
{{if eq (len $.Knots) 1}}checked{{end}}
/>
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
</div>
-26
appview/pages/templates/repo/fragments/repoStar.html
···
-
{{ define "repo/fragments/repoStar" }}
-
<button
-
id="starBtn"
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
-
{{ if .IsStarred }}
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ else }}
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ end }}
-
-
hx-trigger="click"
-
hx-target="this"
-
hx-swap="outerHTML"
-
hx-disabled-elt="#starBtn"
-
>
-
{{ if .IsStarred }}
-
{{ i "star" "w-4 h-4 fill-current" }}
-
{{ else }}
-
{{ i "star" "w-4 h-4" }}
-
{{ end }}
-
<span class="text-sm">
-
{{ .Stats.StarCount }}
-
</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+19 -8
appview/pages/templates/repo/issues/issues.html
···
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
-
{{ i "search" "w-4 h-4" }}
+
<div class="flex-1 flex relative">
+
<input
+
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
+
type="text"
+
name="q"
+
value="{{ .FilterQuery }}"
+
placeholder=" "
+
>
+
<a
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
+
>
+
{{ i "x" "w-4 h-4" }}
+
</a>
</div>
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
-
<a
-
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
+
<button
+
type="submit"
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
>
-
{{ i "x" "w-4 h-4" }}
-
</a>
+
{{ i "search" "w-4 h-4" }}
+
</button>
</form>
<div class="sm:row-start-1">
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+1
appview/pages/templates/repo/new.html
···
class="mr-2"
id="domain-{{ . }}"
required
+
{{if eq (len $.Knots) 1}}checked{{end}}
/>
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
</div>
+19 -8
appview/pages/templates/repo/pulls/pulls.html
···
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
-
{{ i "search" "w-4 h-4" }}
+
<div class="flex-1 flex relative">
+
<input
+
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer"
+
type="text"
+
name="q"
+
value="{{ .FilterQuery }}"
+
placeholder=" "
+
>
+
<a
+
href="?state={{ .FilteringBy.String }}"
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
+
>
+
{{ i "x" "w-4 h-4" }}
+
</a>
</div>
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
-
<a
-
href="?state={{ .FilteringBy.String }}"
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
+
<button
+
type="submit"
+
class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600"
>
-
{{ i "x" "w-4 h-4" }}
-
</a>
+
{{ i "search" "w-4 h-4" }}
+
</button>
</form>
<div class="sm:row-start-1">
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+1 -1
appview/pages/templates/repo/settings/general.html
···
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
</div>
-
<fieldset>
+
</fieldset>
</form>
{{ end }}
+8 -4
appview/pages/templates/strings/string.html
···
<span class="select-none">/</span>
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
</div>
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
-
<div class="flex gap-2 text-base">
+
<div class="flex gap-2 text-base">
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
hx-boost="true"
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
···
<span class="hidden md:inline">delete</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
-
</div>
-
{{ end }}
+
{{ end }}
+
{{ template "fragments/starBtn"
+
(dict "SubjectAt" .String.AtUri
+
"IsStarred" .IsStarred
+
"StarCount" .StarCount) }}
+
</div>
</div>
<span>
{{ with .String.Description }}
+1 -2
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
<a href="/goodfirstissues" class="no-underline hover:no-underline">
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
<div class="flex-1 flex flex-col gap-2">
-
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
<p>
-
Make your first contribution to an open-source project this October.
+
Make your first contribution to an open-source project.
<em>good-first-issue</em> helps new contributors find easy ways to
start contributing to open-source projects.
</p>
+5 -5
appview/pages/templates/timeline/fragments/timeline.html
···
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
{{ if .Repo }}
{{ template "timeline/fragments/repoEvent" (list $ .) }}
-
{{ else if .Star }}
+
{{ else if .RepoStar }}
{{ template "timeline/fragments/starEvent" (list $ .) }}
{{ else if .Follow }}
{{ template "timeline/fragments/followEvent" (list $ .) }}
···
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
</div>
{{ with $repo }}
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
{{ end }}
{{ end }}
{{ define "timeline/fragments/starEvent" }}
{{ $root := index . 0 }}
{{ $event := index . 1 }}
-
{{ $star := $event.Star }}
+
{{ $star := $event.RepoStar }}
{{ with $star }}
-
{{ $starrerHandle := resolve .StarredByDid }}
+
{{ $starrerHandle := resolve .Did }}
{{ $repoOwnerHandle := resolve .Repo.Did }}
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
</div>
{{ with .Repo }}
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
{{ end }}
{{ end }}
{{ end }}
-1
appview/pages/templates/user/fragments/editBio.html
···
class="py-1 px-1 w-full"
name="pronouns"
placeholder="they/them"
-
pattern="[a-zA-Z]{1,6}[\/\s\-][a-zA-Z]{1,6}"
value="{{ $pronouns }}"
>
</div>
+2 -1
appview/pages/templates/user/fragments/repoCard.html
···
{{ define "user/fragments/repoCard" }}
+
{{/* root, repo, fullName [,starButton [,starData]] */}}
{{ $root := index . 0 }}
{{ $repo := index . 1 }}
{{ $fullName := index . 2 }}
···
</div>
{{ if and $starButton $root.LoggedInUser }}
<div class="shrink-0">
-
{{ template "repo/fragments/repoStar" $starData }}
+
{{ template "fragments/starBtn" $starData }}
</div>
{{ end }}
</div>
+3
appview/pipelines/pipelines.go
···
ps, err := db.GetPipelineStatuses(
p.db,
+
30,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
+
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
+
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
+2
appview/pulls/pulls.go
···
ps, err := db.GetPipelineStatuses(
s.db,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
repoInfo := f.RepoInfo(user)
ps, err := db.GetPipelineStatuses(
s.db,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
+14 -10
appview/repo/compare.go
···
}
// if user is navigating to one of
-
// /compare/{base}/{head}
// /compare/{base}...{head}
-
base := chi.URLParam(r, "base")
-
head := chi.URLParam(r, "head")
-
if base == "" && head == "" {
-
rest := chi.URLParam(r, "*") // master...feature/xyz
-
parts := strings.SplitN(rest, "...", 2)
-
if len(parts) == 2 {
-
base = parts[0]
-
head = parts[1]
-
}
+
// /compare/{base}/{head}
+
var base, head string
+
rest := chi.URLParam(r, "*")
+
+
var parts []string
+
if strings.Contains(rest, "...") {
+
parts = strings.SplitN(rest, "...", 2)
+
} else if strings.Contains(rest, "/") {
+
parts = strings.SplitN(rest, "/", 2)
+
}
+
+
if len(parts) == 2 {
+
base = parts[0]
+
head = parts[1]
}
base, _ = url.PathUnescape(base)
+2
appview/repo/repo.go
···
defer rollback()
+
// TODO: this could coordinate better with the knot to recieve a clone status
client, err := rp.oauth.ServiceClient(
r,
oauth.WithService(targetKnot),
oauth.WithLxm(tangled.RepoCreateNSID),
oauth.WithDev(rp.config.Core.Dev),
+
oauth.WithTimeout(time.Second*20), // big repos take time to clone
if err != nil {
l.Error("could not create service client", "err", err)
+1 -14
appview/repo/repo_util.go
···
package repo
import (
-
"crypto/rand"
-
"math/big"
"slices"
"sort"
"strings"
···
return
}
-
func randomString(n int) string {
-
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-
result := make([]byte, n)
-
-
for i := 0; i < n; i++ {
-
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
-
result[i] = letters[n.Int64()]
-
}
-
-
return string(result)
-
}
-
// grab pipelines from DB and munge that into a hashmap with commit sha as key
//
// golang is so blessed that it requires 35 lines of imperative code for this
···
ps, err := db.GetPipelineStatuses(
d,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
-1
appview/repo/router.go
···
// for example:
// /compare/master...some/feature
// /compare/master...example.com:another/feature <- this is a fork
-
r.Get("/{base}/{head}", rp.Compare)
r.Get("/*", rp.Compare)
})
-271
appview/service/issue/issue.go
···
-
package issue
-
-
import (
-
"context"
-
"errors"
-
"log/slog"
-
"time"
-
-
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/appview/config"
-
"tangled.org/core/appview/db"
-
issues_indexer "tangled.org/core/appview/indexer/issues"
-
"tangled.org/core/appview/models"
-
"tangled.org/core/appview/notify"
-
"tangled.org/core/appview/pages/markup"
-
"tangled.org/core/appview/session"
-
"tangled.org/core/appview/validator"
-
"tangled.org/core/idresolver"
-
"tangled.org/core/tid"
-
)
-
-
type Service struct {
-
config *config.Config
-
db *db.DB
-
indexer *issues_indexer.Indexer
-
logger *slog.Logger
-
notifier notify.Notifier
-
idResolver *idresolver.Resolver
-
validator *validator.Validator
-
}
-
-
func NewService(
-
logger *slog.Logger,
-
config *config.Config,
-
db *db.DB,
-
notifier notify.Notifier,
-
idResolver *idresolver.Resolver,
-
indexer *issues_indexer.Indexer,
-
validator *validator.Validator,
-
) Service {
-
return Service{
-
config,
-
db,
-
indexer,
-
logger,
-
notifier,
-
idResolver,
-
validator,
-
}
-
}
-
-
var (
-
ErrUnAuthorized = errors.New("unauthorized operation")
-
ErrDatabaseFail = errors.New("db op fail")
-
ErrPDSFail = errors.New("pds op fail")
-
ErrValidationFail = errors.New("issue validation fail")
-
)
-
-
func (s *Service) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) {
-
l := s.logger.With("method", "NewIssue")
-
sess := session.FromContext(ctx)
-
if sess == nil {
-
l.Error("user session is missing in context")
-
return nil, ErrUnAuthorized
-
}
-
authorDid := sess.Data.AccountDID
-
l = l.With("did", authorDid)
-
-
// mentions, references := s.refResolver.Resolve(ctx, body)
-
mentions := func() []syntax.DID {
-
rawMentions := markup.FindUserMentions(body)
-
idents := s.idResolver.ResolveIdents(ctx, rawMentions)
-
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
-
var mentions []syntax.DID
-
for _, ident := range idents {
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
-
mentions = append(mentions, ident.DID)
-
}
-
}
-
return mentions
-
}()
-
-
issue := models.Issue{
-
RepoAt: repo.RepoAt(),
-
Rkey: tid.TID(),
-
Title: title,
-
Body: body,
-
Open: true,
-
Did: authorDid.String(),
-
Created: time.Now(),
-
Repo: repo,
-
}
-
-
if err := s.validator.ValidateIssue(&issue); err != nil {
-
l.Error("validation error", "err", err)
-
return nil, ErrValidationFail
-
}
-
-
tx, err := s.db.BeginTx(ctx, nil)
-
if err != nil {
-
l.Error("db.BeginTx failed", "err", err)
-
return nil, ErrDatabaseFail
-
}
-
defer tx.Rollback()
-
-
if err := db.PutIssue(tx, &issue); err != nil {
-
l.Error("db.PutIssue failed", "err", err)
-
return nil, ErrDatabaseFail
-
}
-
-
atpclient := sess.APIClient()
-
record := issue.AsRecord()
-
_, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{
-
Repo: authorDid.String(),
-
Collection: tangled.RepoIssueNSID,
-
Rkey: issue.Rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &record,
-
},
-
})
-
if err != nil {
-
l.Error("atproto.RepoPutRecord failed", "err", err)
-
return nil, ErrPDSFail
-
}
-
if err = tx.Commit(); err != nil {
-
l.Error("tx.Commit failed", "err", err)
-
return nil, ErrDatabaseFail
-
}
-
-
s.notifier.NewIssue(ctx, &issue, mentions)
-
return &issue, nil
-
}
-
-
func (s *Service) GetIssues(ctx context.Context, repo *models.Repo, searchOpts models.IssueSearchOptions) ([]models.Issue, error) {
-
l := s.logger.With("method", "EditIssue")
-
-
var issues []models.Issue
-
var err error
-
if searchOpts.Keyword != "" {
-
res, err := s.indexer.Search(ctx, searchOpts)
-
if err != nil {
-
l.Error("failed to search for issues", "err", err)
-
return nil, err
-
}
-
l.Debug("searched issues with indexer", "count", len(res.Hits))
-
issues, err = db.GetIssues(s.db, db.FilterIn("id", res.Hits))
-
if err != nil {
-
l.Error("failed to get issues", "err", err)
-
return nil, err
-
}
-
} else {
-
openInt := 0
-
if searchOpts.IsOpen {
-
openInt = 1
-
}
-
issues, err = db.GetIssuesPaginated(
-
s.db,
-
searchOpts.Page,
-
db.FilterEq("repo_at", repo.RepoAt()),
-
db.FilterEq("open", openInt),
-
)
-
if err != nil {
-
l.Error("failed to get issues", "err", err)
-
return nil, err
-
}
-
}
-
-
return issues, nil
-
}
-
-
func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error {
-
l := s.logger.With("method", "EditIssue")
-
sess := session.FromContext(ctx)
-
if sess == nil {
-
l.Error("user session is missing in context")
-
return ErrUnAuthorized
-
}
-
authorDid := sess.Data.AccountDID
-
l = l.With("did", authorDid)
-
-
if err := s.validator.ValidateIssue(issue); err != nil {
-
l.Error("validation error", "err", err)
-
return ErrValidationFail
-
}
-
-
tx, err := s.db.BeginTx(ctx, nil)
-
if err != nil {
-
l.Error("db.BeginTx failed", "err", err)
-
return ErrDatabaseFail
-
}
-
defer tx.Rollback()
-
-
if err := db.PutIssue(tx, issue); err != nil {
-
l.Error("db.PutIssue failed", "err", err)
-
return ErrDatabaseFail
-
}
-
-
atpclient := sess.APIClient()
-
record := issue.AsRecord()
-
-
ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey)
-
if err != nil {
-
l.Error("atproto.RepoGetRecord failed", "err", err)
-
return ErrPDSFail
-
}
-
_, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueNSID,
-
SwapRecord: ex.Cid,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &record,
-
},
-
})
-
if err != nil {
-
l.Error("atproto.RepoPutRecord failed", "err", err)
-
return ErrPDSFail
-
}
-
-
if err = tx.Commit(); err != nil {
-
l.Error("tx.Commit failed", "err", err)
-
return ErrDatabaseFail
-
}
-
-
// TODO: notify PutIssue
-
-
return nil
-
}
-
-
func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error {
-
l := s.logger.With("method", "DeleteIssue")
-
sess := session.FromContext(ctx)
-
if sess == nil {
-
l.Error("user session is missing in context")
-
return ErrUnAuthorized
-
}
-
authorDid := sess.Data.AccountDID
-
l = l.With("did", authorDid)
-
-
tx, err := s.db.BeginTx(ctx, nil)
-
if err != nil {
-
l.Error("db.BeginTx failed", "err", err)
-
return ErrDatabaseFail
-
}
-
defer tx.Rollback()
-
-
if err := db.DeleteIssues(tx, db.FilterEq("id", issue.Id)); err != nil {
-
l.Error("db.DeleteIssues failed", "err", err)
-
return ErrDatabaseFail
-
}
-
-
atpclient := sess.APIClient()
-
_, err = atproto.RepoDeleteRecord(ctx, atpclient, &atproto.RepoDeleteRecord_Input{
-
Collection: tangled.RepoIssueNSID,
-
Repo: issue.Did,
-
Rkey: issue.Rkey,
-
})
-
if err != nil {
-
l.Error("atproto.RepoDeleteRecord failed", "err", err)
-
return ErrPDSFail
-
}
-
-
if err := tx.Commit(); err != nil {
-
l.Error("tx.Commit failed", "err", err)
-
return ErrDatabaseFail
-
}
-
-
s.notifier.DeleteIssue(ctx, issue)
-
return nil
-
}
-15
appview/service/issue/state.go
···
-
package issue
-
-
import (
-
"context"
-
-
"tangled.org/core/appview/models"
-
)
-
-
func (s *Service) CloseIssue(ctx context.Context, iusse *models.Issue) error {
-
panic("unimplemented")
-
}
-
-
func (s *Service) ReopenIssue(ctx context.Context, iusse *models.Issue) error {
-
panic("unimplemented")
-
}
-83
appview/service/repo/repo.go
···
-
package repo
-
-
import (
-
"context"
-
"fmt"
-
"log/slog"
-
"time"
-
-
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/appview/config"
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/models"
-
"tangled.org/core/rbac"
-
"tangled.org/core/tid"
-
)
-
-
type Service struct {
-
logger *slog.Logger
-
config *config.Config
-
db *db.DB
-
enforcer *rbac.Enforcer
-
}
-
-
func NewService(
-
logger *slog.Logger,
-
config *config.Config,
-
db *db.DB,
-
enforcer *rbac.Enforcer,
-
) Service {
-
return Service{
-
logger,
-
config,
-
db,
-
enforcer,
-
}
-
}
-
-
// NewRepo creates a repository
-
// It expects atproto session to be passed in `ctx`
-
func (s *Service) NewRepo(ctx context.Context, name, description, knot string) error {
-
l := s.logger.With("method", "NewRepo")
-
sess := fromContext(ctx)
-
-
ownerDid := sess.Data.AccountDID
-
l = l.With("did", ownerDid)
-
-
repo := models.Repo{
-
Did: ownerDid.String(),
-
Name: name,
-
Knot: knot,
-
Rkey: tid.TID(),
-
Description: description,
-
Created: time.Now(),
-
Labels: s.config.Label.DefaultLabelDefs,
-
}
-
l = l.With("aturi", repo.RepoAt())
-
-
tx, err := s.db.BeginTx(ctx, nil)
-
if err != nil {
-
return fmt.Errorf("db.BeginTx: %w", err)
-
}
-
defer tx.Rollback()
-
-
atpclient := sess.APIClient()
-
_, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{
-
Collection: tangled.RepoNSID,
-
Repo: repo.Did,
-
})
-
if err != nil {
-
return fmt.Errorf("atproto.RepoPutRecord: %w", err)
-
}
-
l.Info("wrote to PDS")
-
-
// knotclient, err := s.oauth.ServiceClient(
-
// )
-
panic("unimplemented")
-
}
-
-
func fromContext(ctx context.Context) oauth.ClientSession {
-
panic("todo")
-
}
-81
appview/service/repo/repoinfo.go
···
-
package repo
-
-
import (
-
"context"
-
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/models"
-
"tangled.org/core/appview/oauth"
-
"tangled.org/core/appview/pages/repoinfo"
-
)
-
-
// GetRepoInfo converts given `Repo` to `RepoInfo` object.
-
// The `user` can be nil.
-
func (s *Service) GetRepoInfo(ctx context.Context, baseRepo *models.Repo, user *oauth.User) (*repoinfo.RepoInfo, error) {
-
var (
-
repoAt = baseRepo.RepoAt()
-
isStarred = false
-
roles = repoinfo.RolesInRepo{}
-
)
-
if user != nil {
-
isStarred = db.GetStarStatus(s.db, user.Did, repoAt)
-
roles.Roles = s.enforcer.GetPermissionsInRepo(user.Did, baseRepo.Knot, baseRepo.DidSlashRepo())
-
}
-
-
stats := baseRepo.RepoStats
-
if stats == nil {
-
starCount, err := db.GetStarCount(s.db, repoAt)
-
if err != nil {
-
return nil, err
-
}
-
issueCount, err := db.GetIssueCount(s.db, repoAt)
-
if err != nil {
-
return nil, err
-
}
-
pullCount, err := db.GetPullCount(s.db, repoAt)
-
if err != nil {
-
return nil, err
-
}
-
stats = &models.RepoStats{
-
StarCount: starCount,
-
IssueCount: issueCount,
-
PullCount: pullCount,
-
}
-
}
-
-
var sourceRepo *models.Repo
-
var err error
-
if baseRepo.Source != "" {
-
sourceRepo, err = db.GetRepoByAtUri(s.db, baseRepo.Source)
-
if err != nil {
-
return nil, err
-
}
-
}
-
-
repoInfo := &repoinfo.RepoInfo{
-
// ok this is basically a models.Repo
-
OwnerDid: baseRepo.Did,
-
OwnerHandle: "", // TODO: shouldn't use
-
Name: baseRepo.Name,
-
Rkey: baseRepo.Rkey,
-
Description: baseRepo.Description,
-
Website: baseRepo.Website,
-
Topics: baseRepo.Topics,
-
Knot: baseRepo.Knot,
-
Spindle: baseRepo.Spindle,
-
Stats: *stats,
-
-
// fork repo upstream
-
Source: sourceRepo,
-
-
// repo path (context)
-
CurrentDir: "",
-
Ref: "",
-
-
// info related to the session
-
IsStarred: isStarred,
-
Roles: roles,
-
}
-
-
return repoInfo, nil
-
}
-29
appview/session/context.go
···
-
package session
-
-
import (
-
"context"
-
-
toauth "tangled.org/core/appview/oauth"
-
)
-
-
type ctxKey struct{}
-
-
func IntoContext(ctx context.Context, sess Session) context.Context {
-
return context.WithValue(ctx, ctxKey{}, &sess)
-
}
-
-
func FromContext(ctx context.Context) *Session {
-
sess, ok := ctx.Value(ctxKey{}).(*Session)
-
if !ok {
-
return nil
-
}
-
return sess
-
}
-
-
func UserFromContext(ctx context.Context) *toauth.User {
-
sess := FromContext(ctx)
-
if sess == nil {
-
return nil
-
}
-
return sess.User()
-
}
-24
appview/session/session.go
···
-
package session
-
-
import (
-
"github.com/bluesky-social/indigo/atproto/auth/oauth"
-
toauth "tangled.org/core/appview/oauth"
-
)
-
-
// Session is a lightweight wrapper over indigo-oauth ClientSession
-
type Session struct {
-
*oauth.ClientSession
-
}
-
-
func New(atSess *oauth.ClientSession) Session {
-
return Session{
-
atSess,
-
}
-
}
-
-
func (s *Session) User() *toauth.User {
-
return &toauth.User{
-
Did: string(s.Data.AccountDID),
-
Pds: s.Data.HostURL,
-
}
-
}
-62
appview/state/legacy_bridge.go
···
-
package state
-
-
import (
-
"log/slog"
-
-
"tangled.org/core/appview/config"
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/indexer"
-
"tangled.org/core/appview/issues"
-
"tangled.org/core/appview/middleware"
-
"tangled.org/core/appview/notify"
-
"tangled.org/core/appview/oauth"
-
"tangled.org/core/appview/pages"
-
"tangled.org/core/appview/validator"
-
"tangled.org/core/idresolver"
-
"tangled.org/core/log"
-
"tangled.org/core/rbac"
-
)
-
-
// Expose exposes private fields in `State`. This is used to bridge between
-
// legacy web routers and new architecture
-
func (s *State) Expose() (
-
*config.Config,
-
*db.DB,
-
*rbac.Enforcer,
-
*idresolver.Resolver,
-
*indexer.Indexer,
-
*slog.Logger,
-
notify.Notifier,
-
*oauth.OAuth,
-
*pages.Pages,
-
*validator.Validator,
-
) {
-
return s.config, s.db, s.enforcer, s.idResolver, s.indexer, s.logger, s.notifier, s.oauth, s.pages, s.validator
-
}
-
-
func (s *State) ExposeIssue() *issues.Issues {
-
return issues.New(
-
s.oauth,
-
s.repoResolver,
-
s.pages,
-
s.idResolver,
-
s.db,
-
s.config,
-
s.notifier,
-
s.validator,
-
s.indexer.Issues,
-
log.SubLogger(s.logger, "issues"),
-
)
-
}
-
-
func (s *State) Middleware() *middleware.Middleware {
-
mw := middleware.New(
-
s.oauth,
-
s.db,
-
s.enforcer,
-
s.repoResolver,
-
s.idResolver,
-
s.pages,
-
)
-
return &mw
-
}
+3 -5
appview/state/profile.go
···
return nil, fmt.Errorf("failed to get string count: %w", err)
}
-
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
+
starredCount, err := db.CountStars(s.db, db.FilterEq("did", did))
if err != nil {
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
}
···
}
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
-
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
+
stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid))
if err != nil {
l.Error("failed to get stars", "err", err)
s.pages.Error500(w)
···
}
var repos []models.Repo
for _, s := range stars {
-
if s.Repo != nil {
-
repos = append(repos, *s.Repo)
-
}
+
repos = append(repos, *s.Repo)
}
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
+9 -13
appview/state/star.go
···
log.Println("created atproto record: ", resp.Uri)
star := &models.Star{
-
StarredByDid: currentUser.Did,
-
RepoAt: subjectUri,
-
Rkey: rkey,
+
Did: currentUser.Did,
+
RepoAt: subjectUri,
+
Rkey: rkey,
}
err = db.AddStar(s.db, star)
···
s.notifier.NewStar(r.Context(), star)
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
IsStarred: true,
-
RepoAt: subjectUri,
-
Stats: models.RepoStats{
-
StarCount: starCount,
-
},
+
SubjectAt: subjectUri,
+
StarCount: starCount,
})
return
···
s.notifier.DeleteStar(r.Context(), star)
-
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
+
s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{
IsStarred: false,
-
RepoAt: subjectUri,
-
Stats: models.RepoStats{
-
StarCount: starCount,
-
},
+
SubjectAt: subjectUri,
+
StarCount: starCount,
})
return
+14 -2
appview/strings/strings.go
···
showRendered = r.URL.Query().Get("code") != "true"
}
+
starCount, err := db.GetStarCount(s.Db, string.AtUri())
+
if err != nil {
+
l.Error("failed to get star count", "err", err)
+
}
+
user := s.OAuth.GetUser(r)
+
isStarred := false
+
if user != nil {
+
isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri())
+
}
+
s.Pages.SingleString(w, pages.SingleStringParams{
-
LoggedInUser: s.OAuth.GetUser(r),
+
LoggedInUser: user,
RenderToggle: renderToggle,
ShowRendered: showRendered,
-
String: string,
+
String: &string,
Stats: string.Stats(),
+
IsStarred: isStarred,
+
StarCount: starCount,
Owner: id,
})
}
-23
appview/web/handler/oauth_client_metadata.go
···
-
package handler
-
-
import (
-
"encoding/json"
-
"net/http"
-
-
"tangled.org/core/appview/oauth"
-
)
-
-
func OauthClientMetadata(o *oauth.OAuth) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
doc := o.ClientApp.Config.ClientMetadata()
-
doc.JWKSURI = &o.JwksUri
-
doc.ClientName = &o.ClientName
-
doc.ClientURI = &o.ClientUri
-
-
w.Header().Set("Content-Type", "application/json")
-
if err := json.NewEncoder(w).Encode(doc); err != nil {
-
http.Error(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
}
-
}
-19
appview/web/handler/oauth_jwks.go
···
-
package handler
-
-
import (
-
"encoding/json"
-
"net/http"
-
-
"tangled.org/core/appview/oauth"
-
)
-
-
func OauthJwks(o *oauth.OAuth) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
w.Header().Set("Content-Type", "application/json")
-
body := o.ClientApp.Config.PublicJWKS()
-
if err := json.NewEncoder(w).Encode(body); err != nil {
-
http.Error(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
}
-
}
-80
appview/web/handler/user_repo_issues.go
···
-
package handler
-
-
import (
-
"net/http"
-
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/models"
-
"tangled.org/core/appview/pages"
-
"tangled.org/core/appview/pagination"
-
isvc "tangled.org/core/appview/service/issue"
-
rsvc "tangled.org/core/appview/service/repo"
-
"tangled.org/core/appview/session"
-
"tangled.org/core/appview/web/request"
-
"tangled.org/core/log"
-
)
-
-
func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx).With("handler", "RepoIssues")
-
repo, ok := request.RepoFromContext(ctx)
-
if !ok {
-
l.Error("malformed request")
-
p.Error503(w)
-
return
-
}
-
-
query := r.URL.Query()
-
searchOpts := models.IssueSearchOptions{
-
RepoAt: repo.RepoAt().String(),
-
Keyword: query.Get("q"),
-
IsOpen: query.Get("state") != "closed",
-
Page: pagination.FromContext(ctx),
-
}
-
-
issues, err := is.GetIssues(ctx, repo, searchOpts)
-
if err != nil {
-
l.Error("failed to get issues")
-
p.Error503(w)
-
return
-
}
-
-
// render page
-
err = func() error {
-
user := session.UserFromContext(ctx)
-
repoinfo, err := rs.GetRepoInfo(ctx, repo, user)
-
if err != nil {
-
return err
-
}
-
labelDefs, err := db.GetLabelDefinitions(
-
d,
-
db.FilterIn("at_uri", repo.Labels),
-
db.FilterContains("scope", tangled.RepoIssueNSID),
-
)
-
if err != nil {
-
return err
-
}
-
defs := make(map[string]*models.LabelDefinition)
-
for _, l := range labelDefs {
-
defs[l.AtUri().String()] = &l
-
}
-
return p.RepoIssues(w, pages.RepoIssuesParams{
-
LoggedInUser: user,
-
RepoInfo: *repoinfo,
-
-
Issues: issues,
-
LabelDefs: defs,
-
FilteringByOpen: searchOpts.IsOpen,
-
FilterQuery: searchOpts.Keyword,
-
Page: searchOpts.Page,
-
})
-
}()
-
if err != nil {
-
l.Error("failed to render", "err", err)
-
p.Error503(w)
-
return
-
}
-
}
-
}
-65
appview/web/handler/user_repo_issues_issue.go
···
-
package handler
-
-
import (
-
"net/http"
-
-
"tangled.org/core/appview/pages"
-
isvc "tangled.org/core/appview/service/issue"
-
rsvc "tangled.org/core/appview/service/repo"
-
"tangled.org/core/appview/session"
-
"tangled.org/core/appview/web/request"
-
"tangled.org/core/log"
-
)
-
-
func Issue(s isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx).With("handler", "Issue")
-
issue, ok := request.IssueFromContext(ctx)
-
if !ok {
-
l.Error("malformed request, failed to get issue")
-
p.Error503(w)
-
return
-
}
-
-
// render
-
err := func() error {
-
user := session.UserFromContext(ctx)
-
repoinfo, err := rs.GetRepoInfo(ctx, issue.Repo, user)
-
if err != nil {
-
return err
-
}
-
return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{
-
LoggedInUser: user,
-
RepoInfo: *repoinfo,
-
Issue: issue,
-
})
-
}()
-
if err != nil {
-
l.Error("failed to render", "err", err)
-
p.Error503(w)
-
return
-
}
-
}
-
}
-
-
func IssueDelete(s isvc.Service, p *pages.Pages) http.HandlerFunc {
-
noticeId := "issue-actions-error"
-
return func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx).With("handler", "IssueDelete")
-
issue, ok := request.IssueFromContext(ctx)
-
if !ok {
-
l.Error("failed to get issue")
-
// TODO: 503 error with more detailed messages
-
p.Error503(w)
-
return
-
}
-
err := s.DeleteIssue(ctx, issue)
-
if err != nil {
-
p.Notice(w, noticeId, "failed to delete issue")
-
return
-
}
-
p.HxLocation(w, "/")
-
}
-
}
-13
appview/web/handler/user_repo_issues_issue_close.go
···
-
package handler
-
-
import (
-
"net/http"
-
-
"tangled.org/core/appview/service/issue"
-
)
-
-
func CloseIssue(s issue.Service) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
panic("unimplemented")
-
}
-
}
-77
appview/web/handler/user_repo_issues_issue_edit.go
···
-
package handler
-
-
import (
-
"errors"
-
"net/http"
-
-
"tangled.org/core/appview/pages"
-
isvc "tangled.org/core/appview/service/issue"
-
rsvc "tangled.org/core/appview/service/repo"
-
"tangled.org/core/appview/session"
-
"tangled.org/core/appview/web/request"
-
"tangled.org/core/log"
-
)
-
-
func IssueEdit(is isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx).With("handler", "IssueEdit")
-
issue, ok := request.IssueFromContext(ctx)
-
if !ok {
-
l.Error("malformed request, failed to get issue")
-
p.Error503(w)
-
return
-
}
-
-
// render
-
err := func() error {
-
user := session.UserFromContext(ctx)
-
repoinfo, err := rs.GetRepoInfo(ctx, issue.Repo, user)
-
if err != nil {
-
return err
-
}
-
return p.EditIssueFragment(w, pages.EditIssueParams{
-
LoggedInUser: user,
-
RepoInfo: *repoinfo,
-
-
Issue: issue,
-
})
-
}()
-
if err != nil {
-
l.Error("failed to render", "err", err)
-
p.Error503(w)
-
return
-
}
-
}
-
}
-
-
func IssueEditPost(is isvc.Service, p *pages.Pages) http.HandlerFunc {
-
noticeId := "issues"
-
return func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx).With("handler", "IssueEdit")
-
issue, ok := request.IssueFromContext(ctx)
-
if !ok {
-
l.Error("malformed request, failed to get issue")
-
p.Error503(w)
-
return
-
}
-
-
newIssue := *issue
-
newIssue.Title = r.FormValue("title")
-
newIssue.Body = r.FormValue("body")
-
-
err := is.EditIssue(ctx, &newIssue)
-
if err != nil {
-
if errors.Is(err, isvc.ErrDatabaseFail) {
-
p.Notice(w, noticeId, "Failed to edit issue.")
-
} else if errors.Is(err, isvc.ErrPDSFail) {
-
p.Notice(w, noticeId, "Failed to edit issue.")
-
} else {
-
p.Notice(w, noticeId, "Failed to edit issue.")
-
}
-
}
-
-
p.HxRefresh(w)
-
}
-
}
-13
appview/web/handler/user_repo_issues_issue_opengraph.go
···
-
package handler
-
-
import (
-
"net/http"
-
-
"tangled.org/core/appview/service/issue"
-
)
-
-
func IssueOpenGraph(s issue.Service) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
panic("unimplemented")
-
}
-
}
-13
appview/web/handler/user_repo_issues_issue_reopen.go
···
-
package handler
-
-
import (
-
"net/http"
-
-
"tangled.org/core/appview/service/issue"
-
)
-
-
func ReopenIssue(s issue.Service) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
panic("unimplemented")
-
}
-
}
-74
appview/web/handler/user_repo_issues_new.go
···
-
package handler
-
-
import (
-
"errors"
-
"fmt"
-
"net/http"
-
-
"tangled.org/core/appview/pages"
-
isvc "tangled.org/core/appview/service/issue"
-
rsvc "tangled.org/core/appview/service/repo"
-
"tangled.org/core/appview/session"
-
"tangled.org/core/appview/web/request"
-
"tangled.org/core/log"
-
)
-
-
func NewIssue(rs rsvc.Service, p *pages.Pages) http.HandlerFunc {
-
return func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx).With("handler", "NewIssue")
-
-
// render
-
err := func() error {
-
user := session.UserFromContext(ctx)
-
repo, ok := request.RepoFromContext(ctx)
-
if !ok {
-
return fmt.Errorf("malformed request")
-
}
-
repoinfo, err := rs.GetRepoInfo(ctx, repo, user)
-
if err != nil {
-
return err
-
}
-
return p.RepoNewIssue(w, pages.RepoNewIssueParams{
-
LoggedInUser: user,
-
RepoInfo: *repoinfo,
-
})
-
}()
-
if err != nil {
-
l.Error("failed to render", "err", err)
-
p.Error503(w)
-
return
-
}
-
}
-
}
-
-
func NewIssuePost(is isvc.Service, p *pages.Pages) http.HandlerFunc {
-
noticeId := "issues"
-
return func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx).With("handler", "NewIssuePost")
-
repo, ok := request.RepoFromContext(ctx)
-
if !ok {
-
l.Error("malformed request, failed to get repo")
-
// TODO: 503 error with more detailed messages
-
p.Error503(w)
-
return
-
}
-
var (
-
title = r.FormValue("title")
-
body = r.FormValue("body")
-
)
-
-
_, err := is.NewIssue(ctx, repo, title, body)
-
if err != nil {
-
if errors.Is(err, isvc.ErrDatabaseFail) {
-
p.Notice(w, noticeId, "Failed to create issue.")
-
} else if errors.Is(err, isvc.ErrPDSFail) {
-
p.Notice(w, noticeId, "Failed to create issue.")
-
} else {
-
p.Notice(w, noticeId, "Failed to create issue.")
-
}
-
}
-
p.HxLocation(w, "/")
-
}
-
}
-67
appview/web/middleware/auth.go
···
-
package middleware
-
-
import (
-
"fmt"
-
"net/http"
-
"net/url"
-
-
"tangled.org/core/appview/oauth"
-
"tangled.org/core/appview/session"
-
"tangled.org/core/log"
-
)
-
-
// WithSession resumes atp session from cookie, ensure it's not malformed and
-
// pass the session through context
-
func WithSession(o *oauth.OAuth) middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
atSess, err := o.ResumeSession(r)
-
if err != nil {
-
next.ServeHTTP(w, r)
-
return
-
}
-
-
sess := session.New(atSess)
-
-
ctx := session.IntoContext(r.Context(), sess)
-
next.ServeHTTP(w, r.WithContext(ctx))
-
})
-
}
-
}
-
-
// AuthMiddleware ensures the request is authorized and redirect to login page
-
// when unauthorized
-
func AuthMiddleware() middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx)
-
-
returnURL := "/"
-
if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
-
returnURL = u.RequestURI()
-
}
-
-
loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL))
-
-
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
-
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
-
}
-
if r.Header.Get("HX-Request") == "true" {
-
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
-
w.Header().Set("HX-Redirect", loginURL)
-
w.WriteHeader(http.StatusOK)
-
}
-
}
-
-
sess := session.FromContext(ctx)
-
if sess == nil {
-
l.Debug("no session, redirecting...")
-
redirectFunc(w, r)
-
return
-
}
-
-
next.ServeHTTP(w, r)
-
})
-
}
-
}
-30
appview/web/middleware/ensuredidorhandle.go
···
-
package middleware
-
-
import (
-
"net/http"
-
-
"github.com/go-chi/chi/v5"
-
"tangled.org/core/appview/pages"
-
"tangled.org/core/appview/state/userutil"
-
)
-
-
// EnsureDidOrHandle ensures the "user" url param is valid did/handle format.
-
// If not, respond with 404
-
func EnsureDidOrHandle(p *pages.Pages) middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
user := chi.URLParam(r, "user")
-
-
// if using a DID or handle, just continue as per usual
-
if userutil.IsDid(user) || userutil.IsHandle(user) {
-
next.ServeHTTP(w, r)
-
return
-
}
-
-
// TODO: run Normalize middleware from here
-
-
p.Error404(w)
-
return
-
})
-
}
-
}
-18
appview/web/middleware/log.go
···
-
package middleware
-
-
import (
-
"log/slog"
-
"net/http"
-
-
"tangled.org/core/log"
-
)
-
-
func WithLogger(l *slog.Logger) middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
// NOTE: can add some metadata here
-
ctx := log.IntoContext(r.Context(), l)
-
next.ServeHTTP(w, r.WithContext(ctx))
-
})
-
}
-
}
-7
appview/web/middleware/middleware.go
···
-
package middleware
-
-
import (
-
"net/http"
-
)
-
-
type middlewareFunc func(http.Handler) http.Handler
-50
appview/web/middleware/normalize.go
···
-
package middleware
-
-
import (
-
"net/http"
-
"strings"
-
-
"github.com/go-chi/chi/v5"
-
"tangled.org/core/appview/state/userutil"
-
)
-
-
func Normalize() middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
pat := chi.URLParam(r, "*")
-
pathParts := strings.SplitN(pat, "/", 2)
-
if len(pathParts) == 0 {
-
next.ServeHTTP(w, r)
-
return
-
}
-
-
firstPart := pathParts[0]
-
-
// if using a flattened DID (like you would in go modules), unflatten
-
if userutil.IsFlattenedDid(firstPart) {
-
unflattenedDid := userutil.UnflattenDid(firstPart)
-
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
-
-
redirectURL := *r.URL
-
redirectURL.Path = "/" + redirectPath
-
-
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
-
return
-
}
-
-
// if using a handle with @, rewrite to work without @
-
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
-
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
-
-
redirectURL := *r.URL
-
redirectURL.Path = "/" + redirectPath
-
-
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
-
return
-
}
-
-
next.ServeHTTP(w, r)
-
return
-
})
-
}
-
}
-38
appview/web/middleware/paginate.go
···
-
package middleware
-
-
import (
-
"log"
-
"net/http"
-
"strconv"
-
-
"tangled.org/core/appview/pagination"
-
)
-
-
func Paginate(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
page := pagination.FirstPage()
-
-
offsetVal := r.URL.Query().Get("offset")
-
if offsetVal != "" {
-
offset, err := strconv.Atoi(offsetVal)
-
if err != nil {
-
log.Println("invalid offset")
-
} else {
-
page.Offset = offset
-
}
-
}
-
-
limitVal := r.URL.Query().Get("limit")
-
if limitVal != "" {
-
limit, err := strconv.Atoi(limitVal)
-
if err != nil {
-
log.Println("invalid limit")
-
} else {
-
page.Limit = limit
-
}
-
}
-
-
ctx := pagination.IntoContext(r.Context(), page)
-
next.ServeHTTP(w, r.WithContext(ctx))
-
})
-
}
-120
appview/web/middleware/resolve.go
···
-
package middleware
-
-
import (
-
"context"
-
"net/http"
-
"strconv"
-
"strings"
-
-
"github.com/go-chi/chi/v5"
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/pages"
-
"tangled.org/core/appview/web/request"
-
"tangled.org/core/idresolver"
-
"tangled.org/core/log"
-
)
-
-
func ResolveIdent(
-
idResolver *idresolver.Resolver,
-
pages *pages.Pages,
-
) middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx)
-
didOrHandle := chi.URLParam(r, "user")
-
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
-
-
id, err := idResolver.ResolveIdent(ctx, didOrHandle)
-
if err != nil {
-
// invalid did or handle
-
l.Warn("failed to resolve did/handle", "handle", didOrHandle, "err", err)
-
pages.Error404(w)
-
return
-
}
-
-
ctx = request.WithOwner(ctx, id)
-
// TODO: reomove this later
-
ctx = context.WithValue(ctx, "resolvedId", *id)
-
-
next.ServeHTTP(w, r.WithContext(ctx))
-
})
-
}
-
}
-
-
func ResolveRepo(
-
e *db.DB,
-
pages *pages.Pages,
-
) middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx)
-
repoName := chi.URLParam(r, "repo")
-
repoOwner, ok := request.OwnerFromContext(ctx)
-
if !ok {
-
l.Error("malformed middleware")
-
w.WriteHeader(http.StatusInternalServerError)
-
return
-
}
-
-
repo, err := db.GetRepo(
-
e,
-
db.FilterEq("did", repoOwner.DID.String()),
-
db.FilterEq("name", repoName),
-
)
-
if err != nil {
-
l.Warn("failed to resolve repo", "err", err)
-
pages.ErrorKnot404(w)
-
return
-
}
-
-
// TODO: pass owner id into repository object
-
-
ctx = request.WithRepo(ctx, repo)
-
// TODO: reomove this later
-
ctx = context.WithValue(ctx, "repo", repo)
-
-
next.ServeHTTP(w, r.WithContext(ctx))
-
})
-
}
-
}
-
-
func ResolveIssue(
-
e *db.DB,
-
pages *pages.Pages,
-
) middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
ctx := r.Context()
-
l := log.FromContext(ctx)
-
issueIdStr := chi.URLParam(r, "issue")
-
issueId, err := strconv.Atoi(issueIdStr)
-
if err != nil {
-
l.Warn("failed to fully resolve issue ID", "err", err)
-
pages.Error404(w)
-
return
-
}
-
repo, ok := request.RepoFromContext(ctx)
-
if !ok {
-
l.Error("malformed middleware")
-
w.WriteHeader(http.StatusInternalServerError)
-
return
-
}
-
-
issue, err := db.GetIssue(e, repo.RepoAt(), issueId)
-
if err != nil {
-
l.Warn("failed to resolve issue", "err", err)
-
pages.ErrorKnot404(w)
-
return
-
}
-
issue.Repo = repo
-
-
ctx = request.WithIssue(ctx, issue)
-
// TODO: reomove this later
-
ctx = context.WithValue(ctx, "issue", issue)
-
-
next.ServeHTTP(w, r.WithContext(ctx))
-
})
-
}
-
}
-39
appview/web/request/context.go
···
-
package request
-
-
import (
-
"context"
-
-
"github.com/bluesky-social/indigo/atproto/identity"
-
"tangled.org/core/appview/models"
-
)
-
-
type ctxKeyOwner struct{}
-
type ctxKeyRepo struct{}
-
type ctxKeyIssue struct{}
-
-
func WithOwner(ctx context.Context, owner *identity.Identity) context.Context {
-
return context.WithValue(ctx, ctxKeyOwner{}, owner)
-
}
-
-
func OwnerFromContext(ctx context.Context) (*identity.Identity, bool) {
-
owner, ok := ctx.Value(ctxKeyOwner{}).(*identity.Identity)
-
return owner, ok
-
}
-
-
func WithRepo(ctx context.Context, repo *models.Repo) context.Context {
-
return context.WithValue(ctx, ctxKeyRepo{}, repo)
-
}
-
-
func RepoFromContext(ctx context.Context) (*models.Repo, bool) {
-
repo, ok := ctx.Value(ctxKeyRepo{}).(*models.Repo)
-
return repo, ok
-
}
-
-
func WithIssue(ctx context.Context, issue *models.Issue) context.Context {
-
return context.WithValue(ctx, ctxKeyIssue{}, issue)
-
}
-
-
func IssueFromContext(ctx context.Context) (*models.Issue, bool) {
-
issue, ok := ctx.Value(ctxKeyIssue{}).(*models.Issue)
-
return issue, ok
-
}
-213
appview/web/routes.go
···
-
package web
-
-
import (
-
"log/slog"
-
"net/http"
-
-
"github.com/go-chi/chi/v5"
-
"tangled.org/core/appview/config"
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/indexer"
-
"tangled.org/core/appview/notify"
-
"tangled.org/core/appview/oauth"
-
"tangled.org/core/appview/pages"
-
isvc "tangled.org/core/appview/service/issue"
-
rsvc "tangled.org/core/appview/service/repo"
-
"tangled.org/core/appview/state"
-
"tangled.org/core/appview/validator"
-
"tangled.org/core/appview/web/handler"
-
"tangled.org/core/appview/web/middleware"
-
"tangled.org/core/idresolver"
-
"tangled.org/core/rbac"
-
)
-
-
// Rules
-
// - Use single function for each endpoints (unless it doesn't make sense.)
-
// - Name handler files following the related path (ancestor paths can be
-
// trimmed.)
-
// - Pass dependencies to each handlers, don't create structs with shared
-
// dependencies unless it serves some domain-specific roles like
-
// service/issue. Same rule goes to middlewares.
-
-
// RouterFromState creates a web router from `state.State`. This exist to
-
// bridge between legacy web routers under `State` and new architecture
-
func RouterFromState(s *state.State) http.Handler {
-
config, db, enforcer, idResolver, indexer, logger, notifier, oauth, pages, validator := s.Expose()
-
-
return Router(
-
logger,
-
config,
-
db,
-
enforcer,
-
idResolver,
-
indexer,
-
notifier,
-
oauth,
-
pages,
-
validator,
-
s,
-
)
-
}
-
-
func Router(
-
// NOTE: put base dependencies (db, idResolver, oauth etc)
-
logger *slog.Logger,
-
config *config.Config,
-
db *db.DB,
-
enforcer *rbac.Enforcer,
-
idResolver *idresolver.Resolver,
-
indexer *indexer.Indexer,
-
notifier notify.Notifier,
-
oauth *oauth.OAuth,
-
pages *pages.Pages,
-
validator *validator.Validator,
-
// to use legacy web handlers. will be removed later
-
s *state.State,
-
) http.Handler {
-
repo := rsvc.NewService(
-
logger,
-
config,
-
db,
-
enforcer,
-
)
-
issue := isvc.NewService(
-
logger,
-
config,
-
db,
-
notifier,
-
idResolver,
-
indexer.Issues,
-
validator,
-
)
-
-
i := s.ExposeIssue()
-
-
r := chi.NewRouter()
-
-
mw := s.Middleware()
-
auth := middleware.AuthMiddleware()
-
-
r.Use(middleware.WithLogger(logger))
-
r.Use(middleware.WithSession(oauth))
-
-
r.Use(middleware.Normalize())
-
-
r.Get("/favicon.svg", s.Favicon)
-
r.Get("/favicon.ico", s.Favicon)
-
r.Get("/pwa-manifest.json", s.PWAManifest)
-
r.Get("/robots.txt", s.RobotsTxt)
-
-
r.Handle("/static/*", pages.Static())
-
-
r.Get("/", s.HomeOrTimeline)
-
r.Get("/timeline", s.Timeline)
-
r.Get("/upgradeBanner", s.UpgradeBanner)
-
-
r.Get("/terms", s.TermsOfService)
-
r.Get("/privacy", s.PrivacyPolicy)
-
r.Get("/brand", s.Brand)
-
// special-case handler for serving tangled.org/core
-
r.Get("/core", s.Core())
-
-
r.Get("/login", s.Login)
-
r.Post("/login", s.Login)
-
r.Post("/logout", s.Logout)
-
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
-
-
r.With(auth).Get("/repo/new", s.NewRepo)
-
r.With(auth).Post("/repo/new", s.NewRepo)
-
-
r.With(auth).Post("/follow", s.Follow)
-
r.With(auth).Delete("/follow", s.Follow)
-
-
r.With(auth).Post("/star", s.Star)
-
r.With(auth).Delete("/star", s.Star)
-
-
r.With(auth).Post("/react", s.React)
-
r.With(auth).Delete("/react", s.React)
-
-
r.With(auth).Get("/profile/edit-bio", s.EditBioFragment)
-
r.With(auth).Get("/profile/edit-pins", s.EditPinsFragment)
-
r.With(auth).Post("/profile/bio", s.UpdateProfileBio)
-
r.With(auth).Post("/profile/pins", s.UpdateProfilePins)
-
-
r.Mount("/settings", s.SettingsRouter())
-
r.Mount("/strings", s.StringsRouter(mw))
-
r.Mount("/knots", s.KnotsRouter())
-
r.Mount("/spindles", s.SpindlesRouter())
-
r.Mount("/notifications", s.NotificationsRouter(mw))
-
-
r.Mount("/signup", s.SignupRouter())
-
r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(oauth))
-
r.Get("/oauth/jwks.json", handler.OauthJwks(oauth))
-
r.Get("/oauth/callback", oauth.Callback)
-
-
// special-case handler. should replace with xrpc later
-
r.Get("/keys/{user}", s.Keys)
-
-
r.HandleFunc("/@*", func(w http.ResponseWriter, r *http.Request) {
-
http.Redirect(w, r, "/"+chi.URLParam(r, "*"), http.StatusFound)
-
})
-
-
r.Route("/{user}", func(r chi.Router) {
-
r.Use(middleware.EnsureDidOrHandle(pages))
-
r.Use(middleware.ResolveIdent(idResolver, pages))
-
-
r.Get("/", s.Profile)
-
r.Get("/feed.atom", s.AtomFeedPage)
-
-
r.Route("/{repo}", func(r chi.Router) {
-
r.Use(middleware.ResolveRepo(db, pages))
-
-
r.Mount("/", s.RepoRouter(mw))
-
-
// /{user}/{repo}/issues/*
-
r.With(middleware.Paginate).Get("/issues", handler.RepoIssues(issue, repo, pages, db))
-
r.With(auth).Get("/issues/new", handler.NewIssue(repo, pages))
-
r.With(auth).Post("/issues/new", handler.NewIssuePost(issue, pages))
-
r.Route("/issues/{issue}", func(r chi.Router) {
-
r.Use(middleware.ResolveIssue(db, pages))
-
-
r.Get("/", handler.Issue(issue, repo, pages))
-
r.Get("/opengraph", i.IssueOpenGraphSummary)
-
-
r.With(auth).Delete("/", handler.IssueDelete(issue, pages))
-
-
r.With(auth).Get("/edit", handler.IssueEdit(issue, repo, pages))
-
r.With(auth).Post("/edit", handler.IssueEditPost(issue, pages))
-
-
// r.With(auth).Post("/close", handler.CloseIssue(issue))
-
// r.With(auth).Post("/reopen", handler.ReopenIssue(issue))
-
-
r.With(auth).Post("/close", i.CloseIssue)
-
r.With(auth).Post("/reopen", i.ReopenIssue)
-
-
r.With(auth).Post("/comment", i.NewIssueComment)
-
r.With(auth).Route("/comment/{commentId}/", func(r chi.Router) {
-
r.Get("/", i.IssueComment)
-
r.Delete("/", i.DeleteIssueComment)
-
r.Get("/edit", i.EditIssueComment)
-
r.Post("/edit", i.EditIssueComment)
-
r.Get("/reply", i.ReplyIssueComment)
-
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
-
})
-
})
-
-
r.Mount("/pulls", s.PullsRouter(mw))
-
r.Mount("/pipelines", s.PipelinesRouter())
-
r.Mount("/labels", s.LabelsRouter())
-
-
// These routes get proxied to the knot
-
r.Get("/info/refs", s.InfoRefs)
-
r.Post("/git-upload-pack", s.UploadPack)
-
r.Post("/git-receive-pack", s.ReceivePack)
-
})
-
})
-
-
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
-
pages.Error404(w)
-
})
-
-
return r
-
}
+1 -2
cmd/appview/main.go
···
"tangled.org/core/appview/config"
"tangled.org/core/appview/state"
-
"tangled.org/core/appview/web"
tlog "tangled.org/core/log"
)
···
logger.Info("starting server", "address", c.Core.ListenAddr)
-
if err := http.ListenAndServe(c.Core.ListenAddr, web.RouterFromState(state)); err != nil {
+
if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil {
logger.Error("failed to start appview", "err", err)
}
}
+1 -1
nix/pkgs/knot-unwrapped.nix
···
sqlite-lib,
src,
}: let
-
version = "1.9.1-alpha";
+
version = "1.11.0-alpha";
in
buildGoApplication {
pname = "knot";
+1 -1
spindle/engines/nixery/engine.go
···
setup := &setupSteps{}
setup.addStep(nixConfStep())
-
setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
+
setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
// this step could be empty
if s := dependencyStep(dwf.Dependencies); s != nil {
setup.addStep(*s)
-73
spindle/engines/nixery/setup_steps.go
···
import (
"fmt"
-
"path"
"strings"
-
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/workflow"
)
func nixConfStep() Step {
···
command: setupCmd,
name: "Configure Nix",
}
-
}
-
-
// cloneOptsAsSteps processes clone options and adds corresponding steps
-
// to the beginning of the workflow's step list if cloning is not skipped.
-
//
-
// the steps to do here are:
-
// - git init
-
// - git remote add origin <url>
-
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
-
// - git checkout FETCH_HEAD
-
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
-
if twf.Clone.Skip {
-
return Step{}
-
}
-
-
var commands []string
-
-
// initialize git repo in workspace
-
commands = append(commands, "git init")
-
-
// add repo as git remote
-
scheme := "https://"
-
if dev {
-
scheme = "http://"
-
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
-
}
-
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
-
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
-
-
// run git fetch
-
{
-
var fetchArgs []string
-
-
// default clone depth is 1
-
depth := 1
-
if twf.Clone.Depth > 1 {
-
depth = int(twf.Clone.Depth)
-
}
-
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
-
-
// optionally recurse submodules
-
if twf.Clone.Submodules {
-
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
-
}
-
-
// set remote to fetch from
-
fetchArgs = append(fetchArgs, "origin")
-
-
// set revision to checkout
-
switch workflow.TriggerKind(tr.Kind) {
-
case workflow.TriggerKindManual:
-
// TODO: unimplemented
-
case workflow.TriggerKindPush:
-
fetchArgs = append(fetchArgs, tr.Push.NewSha)
-
case workflow.TriggerKindPullRequest:
-
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
-
}
-
-
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
-
}
-
-
// run git checkout
-
commands = append(commands, "git checkout FETCH_HEAD")
-
-
cloneStep := Step{
-
command: strings.Join(commands, "\n"),
-
name: "Clone repository into workspace",
-
}
-
return cloneStep
}
// dependencyStep processes dependencies defined in the workflow.
+151
spindle/models/clone.go
···
+
package models
+
+
import (
+
"fmt"
+
"strings"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/workflow"
+
)
+
+
type CloneStep struct {
+
name string
+
kind StepKind
+
commands []string
+
}
+
+
func (s CloneStep) Name() string {
+
return s.name
+
}
+
+
func (s CloneStep) Commands() []string {
+
return s.commands
+
}
+
+
func (s CloneStep) Command() string {
+
return strings.Join(s.commands, "\n")
+
}
+
+
func (s CloneStep) Kind() StepKind {
+
return s.kind
+
}
+
+
// BuildCloneStep generates git clone commands.
+
// The caller must ensure the current working directory is set to the desired
+
// workspace directory before executing these commands.
+
//
+
// The generated commands are:
+
// - git init
+
// - git remote add origin <url>
+
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
+
// - git checkout FETCH_HEAD
+
//
+
// Supports all trigger types (push, PR, manual) and clone options.
+
func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) CloneStep {
+
if twf.Clone != nil && twf.Clone.Skip {
+
return CloneStep{}
+
}
+
+
commitSHA, err := extractCommitSHA(tr)
+
if err != nil {
+
return CloneStep{
+
kind: StepKindSystem,
+
name: "Clone repository into workspace (error)",
+
commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())},
+
}
+
}
+
+
repoURL := buildRepoURL(tr, dev)
+
+
var cloneOpts tangled.Pipeline_CloneOpts
+
if twf.Clone != nil {
+
cloneOpts = *twf.Clone
+
}
+
fetchArgs := buildFetchArgs(cloneOpts, commitSHA)
+
+
return CloneStep{
+
kind: StepKindSystem,
+
name: "Clone repository into workspace",
+
commands: []string{
+
"git init",
+
fmt.Sprintf("git remote add origin %s", repoURL),
+
fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")),
+
"git checkout FETCH_HEAD",
+
},
+
}
+
}
+
+
// extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type
+
func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) {
+
switch workflow.TriggerKind(tr.Kind) {
+
case workflow.TriggerKindPush:
+
if tr.Push == nil {
+
return "", fmt.Errorf("push trigger metadata is nil")
+
}
+
return tr.Push.NewSha, nil
+
+
case workflow.TriggerKindPullRequest:
+
if tr.PullRequest == nil {
+
return "", fmt.Errorf("pull request trigger metadata is nil")
+
}
+
return tr.PullRequest.SourceSha, nil
+
+
case workflow.TriggerKindManual:
+
// Manual triggers don't have an explicit SHA in the metadata
+
// For now, return empty string - could be enhanced to fetch from default branch
+
// TODO: Implement manual trigger SHA resolution (fetch default branch HEAD)
+
return "", nil
+
+
default:
+
return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind)
+
}
+
}
+
+
// buildRepoURL constructs the repository URL from trigger metadata
+
func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string {
+
if tr.Repo == nil {
+
return ""
+
}
+
+
// Determine protocol
+
scheme := "https://"
+
if devMode {
+
scheme = "http://"
+
}
+
+
// Get host from knot
+
host := tr.Repo.Knot
+
+
// In dev mode, replace localhost with host.docker.internal for Docker networking
+
if devMode && strings.Contains(host, "localhost") {
+
host = strings.ReplaceAll(host, "localhost", "host.docker.internal")
+
}
+
+
// Build URL: {scheme}{knot}/{did}/{repo}
+
return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo)
+
}
+
+
// buildFetchArgs constructs the arguments for git fetch based on clone options
+
func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string {
+
args := []string{}
+
+
// Set fetch depth (default to 1 for shallow clone)
+
depth := clone.Depth
+
if depth == 0 {
+
depth = 1
+
}
+
args = append(args, fmt.Sprintf("--depth=%d", depth))
+
+
// Add submodules if requested
+
if clone.Submodules {
+
args = append(args, "--recurse-submodules=yes")
+
}
+
+
// Add remote and SHA
+
args = append(args, "origin")
+
if sha != "" {
+
args = append(args, sha)
+
}
+
+
return args
+
}
+371
spindle/models/clone_test.go
···
+
package models
+
+
import (
+
"strings"
+
"testing"
+
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/workflow"
+
)
+
+
func TestBuildCloneStep_PushTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Submodules: false,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
OldSha: "def456",
+
Ref: "refs/heads/main",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
if step.Kind() != StepKindSystem {
+
t.Errorf("Expected StepKindSystem, got %v", step.Kind())
+
}
+
+
if step.Name() != "Clone repository into workspace" {
+
t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name())
+
}
+
+
commands := step.Commands()
+
if len(commands) != 4 {
+
t.Errorf("Expected 4 commands, got %d", len(commands))
+
}
+
+
// Verify commands contain expected git operations
+
allCmds := strings.Join(commands, " ")
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
if !strings.Contains(allCmds, "git remote add origin") {
+
t.Error("Commands should contain 'git remote add origin'")
+
}
+
if !strings.Contains(allCmds, "git fetch") {
+
t.Error("Commands should contain 'git fetch'")
+
}
+
if !strings.Contains(allCmds, "abc123") {
+
t.Error("Commands should contain commit SHA")
+
}
+
if !strings.Contains(allCmds, "git checkout FETCH_HEAD") {
+
t.Error("Commands should contain 'git checkout FETCH_HEAD'")
+
}
+
if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") {
+
t.Error("Commands should contain expected repo URL")
+
}
+
}
+
+
func TestBuildCloneStep_PullRequestTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPullRequest),
+
PullRequest: &tangled.Pipeline_PullRequestTriggerData{
+
SourceSha: "pr-sha-789",
+
SourceBranch: "feature-branch",
+
TargetBranch: "main",
+
Action: "opened",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "pr-sha-789") {
+
t.Error("Commands should contain PR commit SHA")
+
}
+
}
+
+
func TestBuildCloneStep_ManualTrigger(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindManual),
+
Manual: &tangled.Pipeline_ManualTriggerData{
+
Inputs: nil,
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA
+
allCmds := strings.Join(step.Commands(), " ")
+
// Should still have basic git commands
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
if !strings.Contains(allCmds, "git fetch") {
+
t.Error("Commands should contain 'git fetch'")
+
}
+
}
+
+
func TestBuildCloneStep_SkipFlag(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Skip: true,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Empty step when skip is true
+
if step.Name() != "" {
+
t.Error("Expected empty step name when Skip is true")
+
}
+
if len(step.Commands()) != 0 {
+
t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands()))
+
}
+
}
+
+
func TestBuildCloneStep_DevMode(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "localhost:3000",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, true)
+
+
// In dev mode, should use http:// and replace localhost with host.docker.internal
+
allCmds := strings.Join(step.Commands(), " ")
+
expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo"
+
if !strings.Contains(allCmds, expectedURL) {
+
t.Errorf("Expected dev mode URL '%s' in commands", expectedURL)
+
}
+
}
+
+
func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 10,
+
Submodules: true,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=10") {
+
t.Error("Commands should contain '--depth=10'")
+
}
+
+
if !strings.Contains(allCmds, "--recurse-submodules=yes") {
+
t.Error("Commands should contain '--recurse-submodules=yes'")
+
}
+
}
+
+
func TestBuildCloneStep_DefaultDepth(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 0, // Default should be 1
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=1") {
+
t.Error("Commands should default to '--depth=1'")
+
}
+
}
+
+
func TestBuildCloneStep_NilPushData(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: nil, // Nil push data should create error step
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name when push data is nil")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "Failed to get clone info") {
+
t.Error("Commands should contain error message")
+
}
+
if !strings.Contains(allCmds, "exit 1") {
+
t.Error("Commands should exit with error")
+
}
+
}
+
+
func TestBuildCloneStep_NilPRData(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPullRequest),
+
PullRequest: nil, // Nil PR data should create error step
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name when pull request data is nil")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "Failed to get clone info") {
+
t.Error("Commands should contain error message")
+
}
+
}
+
+
func TestBuildCloneStep_UnknownTriggerKind(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: &tangled.Pipeline_CloneOpts{
+
Depth: 1,
+
Skip: false,
+
},
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: "unknown_trigger",
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should return an error step
+
if !strings.Contains(step.Name(), "error") {
+
t.Error("Expected error in step name for unknown trigger kind")
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "unknown trigger kind") {
+
t.Error("Commands should contain error message about unknown trigger kind")
+
}
+
}
+
+
func TestBuildCloneStep_NilCloneOpts(t *testing.T) {
+
twf := tangled.Pipeline_Workflow{
+
Clone: nil, // Nil clone options should use defaults
+
}
+
tr := tangled.Pipeline_TriggerMetadata{
+
Kind: string(workflow.TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
NewSha: "abc123",
+
},
+
Repo: &tangled.Pipeline_TriggerRepo{
+
Knot: "example.com",
+
Did: "did:plc:user123",
+
Repo: "my-repo",
+
},
+
}
+
+
step := BuildCloneStep(twf, tr, false)
+
+
// Should still work with default options
+
if step.Kind() != StepKindSystem {
+
t.Errorf("Expected StepKindSystem, got %v", step.Kind())
+
}
+
+
allCmds := strings.Join(step.Commands(), " ")
+
if !strings.Contains(allCmds, "--depth=1") {
+
t.Error("Commands should default to '--depth=1' when Clone is nil")
+
}
+
if !strings.Contains(allCmds, "git init") {
+
t.Error("Commands should contain 'git init'")
+
}
+
}
+15 -7
spindle/secrets/openbao.go
···
)
type OpenBaoManager struct {
-
client *vault.Client
-
mountPath string
-
logger *slog.Logger
+
client *vault.Client
+
mountPath string
+
logger *slog.Logger
+
connectionTimeout time.Duration
}
type OpenBaoManagerOpt func(*OpenBaoManager)
···
}
}
+
func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt {
+
return func(v *OpenBaoManager) {
+
v.connectionTimeout = timeout
+
}
+
}
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
// The proxy handles all authentication automatically via Auto-Auth
···
}
manager := &OpenBaoManager{
-
client: client,
-
mountPath: "spindle", // default KV v2 mount path
-
logger: logger,
+
client: client,
+
mountPath: "spindle", // default KV v2 mount path
+
logger: logger,
+
connectionTimeout: 10 * time.Second, // default connection timeout
}
for _, opt := range opts {
···
// testConnection verifies that we can connect to the proxy
func (v *OpenBaoManager) testConnection() error {
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout)
defer cancel()
// try token self-lookup as a quick way to verify proxy works
+5 -2
spindle/secrets/openbao_test.go
···
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
+
// Use shorter timeout for tests to avoid long waits
+
opts := append(tt.opts, WithConnectionTimeout(1*time.Second))
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...)
if tt.expectError {
assert.Error(t, err)
···
// All these will fail because no real proxy is running
// but we can test that the configuration is properly accepted
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
+
// Use shorter timeout for tests to avoid long waits
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second))
assert.Error(t, err) // Expected because no real proxy
assert.Nil(t, manager)
assert.Contains(t, err.Error(), "failed to connect to bao proxy")