Compare changes

Choose any two refs to compare.

Changed files
+1030 -296
api
appview
db
models
notify
db
posthog
pages
markup
templates
fragments
layouts
repo
fragments
issues
pulls
strings
timeline
fragments
user
state
strings
cmd
cborgen
docs
spindle
lexicons
pipeline
spindle
workflow
+168 -1
api/tangled/cbor_gen.go
··· 4254 4255 return nil 4256 } 4257 func (t *Pipeline_PullRequestTriggerData) MarshalCBOR(w io.Writer) error { 4258 if t == nil { 4259 _, err := w.Write(cbg.CborNull) ··· 4993 } 4994 4995 cw := cbg.NewCborWriter(w) 4996 - fieldCount := 5 4997 4998 if t.Manual == nil { 4999 fieldCount-- ··· 5004 } 5005 5006 if t.Push == nil { 5007 fieldCount-- 5008 } 5009 ··· 5088 } 5089 } 5090 5091 // t.PullRequest (tangled.Pipeline_PullRequestTriggerData) (struct) 5092 if t.PullRequest != nil { 5093 ··· 5217 t.Manual = new(Pipeline_ManualTriggerData) 5218 if err := t.Manual.UnmarshalCBOR(cr); err != nil { 5219 return xerrors.Errorf("unmarshaling t.Manual pointer: %w", err) 5220 } 5221 } 5222
··· 4254 4255 return nil 4256 } 4257 + func (t *Pipeline_ScheduleTriggerData) MarshalCBOR(w io.Writer) error { 4258 + if t == nil { 4259 + _, err := w.Write(cbg.CborNull) 4260 + return err 4261 + } 4262 + 4263 + cw := cbg.NewCborWriter(w) 4264 + fieldCount := 1 4265 + 4266 + if t.CronExpression == nil { 4267 + fieldCount-- 4268 + } 4269 + 4270 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 4271 + return err 4272 + } 4273 + 4274 + // t.CronExpression (string) (string) 4275 + if t.CronExpression != nil { 4276 + 4277 + if len("cronExpression") > 1000000 { 4278 + return xerrors.Errorf("Value in field \"cronExpression\" was too long") 4279 + } 4280 + 4281 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("cronExpression"))); err != nil { 4282 + return err 4283 + } 4284 + if _, err := cw.WriteString(string("cronExpression")); err != nil { 4285 + return err 4286 + } 4287 + 4288 + if t.CronExpression == nil { 4289 + if _, err := cw.Write(cbg.CborNull); err != nil { 4290 + return err 4291 + } 4292 + } else { 4293 + if len(*t.CronExpression) > 1000000 { 4294 + return xerrors.Errorf("Value in field t.CronExpression was too long") 4295 + } 4296 + 4297 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CronExpression))); err != nil { 4298 + return err 4299 + } 4300 + if _, err := cw.WriteString(string(*t.CronExpression)); err != nil { 4301 + return err 4302 + } 4303 + } 4304 + } 4305 + return nil 4306 + } 4307 + 4308 + func (t *Pipeline_ScheduleTriggerData) UnmarshalCBOR(r io.Reader) (err error) { 4309 + *t = Pipeline_ScheduleTriggerData{} 4310 + 4311 + cr := cbg.NewCborReader(r) 4312 + 4313 + maj, extra, err := cr.ReadHeader() 4314 + if err != nil { 4315 + return err 4316 + } 4317 + defer func() { 4318 + if err == io.EOF { 4319 + err = io.ErrUnexpectedEOF 4320 + } 4321 + }() 4322 + 4323 + if maj != cbg.MajMap { 4324 + return fmt.Errorf("cbor input should be of type map") 4325 + } 4326 + 4327 + if extra > cbg.MaxLength { 4328 + return fmt.Errorf("Pipeline_ScheduleTriggerData: map struct too large (%d)", extra) 4329 + } 4330 + 4331 + n := extra 4332 + 4333 + nameBuf := make([]byte, 14) 4334 + for i := uint64(0); i < n; i++ { 4335 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4336 + if err != nil { 4337 + return err 4338 + } 4339 + 4340 + if !ok { 4341 + // Field doesn't exist on this type, so ignore it 4342 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4343 + return err 4344 + } 4345 + continue 4346 + } 4347 + 4348 + switch string(nameBuf[:nameLen]) { 4349 + // t.CronExpression (string) (string) 4350 + case "cronExpression": 4351 + 4352 + { 4353 + b, err := cr.ReadByte() 4354 + if err != nil { 4355 + return err 4356 + } 4357 + if b != cbg.CborNull[0] { 4358 + if err := cr.UnreadByte(); err != nil { 4359 + return err 4360 + } 4361 + 4362 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4363 + if err != nil { 4364 + return err 4365 + } 4366 + 4367 + t.CronExpression = (*string)(&sval) 4368 + } 4369 + } 4370 + 4371 + default: 4372 + // Field doesn't exist on this type, so ignore it 4373 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4374 + return err 4375 + } 4376 + } 4377 + } 4378 + 4379 + return nil 4380 + } 4381 func (t *Pipeline_PullRequestTriggerData) MarshalCBOR(w io.Writer) error { 4382 if t == nil { 4383 _, err := w.Write(cbg.CborNull) ··· 5117 } 5118 5119 cw := cbg.NewCborWriter(w) 5120 + fieldCount := 6 5121 5122 if t.Manual == nil { 5123 fieldCount-- ··· 5128 } 5129 5130 if t.Push == nil { 5131 + fieldCount-- 5132 + } 5133 + 5134 + if t.Schedule == nil { 5135 fieldCount-- 5136 } 5137 ··· 5216 } 5217 } 5218 5219 + // t.Schedule (tangled.Pipeline_ScheduleTriggerData) (struct) 5220 + if t.Schedule != nil { 5221 + 5222 + if len("schedule") > 1000000 { 5223 + return xerrors.Errorf("Value in field \"schedule\" was too long") 5224 + } 5225 + 5226 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("schedule"))); err != nil { 5227 + return err 5228 + } 5229 + if _, err := cw.WriteString(string("schedule")); err != nil { 5230 + return err 5231 + } 5232 + 5233 + if err := t.Schedule.MarshalCBOR(cw); err != nil { 5234 + return err 5235 + } 5236 + } 5237 + 5238 // t.PullRequest (tangled.Pipeline_PullRequestTriggerData) (struct) 5239 if t.PullRequest != nil { 5240 ··· 5364 t.Manual = new(Pipeline_ManualTriggerData) 5365 if err := t.Manual.UnmarshalCBOR(cr); err != nil { 5366 return xerrors.Errorf("unmarshaling t.Manual pointer: %w", err) 5367 + } 5368 + } 5369 + 5370 + } 5371 + // t.Schedule (tangled.Pipeline_ScheduleTriggerData) (struct) 5372 + case "schedule": 5373 + 5374 + { 5375 + 5376 + b, err := cr.ReadByte() 5377 + if err != nil { 5378 + return err 5379 + } 5380 + if b != cbg.CborNull[0] { 5381 + if err := cr.UnreadByte(); err != nil { 5382 + return err 5383 + } 5384 + t.Schedule = new(Pipeline_ScheduleTriggerData) 5385 + if err := t.Schedule.UnmarshalCBOR(cr); err != nil { 5386 + return xerrors.Errorf("unmarshaling t.Schedule pointer: %w", err) 5387 } 5388 } 5389
+6
api/tangled/tangledpipeline.go
··· 55 Ref string `json:"ref" cborgen:"ref"` 56 } 57 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 59 type Pipeline_TriggerMetadata struct { 60 Kind string `json:"kind" cborgen:"kind"` ··· 62 PullRequest *Pipeline_PullRequestTriggerData `json:"pullRequest,omitempty" cborgen:"pullRequest,omitempty"` 63 Push *Pipeline_PushTriggerData `json:"push,omitempty" cborgen:"push,omitempty"` 64 Repo *Pipeline_TriggerRepo `json:"repo" cborgen:"repo"` 65 } 66 67 // Pipeline_TriggerRepo is a "triggerRepo" in the sh.tangled.pipeline schema.
··· 55 Ref string `json:"ref" cborgen:"ref"` 56 } 57 58 + // Pipeline_ScheduleTriggerData is a "scheduleTriggerData" in the sh.tangled.pipeline schema. 59 + type Pipeline_ScheduleTriggerData struct { 60 + CronExpression *string `json:"cronExpression,omitempty" cborgen:"cronExpression,omitempty"` 61 + } 62 + 63 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 64 type Pipeline_TriggerMetadata struct { 65 Kind string `json:"kind" cborgen:"kind"` ··· 67 PullRequest *Pipeline_PullRequestTriggerData `json:"pullRequest,omitempty" cborgen:"pullRequest,omitempty"` 68 Push *Pipeline_PushTriggerData `json:"push,omitempty" cborgen:"push,omitempty"` 69 Repo *Pipeline_TriggerRepo `json:"repo" cborgen:"repo"` 70 + Schedule *Pipeline_ScheduleTriggerData `json:"schedule,omitempty" cborgen:"schedule,omitempty"` 71 } 72 73 // Pipeline_TriggerRepo is a "triggerRepo" in the sh.tangled.pipeline schema.
+39 -2
appview/db/db.go
··· 569 -- indexes for better performance 570 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 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 `) 575 if err != nil { 576 return nil, err ··· 1124 runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1125 _, err := tx.Exec(` 1126 alter table notification_preferences add column user_mentioned integer not null default 1; 1127 `) 1128 return err 1129 })
··· 569 -- indexes for better performance 570 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 572 `) 573 if err != nil { 574 return nil, err ··· 1122 runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1123 _, err := tx.Exec(` 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); 1164 `) 1165 return err 1166 })
+3 -3
appview/db/repos.go
··· 208 209 starCountQuery := fmt.Sprintf( 210 `select 211 - repo_at, count(1) 212 from stars 213 - where repo_at in (%s) 214 - group by repo_at`, 215 inClause, 216 ) 217 rows, err = e.Query(starCountQuery, args...)
··· 208 209 starCountQuery := fmt.Sprintf( 210 `select 211 + subject_at, count(1) 212 from stars 213 + where subject_at in (%s) 214 + group by subject_at`, 215 inClause, 216 ) 217 rows, err = e.Query(starCountQuery, args...)
+39 -99
appview/db/star.go
··· 14 ) 15 16 func AddStar(e Execer, star *models.Star) error { 17 - query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 18 _, err := e.Exec( 19 query, 20 - star.StarredByDid, 21 star.RepoAt.String(), 22 star.Rkey, 23 ) ··· 25 } 26 27 // Get a star record 28 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 29 query := ` 30 - select starred_by_did, repo_at, created, rkey 31 from stars 32 - where starred_by_did = ? and repo_at = ?` 33 - row := e.QueryRow(query, starredByDid, repoAt) 34 35 var star models.Star 36 var created string 37 - err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 38 if err != nil { 39 return nil, err 40 } ··· 51 } 52 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) 56 return err 57 } 58 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) 62 return err 63 } 64 65 - func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 66 stars := 0 67 err := e.QueryRow( 68 - `select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars) 69 if err != nil { 70 return 0, err 71 } ··· 89 } 90 91 query := fmt.Sprintf(` 92 - SELECT repo_at 93 FROM stars 94 - WHERE starred_by_did = ? AND repo_at IN (%s) 95 `, strings.Join(placeholders, ",")) 96 97 rows, err := e.Query(query, args...) ··· 118 return result, nil 119 } 120 121 - func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 122 - statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 123 if err != nil { 124 return false 125 } 126 - return statuses[repoAt.String()] 127 } 128 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) 132 } 133 - func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 134 var conditions []string 135 var args []any 136 for _, filter := range filters { ··· 149 } 150 151 repoQuery := fmt.Sprintf( 152 - `select starred_by_did, repo_at, created, rkey 153 from stars 154 %s 155 order by created desc ··· 166 for rows.Next() { 167 var star models.Star 168 var created string 169 - err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 170 if err != nil { 171 return nil, err 172 } ··· 197 return nil, err 198 } 199 200 for _, r := range repos { 201 if stars, ok := starMap[string(r.RepoAt())]; ok { 202 - for i := range stars { 203 - stars[i].Repo = &r 204 } 205 } 206 } 207 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 { 214 if a.Created.After(b.Created) { 215 return -1 216 } ··· 220 return 0 221 }) 222 223 - return stars, nil 224 } 225 226 func CountStars(e Execer, filters ...filter) (int64, error) { ··· 247 return count, nil 248 } 249 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 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 313 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 314 // first, get the top repo URIs by star count from the last week 315 query := ` 316 with recent_starred_repos as ( 317 - select distinct repo_at 318 from stars 319 where created >= datetime('now', '-7 days') 320 ), 321 repo_star_counts as ( 322 select 323 - s.repo_at, 324 count(*) as stars_gained_last_week 325 from stars s 326 - join recent_starred_repos rsr on s.repo_at = rsr.repo_at 327 where s.created >= datetime('now', '-7 days') 328 - group by s.repo_at 329 ) 330 - select rsc.repo_at 331 from repo_star_counts rsc 332 order by rsc.stars_gained_last_week desc 333 limit 8
··· 14 ) 15 16 func AddStar(e Execer, star *models.Star) error { 17 + query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 18 _, err := e.Exec( 19 query, 20 + star.Did, 21 star.RepoAt.String(), 22 star.Rkey, 23 ) ··· 25 } 26 27 // Get a star record 28 + func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 29 query := ` 30 + select did, subject_at, created, rkey 31 from stars 32 + where did = ? and subject_at = ?` 33 + row := e.QueryRow(query, did, subjectAt) 34 35 var star models.Star 36 var created string 37 + err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 38 if err != nil { 39 return nil, err 40 } ··· 51 } 52 53 // Remove a star 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 return err 57 } 58 59 // Remove a star 60 + func DeleteStarByRkey(e Execer, did string, rkey string) error { 61 + _, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey) 62 return err 63 } 64 65 + func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) { 66 stars := 0 67 err := e.QueryRow( 68 + `select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars) 69 if err != nil { 70 return 0, err 71 } ··· 89 } 90 91 query := fmt.Sprintf(` 92 + SELECT subject_at 93 FROM stars 94 + WHERE did = ? AND subject_at IN (%s) 95 `, strings.Join(placeholders, ",")) 96 97 rows, err := e.Query(query, args...) ··· 118 return result, nil 119 } 120 121 + func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool { 122 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt}) 123 if err != nil { 124 return false 125 } 126 + return statuses[subjectAt.String()] 127 } 128 129 // GetStarStatuses returns a map of repo URIs to star status for a given user 130 + func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) { 131 + return getStarStatuses(e, userDid, subjectAts) 132 } 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) { 137 var conditions []string 138 var args []any 139 for _, filter := range filters { ··· 152 } 153 154 repoQuery := fmt.Sprintf( 155 + `select did, subject_at, created, rkey 156 from stars 157 %s 158 order by created desc ··· 169 for rows.Next() { 170 var star models.Star 171 var created string 172 + err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 173 if err != nil { 174 return nil, err 175 } ··· 200 return nil, err 201 } 202 203 + var repoStars []models.RepoStar 204 for _, r := range repos { 205 if stars, ok := starMap[string(r.RepoAt())]; ok { 206 + for _, star := range stars { 207 + repoStars = append(repoStars, models.RepoStar{ 208 + Star: star, 209 + Repo: &r, 210 + }) 211 } 212 } 213 } 214 215 + slices.SortFunc(repoStars, func(a, b models.RepoStar) int { 216 if a.Created.After(b.Created) { 217 return -1 218 } ··· 222 return 0 223 }) 224 225 + return repoStars, nil 226 } 227 228 func CountStars(e Execer, filters ...filter) (int64, error) { ··· 249 return count, nil 250 } 251 252 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 253 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 254 // first, get the top repo URIs by star count from the last week 255 query := ` 256 with recent_starred_repos as ( 257 + select distinct subject_at 258 from stars 259 where created >= datetime('now', '-7 days') 260 ), 261 repo_star_counts as ( 262 select 263 + s.subject_at, 264 count(*) as stars_gained_last_week 265 from stars s 266 + join recent_starred_repos rsr on s.subject_at = rsr.subject_at 267 where s.created >= datetime('now', '-7 days') 268 + group by s.subject_at 269 ) 270 + select rsc.subject_at 271 from repo_star_counts rsc 272 order by rsc.stars_gained_last_week desc 273 limit 8
+3 -13
appview/db/timeline.go
··· 146 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 filters := make([]filter, 0) 148 if userIsFollowing != nil { 149 - filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 } 151 152 - stars, err := GetStars(e, limit, filters...) 153 if err != nil { 154 return nil, err 155 } 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 var repos []models.Repo 168 for _, s := range stars { 169 repos = append(repos, *s.Repo) ··· 179 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 180 181 events = append(events, models.TimelineEvent{ 182 - Star: &s, 183 EventAt: s.Created, 184 IsStarred: isStarred, 185 StarCount: starCount,
··· 146 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 filters := make([]filter, 0) 148 if userIsFollowing != nil { 149 + filters = append(filters, FilterIn("did", userIsFollowing)) 150 } 151 152 + stars, err := GetRepoStars(e, limit, filters...) 153 if err != nil { 154 return nil, err 155 } 156 157 var repos []models.Repo 158 for _, s := range stars { 159 repos = append(repos, *s.Repo) ··· 169 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 170 171 events = append(events, models.TimelineEvent{ 172 + RepoStar: &s, 173 EventAt: s.Created, 174 IsStarred: isStarred, 175 StarCount: starCount,
+3 -3
appview/ingester.go
··· 121 return err 122 } 123 err = db.AddStar(i.Db, &models.Star{ 124 - StarredByDid: did, 125 - RepoAt: subjectUri, 126 - Rkey: e.Commit.RKey, 127 }) 128 case jmodels.CommitOperationDelete: 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
··· 121 return err 122 } 123 err = db.AddStar(i.Db, &models.Star{ 124 + Did: did, 125 + RepoAt: subjectUri, 126 + Rkey: e.Commit.RKey, 127 }) 128 case jmodels.CommitOperationDelete: 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
+14 -5
appview/models/star.go
··· 7 ) 8 9 type Star struct { 10 - StarredByDid string 11 - RepoAt syntax.ATURI 12 - Created time.Time 13 - Rkey string 14 15 - // optionally, populate this when querying for reverse mappings 16 Repo *Repo 17 }
··· 7 ) 8 9 type Star struct { 10 + Did string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + } 15 16 + // RepoStar is used for reverse mapping to repos 17 + type RepoStar struct { 18 + Star 19 Repo *Repo 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 Edited *time.Time 23 } 24 25 - func (s *String) StringAt() syntax.ATURI { 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 } 28
··· 22 Edited *time.Time 23 } 24 25 + func (s *String) AtUri() syntax.ATURI { 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 } 28
+1 -1
appview/models/timeline.go
··· 5 type TimelineEvent struct { 6 *Repo 7 *Follow 8 - *Star 9 10 EventAt time.Time 11
··· 5 type TimelineEvent struct { 6 *Repo 7 *Follow 8 + *RepoStar 9 10 EventAt time.Time 11
+6 -1
appview/notify/db/db.go
··· 7 "slices" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "tangled.org/core/appview/db" 11 "tangled.org/core/appview/models" 12 "tangled.org/core/appview/notify" ··· 36 } 37 38 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 39 var err error 40 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 41 if err != nil { ··· 43 return 44 } 45 46 - actorDid := syntax.DID(star.StarredByDid) 47 recipients := []syntax.DID{syntax.DID(repo.Did)} 48 eventType := models.NotificationTypeRepoStarred 49 entityType := "repo"
··· 7 "slices" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 "tangled.org/core/appview/db" 12 "tangled.org/core/appview/models" 13 "tangled.org/core/appview/notify" ··· 37 } 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 + } 44 var err error 45 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 46 if err != nil { ··· 48 return 49 } 50 51 + actorDid := syntax.DID(star.Did) 52 recipients := []syntax.DID{syntax.DID(repo.Did)} 53 eventType := models.NotificationTypeRepoStarred 54 entityType := "repo"
+2 -2
appview/notify/posthog/notifier.go
··· 37 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 39 err := n.client.Enqueue(posthog.Capture{ 40 - DistinctId: star.StarredByDid, 41 Event: "star", 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 43 }) ··· 48 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 50 err := n.client.Enqueue(posthog.Capture{ 51 - DistinctId: star.StarredByDid, 52 Event: "unstar", 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 54 })
··· 37 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 39 err := n.client.Enqueue(posthog.Capture{ 40 + DistinctId: star.Did, 41 Event: "star", 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 43 }) ··· 48 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 50 err := n.client.Enqueue(posthog.Capture{ 51 + DistinctId: star.Did, 52 Event: "unstar", 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 54 })
+1 -1
appview/pages/markup/extension/atlink.go
··· 89 if entering { 90 w.WriteString(`<a href="/@`) 91 w.WriteString(n.(*AtNode).Handle) 92 - w.WriteString(`" class="mention">`) 93 } else { 94 w.WriteString("</a>") 95 }
··· 89 if entering { 90 w.WriteString(`<a href="/@`) 91 w.WriteString(n.(*AtNode).Handle) 92 + w.WriteString(`" class="mention font-bold">`) 93 } else { 94 w.WriteString("</a>") 95 }
+1 -1
appview/pages/markup/markdown.go
··· 249 repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 250 251 query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 252 - url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 253 254 parsedURL := &url.URL{ 255 Scheme: scheme,
··· 249 repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 250 251 query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 252 + url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath) 253 254 parsedURL := &url.URL{ 255 Scheme: scheme,
+8 -6
appview/pages/pages.go
··· 625 return p.executePlain("user/fragments/editPins", w, params) 626 } 627 628 - type RepoStarFragmentParams struct { 629 IsStarred bool 630 - RepoAt syntax.ATURI 631 - Stats models.RepoStats 632 } 633 634 - func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 635 - return p.executePlain("repo/fragments/repoStar", w, params) 636 } 637 638 type RepoIndexParams struct { ··· 1376 ShowRendered bool 1377 RenderToggle bool 1378 RenderedContents template.HTML 1379 - String models.String 1380 Stats models.StringStats 1381 Owner identity.Identity 1382 } 1383
··· 625 return p.executePlain("user/fragments/editPins", w, params) 626 } 627 628 + type StarBtnFragmentParams struct { 629 IsStarred bool 630 + SubjectAt syntax.ATURI 631 + StarCount int 632 } 633 634 + func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 635 + return p.executePlain("fragments/starBtn", w, params) 636 } 637 638 type RepoIndexParams struct { ··· 1376 ShowRendered bool 1377 RenderToggle bool 1378 RenderedContents template.HTML 1379 + String *models.String 1380 Stats models.StringStats 1381 + IsStarred bool 1382 + StarCount int 1383 Owner identity.Identity 1384 } 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 </div> 50 51 <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 - {{ template "repo/fragments/repoStar" .RepoInfo }} 53 <a 54 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 55 hx-boost="true"
··· 49 </div> 50 51 <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 + {{ template "fragments/starBtn" 53 + (dict "SubjectAt" .RepoInfo.RepoAt 54 + "IsStarred" .RepoInfo.IsStarred 55 + "StarCount" .RepoInfo.Stats.StarCount) }} 56 <a 57 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 58 hx-boost="true"
-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 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 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" }} 35 </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" 40 > 41 - {{ i "x" "w-4 h-4" }} 42 - </a> 43 </form> 44 <div class="sm:row-start-1"> 45 {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
··· 30 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 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> 47 </div> 48 + <button 49 + type="submit" 50 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 51 > 52 + {{ i "search" "w-4 h-4" }} 53 + </button> 54 </form> 55 <div class="sm:row-start-1"> 56 {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+19 -8
appview/pages/templates/repo/pulls/pulls.html
··· 36 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 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" }} 41 </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" 46 > 47 - {{ i "x" "w-4 h-4" }} 48 - </a> 49 </form> 50 <div class="sm:row-start-1"> 51 {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
··· 36 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 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> 53 </div> 54 + <button 55 + type="submit" 56 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 57 > 58 + {{ i "search" "w-4 h-4" }} 59 + </button> 60 </form> 61 <div class="sm:row-start-1"> 62 {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+8 -4
appview/pages/templates/strings/string.html
··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 21 - <div class="flex gap-2 text-base"> 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true" 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> ··· 37 <span class="hidden md:inline">delete</span> 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 </button> 40 - </div> 41 - {{ end }} 42 </div> 43 <span> 44 {{ with .String.Description }}
··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 + <div class="flex gap-2 text-base"> 21 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true" 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> ··· 37 <span class="hidden md:inline">delete</span> 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 </button> 40 + {{ end }} 41 + {{ template "fragments/starBtn" 42 + (dict "SubjectAt" .String.AtUri 43 + "IsStarred" .IsStarred 44 + "StarCount" .StarCount) }} 45 + </div> 46 </div> 47 <span> 48 {{ with .String.Description }}
+4 -4
appview/pages/templates/timeline/fragments/timeline.html
··· 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 </div> 54 {{ with $repo }} 55 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 56 {{ end }} 57 {{ end }} 58 59 {{ define "timeline/fragments/starEvent" }} 60 {{ $root := index . 0 }} 61 {{ $event := index . 1 }} 62 - {{ $star := $event.Star }} 63 {{ with $star }} 64 - {{ $starrerHandle := resolve .StarredByDid }} 65 {{ $repoOwnerHandle := resolve .Repo.Did }} 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 {{ template "user/fragments/picHandleLink" $starrerHandle }} ··· 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 </div> 74 {{ with .Repo }} 75 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 76 {{ end }} 77 {{ end }} 78 {{ end }}
··· 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 </div> 54 {{ with $repo }} 55 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 56 {{ end }} 57 {{ end }} 58 59 {{ define "timeline/fragments/starEvent" }} 60 {{ $root := index . 0 }} 61 {{ $event := index . 1 }} 62 + {{ $star := $event.RepoStar }} 63 {{ with $star }} 64 + {{ $starrerHandle := resolve .Did }} 65 {{ $repoOwnerHandle := resolve .Repo.Did }} 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 {{ template "user/fragments/picHandleLink" $starrerHandle }} ··· 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 </div> 74 {{ with .Repo }} 75 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 76 {{ end }} 77 {{ end }} 78 {{ end }}
-1
appview/pages/templates/user/fragments/editBio.html
··· 31 class="py-1 px-1 w-full" 32 name="pronouns" 33 placeholder="they/them" 34 - pattern="[a-zA-Z]{1,6}[\/\s\-][a-zA-Z]{1,6}" 35 value="{{ $pronouns }}" 36 > 37 </div>
··· 31 class="py-1 px-1 w-full" 32 name="pronouns" 33 placeholder="they/them" 34 value="{{ $pronouns }}" 35 > 36 </div>
+2 -1
appview/pages/templates/user/fragments/repoCard.html
··· 1 {{ define "user/fragments/repoCard" }} 2 {{ $root := index . 0 }} 3 {{ $repo := index . 1 }} 4 {{ $fullName := index . 2 }} ··· 29 </div> 30 {{ if and $starButton $root.LoggedInUser }} 31 <div class="shrink-0"> 32 - {{ template "repo/fragments/repoStar" $starData }} 33 </div> 34 {{ end }} 35 </div>
··· 1 {{ define "user/fragments/repoCard" }} 2 + {{/* root, repo, fullName [,starButton [,starData]] */}} 3 {{ $root := index . 0 }} 4 {{ $repo := index . 1 }} 5 {{ $fullName := index . 2 }} ··· 30 </div> 31 {{ if and $starButton $root.LoggedInUser }} 32 <div class="shrink-0"> 33 + {{ template "fragments/starBtn" $starData }} 34 </div> 35 {{ end }} 36 </div>
+3 -5
appview/state/profile.go
··· 66 return nil, fmt.Errorf("failed to get string count: %w", err) 67 } 68 69 - starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 70 if err != nil { 71 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 } ··· 211 } 212 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 213 214 - stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 215 if err != nil { 216 l.Error("failed to get stars", "err", err) 217 s.pages.Error500(w) ··· 219 } 220 var repos []models.Repo 221 for _, s := range stars { 222 - if s.Repo != nil { 223 - repos = append(repos, *s.Repo) 224 - } 225 } 226 227 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
··· 66 return nil, fmt.Errorf("failed to get string count: %w", err) 67 } 68 69 + starredCount, err := db.CountStars(s.db, db.FilterEq("did", did)) 70 if err != nil { 71 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 } ··· 211 } 212 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 213 214 + stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid)) 215 if err != nil { 216 l.Error("failed to get stars", "err", err) 217 s.pages.Error500(w) ··· 219 } 220 var repos []models.Repo 221 for _, s := range stars { 222 + repos = append(repos, *s.Repo) 223 } 224 225 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
+9 -13
appview/state/star.go
··· 57 log.Println("created atproto record: ", resp.Uri) 58 59 star := &models.Star{ 60 - StarredByDid: currentUser.Did, 61 - RepoAt: subjectUri, 62 - Rkey: rkey, 63 } 64 65 err = db.AddStar(s.db, star) ··· 75 76 s.notifier.NewStar(r.Context(), star) 77 78 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 79 IsStarred: true, 80 - RepoAt: subjectUri, 81 - Stats: models.RepoStats{ 82 - StarCount: starCount, 83 - }, 84 }) 85 86 return ··· 117 118 s.notifier.DeleteStar(r.Context(), star) 119 120 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 121 IsStarred: false, 122 - RepoAt: subjectUri, 123 - Stats: models.RepoStats{ 124 - StarCount: starCount, 125 - }, 126 }) 127 128 return
··· 57 log.Println("created atproto record: ", resp.Uri) 58 59 star := &models.Star{ 60 + Did: currentUser.Did, 61 + RepoAt: subjectUri, 62 + Rkey: rkey, 63 } 64 65 err = db.AddStar(s.db, star) ··· 75 76 s.notifier.NewStar(r.Context(), star) 77 78 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 79 IsStarred: true, 80 + SubjectAt: subjectUri, 81 + StarCount: starCount, 82 }) 83 84 return ··· 115 116 s.notifier.DeleteStar(r.Context(), star) 117 118 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 119 IsStarred: false, 120 + SubjectAt: subjectUri, 121 + StarCount: starCount, 122 }) 123 124 return
+14 -2
appview/strings/strings.go
··· 148 showRendered = r.URL.Query().Get("code") != "true" 149 } 150 151 s.Pages.SingleString(w, pages.SingleStringParams{ 152 - LoggedInUser: s.OAuth.GetUser(r), 153 RenderToggle: renderToggle, 154 ShowRendered: showRendered, 155 - String: string, 156 Stats: string.Stats(), 157 Owner: id, 158 }) 159 }
··· 148 showRendered = r.URL.Query().Get("code") != "true" 149 } 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 + 161 s.Pages.SingleString(w, pages.SingleStringParams{ 162 + LoggedInUser: user, 163 RenderToggle: renderToggle, 164 ShowRendered: showRendered, 165 + String: &string, 166 Stats: string.Stats(), 167 + IsStarred: isStarred, 168 + StarCount: starCount, 169 Owner: id, 170 }) 171 }
+1
cmd/cborgen/cborgen.go
··· 34 tangled.Pipeline_CloneOpts{}, 35 tangled.Pipeline_ManualTriggerData{}, 36 tangled.Pipeline_Pair{}, 37 tangled.Pipeline_PullRequestTriggerData{}, 38 tangled.Pipeline_PushTriggerData{}, 39 tangled.PipelineStatus{},
··· 34 tangled.Pipeline_CloneOpts{}, 35 tangled.Pipeline_ManualTriggerData{}, 36 tangled.Pipeline_Pair{}, 37 + tangled.Pipeline_ScheduleTriggerData{}, 38 tangled.Pipeline_PullRequestTriggerData{}, 39 tangled.Pipeline_PushTriggerData{}, 40 tangled.PipelineStatus{},
+2
docs/spindle/pipeline.md
··· 18 - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 25 For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26
··· 18 - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 21 + - `schedule`: The workflow should run on a schedule. 22 - `manual`: The workflow can be triggered manually. 23 - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 25 + - `cron`: This field is **required** when using the `schedule` event. It defines the cron expression that specifies when the workflow should run. The cron syntax follows the [standard crontab format](https://www.man7.org/linux/man-pages/man5/crontab.5.html). For example, to run a workflow every day at midnight, you would use `0 0 * * *`. All jobs are run in UTC. 26 27 For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 28
+4 -1
go.mod
··· 41 github.com/sethvargo/go-envconfig v1.1.0 42 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 43 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 44 - github.com/stretchr/testify v1.10.0 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 github.com/wyatt915/goldmark-treeblood v0.0.1 ··· 106 github.com/emirpasic/gods v1.18.1 // indirect 107 github.com/felixge/httpsnoop v1.0.4 // indirect 108 github.com/fsnotify/fsnotify v1.6.0 // indirect 109 github.com/go-enry/go-oniguruma v1.2.1 // indirect 110 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 111 github.com/go-git/go-billy/v5 v5.6.2 // indirect ··· 147 github.com/ipfs/go-log v1.0.5 // indirect 148 github.com/ipfs/go-log/v2 v2.6.0 // indirect 149 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 150 github.com/json-iterator/go v1.1.12 // indirect 151 github.com/kevinburke/ssh_config v1.2.0 // indirect 152 github.com/klauspost/compress v1.18.0 // indirect ··· 184 github.com/prometheus/common v0.64.0 // indirect 185 github.com/prometheus/procfs v0.16.1 // indirect 186 github.com/rivo/uniseg v0.4.7 // indirect 187 github.com/ryanuber/go-glob v1.0.0 // indirect 188 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 189 github.com/spaolacci/murmur3 v1.1.0 // indirect
··· 41 github.com/sethvargo/go-envconfig v1.1.0 42 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 43 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 44 + github.com/stretchr/testify v1.11.1 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 github.com/wyatt915/goldmark-treeblood v0.0.1 ··· 106 github.com/emirpasic/gods v1.18.1 // indirect 107 github.com/felixge/httpsnoop v1.0.4 // indirect 108 github.com/fsnotify/fsnotify v1.6.0 // indirect 109 + github.com/go-co-op/gocron/v2 v2.18.2 // indirect 110 github.com/go-enry/go-oniguruma v1.2.1 // indirect 111 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 112 github.com/go-git/go-billy/v5 v5.6.2 // indirect ··· 148 github.com/ipfs/go-log v1.0.5 // indirect 149 github.com/ipfs/go-log/v2 v2.6.0 // indirect 150 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 151 + github.com/jonboulle/clockwork v0.5.0 // indirect 152 github.com/json-iterator/go v1.1.12 // indirect 153 github.com/kevinburke/ssh_config v1.2.0 // indirect 154 github.com/klauspost/compress v1.18.0 // indirect ··· 186 github.com/prometheus/common v0.64.0 // indirect 187 github.com/prometheus/procfs v0.16.1 // indirect 188 github.com/rivo/uniseg v0.4.7 // indirect 189 + github.com/robfig/cron/v3 v3.0.1 // indirect 190 github.com/ryanuber/go-glob v1.0.0 // indirect 191 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 192 github.com/spaolacci/murmur3 v1.1.0 // indirect
+8
go.sum
··· 160 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 161 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 162 github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 163 github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY= 164 github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= 165 github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= ··· 306 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 307 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 308 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 309 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 310 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 311 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 444 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 445 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 446 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 447 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 448 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 449 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 481 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 482 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 483 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 484 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 485 github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 486 github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
··· 160 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 161 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 162 github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 163 + github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q= 164 + github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= 165 github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY= 166 github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= 167 github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= ··· 308 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 309 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 310 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 311 + github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= 312 + github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= 313 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 314 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 315 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 448 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 449 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 450 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 451 + github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 452 + github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 453 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 454 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 455 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 487 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 488 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 489 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 490 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 491 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 492 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 493 github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 494 github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
+13
lexicons/pipeline/pipeline.json
··· 40 "enum": [ 41 "push", 42 "pull_request", 43 "manual" 44 ] 45 }, ··· 54 "pullRequest": { 55 "type": "ref", 56 "ref": "#pullRequestTriggerData" 57 }, 58 "manual": { 59 "type": "ref", ··· 129 "maxLength": 40 130 }, 131 "action": { 132 "type": "string" 133 } 134 }
··· 40 "enum": [ 41 "push", 42 "pull_request", 43 + "schedule", 44 "manual" 45 ] 46 }, ··· 55 "pullRequest": { 56 "type": "ref", 57 "ref": "#pullRequestTriggerData" 58 + }, 59 + "schedule": { 60 + "type": "ref", 61 + "ref": "#scheduleTriggerData" 62 }, 63 "manual": { 64 "type": "ref", ··· 134 "maxLength": 40 135 }, 136 "action": { 137 + "type": "string" 138 + } 139 + } 140 + }, 141 + "scheduleTriggerData": { 142 + "type": "object", 143 + "properties": { 144 + "cronExpression": { 145 "type": "string" 146 } 147 }
+1 -1
spindle/engines/nixery/engine.go
··· 109 setup := &setupSteps{} 110 111 setup.addStep(nixConfStep()) 112 - setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 // this step could be empty 114 if s := dependencyStep(dwf.Dependencies); s != nil { 115 setup.addStep(*s)
··· 109 setup := &setupSteps{} 110 111 setup.addStep(nixConfStep()) 112 + setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 // this step could be empty 114 if s := dependencyStep(dwf.Dependencies); s != nil { 115 setup.addStep(*s)
-73
spindle/engines/nixery/setup_steps.go
··· 2 3 import ( 4 "fmt" 5 - "path" 6 "strings" 7 - 8 - "tangled.org/core/api/tangled" 9 - "tangled.org/core/workflow" 10 ) 11 12 func nixConfStep() Step { ··· 17 command: setupCmd, 18 name: "Configure Nix", 19 } 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 } 90 91 // dependencyStep processes dependencies defined in the workflow.
··· 2 3 import ( 4 "fmt" 5 "strings" 6 ) 7 8 func nixConfStep() Step { ··· 13 command: setupCmd, 14 name: "Configure Nix", 15 } 16 } 17 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 ) 14 15 type OpenBaoManager struct { 16 - client *vault.Client 17 - mountPath string 18 - logger *slog.Logger 19 } 20 21 type OpenBaoManagerOpt func(*OpenBaoManager) ··· 26 } 27 } 28 29 // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 // The proxy handles all authentication automatically via Auto-Auth ··· 43 } 44 45 manager := &OpenBaoManager{ 46 - client: client, 47 - mountPath: "spindle", // default KV v2 mount path 48 - logger: logger, 49 } 50 51 for _, opt := range opts { ··· 62 63 // testConnection verifies that we can connect to the proxy 64 func (v *OpenBaoManager) testConnection() error { 65 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 defer cancel() 67 68 // try token self-lookup as a quick way to verify proxy works
··· 13 ) 14 15 type OpenBaoManager struct { 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + connectionTimeout time.Duration 20 } 21 22 type OpenBaoManagerOpt func(*OpenBaoManager) ··· 27 } 28 } 29 30 + func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt { 31 + return func(v *OpenBaoManager) { 32 + v.connectionTimeout = timeout 33 + } 34 + } 35 + 36 // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 37 // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 38 // The proxy handles all authentication automatically via Auto-Auth ··· 50 } 51 52 manager := &OpenBaoManager{ 53 + client: client, 54 + mountPath: "spindle", // default KV v2 mount path 55 + logger: logger, 56 + connectionTimeout: 10 * time.Second, // default connection timeout 57 } 58 59 for _, opt := range opts { ··· 70 71 // testConnection verifies that we can connect to the proxy 72 func (v *OpenBaoManager) testConnection() error { 73 + ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout) 74 defer cancel() 75 76 // try token self-lookup as a quick way to verify proxy works
+5 -2
spindle/secrets/openbao_test.go
··· 152 for _, tt := range tests { 153 t.Run(tt.name, func(t *testing.T) { 154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 156 157 if tt.expectError { 158 assert.Error(t, err) ··· 596 597 // All these will fail because no real proxy is running 598 // but we can test that the configuration is properly accepted 599 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 600 assert.Error(t, err) // Expected because no real proxy 601 assert.Nil(t, manager) 602 assert.Contains(t, err.Error(), "failed to connect to bao proxy")
··· 152 for _, tt := range tests { 153 t.Run(tt.name, func(t *testing.T) { 154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 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...) 158 159 if tt.expectError { 160 assert.Error(t, err) ··· 598 599 // All these will fail because no real proxy is running 600 // but we can test that the configuration is properly accepted 601 + // Use shorter timeout for tests to avoid long waits 602 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second)) 603 assert.Error(t, err) // Expected because no real proxy 604 assert.Nil(t, manager) 605 assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+18
spindle/server.go
··· 7 "fmt" 8 "log/slog" 9 "net/http" 10 11 "github.com/go-chi/chi/v5" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/eventconsumer" 14 "tangled.org/core/eventconsumer/cursor" ··· 47 ks *eventconsumer.Consumer 48 res *idresolver.Resolver 49 vault secrets.Manager 50 } 51 52 // New creates a new Spindle server with the provided configuration and engines. ··· 116 117 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 118 119 spindle := &Spindle{ 120 jc: jc, 121 e: e, ··· 127 cfg: cfg, 128 res: resolver, 129 vault: vault, 130 } 131 132 err = e.AddSpindle(rbacDomain) ··· 200 return s.e 201 } 202 203 // Start starts the Spindle server (blocking). 204 func (s *Spindle) Start(ctx context.Context) error { 205 // starts a job queue runner in the background ··· 210 if stopper, ok := s.vault.(secrets.Stopper); ok { 211 defer stopper.Stop() 212 } 213 214 go func() { 215 s.l.Info("starting knot event consumer")
··· 7 "fmt" 8 "log/slog" 9 "net/http" 10 + "time" 11 12 "github.com/go-chi/chi/v5" 13 + "github.com/go-co-op/gocron/v2" 14 + 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/eventconsumer" 17 "tangled.org/core/eventconsumer/cursor" ··· 50 ks *eventconsumer.Consumer 51 res *idresolver.Resolver 52 vault secrets.Manager 53 + crons gocron.Scheduler 54 } 55 56 // New creates a new Spindle server with the provided configuration and engines. ··· 120 121 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 122 123 + scheduler, err := gocron.NewScheduler(gocron.WithLocation(time.UTC)) 124 + if err != nil { 125 + return nil, fmt.Errorf("failed to create cron scheduler: %w", err) 126 + } 127 + defer func() { _ = scheduler.Shutdown() }() 128 + 129 spindle := &Spindle{ 130 jc: jc, 131 e: e, ··· 137 cfg: cfg, 138 res: resolver, 139 vault: vault, 140 + crons: scheduler, 141 } 142 143 err = e.AddSpindle(rbacDomain) ··· 211 return s.e 212 } 213 214 + // Scheduler returns the cron scheduler instance. 215 + func (s *Spindle) Scheduler() *gocron.Scheduler { 216 + return &s.crons 217 + } 218 + 219 // Start starts the Spindle server (blocking). 220 func (s *Spindle) Start(ctx context.Context) error { 221 // starts a job queue runner in the background ··· 226 if stopper, ok := s.vault.(secrets.Stopper); ok { 227 defer stopper.Stop() 228 } 229 + 230 + s.crons.Start() 231 232 go func() { 233 s.l.Info("starting knot event consumer")
+20
workflow/def.go
··· 9 "tangled.org/core/api/tangled" 10 11 "github.com/bmatcuk/doublestar/v4" 12 "github.com/go-git/go-git/v5/plumbing" 13 "gopkg.in/yaml.v3" 14 ) ··· 36 Event StringList `yaml:"event"` 37 Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 38 Tag StringList `yaml:"tag"` // optional; only applies to push events 39 } 40 41 CloneOpts struct { ··· 54 55 TriggerKindPush TriggerKind = "push" 56 TriggerKindPullRequest TriggerKind = "pull_request" 57 TriggerKindManual TriggerKind = "manual" 58 ) 59 ··· 145 return false, err 146 } 147 match = match && matched 148 } 149 150 return match, nil
··· 9 "tangled.org/core/api/tangled" 10 11 "github.com/bmatcuk/doublestar/v4" 12 + "github.com/go-co-op/gocron/v2" 13 "github.com/go-git/go-git/v5/plumbing" 14 "gopkg.in/yaml.v3" 15 ) ··· 37 Event StringList `yaml:"event"` 38 Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 39 Tag StringList `yaml:"tag"` // optional; only applies to push events 40 + Cron StringList `yaml:"cron"` // required for schedule events 41 } 42 43 CloneOpts struct { ··· 56 57 TriggerKindPush TriggerKind = "push" 58 TriggerKindPullRequest TriggerKind = "pull_request" 59 + TriggerKindSchedule TriggerKind = "schedule" 60 TriggerKindManual TriggerKind = "manual" 61 ) 62 ··· 148 return false, err 149 } 150 match = match && matched 151 + } 152 + 153 + // apply cron constraints for schedules 154 + if trigger.Schedule != nil { 155 + s, _ := gocron.NewScheduler() 156 + 157 + _, err := s.NewJob(gocron.CronJob( 158 + *trigger.Schedule.CronExpression, 159 + false, 160 + ), 161 + gocron.NewTask( 162 + func() {}, 163 + ), 164 + ) 165 + if err != nil { 166 + return false, err 167 + } 168 } 169 170 return match, nil
+16
workflow/def_test.go
··· 68 assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) 69 } 70 71 func TestMatchesPattern(t *testing.T) { 72 tests := []struct { 73 name string
··· 68 assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) 69 } 70 71 + func TestUnmarshalWorkflowWithCron(t *testing.T) { 72 + yamlData := ` 73 + when: 74 + - event: ["schedule"] 75 + cron: "0 0 * * *"` 76 + 77 + wf, err := FromFile("test.yml", []byte(yamlData)) 78 + assert.NoError(t, err, "YAML should unmarshal without error") 79 + 80 + assert.Len(t, wf.When, 1, "Should have one constraint") 81 + assert.ElementsMatch(t, []string{"schedule"}, wf.When[0].Event) 82 + assert.ElementsMatch(t, []string{"0 0 * * *"}, wf.When[0].Cron) 83 + 84 + assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 85 + } 86 + 87 func TestMatchesPattern(t *testing.T) { 88 tests := []struct { 89 name string