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

Compare changes

Choose any two refs to compare.

+8 -6
.air/appview.toml
··· 1 - [build] 2 - cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go" 3 - bin = ";set -o allexport && source .env && set +o allexport; .bin/app" 4 1 root = "." 2 + tmp_dir = "out" 5 3 6 - exclude_regex = [".*_templ.go"] 7 - include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium", "nix"] 4 + [build] 5 + cmd = "go build -o out/appview.out cmd/appview/main.go" 6 + bin = "out/appview.out" 7 + 8 + include_ext = ["go"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 + stop_on_error = true
+11
.air/knot.toml
··· 1 + root = "." 2 + tmp_dir = "out" 3 + 4 + [build] 5 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o out/knot.out cmd/knot/main.go' 6 + bin = "out/knot.out" 7 + args_bin = ["server"] 8 + 9 + include_ext = ["go"] 10 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 11 + stop_on_error = true
-7
.air/knotserver.toml
··· 1 - [build] 2 - cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 - bin = ".bin/knot server" 4 - root = "." 5 - 6 - exclude_regex = [""] 7 - include_ext = ["go", "templ"]
+10
.air/spindle.toml
··· 1 + root = "." 2 + tmp_dir = "out" 3 + 4 + [build] 5 + cmd = "go build -o out/spindle.out cmd/spindle/main.go" 6 + bin = "out/spindle.out" 7 + 8 + include_ext = ["go"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 + stop_on_error = true
+39 -2
appview/db/db.go
··· 569 569 -- indexes for better performance 570 570 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 571 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 572 - create index if not exists idx_stars_created on stars(created); 573 - create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 574 572 `) 575 573 if err != nil { 576 574 return nil, err ··· 1124 1122 runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1125 1123 _, err := tx.Exec(` 1126 1124 alter table notification_preferences add column user_mentioned integer not null default 1; 1125 + `) 1126 + return err 1127 + }) 1128 + 1129 + // remove the foreign key constraints from stars. 1130 + runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error { 1131 + _, err := tx.Exec(` 1132 + create table stars_new ( 1133 + id integer primary key autoincrement, 1134 + did text not null, 1135 + rkey text not null, 1136 + 1137 + subject_at text not null, 1138 + 1139 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1140 + unique(did, rkey), 1141 + unique(did, subject_at) 1142 + ); 1143 + 1144 + insert into stars_new ( 1145 + id, 1146 + did, 1147 + rkey, 1148 + subject_at, 1149 + created 1150 + ) 1151 + select 1152 + id, 1153 + starred_by_did, 1154 + rkey, 1155 + repo_at, 1156 + created 1157 + from stars; 1158 + 1159 + drop table stars; 1160 + alter table stars_new rename to stars; 1161 + 1162 + create index if not exists idx_stars_created on stars(created); 1163 + create index if not exists idx_stars_subject_at_created on stars(subject_at, created); 1127 1164 `) 1128 1165 return err 1129 1166 })
+4 -2
appview/db/pipeline.go
··· 168 168 169 169 // this is a mega query, but the most useful one: 170 170 // get N pipelines, for each one get the latest status of its N workflows 171 - func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 171 + func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) { 172 172 var conditions []string 173 173 var args []any 174 174 for _, filter := range filters { ··· 205 205 join 206 206 triggers t ON p.trigger_id = t.id 207 207 %s 208 - `, whereClause) 208 + order by p.created desc 209 + limit %d 210 + `, whereClause, limit) 209 211 210 212 rows, err := e.Query(query, args...) 211 213 if err != nil {
+3 -3
appview/db/repos.go
··· 208 208 209 209 starCountQuery := fmt.Sprintf( 210 210 `select 211 - repo_at, count(1) 211 + subject_at, count(1) 212 212 from stars 213 - where repo_at in (%s) 214 - group by repo_at`, 213 + where subject_at in (%s) 214 + group by subject_at`, 215 215 inClause, 216 216 ) 217 217 rows, err = e.Query(starCountQuery, args...)
+39 -99
appview/db/star.go
··· 14 14 ) 15 15 16 16 func AddStar(e Execer, star *models.Star) error { 17 - query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 17 + query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 18 18 _, err := e.Exec( 19 19 query, 20 - star.StarredByDid, 20 + star.Did, 21 21 star.RepoAt.String(), 22 22 star.Rkey, 23 23 ) ··· 25 25 } 26 26 27 27 // Get a star record 28 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 28 + func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 29 29 query := ` 30 - select starred_by_did, repo_at, created, rkey 30 + select did, subject_at, created, rkey 31 31 from stars 32 - where starred_by_did = ? and repo_at = ?` 33 - row := e.QueryRow(query, starredByDid, repoAt) 32 + where did = ? and subject_at = ?` 33 + row := e.QueryRow(query, did, subjectAt) 34 34 35 35 var star models.Star 36 36 var created string 37 - err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 37 + err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 38 38 if err != nil { 39 39 return nil, err 40 40 } ··· 51 51 } 52 52 53 53 // Remove a star 54 - func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error { 55 - _, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt) 54 + func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { 55 + _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) 56 56 return err 57 57 } 58 58 59 59 // Remove a star 60 - func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error { 61 - _, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey) 60 + func DeleteStarByRkey(e Execer, did string, rkey string) error { 61 + _, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey) 62 62 return err 63 63 } 64 64 65 - func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 65 + func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) { 66 66 stars := 0 67 67 err := e.QueryRow( 68 - `select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars) 68 + `select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars) 69 69 if err != nil { 70 70 return 0, err 71 71 } ··· 89 89 } 90 90 91 91 query := fmt.Sprintf(` 92 - SELECT repo_at 92 + SELECT subject_at 93 93 FROM stars 94 - WHERE starred_by_did = ? AND repo_at IN (%s) 94 + WHERE did = ? AND subject_at IN (%s) 95 95 `, strings.Join(placeholders, ",")) 96 96 97 97 rows, err := e.Query(query, args...) ··· 118 118 return result, nil 119 119 } 120 120 121 - func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 122 - statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 121 + func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool { 122 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt}) 123 123 if err != nil { 124 124 return false 125 125 } 126 - return statuses[repoAt.String()] 126 + return statuses[subjectAt.String()] 127 127 } 128 128 129 129 // GetStarStatuses returns a map of repo URIs to star status for a given user 130 - func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 131 - return getStarStatuses(e, userDid, repoAts) 130 + func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) { 131 + return getStarStatuses(e, userDid, subjectAts) 132 132 } 133 - func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 133 + 134 + // GetRepoStars return a list of stars each holding target repository. 135 + // If there isn't known repo with starred at-uri, those stars will be ignored. 136 + func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) { 134 137 var conditions []string 135 138 var args []any 136 139 for _, filter := range filters { ··· 149 152 } 150 153 151 154 repoQuery := fmt.Sprintf( 152 - `select starred_by_did, repo_at, created, rkey 155 + `select did, subject_at, created, rkey 153 156 from stars 154 157 %s 155 158 order by created desc ··· 166 169 for rows.Next() { 167 170 var star models.Star 168 171 var created string 169 - err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 172 + err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 170 173 if err != nil { 171 174 return nil, err 172 175 } ··· 197 200 return nil, err 198 201 } 199 202 203 + var repoStars []models.RepoStar 200 204 for _, r := range repos { 201 205 if stars, ok := starMap[string(r.RepoAt())]; ok { 202 - for i := range stars { 203 - stars[i].Repo = &r 206 + for _, star := range stars { 207 + repoStars = append(repoStars, models.RepoStar{ 208 + Star: star, 209 + Repo: &r, 210 + }) 204 211 } 205 212 } 206 213 } 207 214 208 - var stars []models.Star 209 - for _, s := range starMap { 210 - stars = append(stars, s...) 211 - } 212 - 213 - slices.SortFunc(stars, func(a, b models.Star) int { 215 + slices.SortFunc(repoStars, func(a, b models.RepoStar) int { 214 216 if a.Created.After(b.Created) { 215 217 return -1 216 218 } ··· 220 222 return 0 221 223 }) 222 224 223 - return stars, nil 225 + return repoStars, nil 224 226 } 225 227 226 228 func CountStars(e Execer, filters ...filter) (int64, error) { ··· 247 249 return count, nil 248 250 } 249 251 250 - func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 - var stars []models.Star 252 - 253 - rows, err := e.Query(` 254 - select 255 - s.starred_by_did, 256 - s.repo_at, 257 - s.rkey, 258 - s.created, 259 - r.did, 260 - r.name, 261 - r.knot, 262 - r.rkey, 263 - r.created 264 - from stars s 265 - join repos r on s.repo_at = r.at_uri 266 - `) 267 - 268 - if err != nil { 269 - return nil, err 270 - } 271 - defer rows.Close() 272 - 273 - for rows.Next() { 274 - var star models.Star 275 - var repo models.Repo 276 - var starCreatedAt, repoCreatedAt string 277 - 278 - if err := rows.Scan( 279 - &star.StarredByDid, 280 - &star.RepoAt, 281 - &star.Rkey, 282 - &starCreatedAt, 283 - &repo.Did, 284 - &repo.Name, 285 - &repo.Knot, 286 - &repo.Rkey, 287 - &repoCreatedAt, 288 - ); err != nil { 289 - return nil, err 290 - } 291 - 292 - star.Created, err = time.Parse(time.RFC3339, starCreatedAt) 293 - if err != nil { 294 - star.Created = time.Now() 295 - } 296 - repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt) 297 - if err != nil { 298 - repo.Created = time.Now() 299 - } 300 - star.Repo = &repo 301 - 302 - stars = append(stars, star) 303 - } 304 - 305 - if err := rows.Err(); err != nil { 306 - return nil, err 307 - } 308 - 309 - return stars, nil 310 - } 311 - 312 252 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 313 253 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 314 254 // first, get the top repo URIs by star count from the last week 315 255 query := ` 316 256 with recent_starred_repos as ( 317 - select distinct repo_at 257 + select distinct subject_at 318 258 from stars 319 259 where created >= datetime('now', '-7 days') 320 260 ), 321 261 repo_star_counts as ( 322 262 select 323 - s.repo_at, 263 + s.subject_at, 324 264 count(*) as stars_gained_last_week 325 265 from stars s 326 - join recent_starred_repos rsr on s.repo_at = rsr.repo_at 266 + join recent_starred_repos rsr on s.subject_at = rsr.subject_at 327 267 where s.created >= datetime('now', '-7 days') 328 - group by s.repo_at 268 + group by s.subject_at 329 269 ) 330 - select rsc.repo_at 270 + select rsc.subject_at 331 271 from repo_star_counts rsc 332 272 order by rsc.stars_gained_last_week desc 333 273 limit 8
+3 -13
appview/db/timeline.go
··· 146 146 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 147 filters := make([]filter, 0) 148 148 if userIsFollowing != nil { 149 - filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 149 + filters = append(filters, FilterIn("did", userIsFollowing)) 150 150 } 151 151 152 - stars, err := GetStars(e, limit, filters...) 152 + stars, err := GetRepoStars(e, limit, filters...) 153 153 if err != nil { 154 154 return nil, err 155 155 } 156 156 157 - // filter star records without a repo 158 - n := 0 159 - for _, s := range stars { 160 - if s.Repo != nil { 161 - stars[n] = s 162 - n++ 163 - } 164 - } 165 - stars = stars[:n] 166 - 167 157 var repos []models.Repo 168 158 for _, s := range stars { 169 159 repos = append(repos, *s.Repo) ··· 179 169 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 180 170 181 171 events = append(events, models.TimelineEvent{ 182 - Star: &s, 172 + RepoStar: &s, 183 173 EventAt: s.Created, 184 174 IsStarred: isStarred, 185 175 StarCount: starCount,
+3 -3
appview/ingester.go
··· 121 121 return err 122 122 } 123 123 err = db.AddStar(i.Db, &models.Star{ 124 - StarredByDid: did, 125 - RepoAt: subjectUri, 126 - Rkey: e.Commit.RKey, 124 + Did: did, 125 + RepoAt: subjectUri, 126 + Rkey: e.Commit.RKey, 127 127 }) 128 128 case jmodels.CommitOperationDelete: 129 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
+9
appview/issues/issues.go
··· 804 804 return 805 805 } 806 806 807 + totalIssues := 0 808 + if isOpen { 809 + totalIssues = f.RepoStats.IssueCount.Open 810 + } else { 811 + totalIssues = f.RepoStats.IssueCount.Closed 812 + } 813 + 807 814 keyword := params.Get("q") 808 815 809 816 var issues []models.Issue ··· 820 827 return 821 828 } 822 829 l.Debug("searched issues with indexer", "count", len(res.Hits)) 830 + totalIssues = int(res.Total) 823 831 824 832 issues, err = db.GetIssues( 825 833 rp.db, ··· 869 877 LoggedInUser: rp.oauth.GetUser(r), 870 878 RepoInfo: f.RepoInfo(user), 871 879 Issues: issues, 880 + IssueCount: totalIssues, 872 881 LabelDefs: defs, 873 882 FilteringByOpen: isOpen, 874 883 FilterQuery: keyword,
+14 -5
appview/models/star.go
··· 7 7 ) 8 8 9 9 type Star struct { 10 - StarredByDid string 11 - RepoAt syntax.ATURI 12 - Created time.Time 13 - Rkey string 10 + Did string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + } 14 15 15 - // optionally, populate this when querying for reverse mappings 16 + // RepoStar is used for reverse mapping to repos 17 + type RepoStar struct { 18 + Star 16 19 Repo *Repo 17 20 } 21 + 22 + // StringStar is used for reverse mapping to strings 23 + type StringStar struct { 24 + Star 25 + String *String 26 + }
+1 -1
appview/models/string.go
··· 22 22 Edited *time.Time 23 23 } 24 24 25 - func (s *String) StringAt() syntax.ATURI { 25 + func (s *String) AtUri() syntax.ATURI { 26 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 27 } 28 28
+1 -1
appview/models/timeline.go
··· 5 5 type TimelineEvent struct { 6 6 *Repo 7 7 *Follow 8 - *Star 8 + *RepoStar 9 9 10 10 EventAt time.Time 11 11
+6 -1
appview/notify/db/db.go
··· 7 7 "slices" 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 10 11 "tangled.org/core/appview/db" 11 12 "tangled.org/core/appview/models" 12 13 "tangled.org/core/appview/notify" ··· 36 37 } 37 38 38 39 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 40 + if star.RepoAt.Collection().String() != tangled.RepoNSID { 41 + // skip string stars for now 42 + return 43 + } 39 44 var err error 40 45 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 41 46 if err != nil { ··· 43 48 return 44 49 } 45 50 46 - actorDid := syntax.DID(star.StarredByDid) 51 + actorDid := syntax.DID(star.Did) 47 52 recipients := []syntax.DID{syntax.DID(repo.Did)} 48 53 eventType := models.NotificationTypeRepoStarred 49 54 entityType := "repo"
+2 -2
appview/notify/posthog/notifier.go
··· 37 37 38 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 39 39 err := n.client.Enqueue(posthog.Capture{ 40 - DistinctId: star.StarredByDid, 40 + DistinctId: star.Did, 41 41 Event: "star", 42 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 43 43 }) ··· 48 48 49 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 50 50 err := n.client.Enqueue(posthog.Capture{ 51 - DistinctId: star.StarredByDid, 51 + DistinctId: star.Did, 52 52 Event: "unstar", 53 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 54 54 })
+15 -2
appview/oauth/oauth.go
··· 202 202 exp int64 203 203 lxm string 204 204 dev bool 205 + timeout time.Duration 205 206 } 206 207 207 208 type ServiceClientOpt func(*ServiceClientOpts) 209 + 210 + func DefaultServiceClientOpts() ServiceClientOpts { 211 + return ServiceClientOpts{ 212 + timeout: time.Second * 5, 213 + } 214 + } 208 215 209 216 func WithService(service string) ServiceClientOpt { 210 217 return func(s *ServiceClientOpts) { ··· 233 240 } 234 241 } 235 242 243 + func WithTimeout(timeout time.Duration) ServiceClientOpt { 244 + return func(s *ServiceClientOpts) { 245 + s.timeout = timeout 246 + } 247 + } 248 + 236 249 func (s *ServiceClientOpts) Audience() string { 237 250 return fmt.Sprintf("did:web:%s", s.service) 238 251 } ··· 247 260 } 248 261 249 262 func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 250 - opts := ServiceClientOpts{} 263 + opts := DefaultServiceClientOpts() 251 264 for _, o := range os { 252 265 o(&opts) 253 266 } ··· 274 287 }, 275 288 Host: opts.Host(), 276 289 Client: &http.Client{ 277 - Timeout: time.Second * 5, 290 + Timeout: opts.timeout, 278 291 }, 279 292 }, nil 280 293 }
+11 -1
appview/pages/funcmap.go
··· 25 25 "github.com/bluesky-social/indigo/atproto/syntax" 26 26 "github.com/dustin/go-humanize" 27 27 "github.com/go-enry/go-enry/v2" 28 + "github.com/yuin/goldmark" 28 29 "tangled.org/core/appview/filetree" 29 30 "tangled.org/core/appview/pages/markup" 30 31 "tangled.org/core/crypto" ··· 98 99 }, 99 100 "sub": func(a, b int) int { 100 101 return a - b 102 + }, 103 + "mul": func (a, b int) int { 104 + return a * b 105 + }, 106 + "div": func (a, b int) int { 107 + return a / b 108 + }, 109 + "mod": func(a, b int) int { 110 + return a % b 101 111 }, 102 112 "f64": func(a int) float64 { 103 113 return float64(a) ··· 247 257 }, 248 258 "description": func(text string) template.HTML { 249 259 p.rctx.RendererType = markup.RendererTypeDefault 250 - htmlString := p.rctx.RenderMarkdown(text) 260 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 251 261 sanitized := p.rctx.SanitizeDescription(htmlString) 252 262 return template.HTML(sanitized) 253 263 },
+1 -1
appview/pages/markup/extension/atlink.go
··· 89 89 if entering { 90 90 w.WriteString(`<a href="/@`) 91 91 w.WriteString(n.(*AtNode).Handle) 92 - w.WriteString(`" class="mention">`) 92 + w.WriteString(`" class="mention font-bold">`) 93 93 } else { 94 94 w.WriteString("</a>") 95 95 }
+4 -2
appview/pages/markup/markdown.go
··· 78 78 } 79 79 80 80 func (rctx *RenderContext) RenderMarkdown(source string) string { 81 - md := NewMarkdown() 81 + return rctx.RenderMarkdownWith(source, NewMarkdown()) 82 + } 82 83 84 + func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string { 83 85 if rctx != nil { 84 86 var transformers []util.PrioritizedValue 85 87 ··· 247 249 repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 248 250 249 251 query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 250 - url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 252 + url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath) 251 253 252 254 parsedURL := &url.URL{ 253 255 Scheme: scheme,
+9 -6
appview/pages/pages.go
··· 625 625 return p.executePlain("user/fragments/editPins", w, params) 626 626 } 627 627 628 - type RepoStarFragmentParams struct { 628 + type StarBtnFragmentParams struct { 629 629 IsStarred bool 630 - RepoAt syntax.ATURI 631 - Stats models.RepoStats 630 + SubjectAt syntax.ATURI 631 + StarCount int 632 632 } 633 633 634 - func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 635 - return p.executePlain("repo/fragments/repoStar", w, params) 634 + func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 635 + return p.executePlain("fragments/starBtn", w, params) 636 636 } 637 637 638 638 type RepoIndexParams struct { ··· 908 908 RepoInfo repoinfo.RepoInfo 909 909 Active string 910 910 Issues []models.Issue 911 + IssueCount int 911 912 LabelDefs map[string]*models.LabelDefinition 912 913 Page pagination.Page 913 914 FilteringByOpen bool ··· 1376 1377 ShowRendered bool 1377 1378 RenderToggle bool 1378 1379 RenderedContents template.HTML 1379 - String models.String 1380 + String *models.String 1380 1381 Stats models.StringStats 1382 + IsStarred bool 1383 + StarCount int 1381 1384 Owner identity.Identity 1382 1385 } 1383 1386
+28
appview/pages/templates/fragments/starBtn.html
··· 1 + {{ define "fragments/starBtn" }} 2 + <button 3 + id="starBtn" 4 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 + data-star-subject-at="{{ .SubjectAt }}" 6 + {{ if .IsStarred }} 7 + hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 8 + {{ else }} 9 + hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 10 + {{ end }} 11 + 12 + hx-trigger="click" 13 + hx-target="this" 14 + hx-swap="outerHTML" 15 + hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]' 16 + hx-disabled-elt="#starBtn" 17 + > 18 + {{ if .IsStarred }} 19 + {{ i "star" "w-4 h-4 fill-current" }} 20 + {{ else }} 21 + {{ i "star" "w-4 h-4" }} 22 + {{ end }} 23 + <span class="text-sm"> 24 + {{ .StarCount }} 25 + </span> 26 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 + </button> 28 + {{ end }}
+4 -1
appview/pages/templates/layouts/repobase.html
··· 49 49 </div> 50 50 51 51 <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 - {{ template "repo/fragments/repoStar" .RepoInfo }} 52 + {{ template "fragments/starBtn" 53 + (dict "SubjectAt" .RepoInfo.RepoAt 54 + "IsStarred" .RepoInfo.IsStarred 55 + "StarCount" .RepoInfo.Stats.StarCount) }} 53 56 <a 54 57 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 55 58 hx-boost="true"
+3 -1
appview/pages/templates/repo/blob.html
··· 99 99 {{ end }} 100 100 </div> 101 101 {{ else if .BlobView.ContentType.IsCode }} 102 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 102 + <div class="overflow-auto relative"> 103 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 104 + </div> 103 105 {{ end }} 104 106 {{ template "fragments/multiline-select" }} 105 107 {{ end }}
+1 -1
appview/pages/templates/repo/compare/compare.html
··· 17 17 {{ end }} 18 18 19 19 {{ define "mainLayout" }} 20 - <div class="px-1 col-span-full flex flex-col gap-4"> 20 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 21 21 {{ block "contentLayout" . }} 22 22 {{ block "content" . }}{{ end }} 23 23 {{ end }}
+1
appview/pages/templates/repo/fork.html
··· 25 25 value="{{ . }}" 26 26 class="mr-2" 27 27 id="domain-{{ . }}" 28 + {{if eq (len $.Knots) 1}}checked{{end}} 28 29 /> 29 30 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 30 31 </div>
-26
appview/pages/templates/repo/fragments/repoStar.html
··· 1 - {{ define "repo/fragments/repoStar" }} 2 - <button 3 - id="starBtn" 4 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 - {{ if .IsStarred }} 6 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 - {{ else }} 8 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 - {{ end }} 10 - 11 - hx-trigger="click" 12 - hx-target="this" 13 - hx-swap="outerHTML" 14 - hx-disabled-elt="#starBtn" 15 - > 16 - {{ if .IsStarred }} 17 - {{ i "star" "w-4 h-4 fill-current" }} 18 - {{ else }} 19 - {{ i "star" "w-4 h-4" }} 20 - {{ end }} 21 - <span class="text-sm"> 22 - {{ .Stats.StarCount }} 23 - </span> 24 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 - </button> 26 - {{ end }}
+114 -34
appview/pages/templates/repo/issues/issues.html
··· 30 30 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 31 <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 32 <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 - <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 34 - {{ i "search" "w-4 h-4" }} 33 + <div class="flex-1 flex relative"> 34 + <input 35 + 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" 36 + type="text" 37 + name="q" 38 + value="{{ .FilterQuery }}" 39 + placeholder=" " 40 + > 41 + <a 42 + href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 43 + 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" 44 + > 45 + {{ i "x" "w-4 h-4" }} 46 + </a> 35 47 </div> 36 - <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 37 - <a 38 - href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 39 - 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" 48 + <button 49 + type="submit" 50 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 40 51 > 41 - {{ i "x" "w-4 h-4" }} 42 - </a> 52 + {{ i "search" "w-4 h-4" }} 53 + </button> 43 54 </form> 44 55 <div class="sm:row-start-1"> 45 56 {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} ··· 59 70 <div class="mt-2"> 60 71 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 61 72 </div> 62 - {{ block "pagination" . }} {{ end }} 73 + {{if gt .IssueCount .Page.Limit }} 74 + {{ block "pagination" . }} {{ end }} 75 + {{ end }} 63 76 {{ end }} 64 77 65 78 {{ define "pagination" }} 66 - <div class="flex justify-end mt-4 gap-2"> 67 - {{ $currentState := "closed" }} 68 - {{ if .FilteringByOpen }} 69 - {{ $currentState = "open" }} 70 - {{ end }} 79 + <div class="flex justify-center items-center mt-4 gap-2"> 80 + {{ $currentState := "closed" }} 81 + {{ if .FilteringByOpen }} 82 + {{ $currentState = "open" }} 83 + {{ end }} 71 84 85 + {{ $prev := .Page.Previous.Offset }} 86 + {{ $next := .Page.Next.Offset }} 87 + {{ $lastPage := sub .IssueCount (mod .IssueCount .Page.Limit) }} 88 + 89 + <a 90 + class=" 91 + btn flex items-center gap-2 no-underline hover:no-underline 92 + dark:text-white dark:hover:bg-gray-700 93 + {{ if le .Page.Offset 0 }} 94 + cursor-not-allowed opacity-50 95 + {{ end }} 96 + " 72 97 {{ if gt .Page.Offset 0 }} 73 - {{ $prev := .Page.Previous }} 74 - <a 75 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 76 - hx-boost="true" 77 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 78 - > 79 - {{ i "chevron-left" "w-4 h-4" }} 80 - previous 81 - </a> 82 - {{ else }} 83 - <div></div> 98 + hx-boost="true" 99 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 84 100 {{ end }} 101 + > 102 + {{ i "chevron-left" "w-4 h-4" }} 103 + previous 104 + </a> 85 105 106 + <!-- dont show first page if current page is first page --> 107 + {{ if gt .Page.Offset 0 }} 108 + <a 109 + hx-boost="true" 110 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset=0&limit={{ .Page.Limit }}" 111 + > 112 + 1 113 + </a> 114 + {{ end }} 115 + 116 + <!-- if previous page is not first or second page (prev > limit) --> 117 + {{ if gt $prev .Page.Limit }} 118 + <span>...</span> 119 + {{ end }} 120 + 121 + <!-- if previous page is not the first page --> 122 + {{ if gt $prev 0 }} 123 + <a 124 + hx-boost="true" 125 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 126 + > 127 + {{ add (div $prev .Page.Limit) 1 }} 128 + </a> 129 + {{ end }} 130 + 131 + <!-- current page. this is always visible --> 132 + <span class="font-bold"> 133 + {{ add (div .Page.Offset .Page.Limit) 1 }} 134 + </span> 135 + 136 + <!-- if next page is not last page --> 137 + {{ if lt $next $lastPage }} 138 + <a 139 + hx-boost="true" 140 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 141 + > 142 + {{ add (div $next .Page.Limit) 1 }} 143 + </a> 144 + {{ end }} 145 + 146 + <!-- if next page is not second last or last page (next < issues - 2 * limit) --> 147 + {{ if lt ($next) (sub .IssueCount (mul (2) .Page.Limit)) }} 148 + <span>...</span> 149 + {{ end }} 150 + 151 + <!-- if its not the last page --> 152 + {{ if lt .Page.Offset $lastPage }} 153 + <a 154 + hx-boost="true" 155 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $lastPage }}&limit={{ .Page.Limit }}" 156 + > 157 + {{ add (div $lastPage .Page.Limit) 1 }} 158 + </a> 159 + {{ end }} 160 + 161 + <a 162 + class=" 163 + btn flex items-center gap-2 no-underline hover:no-underline 164 + dark:text-white dark:hover:bg-gray-700 165 + {{ if ne (len .Issues) .Page.Limit }} 166 + cursor-not-allowed opacity-50 167 + {{ end }} 168 + " 86 169 {{ if eq (len .Issues) .Page.Limit }} 87 - {{ $next := .Page.Next }} 88 - <a 89 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 90 - hx-boost="true" 91 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 92 - > 93 - next 94 - {{ i "chevron-right" "w-4 h-4" }} 95 - </a> 170 + hx-boost="true" 171 + href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 96 172 {{ end }} 173 + > 174 + next 175 + {{ i "chevron-right" "w-4 h-4" }} 176 + </a> 97 177 </div> 98 178 {{ end }}
+1
appview/pages/templates/repo/new.html
··· 155 155 class="mr-2" 156 156 id="domain-{{ . }}" 157 157 required 158 + {{if eq (len $.Knots) 1}}checked{{end}} 158 159 /> 159 160 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 161 </div>
+3 -3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 2 2 <div id="lines" hx-swap-oob="beforeend"> 3 3 <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 4 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 - <div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div> 6 - <div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div> 5 + <div class="group-open:hidden flex items-center gap-1">{{ i "chevron-right" "w-4 h-4" }} {{ template "stepHeader" . }}</div> 6 + <div class="hidden group-open:flex items-center gap-1">{{ i "chevron-down" "w-4 h-4" }} {{ template "stepHeader" . }}</div> 7 7 </summary> 8 8 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 9 9 </details> ··· 11 11 {{ end }} 12 12 13 13 {{ define "stepHeader" }} 14 - {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 14 + {{ .Name }} 15 15 <span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span> 16 16 {{ end }}
+20 -9
appview/pages/templates/repo/pulls/pulls.html
··· 31 31 "Key" "closed" 32 32 "Value" "closed" 33 33 "Icon" "ban" 34 - "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 34 + "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} 35 35 {{ $values := list $open $merged $closed }} 36 36 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 37 <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 38 <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 - <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 40 - {{ i "search" "w-4 h-4" }} 39 + <div class="flex-1 flex relative"> 40 + <input 41 + 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" 42 + type="text" 43 + name="q" 44 + value="{{ .FilterQuery }}" 45 + placeholder=" " 46 + > 47 + <a 48 + href="?state={{ .FilteringBy.String }}" 49 + 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" 50 + > 51 + {{ i "x" "w-4 h-4" }} 52 + </a> 41 53 </div> 42 - <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 43 - <a 44 - href="?state={{ .FilteringBy.String }}" 45 - 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" 54 + <button 55 + type="submit" 56 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 46 57 > 47 - {{ i "x" "w-4 h-4" }} 48 - </a> 58 + {{ i "search" "w-4 h-4" }} 59 + </button> 49 60 </form> 50 61 <div class="sm:row-start-1"> 51 62 {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+1 -1
appview/pages/templates/repo/settings/general.html
··· 58 58 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 59 </button> 60 60 </div> 61 - <fieldset> 61 + </fieldset> 62 62 </form> 63 63 {{ end }} 64 64
+8 -4
appview/pages/templates/strings/string.html
··· 17 17 <span class="select-none">/</span> 18 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 19 </div> 20 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 21 - <div class="flex gap-2 text-base"> 20 + <div class="flex gap-2 text-base"> 21 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 23 hx-boost="true" 24 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> ··· 37 37 <span class="hidden md:inline">delete</span> 38 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 39 </button> 40 - </div> 41 - {{ end }} 40 + {{ end }} 41 + {{ template "fragments/starBtn" 42 + (dict "SubjectAt" .String.AtUri 43 + "IsStarred" .IsStarred 44 + "StarCount" .StarCount) }} 45 + </div> 42 46 </div> 43 47 <span> 44 48 {{ with .String.Description }}
+1 -2
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 3 3 <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 4 <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 "> 5 5 <div class="flex-1 flex flex-col gap-2"> 6 - <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 6 <p> 8 - Make your first contribution to an open-source project this October. 7 + Make your first contribution to an open-source project. 9 8 <em>good-first-issue</em> helps new contributors find easy ways to 10 9 start contributing to open-source projects. 11 10 </p>
+4 -4
appview/pages/templates/timeline/fragments/timeline.html
··· 52 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 53 </div> 54 54 {{ with $repo }} 55 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 55 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 56 56 {{ end }} 57 57 {{ end }} 58 58 59 59 {{ define "timeline/fragments/starEvent" }} 60 60 {{ $root := index . 0 }} 61 61 {{ $event := index . 1 }} 62 - {{ $star := $event.Star }} 62 + {{ $star := $event.RepoStar }} 63 63 {{ with $star }} 64 - {{ $starrerHandle := resolve .StarredByDid }} 64 + {{ $starrerHandle := resolve .Did }} 65 65 {{ $repoOwnerHandle := resolve .Repo.Did }} 66 66 <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"> 67 67 {{ template "user/fragments/picHandleLink" $starrerHandle }} ··· 72 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 73 </div> 74 74 {{ with .Repo }} 75 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 75 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 76 76 {{ end }} 77 77 {{ end }} 78 78 {{ end }}
+7 -1
appview/pages/templates/user/fragments/editBio.html
··· 26 26 {{ if and .Profile .Profile.Pronouns }} 27 27 {{ $pronouns = .Profile.Pronouns }} 28 28 {{ end }} 29 - <input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}"> 29 + <input 30 + type="text" 31 + class="py-1 px-1 w-full" 32 + name="pronouns" 33 + placeholder="they/them" 34 + value="{{ $pronouns }}" 35 + > 30 36 </div> 31 37 </div> 32 38
+2 -1
appview/pages/templates/user/fragments/repoCard.html
··· 1 1 {{ define "user/fragments/repoCard" }} 2 + {{/* root, repo, fullName [,starButton [,starData]] */}} 2 3 {{ $root := index . 0 }} 3 4 {{ $repo := index . 1 }} 4 5 {{ $fullName := index . 2 }} ··· 29 30 </div> 30 31 {{ if and $starButton $root.LoggedInUser }} 31 32 <div class="shrink-0"> 32 - {{ template "repo/fragments/repoStar" $starData }} 33 + {{ template "fragments/starBtn" $starData }} 33 34 </div> 34 35 {{ end }} 35 36 </div>
+3
appview/pipelines/pipelines.go
··· 82 82 83 83 ps, err := db.GetPipelineStatuses( 84 84 p.db, 85 + 30, 85 86 db.FilterEq("repo_owner", repoInfo.OwnerDid), 86 87 db.FilterEq("repo_name", repoInfo.Name), 87 88 db.FilterEq("knot", repoInfo.Knot), ··· 124 125 125 126 ps, err := db.GetPipelineStatuses( 126 127 p.db, 128 + 1, 127 129 db.FilterEq("repo_owner", repoInfo.OwnerDid), 128 130 db.FilterEq("repo_name", repoInfo.Name), 129 131 db.FilterEq("knot", repoInfo.Knot), ··· 193 195 194 196 ps, err := db.GetPipelineStatuses( 195 197 p.db, 198 + 1, 196 199 db.FilterEq("repo_owner", repoInfo.OwnerDid), 197 200 db.FilterEq("repo_name", repoInfo.Name), 198 201 db.FilterEq("knot", repoInfo.Knot),
+2
appview/pulls/pulls.go
··· 178 178 179 179 ps, err := db.GetPipelineStatuses( 180 180 s.db, 181 + len(shas), 181 182 db.FilterEq("repo_owner", repoInfo.OwnerDid), 182 183 db.FilterEq("repo_name", repoInfo.Name), 183 184 db.FilterEq("knot", repoInfo.Knot), ··· 648 649 repoInfo := f.RepoInfo(user) 649 650 ps, err := db.GetPipelineStatuses( 650 651 s.db, 652 + len(shas), 651 653 db.FilterEq("repo_owner", repoInfo.OwnerDid), 652 654 db.FilterEq("repo_name", repoInfo.Name), 653 655 db.FilterEq("knot", repoInfo.Knot),
+14 -10
appview/repo/compare.go
··· 116 116 } 117 117 118 118 // if user is navigating to one of 119 - // /compare/{base}/{head} 120 119 // /compare/{base}...{head} 121 - base := chi.URLParam(r, "base") 122 - head := chi.URLParam(r, "head") 123 - if base == "" && head == "" { 124 - rest := chi.URLParam(r, "*") // master...feature/xyz 125 - parts := strings.SplitN(rest, "...", 2) 126 - if len(parts) == 2 { 127 - base = parts[0] 128 - head = parts[1] 129 - } 120 + // /compare/{base}/{head} 121 + var base, head string 122 + rest := chi.URLParam(r, "*") 123 + 124 + var parts []string 125 + if strings.Contains(rest, "...") { 126 + parts = strings.SplitN(rest, "...", 2) 127 + } else if strings.Contains(rest, "/") { 128 + parts = strings.SplitN(rest, "/", 2) 129 + } 130 + 131 + if len(parts) == 2 { 132 + base = parts[0] 133 + head = parts[1] 130 134 } 131 135 132 136 base, _ = url.PathUnescape(base)
+2
appview/repo/repo.go
··· 1130 1130 } 1131 1131 defer rollback() 1132 1132 1133 + // TODO: this could coordinate better with the knot to recieve a clone status 1133 1134 client, err := rp.oauth.ServiceClient( 1134 1135 r, 1135 1136 oauth.WithService(targetKnot), 1136 1137 oauth.WithLxm(tangled.RepoCreateNSID), 1137 1138 oauth.WithDev(rp.config.Core.Dev), 1139 + oauth.WithTimeout(time.Second*20), // big repos take time to clone 1138 1140 ) 1139 1141 if err != nil { 1140 1142 l.Error("could not create service client", "err", err)
+1 -14
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "crypto/rand" 5 - "math/big" 6 4 "slices" 7 5 "sort" 8 6 "strings" ··· 90 88 return 91 89 } 92 90 93 - func randomString(n int) string { 94 - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 95 - result := make([]byte, n) 96 - 97 - for i := 0; i < n; i++ { 98 - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 99 - result[i] = letters[n.Int64()] 100 - } 101 - 102 - return string(result) 103 - } 104 - 105 91 // grab pipelines from DB and munge that into a hashmap with commit sha as key 106 92 // 107 93 // golang is so blessed that it requires 35 lines of imperative code for this ··· 118 104 119 105 ps, err := db.GetPipelineStatuses( 120 106 d, 107 + len(shas), 121 108 db.FilterEq("repo_owner", repoInfo.OwnerDid), 122 109 db.FilterEq("repo_name", repoInfo.Name), 123 110 db.FilterEq("knot", repoInfo.Knot),
-1
appview/repo/router.go
··· 61 61 // for example: 62 62 // /compare/master...some/feature 63 63 // /compare/master...example.com:another/feature <- this is a fork 64 - r.Get("/{base}/{head}", rp.Compare) 65 64 r.Get("/*", rp.Compare) 66 65 }) 67 66
+3 -5
appview/state/profile.go
··· 66 66 return nil, fmt.Errorf("failed to get string count: %w", err) 67 67 } 68 68 69 - starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 69 + starredCount, err := db.CountStars(s.db, db.FilterEq("did", did)) 70 70 if err != nil { 71 71 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 72 } ··· 211 211 } 212 212 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 213 213 214 - stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 214 + stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid)) 215 215 if err != nil { 216 216 l.Error("failed to get stars", "err", err) 217 217 s.pages.Error500(w) ··· 219 219 } 220 220 var repos []models.Repo 221 221 for _, s := range stars { 222 - if s.Repo != nil { 223 - repos = append(repos, *s.Repo) 224 - } 222 + repos = append(repos, *s.Repo) 225 223 } 226 224 227 225 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
+1 -1
appview/state/router.go
··· 139 139 // r.Post("/import", s.ImportRepo) 140 140 }) 141 141 142 - r.Get("/goodfirstissues", s.GoodFirstIssues) 142 + r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues) 143 143 144 144 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 145 145 r.Post("/", s.Follow)
+9 -13
appview/state/star.go
··· 57 57 log.Println("created atproto record: ", resp.Uri) 58 58 59 59 star := &models.Star{ 60 - StarredByDid: currentUser.Did, 61 - RepoAt: subjectUri, 62 - Rkey: rkey, 60 + Did: currentUser.Did, 61 + RepoAt: subjectUri, 62 + Rkey: rkey, 63 63 } 64 64 65 65 err = db.AddStar(s.db, star) ··· 75 75 76 76 s.notifier.NewStar(r.Context(), star) 77 77 78 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 79 79 IsStarred: true, 80 - RepoAt: subjectUri, 81 - Stats: models.RepoStats{ 82 - StarCount: starCount, 83 - }, 80 + SubjectAt: subjectUri, 81 + StarCount: starCount, 84 82 }) 85 83 86 84 return ··· 117 115 118 116 s.notifier.DeleteStar(r.Context(), star) 119 117 120 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 118 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 121 119 IsStarred: false, 122 - RepoAt: subjectUri, 123 - Stats: models.RepoStats{ 124 - StarCount: starCount, 125 - }, 120 + SubjectAt: subjectUri, 121 + StarCount: starCount, 126 122 }) 127 123 128 124 return
+14 -2
appview/strings/strings.go
··· 148 148 showRendered = r.URL.Query().Get("code") != "true" 149 149 } 150 150 151 + starCount, err := db.GetStarCount(s.Db, string.AtUri()) 152 + if err != nil { 153 + l.Error("failed to get star count", "err", err) 154 + } 155 + user := s.OAuth.GetUser(r) 156 + isStarred := false 157 + if user != nil { 158 + isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 159 + } 160 + 151 161 s.Pages.SingleString(w, pages.SingleStringParams{ 152 - LoggedInUser: s.OAuth.GetUser(r), 162 + LoggedInUser: user, 153 163 RenderToggle: renderToggle, 154 164 ShowRendered: showRendered, 155 - String: string, 165 + String: &string, 156 166 Stats: string.Stats(), 167 + IsStarred: isStarred, 168 + StarCount: starCount, 157 169 Owner: id, 158 170 }) 159 171 }
+6 -9
flake.nix
··· 184 184 air-watcher = name: arg: 185 185 pkgs.writeShellScriptBin "run" 186 186 '' 187 - ${pkgs.air}/bin/air -c /dev/null \ 188 - -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 189 - -build.bin "./out/${name}.out" \ 190 - -build.args_bin "${arg}" \ 191 - -build.stop_on_error "true" \ 192 - -build.include_ext "go" 187 + export PATH=${pkgs.go}/bin:$PATH 188 + ${pkgs.air}/bin/air -c ./.air/${name}.toml \ 189 + -build.args_bin "${arg}" 193 190 ''; 194 191 tailwind-watcher = 195 192 pkgs.writeShellScriptBin "run" ··· 288 285 }: { 289 286 imports = [./nix/modules/appview.nix]; 290 287 291 - services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 288 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview; 292 289 }; 293 290 nixosModules.knot = { 294 291 lib, ··· 297 294 }: { 298 295 imports = [./nix/modules/knot.nix]; 299 296 300 - services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 297 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot; 301 298 }; 302 299 nixosModules.spindle = { 303 300 lib, ··· 306 303 }: { 307 304 imports = [./nix/modules/spindle.nix]; 308 305 309 - services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 306 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 310 307 }; 311 308 }; 312 309 }
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.1-alpha"; 7 + version = "1.11.0-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+1 -1
spindle/engines/nixery/engine.go
··· 109 109 setup := &setupSteps{} 110 110 111 111 setup.addStep(nixConfStep()) 112 - setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 112 + setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 113 // this step could be empty 114 114 if s := dependencyStep(dwf.Dependencies); s != nil { 115 115 setup.addStep(*s)
-73
spindle/engines/nixery/setup_steps.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "path" 6 5 "strings" 7 - 8 - "tangled.org/core/api/tangled" 9 - "tangled.org/core/workflow" 10 6 ) 11 7 12 8 func nixConfStep() Step { ··· 17 13 command: setupCmd, 18 14 name: "Configure Nix", 19 15 } 20 - } 21 - 22 - // cloneOptsAsSteps processes clone options and adds corresponding steps 23 - // to the beginning of the workflow's step list if cloning is not skipped. 24 - // 25 - // the steps to do here are: 26 - // - git init 27 - // - git remote add origin <url> 28 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 - // - git checkout FETCH_HEAD 30 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 - if twf.Clone.Skip { 32 - return Step{} 33 - } 34 - 35 - var commands []string 36 - 37 - // initialize git repo in workspace 38 - commands = append(commands, "git init") 39 - 40 - // add repo as git remote 41 - scheme := "https://" 42 - if dev { 43 - scheme = "http://" 44 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 - } 46 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 - 49 - // run git fetch 50 - { 51 - var fetchArgs []string 52 - 53 - // default clone depth is 1 54 - depth := 1 55 - if twf.Clone.Depth > 1 { 56 - depth = int(twf.Clone.Depth) 57 - } 58 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 - 60 - // optionally recurse submodules 61 - if twf.Clone.Submodules { 62 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 - } 64 - 65 - // set remote to fetch from 66 - fetchArgs = append(fetchArgs, "origin") 67 - 68 - // set revision to checkout 69 - switch workflow.TriggerKind(tr.Kind) { 70 - case workflow.TriggerKindManual: 71 - // TODO: unimplemented 72 - case workflow.TriggerKindPush: 73 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 - case workflow.TriggerKindPullRequest: 75 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 - } 77 - 78 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 - } 80 - 81 - // run git checkout 82 - commands = append(commands, "git checkout FETCH_HEAD") 83 - 84 - cloneStep := Step{ 85 - command: strings.Join(commands, "\n"), 86 - name: "Clone repository into workspace", 87 - } 88 - return cloneStep 89 16 } 90 17 91 18 // dependencyStep processes dependencies defined in the workflow.
+151
spindle/models/clone.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + type CloneStep struct { 12 + name string 13 + kind StepKind 14 + commands []string 15 + } 16 + 17 + func (s CloneStep) Name() string { 18 + return s.name 19 + } 20 + 21 + func (s CloneStep) Commands() []string { 22 + return s.commands 23 + } 24 + 25 + func (s CloneStep) Command() string { 26 + return strings.Join(s.commands, "\n") 27 + } 28 + 29 + func (s CloneStep) Kind() StepKind { 30 + return s.kind 31 + } 32 + 33 + // BuildCloneStep generates git clone commands. 34 + // The caller must ensure the current working directory is set to the desired 35 + // workspace directory before executing these commands. 36 + // 37 + // The generated commands are: 38 + // - git init 39 + // - git remote add origin <url> 40 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 41 + // - git checkout FETCH_HEAD 42 + // 43 + // Supports all trigger types (push, PR, manual) and clone options. 44 + func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) CloneStep { 45 + if twf.Clone != nil && twf.Clone.Skip { 46 + return CloneStep{} 47 + } 48 + 49 + commitSHA, err := extractCommitSHA(tr) 50 + if err != nil { 51 + return CloneStep{ 52 + kind: StepKindSystem, 53 + name: "Clone repository into workspace (error)", 54 + commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())}, 55 + } 56 + } 57 + 58 + repoURL := buildRepoURL(tr, dev) 59 + 60 + var cloneOpts tangled.Pipeline_CloneOpts 61 + if twf.Clone != nil { 62 + cloneOpts = *twf.Clone 63 + } 64 + fetchArgs := buildFetchArgs(cloneOpts, commitSHA) 65 + 66 + return CloneStep{ 67 + kind: StepKindSystem, 68 + name: "Clone repository into workspace", 69 + commands: []string{ 70 + "git init", 71 + fmt.Sprintf("git remote add origin %s", repoURL), 72 + fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")), 73 + "git checkout FETCH_HEAD", 74 + }, 75 + } 76 + } 77 + 78 + // extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type 79 + func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) { 80 + switch workflow.TriggerKind(tr.Kind) { 81 + case workflow.TriggerKindPush: 82 + if tr.Push == nil { 83 + return "", fmt.Errorf("push trigger metadata is nil") 84 + } 85 + return tr.Push.NewSha, nil 86 + 87 + case workflow.TriggerKindPullRequest: 88 + if tr.PullRequest == nil { 89 + return "", fmt.Errorf("pull request trigger metadata is nil") 90 + } 91 + return tr.PullRequest.SourceSha, nil 92 + 93 + case workflow.TriggerKindManual: 94 + // Manual triggers don't have an explicit SHA in the metadata 95 + // For now, return empty string - could be enhanced to fetch from default branch 96 + // TODO: Implement manual trigger SHA resolution (fetch default branch HEAD) 97 + return "", nil 98 + 99 + default: 100 + return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind) 101 + } 102 + } 103 + 104 + // buildRepoURL constructs the repository URL from trigger metadata 105 + func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string { 106 + if tr.Repo == nil { 107 + return "" 108 + } 109 + 110 + // Determine protocol 111 + scheme := "https://" 112 + if devMode { 113 + scheme = "http://" 114 + } 115 + 116 + // Get host from knot 117 + host := tr.Repo.Knot 118 + 119 + // In dev mode, replace localhost with host.docker.internal for Docker networking 120 + if devMode && strings.Contains(host, "localhost") { 121 + host = strings.ReplaceAll(host, "localhost", "host.docker.internal") 122 + } 123 + 124 + // Build URL: {scheme}{knot}/{did}/{repo} 125 + return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo) 126 + } 127 + 128 + // buildFetchArgs constructs the arguments for git fetch based on clone options 129 + func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string { 130 + args := []string{} 131 + 132 + // Set fetch depth (default to 1 for shallow clone) 133 + depth := clone.Depth 134 + if depth == 0 { 135 + depth = 1 136 + } 137 + args = append(args, fmt.Sprintf("--depth=%d", depth)) 138 + 139 + // Add submodules if requested 140 + if clone.Submodules { 141 + args = append(args, "--recurse-submodules=yes") 142 + } 143 + 144 + // Add remote and SHA 145 + args = append(args, "origin") 146 + if sha != "" { 147 + args = append(args, sha) 148 + } 149 + 150 + return args 151 + }
+371
spindle/models/clone_test.go
··· 1 + package models 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + func TestBuildCloneStep_PushTrigger(t *testing.T) { 12 + twf := tangled.Pipeline_Workflow{ 13 + Clone: &tangled.Pipeline_CloneOpts{ 14 + Depth: 1, 15 + Submodules: false, 16 + Skip: false, 17 + }, 18 + } 19 + tr := tangled.Pipeline_TriggerMetadata{ 20 + Kind: string(workflow.TriggerKindPush), 21 + Push: &tangled.Pipeline_PushTriggerData{ 22 + NewSha: "abc123", 23 + OldSha: "def456", 24 + Ref: "refs/heads/main", 25 + }, 26 + Repo: &tangled.Pipeline_TriggerRepo{ 27 + Knot: "example.com", 28 + Did: "did:plc:user123", 29 + Repo: "my-repo", 30 + }, 31 + } 32 + 33 + step := BuildCloneStep(twf, tr, false) 34 + 35 + if step.Kind() != StepKindSystem { 36 + t.Errorf("Expected StepKindSystem, got %v", step.Kind()) 37 + } 38 + 39 + if step.Name() != "Clone repository into workspace" { 40 + t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name()) 41 + } 42 + 43 + commands := step.Commands() 44 + if len(commands) != 4 { 45 + t.Errorf("Expected 4 commands, got %d", len(commands)) 46 + } 47 + 48 + // Verify commands contain expected git operations 49 + allCmds := strings.Join(commands, " ") 50 + if !strings.Contains(allCmds, "git init") { 51 + t.Error("Commands should contain 'git init'") 52 + } 53 + if !strings.Contains(allCmds, "git remote add origin") { 54 + t.Error("Commands should contain 'git remote add origin'") 55 + } 56 + if !strings.Contains(allCmds, "git fetch") { 57 + t.Error("Commands should contain 'git fetch'") 58 + } 59 + if !strings.Contains(allCmds, "abc123") { 60 + t.Error("Commands should contain commit SHA") 61 + } 62 + if !strings.Contains(allCmds, "git checkout FETCH_HEAD") { 63 + t.Error("Commands should contain 'git checkout FETCH_HEAD'") 64 + } 65 + if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") { 66 + t.Error("Commands should contain expected repo URL") 67 + } 68 + } 69 + 70 + func TestBuildCloneStep_PullRequestTrigger(t *testing.T) { 71 + twf := tangled.Pipeline_Workflow{ 72 + Clone: &tangled.Pipeline_CloneOpts{ 73 + Depth: 1, 74 + Skip: false, 75 + }, 76 + } 77 + tr := tangled.Pipeline_TriggerMetadata{ 78 + Kind: string(workflow.TriggerKindPullRequest), 79 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 80 + SourceSha: "pr-sha-789", 81 + SourceBranch: "feature-branch", 82 + TargetBranch: "main", 83 + Action: "opened", 84 + }, 85 + Repo: &tangled.Pipeline_TriggerRepo{ 86 + Knot: "example.com", 87 + Did: "did:plc:user123", 88 + Repo: "my-repo", 89 + }, 90 + } 91 + 92 + step := BuildCloneStep(twf, tr, false) 93 + 94 + allCmds := strings.Join(step.Commands(), " ") 95 + if !strings.Contains(allCmds, "pr-sha-789") { 96 + t.Error("Commands should contain PR commit SHA") 97 + } 98 + } 99 + 100 + func TestBuildCloneStep_ManualTrigger(t *testing.T) { 101 + twf := tangled.Pipeline_Workflow{ 102 + Clone: &tangled.Pipeline_CloneOpts{ 103 + Depth: 1, 104 + Skip: false, 105 + }, 106 + } 107 + tr := tangled.Pipeline_TriggerMetadata{ 108 + Kind: string(workflow.TriggerKindManual), 109 + Manual: &tangled.Pipeline_ManualTriggerData{ 110 + Inputs: nil, 111 + }, 112 + Repo: &tangled.Pipeline_TriggerRepo{ 113 + Knot: "example.com", 114 + Did: "did:plc:user123", 115 + Repo: "my-repo", 116 + }, 117 + } 118 + 119 + step := BuildCloneStep(twf, tr, false) 120 + 121 + // Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA 122 + allCmds := strings.Join(step.Commands(), " ") 123 + // Should still have basic git commands 124 + if !strings.Contains(allCmds, "git init") { 125 + t.Error("Commands should contain 'git init'") 126 + } 127 + if !strings.Contains(allCmds, "git fetch") { 128 + t.Error("Commands should contain 'git fetch'") 129 + } 130 + } 131 + 132 + func TestBuildCloneStep_SkipFlag(t *testing.T) { 133 + twf := tangled.Pipeline_Workflow{ 134 + Clone: &tangled.Pipeline_CloneOpts{ 135 + Skip: true, 136 + }, 137 + } 138 + tr := tangled.Pipeline_TriggerMetadata{ 139 + Kind: string(workflow.TriggerKindPush), 140 + Push: &tangled.Pipeline_PushTriggerData{ 141 + NewSha: "abc123", 142 + }, 143 + Repo: &tangled.Pipeline_TriggerRepo{ 144 + Knot: "example.com", 145 + Did: "did:plc:user123", 146 + Repo: "my-repo", 147 + }, 148 + } 149 + 150 + step := BuildCloneStep(twf, tr, false) 151 + 152 + // Empty step when skip is true 153 + if step.Name() != "" { 154 + t.Error("Expected empty step name when Skip is true") 155 + } 156 + if len(step.Commands()) != 0 { 157 + t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands())) 158 + } 159 + } 160 + 161 + func TestBuildCloneStep_DevMode(t *testing.T) { 162 + twf := tangled.Pipeline_Workflow{ 163 + Clone: &tangled.Pipeline_CloneOpts{ 164 + Depth: 1, 165 + Skip: false, 166 + }, 167 + } 168 + tr := tangled.Pipeline_TriggerMetadata{ 169 + Kind: string(workflow.TriggerKindPush), 170 + Push: &tangled.Pipeline_PushTriggerData{ 171 + NewSha: "abc123", 172 + }, 173 + Repo: &tangled.Pipeline_TriggerRepo{ 174 + Knot: "localhost:3000", 175 + Did: "did:plc:user123", 176 + Repo: "my-repo", 177 + }, 178 + } 179 + 180 + step := BuildCloneStep(twf, tr, true) 181 + 182 + // In dev mode, should use http:// and replace localhost with host.docker.internal 183 + allCmds := strings.Join(step.Commands(), " ") 184 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 185 + if !strings.Contains(allCmds, expectedURL) { 186 + t.Errorf("Expected dev mode URL '%s' in commands", expectedURL) 187 + } 188 + } 189 + 190 + func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) { 191 + twf := tangled.Pipeline_Workflow{ 192 + Clone: &tangled.Pipeline_CloneOpts{ 193 + Depth: 10, 194 + Submodules: true, 195 + Skip: false, 196 + }, 197 + } 198 + tr := tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + }, 203 + Repo: &tangled.Pipeline_TriggerRepo{ 204 + Knot: "example.com", 205 + Did: "did:plc:user123", 206 + Repo: "my-repo", 207 + }, 208 + } 209 + 210 + step := BuildCloneStep(twf, tr, false) 211 + 212 + allCmds := strings.Join(step.Commands(), " ") 213 + if !strings.Contains(allCmds, "--depth=10") { 214 + t.Error("Commands should contain '--depth=10'") 215 + } 216 + 217 + if !strings.Contains(allCmds, "--recurse-submodules=yes") { 218 + t.Error("Commands should contain '--recurse-submodules=yes'") 219 + } 220 + } 221 + 222 + func TestBuildCloneStep_DefaultDepth(t *testing.T) { 223 + twf := tangled.Pipeline_Workflow{ 224 + Clone: &tangled.Pipeline_CloneOpts{ 225 + Depth: 0, // Default should be 1 226 + Skip: false, 227 + }, 228 + } 229 + tr := tangled.Pipeline_TriggerMetadata{ 230 + Kind: string(workflow.TriggerKindPush), 231 + Push: &tangled.Pipeline_PushTriggerData{ 232 + NewSha: "abc123", 233 + }, 234 + Repo: &tangled.Pipeline_TriggerRepo{ 235 + Knot: "example.com", 236 + Did: "did:plc:user123", 237 + Repo: "my-repo", 238 + }, 239 + } 240 + 241 + step := BuildCloneStep(twf, tr, false) 242 + 243 + allCmds := strings.Join(step.Commands(), " ") 244 + if !strings.Contains(allCmds, "--depth=1") { 245 + t.Error("Commands should default to '--depth=1'") 246 + } 247 + } 248 + 249 + func TestBuildCloneStep_NilPushData(t *testing.T) { 250 + twf := tangled.Pipeline_Workflow{ 251 + Clone: &tangled.Pipeline_CloneOpts{ 252 + Depth: 1, 253 + Skip: false, 254 + }, 255 + } 256 + tr := tangled.Pipeline_TriggerMetadata{ 257 + Kind: string(workflow.TriggerKindPush), 258 + Push: nil, // Nil push data should create error step 259 + Repo: &tangled.Pipeline_TriggerRepo{ 260 + Knot: "example.com", 261 + Did: "did:plc:user123", 262 + Repo: "my-repo", 263 + }, 264 + } 265 + 266 + step := BuildCloneStep(twf, tr, false) 267 + 268 + // Should return an error step 269 + if !strings.Contains(step.Name(), "error") { 270 + t.Error("Expected error in step name when push data is nil") 271 + } 272 + 273 + allCmds := strings.Join(step.Commands(), " ") 274 + if !strings.Contains(allCmds, "Failed to get clone info") { 275 + t.Error("Commands should contain error message") 276 + } 277 + if !strings.Contains(allCmds, "exit 1") { 278 + t.Error("Commands should exit with error") 279 + } 280 + } 281 + 282 + func TestBuildCloneStep_NilPRData(t *testing.T) { 283 + twf := tangled.Pipeline_Workflow{ 284 + Clone: &tangled.Pipeline_CloneOpts{ 285 + Depth: 1, 286 + Skip: false, 287 + }, 288 + } 289 + tr := tangled.Pipeline_TriggerMetadata{ 290 + Kind: string(workflow.TriggerKindPullRequest), 291 + PullRequest: nil, // Nil PR data should create error step 292 + Repo: &tangled.Pipeline_TriggerRepo{ 293 + Knot: "example.com", 294 + Did: "did:plc:user123", 295 + Repo: "my-repo", 296 + }, 297 + } 298 + 299 + step := BuildCloneStep(twf, tr, false) 300 + 301 + // Should return an error step 302 + if !strings.Contains(step.Name(), "error") { 303 + t.Error("Expected error in step name when pull request data is nil") 304 + } 305 + 306 + allCmds := strings.Join(step.Commands(), " ") 307 + if !strings.Contains(allCmds, "Failed to get clone info") { 308 + t.Error("Commands should contain error message") 309 + } 310 + } 311 + 312 + func TestBuildCloneStep_UnknownTriggerKind(t *testing.T) { 313 + twf := tangled.Pipeline_Workflow{ 314 + Clone: &tangled.Pipeline_CloneOpts{ 315 + Depth: 1, 316 + Skip: false, 317 + }, 318 + } 319 + tr := tangled.Pipeline_TriggerMetadata{ 320 + Kind: "unknown_trigger", 321 + Repo: &tangled.Pipeline_TriggerRepo{ 322 + Knot: "example.com", 323 + Did: "did:plc:user123", 324 + Repo: "my-repo", 325 + }, 326 + } 327 + 328 + step := BuildCloneStep(twf, tr, false) 329 + 330 + // Should return an error step 331 + if !strings.Contains(step.Name(), "error") { 332 + t.Error("Expected error in step name for unknown trigger kind") 333 + } 334 + 335 + allCmds := strings.Join(step.Commands(), " ") 336 + if !strings.Contains(allCmds, "unknown trigger kind") { 337 + t.Error("Commands should contain error message about unknown trigger kind") 338 + } 339 + } 340 + 341 + func TestBuildCloneStep_NilCloneOpts(t *testing.T) { 342 + twf := tangled.Pipeline_Workflow{ 343 + Clone: nil, // Nil clone options should use defaults 344 + } 345 + tr := tangled.Pipeline_TriggerMetadata{ 346 + Kind: string(workflow.TriggerKindPush), 347 + Push: &tangled.Pipeline_PushTriggerData{ 348 + NewSha: "abc123", 349 + }, 350 + Repo: &tangled.Pipeline_TriggerRepo{ 351 + Knot: "example.com", 352 + Did: "did:plc:user123", 353 + Repo: "my-repo", 354 + }, 355 + } 356 + 357 + step := BuildCloneStep(twf, tr, false) 358 + 359 + // Should still work with default options 360 + if step.Kind() != StepKindSystem { 361 + t.Errorf("Expected StepKindSystem, got %v", step.Kind()) 362 + } 363 + 364 + allCmds := strings.Join(step.Commands(), " ") 365 + if !strings.Contains(allCmds, "--depth=1") { 366 + t.Error("Commands should default to '--depth=1' when Clone is nil") 367 + } 368 + if !strings.Contains(allCmds, "git init") { 369 + t.Error("Commands should contain 'git init'") 370 + } 371 + }
+15 -7
spindle/secrets/openbao.go
··· 13 13 ) 14 14 15 15 type OpenBaoManager struct { 16 - client *vault.Client 17 - mountPath string 18 - logger *slog.Logger 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + connectionTimeout time.Duration 19 20 } 20 21 21 22 type OpenBaoManagerOpt func(*OpenBaoManager) ··· 26 27 } 27 28 } 28 29 30 + func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt { 31 + return func(v *OpenBaoManager) { 32 + v.connectionTimeout = timeout 33 + } 34 + } 35 + 29 36 // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 37 // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 38 // The proxy handles all authentication automatically via Auto-Auth ··· 43 50 } 44 51 45 52 manager := &OpenBaoManager{ 46 - client: client, 47 - mountPath: "spindle", // default KV v2 mount path 48 - logger: logger, 53 + client: client, 54 + mountPath: "spindle", // default KV v2 mount path 55 + logger: logger, 56 + connectionTimeout: 10 * time.Second, // default connection timeout 49 57 } 50 58 51 59 for _, opt := range opts { ··· 62 70 63 71 // testConnection verifies that we can connect to the proxy 64 72 func (v *OpenBaoManager) testConnection() error { 65 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 73 + ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout) 66 74 defer cancel() 67 75 68 76 // try token self-lookup as a quick way to verify proxy works
+5 -2
spindle/secrets/openbao_test.go
··· 152 152 for _, tt := range tests { 153 153 t.Run(tt.name, func(t *testing.T) { 154 154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 155 + // Use shorter timeout for tests to avoid long waits 156 + opts := append(tt.opts, WithConnectionTimeout(1*time.Second)) 157 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...) 156 158 157 159 if tt.expectError { 158 160 assert.Error(t, err) ··· 596 598 597 599 // All these will fail because no real proxy is running 598 600 // but we can test that the configuration is properly accepted 599 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 601 + // Use shorter timeout for tests to avoid long waits 602 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second)) 600 603 assert.Error(t, err) // Expected because no real proxy 601 604 assert.Nil(t, manager) 602 605 assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+21
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 + "encoding/json" 5 + 4 6 "github.com/bluekeyes/go-gitdiff/gitdiff" 5 7 "github.com/go-git/go-git/v5/plumbing/object" 6 8 ) ··· 67 69 Reference `json:"reference"` 68 70 Commit *object.Commit `json:"commit,omitempty"` 69 71 IsDefault bool `json:"is_default,omitempty"` 72 + } 73 + 74 + func (b *Branch) UnmarshalJSON(data []byte) error { 75 + aux := &struct { 76 + Reference `json:"reference"` 77 + Commit *object.Commit `json:"commit,omitempty"` 78 + IsDefault bool `json:"is_default,omitempty"` 79 + MispelledIsDefault bool `json:"is_deafult,omitempty"` // mispelled name 80 + }{} 81 + 82 + if err := json.Unmarshal(data, aux); err != nil { 83 + return err 84 + } 85 + 86 + b.Reference = aux.Reference 87 + b.Commit = aux.Commit 88 + b.IsDefault = aux.IsDefault || aux.MispelledIsDefault // whichever was set 89 + 90 + return nil 70 91 } 71 92 72 93 type RepoTagsResponse struct {
+61 -1
types/tree.go
··· 1 1 package types 2 2 3 3 import ( 4 + "fmt" 5 + "os" 4 6 "time" 5 7 6 8 "github.com/go-git/go-git/v5/plumbing" ··· 18 20 } 19 21 20 22 func (t *NiceTree) FileMode() (filemode.FileMode, error) { 21 - return filemode.New(t.Mode) 23 + if numericMode, err := filemode.New(t.Mode); err == nil { 24 + return numericMode, nil 25 + } 26 + 27 + // TODO: this is here for backwards compat, can be removed in future versions 28 + osMode, err := parseModeString(t.Mode) 29 + if err != nil { 30 + return filemode.Empty, nil 31 + } 32 + 33 + conv, err := filemode.NewFromOSFileMode(osMode) 34 + if err != nil { 35 + return filemode.Empty, nil 36 + } 37 + 38 + return conv, nil 39 + } 40 + 41 + // ParseFileModeString parses a file mode string like "-rw-r--r--" 42 + // and returns an os.FileMode 43 + func parseModeString(modeStr string) (os.FileMode, error) { 44 + if len(modeStr) != 10 { 45 + return 0, fmt.Errorf("invalid mode string length: expected 10, got %d", len(modeStr)) 46 + } 47 + 48 + var mode os.FileMode 49 + 50 + // Parse file type (first character) 51 + switch modeStr[0] { 52 + case 'd': 53 + mode |= os.ModeDir 54 + case 'l': 55 + mode |= os.ModeSymlink 56 + case '-': 57 + // regular file 58 + default: 59 + return 0, fmt.Errorf("unknown file type: %c", modeStr[0]) 60 + } 61 + 62 + // parse permissions for owner, group, and other 63 + perms := modeStr[1:] 64 + shifts := []int{6, 3, 0} // bit shifts for owner, group, other 65 + 66 + for i := range 3 { 67 + offset := i * 3 68 + shift := shifts[i] 69 + 70 + if perms[offset] == 'r' { 71 + mode |= os.FileMode(4 << shift) 72 + } 73 + if perms[offset+1] == 'w' { 74 + mode |= os.FileMode(2 << shift) 75 + } 76 + if perms[offset+2] == 'x' { 77 + mode |= os.FileMode(1 << shift) 78 + } 79 + } 80 + 81 + return mode, nil 22 82 } 23 83 24 84 func (t *NiceTree) IsFile() bool {