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

Compare changes

Choose any two refs to compare.

Changed files
+773 -287
appview
db
models
notify
db
posthog
oauth
pages
templates
fragments
layouts
repo
strings
timeline
user
fragments
repo
state
strings
spindle
+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 })
+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)
+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 }
+8 -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 { ··· 1376 1376 ShowRendered bool 1377 1377 RenderToggle bool 1378 1378 RenderedContents template.HTML 1379 - String models.String 1379 + String *models.String 1380 1380 Stats models.StringStats 1381 + IsStarred bool 1382 + StarCount int 1381 1383 Owner identity.Identity 1382 1384 } 1383 1385
+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"
+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 }}
+19 -8
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) }}
+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>
+19 -8
appview/pages/templates/repo/pulls/pulls.html
··· 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) }}
+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>
+5 -5
appview/pages/templates/timeline/fragments/timeline.html
··· 14 14 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 15 {{ if .Repo }} 16 16 {{ template "timeline/fragments/repoEvent" (list $ .) }} 17 - {{ else if .Star }} 17 + {{ else if .RepoStar }} 18 18 {{ template "timeline/fragments/starEvent" (list $ .) }} 19 19 {{ else if .Follow }} 20 20 {{ template "timeline/fragments/followEvent" (list $ .) }} ··· 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 }}
+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>
+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)
+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{
+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 }
+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 + }