+168
-1
api/tangled/cbor_gen.go
+168
-1
api/tangled/cbor_gen.go
···
4254
4254
4255
4255
return nil
4256
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
+
}
4257
4381
func (t *Pipeline_PullRequestTriggerData) MarshalCBOR(w io.Writer) error {
4258
4382
if t == nil {
4259
4383
_, err := w.Write(cbg.CborNull)
···
4993
5117
}
4994
5118
4995
5119
cw := cbg.NewCborWriter(w)
4996
-
fieldCount := 5
5120
+
fieldCount := 6
4997
5121
4998
5122
if t.Manual == nil {
4999
5123
fieldCount--
···
5004
5128
}
5005
5129
5006
5130
if t.Push == nil {
5131
+
fieldCount--
5132
+
}
5133
+
5134
+
if t.Schedule == nil {
5007
5135
fieldCount--
5008
5136
}
5009
5137
···
5088
5216
}
5089
5217
}
5090
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
+
5091
5238
// t.PullRequest (tangled.Pipeline_PullRequestTriggerData) (struct)
5092
5239
if t.PullRequest != nil {
5093
5240
···
5217
5364
t.Manual = new(Pipeline_ManualTriggerData)
5218
5365
if err := t.Manual.UnmarshalCBOR(cr); err != nil {
5219
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)
5220
5387
}
5221
5388
}
5222
5389
+6
api/tangled/tangledpipeline.go
+6
api/tangled/tangledpipeline.go
···
55
55
Ref string `json:"ref" cborgen:"ref"`
56
56
}
57
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
+
58
63
// Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
59
64
type Pipeline_TriggerMetadata struct {
60
65
Kind string `json:"kind" cborgen:"kind"`
···
62
67
PullRequest *Pipeline_PullRequestTriggerData `json:"pullRequest,omitempty" cborgen:"pullRequest,omitempty"`
63
68
Push *Pipeline_PushTriggerData `json:"push,omitempty" cborgen:"push,omitempty"`
64
69
Repo *Pipeline_TriggerRepo `json:"repo" cborgen:"repo"`
70
+
Schedule *Pipeline_ScheduleTriggerData `json:"schedule,omitempty" cborgen:"schedule,omitempty"`
65
71
}
66
72
67
73
// Pipeline_TriggerRepo is a "triggerRepo" in the sh.tangled.pipeline schema.
+39
-2
appview/db/db.go
+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
+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
+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
+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
+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
+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
+1
-1
appview/models/string.go
+1
-1
appview/models/timeline.go
+1
-1
appview/models/timeline.go
+6
-1
appview/notify/db/db.go
+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
+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
})
+1
-1
appview/pages/markup/extension/atlink.go
+1
-1
appview/pages/markup/extension/atlink.go
+1
-1
appview/pages/markup/markdown.go
+1
-1
appview/pages/markup/markdown.go
···
249
249
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
250
250
251
251
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
252
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
252
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
253
253
254
254
parsedURL := &url.URL{
255
255
Scheme: scheme,
+8
-6
appview/pages/pages.go
+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
+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
+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"
-26
appview/pages/templates/repo/fragments/repoStar.html
-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
+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) }}
+19
-8
appview/pages/templates/repo/pulls/pulls.html
+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
+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 }}
+4
-4
appview/pages/templates/timeline/fragments/timeline.html
+4
-4
appview/pages/templates/timeline/fragments/timeline.html
···
52
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
53
53
</div>
54
54
{{ with $repo }}
55
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
55
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
56
56
{{ end }}
57
57
{{ end }}
58
58
59
59
{{ define "timeline/fragments/starEvent" }}
60
60
{{ $root := index . 0 }}
61
61
{{ $event := index . 1 }}
62
-
{{ $star := $event.Star }}
62
+
{{ $star := $event.RepoStar }}
63
63
{{ with $star }}
64
-
{{ $starrerHandle := resolve .StarredByDid }}
64
+
{{ $starrerHandle := resolve .Did }}
65
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
66
66
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
67
67
{{ template "user/fragments/picHandleLink" $starrerHandle }}
···
72
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
73
73
</div>
74
74
{{ with .Repo }}
75
-
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
75
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }}
76
76
{{ end }}
77
77
{{ end }}
78
78
{{ end }}
-1
appview/pages/templates/user/fragments/editBio.html
-1
appview/pages/templates/user/fragments/editBio.html
+2
-1
appview/pages/templates/user/fragments/repoCard.html
+2
-1
appview/pages/templates/user/fragments/repoCard.html
···
1
1
{{ define "user/fragments/repoCard" }}
2
+
{{/* root, repo, fullName [,starButton [,starData]] */}}
2
3
{{ $root := index . 0 }}
3
4
{{ $repo := index . 1 }}
4
5
{{ $fullName := index . 2 }}
···
29
30
</div>
30
31
{{ if and $starButton $root.LoggedInUser }}
31
32
<div class="shrink-0">
32
-
{{ template "repo/fragments/repoStar" $starData }}
33
+
{{ template "fragments/starBtn" $starData }}
33
34
</div>
34
35
{{ end }}
35
36
</div>
+3
-5
appview/state/profile.go
+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
+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
+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
cmd/cborgen/cborgen.go
+1
cmd/cborgen/cborgen.go
+2
docs/spindle/pipeline.md
+2
docs/spindle/pipeline.md
···
18
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
19
- `push`: The workflow should run every time a commit is pushed to the repository.
20
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.
21
22
- `manual`: The workflow can be triggered manually.
22
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.
23
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.
24
26
25
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:
26
28
+4
-1
go.mod
+4
-1
go.mod
···
41
41
github.com/sethvargo/go-envconfig v1.1.0
42
42
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
43
43
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
44
-
github.com/stretchr/testify v1.10.0
44
+
github.com/stretchr/testify v1.11.1
45
45
github.com/urfave/cli/v3 v3.3.3
46
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
47
github.com/wyatt915/goldmark-treeblood v0.0.1
···
106
106
github.com/emirpasic/gods v1.18.1 // indirect
107
107
github.com/felixge/httpsnoop v1.0.4 // indirect
108
108
github.com/fsnotify/fsnotify v1.6.0 // indirect
109
+
github.com/go-co-op/gocron/v2 v2.18.2 // indirect
109
110
github.com/go-enry/go-oniguruma v1.2.1 // indirect
110
111
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
111
112
github.com/go-git/go-billy/v5 v5.6.2 // indirect
···
147
148
github.com/ipfs/go-log v1.0.5 // indirect
148
149
github.com/ipfs/go-log/v2 v2.6.0 // indirect
149
150
github.com/ipfs/go-metrics-interface v0.3.0 // indirect
151
+
github.com/jonboulle/clockwork v0.5.0 // indirect
150
152
github.com/json-iterator/go v1.1.12 // indirect
151
153
github.com/kevinburke/ssh_config v1.2.0 // indirect
152
154
github.com/klauspost/compress v1.18.0 // indirect
···
184
186
github.com/prometheus/common v0.64.0 // indirect
185
187
github.com/prometheus/procfs v0.16.1 // indirect
186
188
github.com/rivo/uniseg v0.4.7 // indirect
189
+
github.com/robfig/cron/v3 v3.0.1 // indirect
187
190
github.com/ryanuber/go-glob v1.0.0 // indirect
188
191
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
189
192
github.com/spaolacci/murmur3 v1.1.0 // indirect
+8
go.sum
+8
go.sum
···
160
160
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
161
161
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
162
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=
163
165
github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY=
164
166
github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
165
167
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
···
306
308
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
307
309
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
308
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=
309
313
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
310
314
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
311
315
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
444
448
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
445
449
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
446
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=
447
453
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
448
454
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
449
455
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
···
481
487
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
482
488
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
483
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=
484
492
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
485
493
github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
486
494
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
+13
lexicons/pipeline/pipeline.json
+13
lexicons/pipeline/pipeline.json
···
40
40
"enum": [
41
41
"push",
42
42
"pull_request",
43
+
"schedule",
43
44
"manual"
44
45
]
45
46
},
···
54
55
"pullRequest": {
55
56
"type": "ref",
56
57
"ref": "#pullRequestTriggerData"
58
+
},
59
+
"schedule": {
60
+
"type": "ref",
61
+
"ref": "#scheduleTriggerData"
57
62
},
58
63
"manual": {
59
64
"type": "ref",
···
129
134
"maxLength": 40
130
135
},
131
136
"action": {
137
+
"type": "string"
138
+
}
139
+
}
140
+
},
141
+
"scheduleTriggerData": {
142
+
"type": "object",
143
+
"properties": {
144
+
"cronExpression": {
132
145
"type": "string"
133
146
}
134
147
}
+1
-1
spindle/engines/nixery/engine.go
+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
-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
+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
+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
+15
-7
spindle/secrets/openbao.go
···
13
13
)
14
14
15
15
type OpenBaoManager struct {
16
-
client *vault.Client
17
-
mountPath string
18
-
logger *slog.Logger
16
+
client *vault.Client
17
+
mountPath string
18
+
logger *slog.Logger
19
+
connectionTimeout time.Duration
19
20
}
20
21
21
22
type OpenBaoManagerOpt func(*OpenBaoManager)
···
26
27
}
27
28
}
28
29
30
+
func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt {
31
+
return func(v *OpenBaoManager) {
32
+
v.connectionTimeout = timeout
33
+
}
34
+
}
35
+
29
36
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
37
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
38
// The proxy handles all authentication automatically via Auto-Auth
···
43
50
}
44
51
45
52
manager := &OpenBaoManager{
46
-
client: client,
47
-
mountPath: "spindle", // default KV v2 mount path
48
-
logger: logger,
53
+
client: client,
54
+
mountPath: "spindle", // default KV v2 mount path
55
+
logger: logger,
56
+
connectionTimeout: 10 * time.Second, // default connection timeout
49
57
}
50
58
51
59
for _, opt := range opts {
···
62
70
63
71
// testConnection verifies that we can connect to the proxy
64
72
func (v *OpenBaoManager) testConnection() error {
65
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
73
+
ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout)
66
74
defer cancel()
67
75
68
76
// try token self-lookup as a quick way to verify proxy works
+5
-2
spindle/secrets/openbao_test.go
+5
-2
spindle/secrets/openbao_test.go
···
152
152
for _, tt := range tests {
153
153
t.Run(tt.name, func(t *testing.T) {
154
154
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
155
+
// Use shorter timeout for tests to avoid long waits
156
+
opts := append(tt.opts, WithConnectionTimeout(1*time.Second))
157
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...)
156
158
157
159
if tt.expectError {
158
160
assert.Error(t, err)
···
596
598
597
599
// All these will fail because no real proxy is running
598
600
// but we can test that the configuration is properly accepted
599
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
601
+
// Use shorter timeout for tests to avoid long waits
602
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second))
600
603
assert.Error(t, err) // Expected because no real proxy
601
604
assert.Nil(t, manager)
602
605
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+18
spindle/server.go
+18
spindle/server.go
···
7
7
"fmt"
8
8
"log/slog"
9
9
"net/http"
10
+
"time"
10
11
11
12
"github.com/go-chi/chi/v5"
13
+
"github.com/go-co-op/gocron/v2"
14
+
12
15
"tangled.org/core/api/tangled"
13
16
"tangled.org/core/eventconsumer"
14
17
"tangled.org/core/eventconsumer/cursor"
···
47
50
ks *eventconsumer.Consumer
48
51
res *idresolver.Resolver
49
52
vault secrets.Manager
53
+
crons gocron.Scheduler
50
54
}
51
55
52
56
// New creates a new Spindle server with the provided configuration and engines.
···
116
120
117
121
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
118
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
+
119
129
spindle := &Spindle{
120
130
jc: jc,
121
131
e: e,
···
127
137
cfg: cfg,
128
138
res: resolver,
129
139
vault: vault,
140
+
crons: scheduler,
130
141
}
131
142
132
143
err = e.AddSpindle(rbacDomain)
···
200
211
return s.e
201
212
}
202
213
214
+
// Scheduler returns the cron scheduler instance.
215
+
func (s *Spindle) Scheduler() *gocron.Scheduler {
216
+
return &s.crons
217
+
}
218
+
203
219
// Start starts the Spindle server (blocking).
204
220
func (s *Spindle) Start(ctx context.Context) error {
205
221
// starts a job queue runner in the background
···
210
226
if stopper, ok := s.vault.(secrets.Stopper); ok {
211
227
defer stopper.Stop()
212
228
}
229
+
230
+
s.crons.Start()
213
231
214
232
go func() {
215
233
s.l.Info("starting knot event consumer")
+20
workflow/def.go
+20
workflow/def.go
···
9
9
"tangled.org/core/api/tangled"
10
10
11
11
"github.com/bmatcuk/doublestar/v4"
12
+
"github.com/go-co-op/gocron/v2"
12
13
"github.com/go-git/go-git/v5/plumbing"
13
14
"gopkg.in/yaml.v3"
14
15
)
···
36
37
Event StringList `yaml:"event"`
37
38
Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
38
39
Tag StringList `yaml:"tag"` // optional; only applies to push events
40
+
Cron StringList `yaml:"cron"` // required for schedule events
39
41
}
40
42
41
43
CloneOpts struct {
···
54
56
55
57
TriggerKindPush TriggerKind = "push"
56
58
TriggerKindPullRequest TriggerKind = "pull_request"
59
+
TriggerKindSchedule TriggerKind = "schedule"
57
60
TriggerKindManual TriggerKind = "manual"
58
61
)
59
62
···
145
148
return false, err
146
149
}
147
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
+
}
148
168
}
149
169
150
170
return match, nil
+16
workflow/def_test.go
+16
workflow/def_test.go
···
68
68
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
69
69
}
70
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
+
71
87
func TestMatchesPattern(t *testing.T) {
72
88
tests := []struct {
73
89
name string