Monorepo for Tangled tangled.org

[mega-merge]

Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me 3b14253c dc2cc2b3

verified
+11737 -3200
+3
.editorconfig
··· 1 1 root = true 2 2 3 + [*] 4 + insert_final_newline = true 5 + 3 6 [*.html] 4 7 indent_size = 2 5 8
+2
.gitattributes
··· 1 + api/tangled/** linguist-generated -diff 2 + flake.lock -diff
-2
api/tangled/repoblob.go
··· 21 21 Hash string `json:"hash" cborgen:"hash"` 22 22 // message: Commit message 23 23 Message string `json:"message" cborgen:"message"` 24 - // shortHash: Short commit hash 25 - ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 26 24 // when: Commit timestamp 27 25 When string `json:"when" cborgen:"when"` 28 26 }
+33
api/tangled/repotag.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tag 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagNSID = "sh.tangled.repo.tag" 16 + ) 17 + 18 + // RepoTag calls the XRPC method "sh.tangled.repo.tag". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // tag: Name of tag, such as v1.3.0 22 + func RepoTag(ctx context.Context, c util.LexClient, repo string, tag string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["repo"] = repo 27 + params["tag"] = tag 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tag", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+14 -2
api/tangled/repotree.go
··· 16 16 17 17 // RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema. 18 18 type RepoTree_LastCommit struct { 19 + Author *RepoTree_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 19 20 // hash: Commit hash 20 21 Hash string `json:"hash" cborgen:"hash"` 21 22 // message: Commit message ··· 27 28 // RepoTree_Output is the output of a sh.tangled.repo.tree call. 28 29 type RepoTree_Output struct { 29 30 // dotdot: Parent directory path 30 - Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 31 - Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 31 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 32 + Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 33 + LastCommit *RepoTree_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 32 34 // parent: The parent path in the tree 33 35 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 36 // readme: Readme for this file tree ··· 43 45 Contents string `json:"contents" cborgen:"contents"` 44 46 // filename: Name of the readme file 45 47 Filename string `json:"filename" cborgen:"filename"` 48 + } 49 + 50 + // RepoTree_Signature is a "signature" in the sh.tangled.repo.tree schema. 51 + type RepoTree_Signature struct { 52 + // email: Author email 53 + Email string `json:"email" cborgen:"email"` 54 + // name: Author name 55 + Name string `json:"name" cborgen:"name"` 56 + // when: Author timestamp 57 + When string `json:"when" cborgen:"when"` 46 58 } 47 59 48 60 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+194
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "fmt" 6 7 "log/slog" 7 8 "strings" 8 9 ··· 568 569 unique (from_at, to_at) 569 570 ); 570 571 572 + create table if not exists webhooks ( 573 + id integer primary key autoincrement, 574 + repo_at text not null, 575 + url text not null, 576 + secret text, 577 + active integer not null default 1, 578 + events text not null, -- comma-separated list of events 579 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 580 + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 581 + 582 + foreign key (repo_at) references repos(at_uri) on delete cascade 583 + ); 584 + 585 + create table if not exists webhook_deliveries ( 586 + id integer primary key autoincrement, 587 + webhook_id integer not null, 588 + event text not null, 589 + delivery_id text not null, 590 + url text not null, 591 + request_body text not null, 592 + response_code integer, 593 + response_body text, 594 + success integer not null default 0, 595 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 596 + 597 + foreign key (webhook_id) references webhooks(id) on delete cascade 598 + ); 599 + 571 600 create table if not exists migrations ( 572 601 id integer primary key autoincrement, 573 602 name text unique 574 603 ); 575 604 605 + create table if not exists punchcard_preferences ( 606 + id integer primary key autoincrement, 607 + user_did text not null unique, 608 + hide_mine integer default 0, 609 + hide_others integer default 0 610 + ); 611 + 576 612 -- indexes for better performance 577 613 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 578 614 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 579 615 create index if not exists idx_references_from_at on reference_links(from_at); 580 616 create index if not exists idx_references_to_at on reference_links(to_at); 617 + create index if not exists idx_webhooks_repo_at on webhooks(repo_at); 618 + create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id); 581 619 `) 582 620 if err != nil { 583 621 return nil, err ··· 1179 1217 alter table profile add column avatar text; 1180 1218 `) 1181 1219 return err 1220 + }) 1221 + 1222 + orm.RunMigration(conn, logger, "remove-profile-stats-column-constraint", func(tx *sql.Tx) error { 1223 + _, err := tx.Exec(` 1224 + -- create new table without the check constraint 1225 + create table profile_stats_new ( 1226 + id integer primary key autoincrement, 1227 + did text not null, 1228 + kind text not null, -- no constraint this time 1229 + foreign key (did) references profile(did) on delete cascade 1230 + ); 1231 + 1232 + -- copy data from old table 1233 + insert into profile_stats_new (id, did, kind) 1234 + select id, did, kind 1235 + from profile_stats; 1236 + 1237 + -- drop old table 1238 + drop table profile_stats; 1239 + 1240 + -- rename new table 1241 + alter table profile_stats_new rename to profile_stats; 1242 + `) 1243 + return err 1244 + }) 1245 + 1246 + // we cannot modify user-owned record on repository delete 1247 + orm.RunMigration(conn, logger, "remove-foreign-key-profile_pinned_repositories-and-repos", func(tx *sql.Tx) error { 1248 + _, err := tx.Exec(` 1249 + create table profile_pinned_repositories_new ( 1250 + did text not null, 1251 + 1252 + -- data 1253 + at_uri text not null, 1254 + 1255 + -- constraints 1256 + unique(did, at_uri), 1257 + foreign key (did) references profile(did) on delete cascade 1258 + ); 1259 + 1260 + insert into profile_pinned_repositories_new (did, at_uri) 1261 + select did, at_uri from profile_pinned_repositories; 1262 + 1263 + drop table profile_pinned_repositories; 1264 + alter table profile_pinned_repositories_new rename to profile_pinned_repositories; 1265 + `) 1266 + return err 1267 + }) 1268 + 1269 + // several changes here 1270 + // 1. remove autoincrement id for these tables 1271 + // 2. remove unique constraints other than (did, rkey) to handle non-unique atproto records 1272 + // 3. add generated at_uri field 1273 + // 1274 + // see comments below and commit message for details 1275 + orm.RunMigration(conn, logger, "flexible-stars-reactions-follows-public_keys", func(tx *sql.Tx) error { 1276 + // - add at_uri 1277 + // - remove unique constraint (did, subject_at) 1278 + if _, err := tx.Exec(` 1279 + create table stars_new ( 1280 + did text not null, 1281 + rkey text not null, 1282 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.feed.star' || '/' || rkey) stored, 1283 + 1284 + subject_at text not null, 1285 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1286 + 1287 + unique(did, rkey) 1288 + ); 1289 + 1290 + insert into stars_new (did, rkey, subject_at, created) 1291 + select did, rkey, subject_at, created from stars; 1292 + 1293 + drop table stars; 1294 + alter table stars_new rename to stars; 1295 + `); err != nil { 1296 + return fmt.Errorf("migrating stars: %w", err) 1297 + } 1298 + 1299 + // - add at_uri 1300 + // - reacted_by_did -> did 1301 + // - thread_at -> subject_at 1302 + // - remove unique constraint 1303 + if _, err := tx.Exec(` 1304 + create table reactions_new ( 1305 + did text not null, 1306 + rkey text not null, 1307 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.feed.reaction' || '/' || rkey) stored, 1308 + 1309 + subject_at text not null, 1310 + kind text not null, 1311 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1312 + 1313 + unique(did, rkey) 1314 + ); 1315 + 1316 + insert into reactions_new (did, rkey, subject_at, kind, created) 1317 + select reacted_by_did, rkey, thread_at, kind, created from reactions; 1318 + 1319 + drop table reactions; 1320 + alter table reactions_new rename to reactions; 1321 + `); err != nil { 1322 + return fmt.Errorf("migrating reactions: %w", err) 1323 + } 1324 + 1325 + // - add at_uri column 1326 + // - user_did -> did 1327 + // - followed_at -> created 1328 + // - remove unique constraint 1329 + // - remove check constraint 1330 + if _, err := tx.Exec(` 1331 + create table follows_new ( 1332 + did text not null, 1333 + rkey text not null, 1334 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.graph.follow' || '/' || rkey) stored, 1335 + 1336 + subject_did text not null, 1337 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1338 + 1339 + unique(did, rkey) 1340 + ); 1341 + 1342 + insert into follows_new (did, rkey, subject_did, created) 1343 + select user_did, rkey, subject_did, followed_at from follows; 1344 + 1345 + drop table follows; 1346 + alter table follows_new rename to follows; 1347 + `); err != nil { 1348 + return fmt.Errorf("migrating follows: %w", err) 1349 + } 1350 + 1351 + // - add at_uri column 1352 + // - remove foreign key relationship from repos 1353 + if _, err := tx.Exec(` 1354 + create table public_keys_new ( 1355 + did text not null, 1356 + rkey text not null, 1357 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.publicKey' || '/' || rkey) stored, 1358 + 1359 + name text not null, 1360 + key text not null, 1361 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1362 + 1363 + unique(did, rkey) 1364 + ); 1365 + 1366 + insert into public_keys_new (did, rkey, name, key, created) 1367 + select did, rkey, name, key, created from public_keys; 1368 + 1369 + drop table public_keys; 1370 + alter table public_keys_new rename to public_keys; 1371 + `); err != nil { 1372 + return fmt.Errorf("migrating public_keys: %w", err) 1373 + } 1374 + 1375 + return nil 1182 1376 }) 1183 1377 1184 1378 return &DB{
+41 -35
appview/db/follow.go
··· 6 6 "strings" 7 7 "time" 8 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/appview/models" 10 11 "tangled.org/core/orm" 11 12 ) 12 13 13 - func AddFollow(e Execer, follow *models.Follow) error { 14 - query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 15 - _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 14 + func UpsertFollow(e Execer, follow models.Follow) error { 15 + _, err := e.Exec( 16 + `insert into follows (did, rkey, subject_did, created) 17 + values (?, ?, ?, ?) 18 + on conflict(did, rkey) do update set 19 + subject_did = excluded.subject_did, 20 + created = excluded.created`, 21 + follow.UserDid, 22 + follow.Rkey, 23 + follow.SubjectDid, 24 + follow.FollowedAt.Format(time.RFC3339), 25 + ) 16 26 return err 17 27 } 18 28 19 - // Get a follow record 20 - func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 21 - query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 22 - row := e.QueryRow(query, userDid, subjectDid) 23 - 24 - var follow models.Follow 25 - var followedAt string 26 - err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 29 + // Remove a follow 30 + func DeleteFollow(e Execer, did, subjectDid syntax.DID) ([]syntax.ATURI, error) { 31 + var deleted []syntax.ATURI 32 + rows, err := e.Query( 33 + `delete from follows 34 + where did = ? and subject_did = ? 35 + returning at_uri`, 36 + did, 37 + subjectDid, 38 + ) 27 39 if err != nil { 28 - return nil, err 40 + return nil, fmt.Errorf("deleting stars: %w", err) 29 41 } 42 + defer rows.Close() 30 43 31 - followedAtTime, err := time.Parse(time.RFC3339, followedAt) 32 - if err != nil { 33 - log.Println("unable to determine followed at time") 34 - follow.FollowedAt = time.Now() 35 - } else { 36 - follow.FollowedAt = followedAtTime 44 + for rows.Next() { 45 + var aturi syntax.ATURI 46 + if err := rows.Scan(&aturi); err != nil { 47 + return nil, fmt.Errorf("scanning at_uri: %w", err) 48 + } 49 + deleted = append(deleted, aturi) 37 50 } 38 - 39 - return &follow, nil 40 - } 41 - 42 - // Remove a follow 43 - func DeleteFollow(e Execer, userDid, subjectDid string) error { 44 - _, err := e.Exec(`delete from follows where user_did = ? and subject_did = ?`, userDid, subjectDid) 45 - return err 51 + return deleted, nil 46 52 } 47 53 48 54 // Remove a follow 49 55 func DeleteFollowByRkey(e Execer, userDid, rkey string) error { 50 - _, err := e.Exec(`delete from follows where user_did = ? and rkey = ?`, userDid, rkey) 56 + _, err := e.Exec(`delete from follows where did = ? and rkey = ?`, userDid, rkey) 51 57 return err 52 58 } 53 59 ··· 56 62 err := e.QueryRow( 57 63 `SELECT 58 64 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 59 - COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 65 + COUNT(CASE WHEN did = ? THEN 1 END) AS following 60 66 FROM follows;`, did, did).Scan(&followers, &following) 61 67 if err != nil { 62 68 return models.FollowStats{}, err ··· 96 102 group by subject_did 97 103 ) f 98 104 full outer join ( 99 - select user_did as did, count(*) as following 105 + select did as did, count(*) as following 100 106 from follows 101 - where user_did in (%s) 102 - group by user_did 107 + where did in (%s) 108 + group by did 103 109 ) g on f.did = g.did`, 104 110 placeholderStr, placeholderStr) 105 111 ··· 156 162 } 157 163 158 164 query := fmt.Sprintf( 159 - `select user_did, subject_did, followed_at, rkey 165 + `select did, subject_did, created, rkey 160 166 from follows 161 167 %s 162 - order by followed_at desc 168 + order by created desc 163 169 %s 164 170 `, whereClause, limitClause) 165 171 ··· 198 204 } 199 205 200 206 func GetFollowing(e Execer, did string) ([]models.Follow, error) { 201 - return GetFollows(e, 0, orm.FilterEq("user_did", did)) 207 + return GetFollows(e, 0, orm.FilterEq("did", did)) 202 208 } 203 209 204 210 func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { ··· 239 245 query := fmt.Sprintf(` 240 246 SELECT subject_did 241 247 FROM follows 242 - WHERE user_did = ? AND subject_did IN (%s) 248 + WHERE did = ? AND subject_did IN (%s) 243 249 `, strings.Join(placeholders, ",")) 244 250 245 251 rows, err := e.Query(query, args...)
+48 -1
appview/db/pipeline.go
··· 170 170 171 171 // this is a mega query, but the most useful one: 172 172 // get N pipelines, for each one get the latest status of its N workflows 173 + // 174 + // the pipelines table is aliased to `p` 175 + // the triggers table is aliased to `t` 173 176 func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) { 174 177 var conditions []string 175 178 var args []any 176 179 for _, filter := range filters { 177 - filter.Key = "p." + filter.Key // the table is aliased in the query to `p` 178 180 conditions = append(conditions, filter.Condition()) 179 181 args = append(args, filter.Arg()...) 180 182 } ··· 366 368 367 369 return all, nil 368 370 } 371 + 372 + // the pipelines table is aliased to `p` 373 + // the triggers table is aliased to `t` 374 + func GetTotalPipelineStatuses(e Execer, filters ...orm.Filter) (int64, error) { 375 + var conditions []string 376 + var args []any 377 + for _, filter := range filters { 378 + conditions = append(conditions, filter.Condition()) 379 + args = append(args, filter.Arg()...) 380 + } 381 + 382 + whereClause := "" 383 + if conditions != nil { 384 + whereClause = " where " + strings.Join(conditions, " and ") 385 + } 386 + 387 + query := fmt.Sprintf(` 388 + select 389 + count(1) 390 + from 391 + pipelines p 392 + join 393 + triggers t ON p.trigger_id = t.id 394 + %s 395 + `, whereClause) 396 + 397 + rows, err := e.Query(query, args...) 398 + if err != nil { 399 + return 0, err 400 + } 401 + defer rows.Close() 402 + 403 + for rows.Next() { 404 + var count int64 405 + err := rows.Scan(&count) 406 + if err != nil { 407 + return 0, err 408 + } 409 + 410 + return count, nil 411 + } 412 + 413 + // unreachable 414 + return 0, nil 415 + }
+52
appview/db/preferences.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + 6 + "tangled.org/core/appview/models" 7 + ) 8 + 9 + func GetPunchcardPreference(e Execer, did string) (models.PunchcardPreference, error) { 10 + preference := models.PunchcardPreference{ 11 + Did: did, 12 + } 13 + 14 + hideMine := 0 15 + hideOthers := 0 16 + 17 + err := e.QueryRow( 18 + `select id, hide_mine, hide_others from punchcard_preferences where user_did = ?`, 19 + did, 20 + ).Scan(&preference.ID, &hideMine, &hideOthers) 21 + if err == sql.ErrNoRows { 22 + return preference, nil 23 + } 24 + 25 + preference.HideMine = hideMine > 0 26 + preference.HideOthers = hideOthers > 0 27 + 28 + if err != nil { 29 + return preference, err 30 + } 31 + 32 + return preference, nil 33 + } 34 + 35 + func UpsertPunchcardPreference(e Execer, did string, hideMine, hideOthers bool) error { 36 + _, err := e.Exec( 37 + `insert or replace into punchcard_preferences ( 38 + user_did, 39 + hide_mine, 40 + hide_others 41 + ) 42 + values (?, ?, ?)`, 43 + did, 44 + hideMine, 45 + hideOthers, 46 + ) 47 + if err != nil { 48 + return err 49 + } 50 + 51 + return nil 52 + }
+9 -7
appview/db/profile.go
··· 131 131 } 132 132 133 133 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 134 - defer tx.Rollback() 135 - 136 134 // update links 137 135 _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) 138 136 if err != nil { ··· 228 226 return err 229 227 } 230 228 } 231 - 232 - return tx.Commit() 229 + return nil 233 230 } 234 231 235 232 func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { ··· 360 357 did, 361 358 ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 362 359 if err == sql.ErrNoRows { 363 - profile := models.Profile{} 364 - profile.Did = did 365 - return &profile, nil 360 + return nil, nil 366 361 } 367 362 368 363 if err != nil { ··· 450 445 case models.VanityStatRepositoryCount: 451 446 query = `select count(id) from repos where did = ?` 452 447 args = append(args, did) 448 + case models.VanityStatStarCount: 449 + query = `select count(id) from stars where subject_at like 'at://' || ? || '%'` 450 + args = append(args, did) 451 + case models.VanityStatNone: 452 + return 0, nil 453 + default: 454 + return 0, fmt.Errorf("invalid vanity stat kind: %s", stat) 453 455 } 454 456 455 457 var result uint64
+17 -7
appview/db/pubkeys.go
··· 5 5 "time" 6 6 ) 7 7 8 - func AddPublicKey(e Execer, did, name, key, rkey string) error { 8 + func UpsertPublicKey(e Execer, pubKey models.PublicKey) error { 9 9 _, err := e.Exec( 10 - `insert or ignore into public_keys (did, name, key, rkey) 11 - values (?, ?, ?, ?)`, 12 - did, name, key, rkey) 10 + `insert into public_keys (did, rkey, name, key, created) 11 + values (?, ?, ?, ?, ?) 12 + on conflict(did, rkey) do update set 13 + name = excluded.name, 14 + key = excluded.key, 15 + created = excluded.created`, 16 + pubKey.Did, 17 + pubKey.Rkey, 18 + pubKey.Name, 19 + pubKey.Key, 20 + pubKey.Created.Format(time.RFC3339), 21 + ) 13 22 return err 14 23 } 15 24 16 - func DeletePublicKey(e Execer, did, name, key string) error { 25 + // for public_keys with empty rkey 26 + func DeletePublicKeyLegacy(e Execer, did, name string) error { 17 27 _, err := e.Exec(` 18 28 delete from public_keys 19 - where did = ? and name = ? and key = ?`, 20 - did, name, key) 29 + where did = ? and name = ? and rkey = ''`, 30 + did, name) 21 31 return err 22 32 } 23 33
+62 -48
appview/db/reaction.go
··· 1 1 package db 2 2 3 3 import ( 4 - "log" 4 + "fmt" 5 5 "time" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 8 "tangled.org/core/appview/models" 9 9 ) 10 10 11 - func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { 12 - query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 13 - _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 11 + func UpsertReaction(e Execer, reaction models.Reaction) error { 12 + _, err := e.Exec( 13 + `insert into reactions (did, rkey, subject_at, kind, created) 14 + values (?, ?, ?, ?, ?) 15 + on conflict(did, rkey) do update set 16 + subject_at = excluded.subject_at, 17 + kind = excluded.kind, 18 + created = excluded.created`, 19 + reaction.ReactedByDid, 20 + reaction.Rkey, 21 + reaction.ThreadAt, 22 + reaction.Kind, 23 + reaction.Created.Format(time.RFC3339), 24 + ) 14 25 return err 15 26 } 16 27 17 - // Get a reaction record 18 - func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { 19 - query := ` 20 - select reacted_by_did, thread_at, created, rkey 21 - from reactions 22 - where reacted_by_did = ? and thread_at = ? and kind = ?` 23 - row := e.QueryRow(query, reactedByDid, threadAt, kind) 24 - 25 - var reaction models.Reaction 26 - var created string 27 - err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 28 + // Remove a reaction 29 + func DeleteReaction(e Execer, did syntax.DID, subjectAt syntax.ATURI, kind models.ReactionKind) ([]syntax.ATURI, error) { 30 + var deleted []syntax.ATURI 31 + rows, err := e.Query( 32 + `delete from reactions 33 + where did = ? and subject_at = ? and kind = ? 34 + returning at_uri`, 35 + did, 36 + subjectAt, 37 + kind, 38 + ) 28 39 if err != nil { 29 - return nil, err 40 + return nil, fmt.Errorf("deleting stars: %w", err) 30 41 } 42 + defer rows.Close() 31 43 32 - createdAtTime, err := time.Parse(time.RFC3339, created) 33 - if err != nil { 34 - log.Println("unable to determine followed at time") 35 - reaction.Created = time.Now() 36 - } else { 37 - reaction.Created = createdAtTime 44 + for rows.Next() { 45 + var aturi syntax.ATURI 46 + if err := rows.Scan(&aturi); err != nil { 47 + return nil, fmt.Errorf("scanning at_uri: %w", err) 48 + } 49 + deleted = append(deleted, aturi) 38 50 } 39 - 40 - return &reaction, nil 41 - } 42 - 43 - // Remove a reaction 44 - func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { 45 - _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 46 - return err 51 + return deleted, nil 47 52 } 48 53 49 54 // Remove a reaction 50 - func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error { 51 - _, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey) 55 + func DeleteReactionByRkey(e Execer, did string, rkey string) error { 56 + _, err := e.Exec(`delete from reactions where did = ? and rkey = ?`, did, rkey) 52 57 return err 53 58 } 54 59 55 - func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { 60 + func GetReactionCount(e Execer, subjectAt syntax.ATURI, kind models.ReactionKind) (int, error) { 56 61 count := 0 57 62 err := e.QueryRow( 58 - `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) 63 + `select count(did) from reactions where subject_at = ? and kind = ?`, subjectAt, kind).Scan(&count) 59 64 if err != nil { 60 65 return 0, err 61 66 } 62 67 return count, nil 63 68 } 64 69 65 - func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 70 + func GetReactionMap(e Execer, userLimit int, subjectAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 71 query := ` 67 - select kind, reacted_by_did, 72 + select kind, did, 68 73 row_number() over (partition by kind order by created asc) as rn, 69 74 count(*) over (partition by kind) as total 70 75 from reactions 71 - where thread_at = ? 76 + where subject_at = ? 72 77 order by kind, created asc` 73 78 74 - rows, err := e.Query(query, threadAt) 79 + rows, err := e.Query(query, subjectAt) 75 80 if err != nil { 76 81 return nil, err 77 82 } ··· 101 106 return reactionMap, rows.Err() 102 107 } 103 108 104 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 105 - if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 106 - return false 107 - } else { 108 - return true 109 - } 109 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) (bool, error) { 110 + var exists bool 111 + err := e.QueryRow( 112 + `select exists ( 113 + select 1 from reactions 114 + where did = ? and subject_at = ? and kind = ? 115 + )`, 116 + userDid, 117 + threadAt, 118 + kind, 119 + ).Scan(&exists) 120 + return exists, err 110 121 } 111 122 112 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 123 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) (map[models.ReactionKind]bool, error) { 113 124 statusMap := map[models.ReactionKind]bool{} 114 125 for _, kind := range models.OrderedReactionKinds { 115 - count := GetReactionStatus(e, userDid, threadAt, kind) 116 - statusMap[kind] = count 126 + reacted, err := GetReactionStatus(e, userDid, threadAt, kind) 127 + if err != nil { 128 + return nil, err 129 + } 130 + statusMap[kind] = reacted 117 131 } 118 - return statusMap 132 + return statusMap, nil 119 133 }
+1 -1
appview/db/signup.go
··· 5 5 ) 6 6 7 7 func AddInflightSignup(e Execer, signup models.InflightSignup) error { 8 - query := `insert into signups_inflight (email, invite_code) values (?, ?)` 8 + query := `insert or replace into signups_inflight (email, invite_code) values (?, ?)` 9 9 _, err := e.Exec(query, signup.Email, signup.InviteCode) 10 10 return err 11 11 }
+27 -31
appview/db/star.go
··· 4 4 "database/sql" 5 5 "errors" 6 6 "fmt" 7 - "log" 8 7 "slices" 9 8 "strings" 10 9 "time" ··· 14 13 "tangled.org/core/orm" 15 14 ) 16 15 17 - func AddStar(e Execer, star *models.Star) error { 18 - query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 16 + func UpsertStar(e Execer, star models.Star) error { 19 17 _, err := e.Exec( 20 - query, 18 + `insert into stars (did, rkey, subject_at, created) 19 + values (?, ?, ?, ?) 20 + on conflict(did, rkey) do update set 21 + subject_at = excluded.subject_at, 22 + created = excluded.created`, 21 23 star.Did, 22 - star.RepoAt.String(), 23 24 star.Rkey, 25 + star.RepoAt, 26 + star.Created.Format(time.RFC3339), 24 27 ) 25 28 return err 26 29 } 27 30 28 - // Get a star record 29 - func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 30 - query := ` 31 - select did, subject_at, created, rkey 32 - from stars 33 - where did = ? and subject_at = ?` 34 - row := e.QueryRow(query, did, subjectAt) 35 - 36 - var star models.Star 37 - var created string 38 - err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 31 + // Remove a star 32 + func DeleteStar(tx *sql.Tx, did syntax.DID, subjectAt syntax.ATURI) ([]syntax.ATURI, error) { 33 + var deleted []syntax.ATURI 34 + rows, err := tx.Query( 35 + `delete from stars 36 + where did = ? and subject_at = ? 37 + returning at_uri`, 38 + did, 39 + subjectAt, 40 + ) 39 41 if err != nil { 40 - return nil, err 42 + return nil, fmt.Errorf("deleting stars: %w", err) 41 43 } 44 + defer rows.Close() 42 45 43 - createdAtTime, err := time.Parse(time.RFC3339, created) 44 - if err != nil { 45 - log.Println("unable to determine followed at time") 46 - star.Created = time.Now() 47 - } else { 48 - star.Created = createdAtTime 46 + for rows.Next() { 47 + var aturi syntax.ATURI 48 + if err := rows.Scan(&aturi); err != nil { 49 + return nil, fmt.Errorf("scanning at_uri: %w", err) 50 + } 51 + deleted = append(deleted, aturi) 49 52 } 50 - 51 - return &star, nil 52 - } 53 - 54 - // Remove a star 55 - func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { 56 - _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) 57 - return err 53 + return deleted, nil 58 54 } 59 55 60 56 // Remove a star
+1 -1
appview/db/timeline.go
··· 183 183 func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 184 184 filters := make([]orm.Filter, 0) 185 185 if userIsFollowing != nil { 186 - filters = append(filters, orm.FilterIn("user_did", userIsFollowing)) 186 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 187 187 } 188 188 189 189 follows, err := GetFollows(e, limit, filters...)
+298
appview/db/webhooks.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 + ) 13 + 14 + // GetWebhooks returns all webhooks for a repository 15 + func GetWebhooks(e Execer, filters ...orm.Filter) ([]models.Webhook, error) { 16 + var conditions []string 17 + var args []any 18 + for _, filter := range filters { 19 + conditions = append(conditions, filter.Condition()) 20 + args = append(args, filter.Arg()...) 21 + } 22 + 23 + whereClause := "" 24 + if conditions != nil { 25 + whereClause = " where " + strings.Join(conditions, " and ") 26 + } 27 + 28 + query := fmt.Sprintf(` 29 + select 30 + id, 31 + repo_at, 32 + url, 33 + secret, 34 + active, 35 + events, 36 + created_at, 37 + updated_at 38 + from webhooks 39 + %s 40 + order by created_at desc 41 + `, whereClause) 42 + 43 + rows, err := e.Query(query, args...) 44 + if err != nil { 45 + return nil, fmt.Errorf("failed to query webhooks: %w", err) 46 + } 47 + defer rows.Close() 48 + 49 + var webhooks []models.Webhook 50 + for rows.Next() { 51 + var wh models.Webhook 52 + var createdAt, updatedAt, eventsStr string 53 + var secret sql.NullString 54 + var active int 55 + 56 + err := rows.Scan( 57 + &wh.Id, 58 + &wh.RepoAt, 59 + &wh.Url, 60 + &secret, 61 + &active, 62 + &eventsStr, 63 + &createdAt, 64 + &updatedAt, 65 + ) 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to scan webhook: %w", err) 68 + } 69 + 70 + if secret.Valid { 71 + wh.Secret = secret.String 72 + } 73 + wh.Active = active == 1 74 + if eventsStr != "" { 75 + wh.Events = strings.Split(eventsStr, ",") 76 + } 77 + 78 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 79 + wh.CreatedAt = t 80 + } 81 + if t, err := time.Parse(time.RFC3339, updatedAt); err == nil { 82 + wh.UpdatedAt = t 83 + } 84 + 85 + webhooks = append(webhooks, wh) 86 + } 87 + 88 + if err = rows.Err(); err != nil { 89 + return nil, fmt.Errorf("failed to iterate webhooks: %w", err) 90 + } 91 + 92 + return webhooks, nil 93 + } 94 + 95 + // GetWebhook returns a single webhook by ID 96 + func GetWebhook(e Execer, id int64) (*models.Webhook, error) { 97 + webhooks, err := GetWebhooks(e, orm.FilterEq("id", id)) 98 + if err != nil { 99 + return nil, err 100 + } 101 + 102 + if len(webhooks) == 0 { 103 + return nil, sql.ErrNoRows 104 + } 105 + 106 + if len(webhooks) != 1 { 107 + return nil, fmt.Errorf("expected 1 webhook, got %d", len(webhooks)) 108 + } 109 + 110 + return &webhooks[0], nil 111 + } 112 + 113 + // AddWebhook creates a new webhook 114 + func AddWebhook(e Execer, webhook *models.Webhook) error { 115 + eventsStr := strings.Join(webhook.Events, ",") 116 + active := 0 117 + if webhook.Active { 118 + active = 1 119 + } 120 + 121 + result, err := e.Exec(` 122 + insert into webhooks (repo_at, url, secret, active, events) 123 + values (?, ?, ?, ?, ?) 124 + `, webhook.RepoAt.String(), webhook.Url, webhook.Secret, active, eventsStr) 125 + 126 + if err != nil { 127 + return fmt.Errorf("failed to insert webhook: %w", err) 128 + } 129 + 130 + id, err := result.LastInsertId() 131 + if err != nil { 132 + return fmt.Errorf("failed to get webhook id: %w", err) 133 + } 134 + 135 + webhook.Id = id 136 + return nil 137 + } 138 + 139 + // UpdateWebhook updates an existing webhook 140 + func UpdateWebhook(e Execer, webhook *models.Webhook) error { 141 + eventsStr := strings.Join(webhook.Events, ",") 142 + active := 0 143 + if webhook.Active { 144 + active = 1 145 + } 146 + 147 + _, err := e.Exec(` 148 + update webhooks 149 + set url = ?, secret = ?, active = ?, events = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 150 + where id = ? 151 + `, webhook.Url, webhook.Secret, active, eventsStr, webhook.Id) 152 + 153 + if err != nil { 154 + return fmt.Errorf("failed to update webhook: %w", err) 155 + } 156 + 157 + return nil 158 + } 159 + 160 + // DeleteWebhook deletes a webhook 161 + func DeleteWebhook(e Execer, id int64) error { 162 + _, err := e.Exec(`delete from webhooks where id = ?`, id) 163 + if err != nil { 164 + return fmt.Errorf("failed to delete webhook: %w", err) 165 + } 166 + return nil 167 + } 168 + 169 + // AddWebhookDelivery records a webhook delivery attempt 170 + func AddWebhookDelivery(e Execer, delivery *models.WebhookDelivery) error { 171 + success := 0 172 + if delivery.Success { 173 + success = 1 174 + } 175 + 176 + result, err := e.Exec(` 177 + insert into webhook_deliveries ( 178 + webhook_id, 179 + event, 180 + delivery_id, 181 + url, 182 + request_body, 183 + response_code, 184 + response_body, 185 + success 186 + ) values (?, ?, ?, ?, ?, ?, ?, ?) 187 + `, 188 + delivery.WebhookId, 189 + delivery.Event, 190 + delivery.DeliveryId, 191 + delivery.Url, 192 + delivery.RequestBody, 193 + delivery.ResponseCode, 194 + delivery.ResponseBody, 195 + success, 196 + ) 197 + 198 + if err != nil { 199 + return fmt.Errorf("failed to insert webhook delivery: %w", err) 200 + } 201 + 202 + id, err := result.LastInsertId() 203 + if err != nil { 204 + return fmt.Errorf("failed to get delivery id: %w", err) 205 + } 206 + 207 + delivery.Id = id 208 + return nil 209 + } 210 + 211 + // GetWebhookDeliveries returns recent deliveries for a webhook 212 + func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.WebhookDelivery, error) { 213 + if limit <= 0 { 214 + limit = 20 215 + } 216 + 217 + query := ` 218 + select 219 + id, 220 + webhook_id, 221 + event, 222 + delivery_id, 223 + url, 224 + request_body, 225 + response_code, 226 + response_body, 227 + success, 228 + created_at 229 + from webhook_deliveries 230 + where webhook_id = ? 231 + order by created_at desc 232 + limit ? 233 + ` 234 + 235 + rows, err := e.Query(query, webhookId, limit) 236 + if err != nil { 237 + return nil, fmt.Errorf("failed to query webhook deliveries: %w", err) 238 + } 239 + defer rows.Close() 240 + 241 + var deliveries []models.WebhookDelivery 242 + for rows.Next() { 243 + var d models.WebhookDelivery 244 + var createdAt string 245 + var success int 246 + var responseCode sql.NullInt64 247 + var responseBody sql.NullString 248 + 249 + err := rows.Scan( 250 + &d.Id, 251 + &d.WebhookId, 252 + &d.Event, 253 + &d.DeliveryId, 254 + &d.Url, 255 + &d.RequestBody, 256 + &responseCode, 257 + &responseBody, 258 + &success, 259 + &createdAt, 260 + ) 261 + if err != nil { 262 + return nil, fmt.Errorf("failed to scan delivery: %w", err) 263 + } 264 + 265 + d.Success = success == 1 266 + if responseCode.Valid { 267 + d.ResponseCode = int(responseCode.Int64) 268 + } 269 + if responseBody.Valid { 270 + d.ResponseBody = responseBody.String 271 + } 272 + 273 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 274 + d.CreatedAt = t 275 + } 276 + 277 + deliveries = append(deliveries, d) 278 + } 279 + 280 + if err = rows.Err(); err != nil { 281 + return nil, fmt.Errorf("failed to iterate deliveries: %w", err) 282 + } 283 + 284 + return deliveries, nil 285 + } 286 + 287 + // GetWebhooksForRepo is a convenience function to get all webhooks for a repository 288 + func GetWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) { 289 + return GetWebhooks(e, orm.FilterEq("repo_at", repoAt.String())) 290 + } 291 + 292 + // GetActiveWebhooksForRepo returns only active webhooks for a repository 293 + func GetActiveWebhooksForRepo(e Execer, repoAt syntax.ATURI) ([]models.Webhook, error) { 294 + return GetWebhooks(e, 295 + orm.FilterEq("repo_at", repoAt.String()), 296 + orm.FilterEq("active", 1), 297 + ) 298 + }
+7
appview/indexer/bleve/query.go
··· 13 13 return q 14 14 } 15 15 16 + func MatchPhraseQuery(field, phrase, analyzer string) query.Query { 17 + q := bleve.NewMatchPhraseQuery(phrase) 18 + q.FieldVal = field 19 + q.Analyzer = analyzer 20 + return q 21 + } 22 + 16 23 func BoolFieldQuery(field string, val bool) query.Query { 17 24 q := bleve.NewBoolFieldQuery(val) 18 25 q.FieldVal = field
+83 -25
appview/indexer/issues/indexer.go
··· 29 29 issueIndexerDocType = "issueIndexerDocType" 30 30 31 31 unicodeNormalizeName = "uicodeNormalize" 32 + 33 + // Bump this when the index mapping changes to trigger a rebuild. 34 + issueIndexerVersion = 2 32 35 ) 33 36 34 37 type Indexer struct { ··· 84 87 85 88 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 86 89 docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 90 + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 91 + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 87 92 88 93 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 89 94 "type": unicodenorm.Name, ··· 116 121 return false, errors.New("indexer is already initialized") 117 122 } 118 123 119 - indexer, err := openIndexer(ctx, ix.path) 124 + indexer, err := openIndexer(ctx, ix.path, issueIndexerVersion) 120 125 if err != nil { 121 126 return false, err 122 127 } ··· 133 138 if err != nil { 134 139 return false, err 135 140 } 141 + indexer.SetInternal([]byte("mapping_version"), []byte{byte(issueIndexerVersion)}) 136 142 137 143 ix.indexer = indexer 138 144 139 145 return false, nil 140 146 } 141 147 142 - func openIndexer(ctx context.Context, path string) (bleve.Index, error) { 148 + func openIndexer(ctx context.Context, path string, version int) (bleve.Index, error) { 143 149 l := tlog.FromContext(ctx) 144 150 indexer, err := bleve.Open(path) 145 151 if err != nil { ··· 149 155 } 150 156 return nil, nil 151 157 } 158 + 159 + storedVersion, _ := indexer.GetInternal([]byte("mapping_version")) 160 + if storedVersion == nil || int(storedVersion[0]) != version { 161 + l.Info("Indexer mapping version changed, deleting and rebuilding") 162 + indexer.Close() 163 + return nil, os.RemoveAll(path) 164 + } 165 + 152 166 return indexer, nil 153 167 } 154 168 ··· 168 182 return err 169 183 } 170 184 171 - // issueData data stored and will be indexed 172 185 type issueData struct { 173 - ID int64 `json:"id"` 174 - RepoAt string `json:"repo_at"` 175 - IssueID int `json:"issue_id"` 176 - Title string `json:"title"` 177 - Body string `json:"body"` 186 + ID int64 `json:"id"` 187 + RepoAt string `json:"repo_at"` 188 + IssueID int `json:"issue_id"` 189 + Title string `json:"title"` 190 + Body string `json:"body"` 191 + IsOpen bool `json:"is_open"` 192 + AuthorDid string `json:"author_did"` 193 + Labels []string `json:"labels"` 178 194 179 - IsOpen bool `json:"is_open"` 180 195 Comments []IssueCommentData `json:"comments"` 181 196 } 182 197 183 198 func makeIssueData(issue *models.Issue) *issueData { 184 199 return &issueData{ 185 - ID: issue.Id, 186 - RepoAt: issue.RepoAt.String(), 187 - IssueID: issue.IssueId, 188 - Title: issue.Title, 189 - Body: issue.Body, 190 - IsOpen: issue.Open, 200 + ID: issue.Id, 201 + RepoAt: issue.RepoAt.String(), 202 + IssueID: issue.IssueId, 203 + Title: issue.Title, 204 + Body: issue.Body, 205 + IsOpen: issue.Open, 206 + AuthorDid: issue.Did, 207 + Labels: issue.Labels.LabelNames(), 191 208 } 192 209 } 193 210 ··· 222 239 return ix.indexer.Delete(base36.Encode(issueId)) 223 240 } 224 241 225 - // Search searches for issues 226 242 func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) { 227 - var queries []query.Query 243 + var musts []query.Query 244 + var mustNots []query.Query 228 245 229 - if opts.Keyword != "" { 230 - queries = append(queries, bleve.NewDisjunctionQuery( 231 - bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0), 232 - bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0), 246 + for _, keyword := range opts.Keywords { 247 + musts = append(musts, bleve.NewDisjunctionQuery( 248 + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), 249 + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), 250 + )) 251 + } 252 + 253 + for _, phrase := range opts.Phrases { 254 + musts = append(musts, bleve.NewDisjunctionQuery( 255 + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), 256 + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), 257 + )) 258 + } 259 + 260 + for _, keyword := range opts.NegatedKeywords { 261 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 262 + bleveutil.MatchAndQuery("title", keyword, issueIndexerAnalyzer, 0), 263 + bleveutil.MatchAndQuery("body", keyword, issueIndexerAnalyzer, 0), 233 264 )) 234 265 } 235 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 236 - queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen)) 237 - // TODO: append more queries 238 266 239 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 267 + for _, phrase := range opts.NegatedPhrases { 268 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 269 + bleveutil.MatchPhraseQuery("title", phrase, issueIndexerAnalyzer), 270 + bleveutil.MatchPhraseQuery("body", phrase, issueIndexerAnalyzer), 271 + )) 272 + } 273 + 274 + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 275 + if opts.IsOpen != nil { 276 + musts = append(musts, bleveutil.BoolFieldQuery("is_open", *opts.IsOpen)) 277 + } 278 + 279 + if opts.AuthorDid != "" { 280 + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) 281 + } 282 + 283 + for _, label := range opts.Labels { 284 + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 285 + } 286 + 287 + if opts.NegatedAuthorDid != "" { 288 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 289 + } 290 + 291 + for _, label := range opts.NegatedLabels { 292 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 293 + } 294 + 295 + indexerQuery := bleve.NewBooleanQuery() 296 + indexerQuery.AddMust(musts...) 297 + indexerQuery.AddMustNot(mustNots...) 240 298 searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false) 241 299 res, err := ix.indexer.SearchInContext(ctx, searchReq) 242 300 if err != nil {
+264
appview/indexer/issues/indexer_test.go
··· 1 + package issues_indexer 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "testing" 7 + 8 + "github.com/blevesearch/bleve/v2" 9 + "github.com/stretchr/testify/assert" 10 + "github.com/stretchr/testify/require" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pagination" 13 + "tangled.org/core/appview/searchquery" 14 + ) 15 + 16 + func setupTestIndexer(t *testing.T) (*Indexer, func()) { 17 + t.Helper() 18 + 19 + tmpDir, err := os.MkdirTemp("", "issue_indexer_test") 20 + require.NoError(t, err) 21 + 22 + ix := NewIndexer(tmpDir) 23 + 24 + mapping, err := generateIssueIndexMapping() 25 + require.NoError(t, err) 26 + 27 + indexer, err := bleve.New(tmpDir, mapping) 28 + require.NoError(t, err) 29 + ix.indexer = indexer 30 + 31 + cleanup := func() { 32 + ix.indexer.Close() 33 + os.RemoveAll(tmpDir) 34 + } 35 + 36 + return ix, cleanup 37 + } 38 + 39 + func boolPtr(b bool) *bool { return &b } 40 + 41 + func makeLabelState(labels ...string) models.LabelState { 42 + state := models.NewLabelState() 43 + for _, label := range labels { 44 + state.Inner()[label] = make(map[string]struct{}) 45 + state.SetName(label, label) 46 + } 47 + return state 48 + } 49 + 50 + func TestSearchFilters(t *testing.T) { 51 + ix, cleanup := setupTestIndexer(t) 52 + defer cleanup() 53 + 54 + ctx := context.Background() 55 + 56 + err := ix.Index(ctx, 57 + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")}, 58 + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob", Labels: makeLabelState("feature")}, 59 + models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login timeout", Body: "Login takes too long", Open: false, Did: "did:plc:alice", Labels: makeLabelState("bug")}, 60 + ) 61 + require.NoError(t, err) 62 + 63 + opts := func() models.IssueSearchOptions { 64 + return models.IssueSearchOptions{ 65 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 66 + IsOpen: boolPtr(true), 67 + Page: pagination.Page{Limit: 10}, 68 + } 69 + } 70 + 71 + // Keyword in title 72 + o := opts() 73 + o.Keywords = []string{"bug"} 74 + result, err := ix.Search(ctx, o) 75 + require.NoError(t, err) 76 + assert.Equal(t, uint64(1), result.Total) 77 + assert.Contains(t, result.Hits, int64(1)) 78 + 79 + // Keyword in body 80 + o = opts() 81 + o.Keywords = []string{"theme"} 82 + result, err = ix.Search(ctx, o) 83 + require.NoError(t, err) 84 + assert.Equal(t, uint64(1), result.Total) 85 + assert.Contains(t, result.Hits, int64(2)) 86 + 87 + // Phrase match 88 + o = opts() 89 + o.Phrases = []string{"login bug"} 90 + result, err = ix.Search(ctx, o) 91 + require.NoError(t, err) 92 + assert.Equal(t, uint64(1), result.Total) 93 + assert.Contains(t, result.Hits, int64(1)) 94 + 95 + // Author filter 96 + o = opts() 97 + o.AuthorDid = "did:plc:alice" 98 + result, err = ix.Search(ctx, o) 99 + require.NoError(t, err) 100 + assert.Equal(t, uint64(1), result.Total) 101 + assert.Contains(t, result.Hits, int64(1)) 102 + 103 + // Label filter 104 + o = opts() 105 + o.Labels = []string{"bug"} 106 + result, err = ix.Search(ctx, o) 107 + require.NoError(t, err) 108 + assert.Equal(t, uint64(1), result.Total) 109 + assert.Contains(t, result.Hits, int64(1)) 110 + 111 + // State filter (closed) 112 + o = opts() 113 + o.IsOpen = boolPtr(false) 114 + o.Labels = []string{"bug"} 115 + result, err = ix.Search(ctx, o) 116 + require.NoError(t, err) 117 + assert.Equal(t, uint64(1), result.Total) 118 + assert.Contains(t, result.Hits, int64(3)) 119 + 120 + // Combined: keyword + author + label 121 + o = opts() 122 + o.Keywords = []string{"login"} 123 + o.AuthorDid = "did:plc:alice" 124 + o.Labels = []string{"bug"} 125 + result, err = ix.Search(ctx, o) 126 + require.NoError(t, err) 127 + assert.Equal(t, uint64(1), result.Total) 128 + assert.Contains(t, result.Hits, int64(1)) 129 + } 130 + 131 + func TestSearchLabelAND(t *testing.T) { 132 + ix, cleanup := setupTestIndexer(t) 133 + defer cleanup() 134 + 135 + ctx := context.Background() 136 + 137 + err := ix.Index(ctx, 138 + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue 1", Body: "Body", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")}, 139 + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue 2", Body: "Body", Open: true, Did: "did:plc:bob", Labels: makeLabelState("bug", "urgent")}, 140 + ) 141 + require.NoError(t, err) 142 + 143 + result, err := ix.Search(ctx, models.IssueSearchOptions{ 144 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 145 + IsOpen: boolPtr(true), 146 + Labels: []string{"bug", "urgent"}, 147 + Page: pagination.Page{Limit: 10}, 148 + }) 149 + require.NoError(t, err) 150 + assert.Equal(t, uint64(1), result.Total) 151 + assert.Contains(t, result.Hits, int64(2)) 152 + } 153 + 154 + func TestSearchNegation(t *testing.T) { 155 + ix, cleanup := setupTestIndexer(t) 156 + defer cleanup() 157 + 158 + ctx := context.Background() 159 + 160 + err := ix.Index(ctx, 161 + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug")}, 162 + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob", Labels: makeLabelState("feature")}, 163 + models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix timeout bug", Body: "Timeout on save", Open: true, Did: "did:plc:alice", Labels: makeLabelState("bug", "urgent")}, 164 + ) 165 + require.NoError(t, err) 166 + 167 + opts := func() models.IssueSearchOptions { 168 + return models.IssueSearchOptions{ 169 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 170 + IsOpen: boolPtr(true), 171 + Page: pagination.Page{Limit: 10}, 172 + } 173 + } 174 + 175 + // Negated label: exclude "bug", should only return issue 2 176 + o := opts() 177 + o.NegatedLabels = []string{"bug"} 178 + result, err := ix.Search(ctx, o) 179 + require.NoError(t, err) 180 + assert.Equal(t, uint64(1), result.Total) 181 + assert.Contains(t, result.Hits, int64(2)) 182 + 183 + // Negated keyword: exclude "login", should return issues 2 and 3 184 + o = opts() 185 + o.NegatedKeywords = []string{"login"} 186 + result, err = ix.Search(ctx, o) 187 + require.NoError(t, err) 188 + assert.Equal(t, uint64(2), result.Total) 189 + assert.Contains(t, result.Hits, int64(2)) 190 + assert.Contains(t, result.Hits, int64(3)) 191 + 192 + // Positive label + negated label: bug but not urgent 193 + o = opts() 194 + o.Labels = []string{"bug"} 195 + o.NegatedLabels = []string{"urgent"} 196 + result, err = ix.Search(ctx, o) 197 + require.NoError(t, err) 198 + assert.Equal(t, uint64(1), result.Total) 199 + assert.Contains(t, result.Hits, int64(1)) 200 + 201 + // Negated phrase 202 + o = opts() 203 + o.NegatedPhrases = []string{"dark theme"} 204 + result, err = ix.Search(ctx, o) 205 + require.NoError(t, err) 206 + assert.Equal(t, uint64(2), result.Total) 207 + assert.NotContains(t, result.Hits, int64(2)) 208 + } 209 + 210 + func TestSearchNegatedPhraseParsed(t *testing.T) { 211 + ix, cleanup := setupTestIndexer(t) 212 + defer cleanup() 213 + 214 + ctx := context.Background() 215 + 216 + err := ix.Index(ctx, 217 + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix login bug", Body: "Users cannot login", Open: true, Did: "did:plc:alice"}, 218 + models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Add dark mode", Body: "Implement dark theme", Open: true, Did: "did:plc:bob"}, 219 + models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Fix timeout bug", Body: "Timeout on save", Open: true, Did: "did:plc:alice"}, 220 + ) 221 + require.NoError(t, err) 222 + 223 + // Parse a query with a negated quoted phrase, as the handler would 224 + query := searchquery.Parse(`-"dark theme"`) 225 + var negatedPhrases []string 226 + for _, item := range query.Items() { 227 + if item.Kind == searchquery.KindQuoted && item.Negated { 228 + negatedPhrases = append(negatedPhrases, item.Value) 229 + } 230 + } 231 + require.Equal(t, []string{"dark theme"}, negatedPhrases) 232 + 233 + result, err := ix.Search(ctx, models.IssueSearchOptions{ 234 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 235 + IsOpen: boolPtr(true), 236 + NegatedPhrases: negatedPhrases, 237 + Page: pagination.Page{Limit: 10}, 238 + }) 239 + require.NoError(t, err) 240 + assert.Equal(t, uint64(2), result.Total) 241 + assert.NotContains(t, result.Hits, int64(2)) 242 + } 243 + 244 + func TestSearchNoResults(t *testing.T) { 245 + ix, cleanup := setupTestIndexer(t) 246 + defer cleanup() 247 + 248 + ctx := context.Background() 249 + 250 + err := ix.Index(ctx, 251 + models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Issue", Body: "Body", Open: true, Did: "did:plc:alice"}, 252 + ) 253 + require.NoError(t, err) 254 + 255 + result, err := ix.Search(ctx, models.IssueSearchOptions{ 256 + Keywords: []string{"nonexistent"}, 257 + RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 258 + IsOpen: boolPtr(true), 259 + Page: pagination.Page{Limit: 10}, 260 + }) 261 + require.NoError(t, err) 262 + assert.Equal(t, uint64(0), result.Total) 263 + assert.Empty(t, result.Hits) 264 + }
+18
appview/indexer/notifier.go
··· 38 38 } 39 39 } 40 40 41 + func (ix *Indexer) NewIssueLabelOp(ctx context.Context, issue *models.Issue) { 42 + l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 43 + l.Debug("reindexing issue after label change") 44 + err := ix.Issues.Index(ctx, *issue) 45 + if err != nil { 46 + l.Error("failed to index an issue", "err", err) 47 + } 48 + } 49 + 50 + func (ix *Indexer) NewPullLabelOp(ctx context.Context, pull *models.Pull) { 51 + l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 52 + l.Debug("reindexing pull after label change") 53 + err := ix.Pulls.Index(ctx, pull) 54 + if err != nil { 55 + l.Error("failed to index a pr", "err", err) 56 + } 57 + } 58 + 41 59 func (ix *Indexer) NewPull(ctx context.Context, pull *models.Pull) { 42 60 l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 43 61 l.Debug("indexing new pr")
+83 -24
appview/indexer/pulls/indexer.go
··· 28 28 pullIndexerDocType = "pullIndexerDocType" 29 29 30 30 unicodeNormalizeName = "uicodeNormalize" 31 + 32 + // Bump this when the index mapping changes to trigger a rebuild. 33 + pullIndexerVersion = 2 31 34 ) 32 35 33 36 type Indexer struct { ··· 79 82 80 83 docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 81 84 docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 85 + docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 86 + docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 82 87 83 88 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 84 89 "type": unicodenorm.Name, ··· 111 116 return false, errors.New("indexer is already initialized") 112 117 } 113 118 114 - indexer, err := openIndexer(ctx, ix.path) 119 + indexer, err := openIndexer(ctx, ix.path, pullIndexerVersion) 115 120 if err != nil { 116 121 return false, err 117 122 } ··· 128 133 if err != nil { 129 134 return false, err 130 135 } 136 + indexer.SetInternal([]byte("mapping_version"), []byte{byte(pullIndexerVersion)}) 131 137 132 138 ix.indexer = indexer 133 139 134 140 return false, nil 135 141 } 136 142 137 - func openIndexer(ctx context.Context, path string) (bleve.Index, error) { 143 + func openIndexer(ctx context.Context, path string, version int) (bleve.Index, error) { 138 144 l := tlog.FromContext(ctx) 139 145 indexer, err := bleve.Open(path) 140 146 if err != nil { ··· 144 150 } 145 151 return nil, nil 146 152 } 153 + 154 + storedVersion, _ := indexer.GetInternal([]byte("mapping_version")) 155 + if storedVersion == nil || int(storedVersion[0]) != version { 156 + l.Info("Indexer mapping version changed, deleting and rebuilding") 157 + indexer.Close() 158 + return nil, os.RemoveAll(path) 159 + } 160 + 147 161 return indexer, nil 148 162 } 149 163 ··· 163 177 return err 164 178 } 165 179 166 - // pullData data stored and will be indexed 167 180 type pullData struct { 168 - ID int64 `json:"id"` 169 - RepoAt string `json:"repo_at"` 170 - PullID int `json:"pull_id"` 171 - Title string `json:"title"` 172 - Body string `json:"body"` 173 - State string `json:"state"` 181 + ID int64 `json:"id"` 182 + RepoAt string `json:"repo_at"` 183 + PullID int `json:"pull_id"` 184 + Title string `json:"title"` 185 + Body string `json:"body"` 186 + State string `json:"state"` 187 + AuthorDid string `json:"author_did"` 188 + Labels []string `json:"labels"` 174 189 175 190 Comments []pullCommentData `json:"comments"` 176 191 } 177 192 178 193 func makePullData(pull *models.Pull) *pullData { 179 194 return &pullData{ 180 - ID: int64(pull.ID), 181 - RepoAt: pull.RepoAt.String(), 182 - PullID: pull.PullId, 183 - Title: pull.Title, 184 - Body: pull.Body, 185 - State: pull.State.String(), 195 + ID: int64(pull.ID), 196 + RepoAt: pull.RepoAt.String(), 197 + PullID: pull.PullId, 198 + Title: pull.Title, 199 + Body: pull.Body, 200 + State: pull.State.String(), 201 + AuthorDid: pull.OwnerDid, 202 + Labels: pull.Labels.LabelNames(), 186 203 } 187 204 } 188 205 ··· 217 234 return ix.indexer.Delete(base36.Encode(pullID)) 218 235 } 219 236 220 - // Search searches for pulls 221 237 func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { 222 - var queries []query.Query 238 + var musts []query.Query 239 + var mustNots []query.Query 223 240 224 241 // TODO(boltless): remove this after implementing pulls page pagination 225 242 limit := opts.Page.Limit ··· 227 244 limit = 500 228 245 } 229 246 230 - if opts.Keyword != "" { 231 - queries = append(queries, bleve.NewDisjunctionQuery( 232 - bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0), 233 - bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0), 247 + for _, keyword := range opts.Keywords { 248 + musts = append(musts, bleve.NewDisjunctionQuery( 249 + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), 250 + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), 251 + )) 252 + } 253 + 254 + for _, phrase := range opts.Phrases { 255 + musts = append(musts, bleve.NewDisjunctionQuery( 256 + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), 257 + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), 258 + )) 259 + } 260 + 261 + for _, keyword := range opts.NegatedKeywords { 262 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 263 + bleveutil.MatchAndQuery("title", keyword, pullIndexerAnalyzer, 0), 264 + bleveutil.MatchAndQuery("body", keyword, pullIndexerAnalyzer, 0), 265 + )) 266 + } 267 + 268 + for _, phrase := range opts.NegatedPhrases { 269 + mustNots = append(mustNots, bleve.NewDisjunctionQuery( 270 + bleveutil.MatchPhraseQuery("title", phrase, pullIndexerAnalyzer), 271 + bleveutil.MatchPhraseQuery("body", phrase, pullIndexerAnalyzer), 234 272 )) 235 273 } 236 - queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 237 - queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String())) 274 + 275 + musts = append(musts, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 276 + if opts.State != nil { 277 + musts = append(musts, bleveutil.KeywordFieldQuery("state", opts.State.String())) 278 + } 279 + 280 + if opts.AuthorDid != "" { 281 + musts = append(musts, bleveutil.KeywordFieldQuery("author_did", opts.AuthorDid)) 282 + } 238 283 239 - var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 284 + for _, label := range opts.Labels { 285 + musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 286 + } 287 + 288 + if opts.NegatedAuthorDid != "" { 289 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 290 + } 291 + 292 + for _, label := range opts.NegatedLabels { 293 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 294 + } 295 + 296 + indexerQuery := bleve.NewBooleanQuery() 297 + indexerQuery.AddMust(musts...) 298 + indexerQuery.AddMustNot(mustNots...) 240 299 searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) 241 300 res, err := ix.indexer.SearchInContext(ctx, searchReq) 242 301 if err != nil {
+39 -13
appview/ingester.go
··· 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/serververify" 22 - "tangled.org/core/appview/validator" 23 22 "tangled.org/core/idresolver" 24 23 "tangled.org/core/orm" 25 24 "tangled.org/core/rbac" ··· 31 30 IdResolver *idresolver.Resolver 32 31 Config *config.Config 33 32 Logger *slog.Logger 34 - Validator *validator.Validator 35 33 } 36 34 37 35 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 121 119 l.Error("invalid record", "err", err) 122 120 return err 123 121 } 124 - err = db.AddStar(i.Db, &models.Star{ 122 + err = db.UpsertStar(i.Db, models.Star{ 125 123 Did: did, 126 124 RepoAt: subjectUri, 127 125 Rkey: e.Commit.RKey, ··· 133 131 if err != nil { 134 132 return fmt.Errorf("failed to %s star record: %w", e.Commit.Operation, err) 135 133 } 134 + l.Info("processed star", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) 136 135 137 136 return nil 138 137 } ··· 154 153 return err 155 154 } 156 155 157 - err = db.AddFollow(i.Db, &models.Follow{ 156 + err = db.UpsertFollow(i.Db, models.Follow{ 158 157 UserDid: did, 159 158 SubjectDid: record.Subject, 160 159 Rkey: e.Commit.RKey, ··· 166 165 if err != nil { 167 166 return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) 168 167 } 168 + l.Info("processed follow", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) 169 169 170 170 return nil 171 171 } ··· 187 187 l.Error("invalid record", "err", err) 188 188 return err 189 189 } 190 + pubKey, err := models.PublicKeyFromRecord(syntax.DID(did), syntax.RecordKey(e.Commit.RKey), record) 191 + if err != nil { 192 + l.Error("invalid record", "err", err) 193 + return err 194 + } 195 + if err := pubKey.Validate(); err != nil { 196 + l.Error("invalid record", "err", err) 197 + return err 198 + } 190 199 191 - name := record.Name 192 - key := record.Key 193 - err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 200 + err = db.UpsertPublicKey(i.Db, pubKey) 194 201 case jmodels.CommitOperationDelete: 195 202 l.Debug("processing delete of pubkey") 196 203 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) ··· 199 206 if err != nil { 200 207 return fmt.Errorf("failed to %s pubkey record: %w", e.Commit.Operation, err) 201 208 } 209 + l.Info("processed pubkey", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) 202 210 203 211 return nil 204 212 } ··· 317 325 var stats [2]models.VanityStat 318 326 for i, s := range record.Stats { 319 327 if i < 2 { 320 - stats[i].Kind = models.VanityStatKind(s) 328 + stats[i].Kind = models.ParseVanityStatKind(s) 321 329 } 322 330 } 323 331 ··· 349 357 if err != nil { 350 358 return fmt.Errorf("failed to start transaction") 351 359 } 360 + defer tx.Rollback() 352 361 353 362 err = db.ValidateProfile(tx, &profile) 354 363 if err != nil { ··· 356 365 } 357 366 358 367 err = db.UpsertProfile(tx, &profile) 368 + if err != nil { 369 + return fmt.Errorf("upserting profile: %w", err) 370 + } 371 + 372 + err = tx.Commit() 359 373 case jmodels.CommitOperationDelete: 360 374 err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 361 375 } ··· 613 627 614 628 string := models.StringFromRecord(did, rkey, record) 615 629 616 - if err = i.Validator.ValidateString(&string); err != nil { 630 + if err = string.Validate(); err != nil { 617 631 l.Error("invalid record", "err", err) 618 632 return err 619 633 } ··· 822 836 823 837 issue := models.IssueFromRecord(did, rkey, record) 824 838 825 - if err := i.Validator.ValidateIssue(&issue); err != nil { 839 + if err := issue.Validate(); err != nil { 826 840 return fmt.Errorf("failed to validate issue: %w", err) 827 841 } 828 842 ··· 902 916 return fmt.Errorf("failed to parse comment from record: %w", err) 903 917 } 904 918 905 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 919 + if err := comment.Validate(); err != nil { 906 920 return fmt.Errorf("failed to validate comment: %w", err) 907 921 } 908 922 ··· 962 976 return fmt.Errorf("failed to parse labeldef from record: %w", err) 963 977 } 964 978 965 - if err := i.Validator.ValidateLabelDefinition(def); err != nil { 979 + if err := def.Validate(); err != nil { 966 980 return fmt.Errorf("failed to validate labeldef: %w", err) 967 981 } 968 982 ··· 1038 1052 if !ok { 1039 1053 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1040 1054 } 1041 - if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1055 + 1056 + // validate permissions: only collaborators can apply labels currently 1057 + // 1058 + // TODO: introduce a repo:triage permission 1059 + ok, err := i.Enforcer.IsPushAllowed(o.Did, repo.Knot, repo.DidSlashRepo()) 1060 + if err != nil { 1061 + return fmt.Errorf("enforcing permission: %w", err) 1062 + } 1063 + if !ok { 1064 + return fmt.Errorf("unauthorized label operation") 1065 + } 1066 + 1067 + if err := def.ValidateOperandValue(&o); err != nil { 1042 1068 return fmt.Errorf("failed to validate labelop: %w", err) 1043 1069 } 1044 1070 }
+146 -51
appview/issues/issues.go
··· 10 10 "time" 11 11 12 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + "github.com/bluesky-social/indigo/atproto/atclient" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" ··· 27 27 "tangled.org/core/appview/pages/repoinfo" 28 28 "tangled.org/core/appview/pagination" 29 29 "tangled.org/core/appview/reporesolver" 30 - "tangled.org/core/appview/validator" 30 + "tangled.org/core/appview/searchquery" 31 31 "tangled.org/core/idresolver" 32 32 "tangled.org/core/orm" 33 33 "tangled.org/core/rbac" ··· 45 45 config *config.Config 46 46 notifier notify.Notifier 47 47 logger *slog.Logger 48 - validator *validator.Validator 49 48 indexer *issues_indexer.Indexer 50 49 } 51 50 ··· 59 58 db *db.DB, 60 59 config *config.Config, 61 60 notifier notify.Notifier, 62 - validator *validator.Validator, 63 61 indexer *issues_indexer.Indexer, 64 62 logger *slog.Logger, 65 63 ) *Issues { ··· 74 72 config: config, 75 73 notifier: notifier, 76 74 logger: logger, 77 - validator: validator, 78 75 indexer: indexer, 79 76 } 80 77 } ··· 102 99 103 100 userReactions := map[models.ReactionKind]bool{} 104 101 if user != nil { 105 - userReactions = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 102 + userReactions, err = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 103 + if err != nil { 104 + l.Error("failed to get issue reaction status", "err", err) 105 + } 106 106 } 107 107 108 108 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) ··· 165 165 newIssue.Body = r.FormValue("body") 166 166 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 167 167 168 - if err := rp.validator.ValidateIssue(newIssue); err != nil { 168 + if err := newIssue.Validate(); err != nil { 169 169 l.Error("validation error", "err", err) 170 170 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 171 171 return ··· 424 424 Mentions: mentions, 425 425 References: references, 426 426 } 427 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 427 + if err = comment.Validate(); err != nil { 428 428 l.Error("failed to validate comment", "err", err) 429 429 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 430 430 return ··· 793 793 l := rp.logger.With("handler", "RepoIssues") 794 794 795 795 params := r.URL.Query() 796 - state := params.Get("state") 797 - isOpen := true 798 - switch state { 799 - case "open": 800 - isOpen = true 801 - case "closed": 802 - isOpen = false 803 - default: 804 - isOpen = true 805 - } 806 - 807 796 page := pagination.FromContext(r.Context()) 808 797 809 798 user := rp.oauth.GetMultiAccountUser(r) ··· 813 802 return 814 803 } 815 804 805 + query := searchquery.Parse(params.Get("q")) 806 + 807 + var isOpen *bool 808 + if urlState := params.Get("state"); urlState != "" { 809 + switch urlState { 810 + case "open": 811 + isOpen = ptrBool(true) 812 + case "closed": 813 + isOpen = ptrBool(false) 814 + } 815 + query.Set("state", urlState) 816 + } else if queryState := query.Get("state"); queryState != nil { 817 + switch *queryState { 818 + case "open": 819 + isOpen = ptrBool(true) 820 + case "closed": 821 + isOpen = ptrBool(false) 822 + } 823 + } else if _, hasQ := params["q"]; !hasQ { 824 + // no q param at all -- default to open 825 + isOpen = ptrBool(true) 826 + query.Set("state", "open") 827 + } 828 + 829 + var authorDid string 830 + if authorHandle := query.Get("author"); authorHandle != nil { 831 + identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) 832 + if err != nil { 833 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 834 + } else { 835 + authorDid = identity.DID.String() 836 + } 837 + } 838 + 839 + var negatedAuthorDid string 840 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 841 + identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 842 + if err != nil { 843 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 844 + } else { 845 + negatedAuthorDid = identity.DID.String() 846 + } 847 + } 848 + 849 + labels := query.GetAll("label") 850 + negatedLabels := query.GetAllNegated("label") 851 + 852 + var keywords, negatedKeywords []string 853 + var phrases, negatedPhrases []string 854 + for _, item := range query.Items() { 855 + switch item.Kind { 856 + case searchquery.KindKeyword: 857 + if item.Negated { 858 + negatedKeywords = append(negatedKeywords, item.Value) 859 + } else { 860 + keywords = append(keywords, item.Value) 861 + } 862 + case searchquery.KindQuoted: 863 + if item.Negated { 864 + negatedPhrases = append(negatedPhrases, item.Value) 865 + } else { 866 + phrases = append(phrases, item.Value) 867 + } 868 + } 869 + } 870 + 871 + searchOpts := models.IssueSearchOptions{ 872 + Keywords: keywords, 873 + Phrases: phrases, 874 + RepoAt: f.RepoAt().String(), 875 + IsOpen: isOpen, 876 + AuthorDid: authorDid, 877 + Labels: labels, 878 + NegatedKeywords: negatedKeywords, 879 + NegatedPhrases: negatedPhrases, 880 + NegatedLabels: negatedLabels, 881 + NegatedAuthorDid: negatedAuthorDid, 882 + Page: page, 883 + } 884 + 816 885 totalIssues := 0 817 - if isOpen { 886 + if isOpen == nil { 887 + totalIssues = f.RepoStats.IssueCount.Open + f.RepoStats.IssueCount.Closed 888 + } else if *isOpen { 818 889 totalIssues = f.RepoStats.IssueCount.Open 819 890 } else { 820 891 totalIssues = f.RepoStats.IssueCount.Closed 821 892 } 822 893 823 - keyword := params.Get("q") 894 + repoInfo := rp.repoResolver.GetRepoInfo(r, user) 824 895 825 896 var issues []models.Issue 826 - searchOpts := models.IssueSearchOptions{ 827 - Keyword: keyword, 828 - RepoAt: f.RepoAt().String(), 829 - IsOpen: isOpen, 830 - Page: page, 831 - } 832 - if keyword != "" { 897 + 898 + if searchOpts.HasSearchFilters() { 833 899 res, err := rp.indexer.Search(r.Context(), searchOpts) 834 900 if err != nil { 835 901 l.Error("failed to search for issues", "err", err) ··· 838 904 l.Debug("searched issues with indexer", "count", len(res.Hits)) 839 905 totalIssues = int(res.Total) 840 906 841 - issues, err = db.GetIssues( 842 - rp.db, 843 - orm.FilterIn("id", res.Hits), 844 - ) 845 - if err != nil { 846 - l.Error("failed to get issues", "err", err) 847 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 848 - return 907 + // update tab counts to reflect filtered results 908 + countOpts := searchOpts 909 + countOpts.Page = pagination.Page{Limit: 1} 910 + countOpts.IsOpen = ptrBool(true) 911 + if openRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 912 + repoInfo.Stats.IssueCount.Open = int(openRes.Total) 913 + } 914 + countOpts.IsOpen = ptrBool(false) 915 + if closedRes, err := rp.indexer.Search(r.Context(), countOpts); err == nil { 916 + repoInfo.Stats.IssueCount.Closed = int(closedRes.Total) 849 917 } 850 918 919 + if len(res.Hits) > 0 { 920 + issues, err = db.GetIssues( 921 + rp.db, 922 + orm.FilterIn("id", res.Hits), 923 + ) 924 + if err != nil { 925 + l.Error("failed to get issues", "err", err) 926 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 927 + return 928 + } 929 + } 851 930 } else { 852 - openInt := 0 853 - if isOpen { 854 - openInt = 1 931 + filters := []orm.Filter{ 932 + orm.FilterEq("repo_at", f.RepoAt()), 933 + } 934 + if isOpen != nil { 935 + openInt := 0 936 + if *isOpen { 937 + openInt = 1 938 + } 939 + filters = append(filters, orm.FilterEq("open", openInt)) 855 940 } 856 941 issues, err = db.GetIssuesPaginated( 857 942 rp.db, 858 943 page, 859 - orm.FilterEq("repo_at", f.RepoAt()), 860 - orm.FilterEq("open", openInt), 944 + filters..., 861 945 ) 862 946 if err != nil { 863 947 l.Error("failed to get issues", "err", err) ··· 882 966 defs[l.AtUri().String()] = &l 883 967 } 884 968 969 + filterState := "" 970 + if isOpen != nil { 971 + if *isOpen { 972 + filterState = "open" 973 + } else { 974 + filterState = "closed" 975 + } 976 + } 977 + 885 978 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 886 - LoggedInUser: rp.oauth.GetMultiAccountUser(r), 887 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 888 - Issues: issues, 889 - IssueCount: totalIssues, 890 - LabelDefs: defs, 891 - FilteringByOpen: isOpen, 892 - FilterQuery: keyword, 893 - Page: page, 979 + LoggedInUser: rp.oauth.GetMultiAccountUser(r), 980 + RepoInfo: repoInfo, 981 + Issues: issues, 982 + IssueCount: totalIssues, 983 + LabelDefs: defs, 984 + FilterState: filterState, 985 + FilterQuery: query.String(), 986 + Page: page, 894 987 }) 895 988 } 989 + 990 + func ptrBool(b bool) *bool { return &b } 896 991 897 992 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 898 993 l := rp.logger.With("handler", "NewIssue") ··· 927 1022 Repo: f, 928 1023 } 929 1024 930 - if err := rp.validator.ValidateIssue(issue); err != nil { 1025 + if err := issue.Validate(); err != nil { 931 1026 l.Error("validation error", "err", err) 932 1027 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 933 1028 return ··· 1003 1098 // this is used to rollback changes made to the PDS 1004 1099 // 1005 1100 // it is a no-op if the provided ATURI is empty 1006 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1101 + func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 1007 1102 if aturi == "" { 1008 1103 return nil 1009 1104 }
+25 -17
appview/issues/opengraph.go
··· 124 124 } 125 125 126 126 // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 - statusCommentsArea, dollyArea := statsArea.Split(true, 80) 127 + statusArea, dollyArea := statsArea.Split(true, 80) 128 128 129 129 // Draw status and comment count in status/comments area 130 - statsBounds := statusCommentsArea.Img.Bounds() 130 + statsBounds := statusArea.Img.Bounds() 131 131 statsX := statsBounds.Min.X + 60 // left padding 132 132 statsY := statsBounds.Min.Y 133 133 ··· 140 140 // Draw status (open/closed) with colored icon and text 141 141 var statusIcon string 142 142 var statusText string 143 - var statusBgColor color.RGBA 143 + var statusColor color.RGBA 144 144 145 145 if issue.Open { 146 146 statusIcon = "circle-dot" 147 147 statusText = "open" 148 - statusBgColor = color.RGBA{34, 139, 34, 255} // green 148 + statusColor = color.RGBA{34, 139, 34, 255} // green 149 149 } else { 150 150 statusIcon = "ban" 151 151 statusText = "closed" 152 - statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 152 + statusColor = color.RGBA{52, 58, 64, 255} // dark gray 153 153 } 154 154 155 - badgeIconSize := 36 155 + statusTextWidth := statusArea.TextWidth(statusText, textSize) 156 + badgePadding := 12 157 + badgeHeight := int(textSize) + (badgePadding * 2) 158 + badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) 159 + cornerRadius := 8 160 + badgeX := 60 161 + badgeY := 0 156 162 157 - // Draw icon with status color (no background) 158 - err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 163 + statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) 164 + 165 + whiteColor := color.RGBA{255, 255, 255, 255} 166 + iconX := statsX + badgePadding 167 + iconY := statsY + (badgeHeight-iconSize)/2 168 + err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) 159 169 if err != nil { 160 170 log.Printf("failed to draw status icon: %v", err) 161 171 } 162 172 163 - // Draw text with status color (no background) 164 - textX := statsX + badgeIconSize + 12 165 - badgeTextSize := 32.0 166 - err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left) 173 + textX := statsX + badgePadding + iconSize + badgePadding 174 + textY := statsY + (badgeHeight-int(textSize))/2 - 5 175 + err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) 167 176 if err != nil { 168 177 log.Printf("failed to draw status text: %v", err) 169 178 } 170 179 171 - statusTextWidth := len(statusText) * 20 172 - currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 180 + currentX := statsX + badgeWidth + 50 173 181 174 182 // Draw comment count 175 - err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 183 + err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) 176 184 if err != nil { 177 185 log.Printf("failed to draw comment icon: %v", err) 178 186 } ··· 182 190 if commentCount == 1 { 183 191 commentText = "1 comment" 184 192 } 185 - err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 193 + err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 186 194 if err != nil { 187 195 log.Printf("failed to draw comment text: %v", err) 188 196 } ··· 205 213 openedDate := issue.Created.Format("Jan 2, 2006") 206 214 metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 207 215 208 - err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 216 + err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 209 217 if err != nil { 210 218 log.Printf("failed to draw metadata: %v", err) 211 219 }
+47 -17
appview/labels/labels.go
··· 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/middleware" 15 15 "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/notify" 16 17 "tangled.org/core/appview/oauth" 17 18 "tangled.org/core/appview/pages" 18 - "tangled.org/core/appview/validator" 19 19 "tangled.org/core/orm" 20 20 "tangled.org/core/rbac" 21 21 "tangled.org/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 - atpclient "github.com/bluesky-social/indigo/atproto/client" 24 + "github.com/bluesky-social/indigo/atproto/atclient" 25 25 "github.com/bluesky-social/indigo/atproto/syntax" 26 26 lexutil "github.com/bluesky-social/indigo/lex/util" 27 27 "github.com/go-chi/chi/v5" 28 28 ) 29 29 30 30 type Labels struct { 31 - oauth *oauth.OAuth 32 - pages *pages.Pages 33 - db *db.DB 34 - logger *slog.Logger 35 - validator *validator.Validator 36 - enforcer *rbac.Enforcer 31 + oauth *oauth.OAuth 32 + pages *pages.Pages 33 + db *db.DB 34 + logger *slog.Logger 35 + enforcer *rbac.Enforcer 36 + notifier notify.Notifier 37 37 } 38 38 39 39 func New( 40 40 oauth *oauth.OAuth, 41 41 pages *pages.Pages, 42 42 db *db.DB, 43 - validator *validator.Validator, 44 43 enforcer *rbac.Enforcer, 44 + notifier notify.Notifier, 45 45 logger *slog.Logger, 46 46 ) *Labels { 47 47 return &Labels{ 48 - oauth: oauth, 49 - pages: pages, 50 - db: db, 51 - logger: logger, 52 - validator: validator, 53 - enforcer: enforcer, 48 + oauth: oauth, 49 + pages: pages, 50 + db: db, 51 + logger: logger, 52 + enforcer: enforcer, 53 + notifier: notifier, 54 54 } 55 55 } 56 56 ··· 163 163 164 164 for i := range labelOps { 165 165 def := actx.Defs[labelOps[i].OperandKey] 166 - if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 166 + op := labelOps[i] 167 + 168 + // validate permissions: only collaborators can apply labels currently 169 + // 170 + // TODO: introduce a repo:triage permission 171 + ok, err := l.enforcer.IsPushAllowed(op.Did, repo.Knot, repo.DidSlashRepo()) 172 + if err != nil { 173 + fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err)) 174 + return 175 + } 176 + if !ok { 177 + fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation")) 178 + return 179 + } 180 + 181 + if err := def.ValidateOperandValue(&op); err != nil { 167 182 fail(fmt.Sprintf("Invalid form data: %s", err), err) 168 183 return 169 184 } 185 + labelOps[i] = op 170 186 } 171 187 172 188 // reduce the opset ··· 245 261 // clear aturi when everything is successful 246 262 atUri = "" 247 263 264 + subject := syntax.ATURI(subjectUri) 265 + if subject.Collection() == tangled.RepoIssueNSID { 266 + issues, err := db.GetIssues(l.db, orm.FilterEq("at_uri", subjectUri)) 267 + if err == nil && len(issues) == 1 { 268 + l.notifier.NewIssueLabelOp(r.Context(), &issues[0]) 269 + } 270 + } 271 + if subject.Collection() == tangled.RepoPullNSID { 272 + pulls, err := db.GetPulls(l.db, orm.FilterEq("at_uri", subjectUri)) 273 + if err == nil && len(pulls) == 1 { 274 + l.notifier.NewPullLabelOp(r.Context(), pulls[0]) 275 + } 276 + } 277 + 248 278 l.pages.HxRefresh(w) 249 279 } 250 280 251 281 // this is used to rollback changes made to the PDS 252 282 // 253 283 // it is a no-op if the provided ATURI is empty 254 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 284 + func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 255 285 if aturi == "" { 256 286 return nil 257 287 }
+9
appview/models/follow.go
··· 2 2 3 3 import ( 4 4 "time" 5 + 6 + "tangled.org/core/api/tangled" 5 7 ) 6 8 7 9 type Follow struct { ··· 9 11 SubjectDid string 10 12 FollowedAt time.Time 11 13 Rkey string 14 + } 15 + 16 + func (f *Follow) AsRecord() tangled.GraphFollow { 17 + return tangled.GraphFollow{ 18 + Subject: f.SubjectDid, 19 + CreatedAt: f.FollowedAt.Format(time.RFC3339), 20 + } 12 21 } 13 22 14 23 type FollowStats struct {
+32
appview/models/issue.go
··· 3 3 import ( 4 4 "fmt" 5 5 "sort" 6 + "strings" 6 7 "time" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages/markup/sanitizer" 10 12 ) 11 13 12 14 type Issue struct { ··· 59 61 return "open" 60 62 } 61 63 return "closed" 64 + } 65 + 66 + var _ Validator = new(Issue) 67 + 68 + func (i *Issue) Validate() error { 69 + if i.Title == "" { 70 + return fmt.Errorf("issue title is empty") 71 + } 72 + if i.Body == "" { 73 + return fmt.Errorf("issue body is empty") 74 + } 75 + 76 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(i.Title)); st == "" { 77 + return fmt.Errorf("title is empty after HTML sanitization") 78 + } 79 + 80 + if st := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); st == "" { 81 + return fmt.Errorf("body is empty after HTML sanitization") 82 + } 83 + return nil 62 84 } 63 85 64 86 type CommentListItem struct { ··· 215 237 216 238 func (i *IssueComment) IsReply() bool { 217 239 return i.ReplyTo != nil 240 + } 241 + 242 + var _ Validator = new(IssueComment) 243 + 244 + func (i *IssueComment) Validate() error { 245 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); sb == "" { 246 + return fmt.Errorf("body is empty after HTML sanitization") 247 + } 248 + 249 + return nil 218 250 } 219 251 220 252 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+204 -4
appview/models/label.go
··· 7 7 "encoding/json" 8 8 "errors" 9 9 "fmt" 10 + "regexp" 10 11 "slices" 12 + "strings" 11 13 "time" 12 14 13 15 "github.com/bluesky-social/indigo/api/atproto" ··· 120 122 } 121 123 } 122 124 125 + var ( 126 + // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 127 + labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 128 + // Color should be a valid hex color 129 + colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 130 + // You can only label issues and pulls presently 131 + validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 132 + ) 133 + 134 + var _ Validator = new(LabelDefinition) 135 + 136 + func (l *LabelDefinition) Validate() error { 137 + if l.Name == "" { 138 + return fmt.Errorf("label name is empty") 139 + } 140 + if len(l.Name) > 40 { 141 + return fmt.Errorf("label name too long (max 40 graphemes)") 142 + } 143 + if len(l.Name) < 1 { 144 + return fmt.Errorf("label name too short (min 1 grapheme)") 145 + } 146 + if !labelNameRegex.MatchString(l.Name) { 147 + return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 148 + } 149 + 150 + if !l.ValueType.IsConcreteType() { 151 + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", l.ValueType.Type) 152 + } 153 + 154 + // null type checks: cannot be enums, multiple or explicit format 155 + if l.ValueType.IsNull() && l.ValueType.IsEnum() { 156 + return fmt.Errorf("null type cannot be used in conjunction with enum type") 157 + } 158 + if l.ValueType.IsNull() && l.Multiple { 159 + return fmt.Errorf("null type labels cannot be multiple") 160 + } 161 + if l.ValueType.IsNull() && !l.ValueType.IsAnyFormat() { 162 + return fmt.Errorf("format cannot be used in conjunction with null type") 163 + } 164 + 165 + // format checks: cannot be used with enum, or integers 166 + if !l.ValueType.IsAnyFormat() && l.ValueType.IsEnum() { 167 + return fmt.Errorf("enum types cannot be used in conjunction with format specification") 168 + } 169 + 170 + if !l.ValueType.IsAnyFormat() && !l.ValueType.IsString() { 171 + return fmt.Errorf("format specifications are only permitted on string types") 172 + } 173 + 174 + // validate scope (nsid format) 175 + if l.Scope == nil { 176 + return fmt.Errorf("scope is required") 177 + } 178 + for _, s := range l.Scope { 179 + if _, err := syntax.ParseNSID(s); err != nil { 180 + return fmt.Errorf("failed to parse scope: %w", err) 181 + } 182 + if !slices.Contains(validScopes, s) { 183 + return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 184 + } 185 + } 186 + 187 + // validate color if provided 188 + if l.Color != nil { 189 + color := strings.TrimSpace(*l.Color) 190 + if color == "" { 191 + // empty color is fine, set to nil 192 + l.Color = nil 193 + } else { 194 + if !colorRegex.MatchString(color) { 195 + return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 196 + } 197 + // expand 3-digit hex to 6-digit hex 198 + if len(color) == 4 { // #ABC 199 + color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 200 + } 201 + // convert to uppercase for consistency 202 + color = strings.ToUpper(color) 203 + l.Color = &color 204 + } 205 + } 206 + 207 + return nil 208 + } 209 + 210 + // ValidateOperandValue validates the label operation operand value based on 211 + // label definition. 212 + // 213 + // NOTE: This can modify the [LabelOp] 214 + func (def *LabelDefinition) ValidateOperandValue(op *LabelOp) error { 215 + expectedKey := def.AtUri().String() 216 + if op.OperandKey != def.AtUri().String() { 217 + return fmt.Errorf("operand key %q does not match label definition URI %q", op.OperandKey, expectedKey) 218 + } 219 + 220 + valueType := def.ValueType 221 + 222 + // this is permitted, it "unsets" a label 223 + if op.OperandValue == "" { 224 + op.Operation = LabelOperationDel 225 + return nil 226 + } 227 + 228 + switch valueType.Type { 229 + case ConcreteTypeNull: 230 + // For null type, value should be empty 231 + if op.OperandValue != "null" { 232 + return fmt.Errorf("null type requires empty value, got %q", op.OperandValue) 233 + } 234 + 235 + case ConcreteTypeString: 236 + // For string type, validate enum constraints if present 237 + if valueType.IsEnum() { 238 + if !slices.Contains(valueType.Enum, op.OperandValue) { 239 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 240 + } 241 + } 242 + 243 + switch valueType.Format { 244 + case ValueTypeFormatDid: 245 + if _, err := syntax.ParseDID(op.OperandValue); err != nil { 246 + return fmt.Errorf("failed to resolve did/handle: %w", err) 247 + } 248 + case ValueTypeFormatAny, "": 249 + default: 250 + return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 251 + } 252 + 253 + case ConcreteTypeInt: 254 + if op.OperandValue == "" { 255 + return fmt.Errorf("integer type requires non-empty value") 256 + } 257 + if _, err := fmt.Sscanf(op.OperandValue, "%d", new(int)); err != nil { 258 + return fmt.Errorf("value %q is not a valid integer", op.OperandValue) 259 + } 260 + 261 + if valueType.IsEnum() { 262 + if !slices.Contains(valueType.Enum, op.OperandValue) { 263 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 264 + } 265 + } 266 + 267 + case ConcreteTypeBool: 268 + if op.OperandValue != "true" && op.OperandValue != "false" { 269 + return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", op.OperandValue) 270 + } 271 + 272 + // validate enum constraints if present (though uncommon for booleans) 273 + if valueType.IsEnum() { 274 + if !slices.Contains(valueType.Enum, op.OperandValue) { 275 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 276 + } 277 + } 278 + 279 + default: 280 + return fmt.Errorf("unsupported value type: %q", valueType.Type) 281 + } 282 + 283 + return nil 284 + } 285 + 123 286 // random color for a given seed 124 287 func randomColor(seed string) string { 125 288 hash := sha1.Sum([]byte(seed)) ··· 131 294 return fmt.Sprintf("#%s%s%s", r, g, b) 132 295 } 133 296 134 - func (ld LabelDefinition) GetColor() string { 135 - if ld.Color == nil { 136 - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 297 + func (l LabelDefinition) GetColor() string { 298 + if l.Color == nil { 299 + seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey) 137 300 color := randomColor(seed) 138 301 return color 139 302 } 140 303 141 - return *ld.Color 304 + return *l.Color 142 305 } 143 306 144 307 func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { ··· 205 368 return indexedAt 206 369 } 207 370 371 + var _ Validator = new(LabelOp) 372 + 373 + func (l *LabelOp) Validate() error { 374 + if _, err := syntax.ParseATURI(string(l.Subject)); err != nil { 375 + return fmt.Errorf("invalid subject URI: %w", err) 376 + } 377 + if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel { 378 + return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation) 379 + } 380 + // Validate performed time is not zero/invalid 381 + if l.PerformedAt.IsZero() { 382 + return fmt.Errorf("performed_at timestamp is required") 383 + } 384 + return nil 385 + } 386 + 208 387 type LabelOperation string 209 388 210 389 const ( ··· 291 470 292 471 type LabelState struct { 293 472 inner map[string]set 473 + names map[string]string 294 474 } 295 475 296 476 func NewLabelState() LabelState { 297 477 return LabelState{ 298 478 inner: make(map[string]set), 479 + names: make(map[string]string), 299 480 } 300 481 } 301 482 483 + func (s LabelState) LabelNames() []string { 484 + var result []string 485 + for key, valset := range s.inner { 486 + if valset == nil { 487 + continue 488 + } 489 + if name, ok := s.names[key]; ok { 490 + result = append(result, name) 491 + } 492 + } 493 + return result 494 + } 495 + 302 496 func (s LabelState) Inner() map[string]set { 303 497 return s.inner 498 + } 499 + 500 + func (s LabelState) SetName(key, name string) { 501 + s.names[key] = name 304 502 } 305 503 306 504 func (s LabelState) ContainsLabel(l string) bool { ··· 347 545 // this def was deleted, but an op exists, so we just skip over the op 348 546 return nil 349 547 } 548 + 549 + state.names[op.OperandKey] = def.Name 350 550 351 551 switch op.Operation { 352 552 case LabelOperationAdd:
+8
appview/models/preferences.go
··· 1 + package models 2 + 3 + type PunchcardPreference struct { 4 + ID int 5 + Did string 6 + HideMine bool 7 + HideOthers bool 8 + }
+27 -1
appview/models/profile.go
··· 59 59 VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 60 60 VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 61 61 VanityStatRepositoryCount VanityStatKind = "repository-count" 62 + VanityStatStarCount VanityStatKind = "star-count" 63 + VanityStatNone VanityStatKind = "" 62 64 ) 63 65 66 + func ParseVanityStatKind(s string) VanityStatKind { 67 + switch s { 68 + case "merged-pull-request-count": 69 + return VanityStatMergedPRCount 70 + case "closed-pull-request-count": 71 + return VanityStatClosedPRCount 72 + case "open-pull-request-count": 73 + return VanityStatOpenPRCount 74 + case "open-issue-count": 75 + return VanityStatOpenIssueCount 76 + case "closed-issue-count": 77 + return VanityStatClosedIssueCount 78 + case "repository-count": 79 + return VanityStatRepositoryCount 80 + case "star-count": 81 + return VanityStatStarCount 82 + default: 83 + return VanityStatNone 84 + } 85 + } 86 + 64 87 func (v VanityStatKind) String() string { 65 88 switch v { 66 89 case VanityStatMergedPRCount: ··· 75 98 return "Closed Issues" 76 99 case VanityStatRepositoryCount: 77 100 return "Repositories" 101 + case VanityStatStarCount: 102 + return "Stars Received" 103 + default: 104 + return "" 78 105 } 79 - return "" 80 106 } 81 107 82 108 type VanityStat struct {
+38
appview/models/pubkey.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/gliderlabs/ssh" 10 + "tangled.org/core/api/tangled" 6 11 ) 7 12 8 13 type PublicKey struct { ··· 23 28 Alias: (*Alias)(&p), 24 29 }) 25 30 } 31 + 32 + func (p *PublicKey) AsRecord() tangled.PublicKey { 33 + return tangled.PublicKey{ 34 + Name: p.Name, 35 + Key: p.Key, 36 + CreatedAt: p.Created.Format(time.RFC3339), 37 + } 38 + } 39 + 40 + var _ Validator = new(PublicKey) 41 + 42 + func (p *PublicKey) Validate() error { 43 + if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(p.Key)); err != nil { 44 + return fmt.Errorf("invalid ssh key format: %w", err) 45 + } 46 + 47 + return nil 48 + } 49 + 50 + func PublicKeyFromRecord(did syntax.DID, rkey syntax.RecordKey, record tangled.PublicKey) (PublicKey, error) { 51 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 52 + if err != nil { 53 + return PublicKey{}, fmt.Errorf("invalid time format '%s'", record.CreatedAt) 54 + } 55 + 56 + return PublicKey{ 57 + Did: did.String(), 58 + Rkey: rkey.String(), 59 + Name: record.Name, 60 + Key: record.Key, 61 + Created: &created, 62 + }, nil 63 + }
+9
appview/models/reaction.go
··· 4 4 "time" 5 5 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 7 8 ) 8 9 9 10 type ReactionKind string ··· 54 55 Created time.Time 55 56 Rkey string 56 57 Kind ReactionKind 58 + } 59 + 60 + func (r *Reaction) AsRecord() tangled.FeedReaction { 61 + return tangled.FeedReaction{ 62 + Subject: r.ThreadAt.String(), 63 + Reaction: r.Kind.String(), 64 + CreatedAt: r.Created.Format(time.RFC3339), 65 + } 57 66 } 58 67 59 68 type ReactionDisplayData struct {
+35 -17
appview/models/search.go
··· 3 3 import "tangled.org/core/appview/pagination" 4 4 5 5 type IssueSearchOptions struct { 6 - Keyword string 7 - RepoAt string 8 - IsOpen bool 6 + Keywords []string 7 + Phrases []string 8 + RepoAt string 9 + IsOpen *bool 10 + AuthorDid string 11 + Labels []string 12 + 13 + NegatedKeywords []string 14 + NegatedPhrases []string 15 + NegatedLabels []string 16 + NegatedAuthorDid string 9 17 10 18 Page pagination.Page 11 19 } 12 20 21 + func (o *IssueSearchOptions) HasSearchFilters() bool { 22 + return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 23 + o.AuthorDid != "" || o.NegatedAuthorDid != "" || 24 + len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 25 + len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 26 + } 27 + 13 28 type PullSearchOptions struct { 14 - Keyword string 15 - RepoAt string 16 - State PullState 29 + Keywords []string 30 + Phrases []string 31 + RepoAt string 32 + State *PullState 33 + AuthorDid string 34 + Labels []string 35 + 36 + NegatedKeywords []string 37 + NegatedPhrases []string 38 + NegatedLabels []string 39 + NegatedAuthorDid string 17 40 18 41 Page pagination.Page 19 42 } 20 43 21 - // func (so *SearchOptions) ToFilters() []filter { 22 - // var filters []filter 23 - // if so.IsOpen != nil { 24 - // openValue := 0 25 - // if *so.IsOpen { 26 - // openValue = 1 27 - // } 28 - // filters = append(filters, FilterEq("open", openValue)) 29 - // } 30 - // return filters 31 - // } 44 + func (o *PullSearchOptions) HasSearchFilters() bool { 45 + return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 46 + o.AuthorDid != "" || o.NegatedAuthorDid != "" || 47 + len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 48 + len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 49 + }
+8
appview/models/star.go
··· 4 4 "time" 5 5 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 7 8 ) 8 9 9 10 type Star struct { ··· 11 12 RepoAt syntax.ATURI 12 13 Created time.Time 13 14 Rkey string 15 + } 16 + 17 + func (s *Star) AsRecord() tangled.FeedStar { 18 + return tangled.FeedStar{ 19 + Subject: s.RepoAt.String(), 20 + CreatedAt: s.Created.Format(time.RFC3339), 21 + } 14 22 } 15 23 16 24 // RepoStar is used for reverse mapping to repos
+21
appview/models/string.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "errors" 5 6 "fmt" 6 7 "io" 7 8 "strings" 8 9 "time" 10 + "unicode/utf8" 9 11 10 12 "github.com/bluesky-social/indigo/atproto/syntax" 11 13 "tangled.org/core/api/tangled" ··· 33 35 Contents: s.Contents, 34 36 CreatedAt: s.Created.Format(time.RFC3339), 35 37 } 38 + } 39 + 40 + var _ Validator = new(String) 41 + 42 + func (s *String) Validate() error { 43 + var err error 44 + if utf8.RuneCountInString(s.Filename) > 140 { 45 + err = errors.Join(err, fmt.Errorf("filename too long")) 46 + } 47 + 48 + if utf8.RuneCountInString(s.Description) > 280 { 49 + err = errors.Join(err, fmt.Errorf("description too long")) 50 + } 51 + 52 + if len(s.Contents) == 0 { 53 + err = errors.Join(err, fmt.Errorf("contents is empty")) 54 + } 55 + 56 + return err 36 57 } 37 58 38 59 func StringFromRecord(did, rkey string, record tangled.String) String {
+6
appview/models/validator.go
··· 1 + package models 2 + 3 + type Validator interface { 4 + // Validate checks the object and returns any error. 5 + Validate() error 6 + }
+74
appview/models/webhook.go
··· 1 + package models 2 + 3 + import ( 4 + "slices" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type WebhookEvent string 11 + 12 + const ( 13 + WebhookEventPush WebhookEvent = "push" 14 + ) 15 + 16 + type Webhook struct { 17 + Id int64 18 + RepoAt syntax.ATURI 19 + Url string 20 + Secret string 21 + Active bool 22 + Events []string // comma-separated event types 23 + CreatedAt time.Time 24 + UpdatedAt time.Time 25 + } 26 + 27 + // HasEvent checks if the webhook is subscribed to a specific event 28 + func (w *Webhook) HasEvent(event WebhookEvent) bool { 29 + return slices.Contains(w.Events, string(event)) 30 + } 31 + 32 + type WebhookDelivery struct { 33 + Id int64 34 + WebhookId int64 35 + Event string 36 + DeliveryId string // UUID for tracking 37 + Url string 38 + RequestBody string 39 + ResponseCode int 40 + ResponseBody string 41 + Success bool 42 + CreatedAt time.Time 43 + } 44 + 45 + // WebhookPayload represents the webhook payload structure 46 + type WebhookPayload struct { 47 + Ref string `json:"ref"` 48 + Before string `json:"before"` 49 + After string `json:"after"` 50 + Repository WebhookRepository `json:"repository"` 51 + Pusher WebhookUser `json:"pusher"` 52 + } 53 + 54 + // WebhookRepository represents repository information in webhook payload 55 + type WebhookRepository struct { 56 + Name string `json:"name"` 57 + FullName string `json:"full_name"` 58 + Description string `json:"description"` 59 + Fork bool `json:"fork"` 60 + HtmlUrl string `json:"html_url"` 61 + CloneUrl string `json:"clone_url"` 62 + SshUrl string `json:"ssh_url"` 63 + Website string `json:"website,omitempty"` 64 + StarsCount int `json:"stars_count,omitempty"` 65 + OpenIssues int `json:"open_issues_count,omitempty"` 66 + CreatedAt string `json:"created_at"` 67 + UpdatedAt string `json:"updated_at"` 68 + Owner WebhookUser `json:"owner"` 69 + } 70 + 71 + // WebhookUser represents user information in webhook payload 72 + type WebhookUser struct { 73 + Did string `json:"did"` 74 + }
+50 -15
appview/notify/db/db.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 5 "slices" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" ··· 11 10 "tangled.org/core/appview/models" 12 11 "tangled.org/core/appview/notify" 13 12 "tangled.org/core/idresolver" 13 + "tangled.org/core/log" 14 14 "tangled.org/core/orm" 15 15 "tangled.org/core/sets" 16 16 ) ··· 38 38 } 39 39 40 40 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 41 + l := log.FromContext(ctx) 42 + 41 43 if star.RepoAt.Collection().String() != tangled.RepoNSID { 42 44 // skip string stars for now 43 45 return ··· 45 47 var err error 46 48 repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(star.RepoAt))) 47 49 if err != nil { 48 - log.Printf("NewStar: failed to get repos: %v", err) 50 + l.Error("failed to get repos", "err", err) 49 51 return 50 52 } 51 53 ··· 59 61 var pullId *int64 60 62 61 63 n.notifyEvent( 64 + ctx, 62 65 actorDid, 63 66 recipients, 64 67 eventType, ··· 75 78 } 76 79 77 80 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 81 + l := log.FromContext(ctx) 82 + 78 83 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 79 84 if err != nil { 80 - log.Printf("failed to fetch collaborators: %v", err) 85 + l.Error("failed to fetch collaborators", "err", err) 81 86 return 82 87 } 83 88 ··· 101 106 var pullId *int64 102 107 103 108 n.notifyEvent( 109 + ctx, 104 110 actorDid, 105 111 recipients, 106 112 models.NotificationTypeIssueCreated, ··· 111 117 pullId, 112 118 ) 113 119 n.notifyEvent( 120 + ctx, 114 121 actorDid, 115 122 sets.Collect(slices.Values(mentions)), 116 123 models.NotificationTypeUserMentioned, ··· 123 130 } 124 131 125 132 func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 133 + l := log.FromContext(ctx) 134 + 126 135 issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 127 136 if err != nil { 128 - log.Printf("NewIssueComment: failed to get issues: %v", err) 137 + l.Error("failed to get issues", "err", err) 129 138 return 130 139 } 131 140 if len(issues) == 0 { 132 - log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 141 + l.Error("no issue found for", "err", comment.IssueAt) 133 142 return 134 143 } 135 144 issue := issues[0] ··· 170 179 var pullId *int64 171 180 172 181 n.notifyEvent( 182 + ctx, 173 183 actorDid, 174 184 recipients, 175 185 models.NotificationTypeIssueCommented, ··· 180 190 pullId, 181 191 ) 182 192 n.notifyEvent( 193 + ctx, 183 194 actorDid, 184 195 sets.Collect(slices.Values(mentions)), 185 196 models.NotificationTypeUserMentioned, ··· 195 206 // no-op for now 196 207 } 197 208 209 + func (n *databaseNotifier) NewIssueLabelOp(ctx context.Context, issue *models.Issue) {} 210 + func (n *databaseNotifier) NewPullLabelOp(ctx context.Context, pull *models.Pull) {} 211 + 198 212 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 199 213 actorDid := syntax.DID(follow.UserDid) 200 214 recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) ··· 204 218 var repoId, issueId, pullId *int64 205 219 206 220 n.notifyEvent( 221 + ctx, 207 222 actorDid, 208 223 recipients, 209 224 eventType, ··· 220 235 } 221 236 222 237 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 238 + l := log.FromContext(ctx) 239 + 223 240 repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 224 241 if err != nil { 225 - log.Printf("NewPull: failed to get repos: %v", err) 242 + l.Error("failed to get repos", "err", err) 226 243 return 227 244 } 228 245 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 229 246 if err != nil { 230 - log.Printf("failed to fetch collaborators: %v", err) 247 + l.Error("failed to fetch collaborators", "err", err) 231 248 return 232 249 } 233 250 ··· 249 266 pullId := &p 250 267 251 268 n.notifyEvent( 269 + ctx, 252 270 actorDid, 253 271 recipients, 254 272 eventType, ··· 261 279 } 262 280 263 281 func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 282 + l := log.FromContext(ctx) 283 + 264 284 pull, err := db.GetPull(n.db, 265 285 syntax.ATURI(comment.RepoAt), 266 286 comment.PullId, 267 287 ) 268 288 if err != nil { 269 - log.Printf("NewPullComment: failed to get pulls: %v", err) 289 + l.Error("failed to get pulls", "err", err) 270 290 return 271 291 } 272 292 273 293 repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 274 294 if err != nil { 275 - log.Printf("NewPullComment: failed to get repos: %v", err) 295 + l.Error("failed to get repos", "err", err) 276 296 return 277 297 } 278 298 ··· 298 318 pullId := &p 299 319 300 320 n.notifyEvent( 321 + ctx, 301 322 actorDid, 302 323 recipients, 303 324 eventType, ··· 308 329 pullId, 309 330 ) 310 331 n.notifyEvent( 332 + ctx, 311 333 actorDid, 312 334 sets.Collect(slices.Values(mentions)), 313 335 models.NotificationTypeUserMentioned, ··· 335 357 // no-op 336 358 } 337 359 360 + func (n *databaseNotifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { 361 + // no-op for now; webhooks are handled by the webhook notifier 362 + } 363 + 338 364 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 365 + l := log.FromContext(ctx) 366 + 339 367 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 340 368 if err != nil { 341 - log.Printf("failed to fetch collaborators: %v", err) 369 + l.Error("failed to fetch collaborators", "err", err) 342 370 return 343 371 } 344 372 ··· 354 382 recipients.Insert(syntax.DID(p)) 355 383 } 356 384 357 - entityType := "pull" 385 + entityType := "issue" 358 386 entityId := issue.AtUri().String() 359 387 repoId := &issue.Repo.Id 360 388 issueId := &issue.Id ··· 368 396 } 369 397 370 398 n.notifyEvent( 399 + ctx, 371 400 actor, 372 401 recipients, 373 402 eventType, ··· 380 409 } 381 410 382 411 func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 412 + l := log.FromContext(ctx) 413 + 383 414 // Get repo details 384 415 repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 385 416 if err != nil { 386 - log.Printf("NewPullState: failed to get repos: %v", err) 417 + l.Error("failed to get repos", "err", err) 387 418 return 388 419 } 389 420 390 421 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 391 422 if err != nil { 392 - log.Printf("failed to fetch collaborators: %v", err) 423 + l.Error("failed to fetch collaborators", "err", err) 393 424 return 394 425 } 395 426 ··· 417 448 case models.PullMerged: 418 449 eventType = models.NotificationTypePullMerged 419 450 default: 420 - log.Println("NewPullState: unexpected new PR state:", pull.State) 451 + l.Error("unexpected new PR state", "state", pull.State) 421 452 return 422 453 } 423 454 p := int64(pull.ID) 424 455 pullId := &p 425 456 426 457 n.notifyEvent( 458 + ctx, 427 459 actor, 428 460 recipients, 429 461 eventType, ··· 436 468 } 437 469 438 470 func (n *databaseNotifier) notifyEvent( 471 + ctx context.Context, 439 472 actorDid syntax.DID, 440 473 recipients sets.Set[syntax.DID], 441 474 eventType models.NotificationType, ··· 445 478 issueId *int64, 446 479 pullId *int64, 447 480 ) { 481 + l := log.FromContext(ctx) 482 + 448 483 // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody 449 484 if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { 450 485 return ··· 494 529 } 495 530 496 531 if err := db.CreateNotification(tx, notif); err != nil { 497 - log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err) 532 + l.Error("failed to create notification", "recipientDid", recipientDid, "err", err) 498 533 } 499 534 } 500 535
+120
appview/notify/logging_notifier.go
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + 7 + "tangled.org/core/appview/models" 8 + tlog "tangled.org/core/log" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + type loggingNotifier struct { 14 + inner Notifier 15 + logger *slog.Logger 16 + } 17 + 18 + func NewLoggingNotifier(inner Notifier, logger *slog.Logger) Notifier { 19 + return &loggingNotifier{ 20 + inner, 21 + logger, 22 + } 23 + } 24 + 25 + var _ Notifier = &loggingNotifier{} 26 + 27 + func (l *loggingNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewRepo")) 29 + l.inner.NewRepo(ctx, repo) 30 + } 31 + 32 + func (l *loggingNotifier) NewStar(ctx context.Context, star *models.Star) { 33 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewStar")) 34 + l.inner.NewStar(ctx, star) 35 + } 36 + 37 + func (l *loggingNotifier) DeleteStar(ctx context.Context, star *models.Star) { 38 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteStar")) 39 + l.inner.DeleteStar(ctx, star) 40 + } 41 + 42 + func (l *loggingNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 43 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssue")) 44 + l.inner.NewIssue(ctx, issue, mentions) 45 + } 46 + 47 + func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 48 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueComment")) 49 + l.inner.NewIssueComment(ctx, comment, mentions) 50 + } 51 + 52 + func (l *loggingNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 53 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueState")) 54 + l.inner.NewIssueState(ctx, actor, issue) 55 + } 56 + 57 + func (l *loggingNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 58 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteIssue")) 59 + l.inner.DeleteIssue(ctx, issue) 60 + } 61 + 62 + func (l *loggingNotifier) NewIssueLabelOp(ctx context.Context, issue *models.Issue) { 63 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueLabelOp")) 64 + l.inner.NewIssueLabelOp(ctx, issue) 65 + } 66 + 67 + func (l *loggingNotifier) NewPullLabelOp(ctx context.Context, pull *models.Pull) { 68 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullLabelOp")) 69 + l.inner.NewPullLabelOp(ctx, pull) 70 + } 71 + 72 + func (l *loggingNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 73 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewFollow")) 74 + l.inner.NewFollow(ctx, follow) 75 + } 76 + 77 + func (l *loggingNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 78 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteFollow")) 79 + l.inner.DeleteFollow(ctx, follow) 80 + } 81 + 82 + func (l *loggingNotifier) NewPull(ctx context.Context, pull *models.Pull) { 83 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPull")) 84 + l.inner.NewPull(ctx, pull) 85 + } 86 + 87 + func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 88 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullComment")) 89 + l.inner.NewPullComment(ctx, comment, mentions) 90 + } 91 + 92 + func (l *loggingNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 93 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullState")) 94 + l.inner.NewPullState(ctx, actor, pull) 95 + } 96 + 97 + func (l *loggingNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 98 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "UpdateProfile")) 99 + l.inner.UpdateProfile(ctx, profile) 100 + } 101 + 102 + func (l *loggingNotifier) NewString(ctx context.Context, s *models.String) { 103 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewString")) 104 + l.inner.NewString(ctx, s) 105 + } 106 + 107 + func (l *loggingNotifier) EditString(ctx context.Context, s *models.String) { 108 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "EditString")) 109 + l.inner.EditString(ctx, s) 110 + } 111 + 112 + func (l *loggingNotifier) DeleteString(ctx context.Context, did, rkey string) { 113 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteString")) 114 + l.inner.DeleteString(ctx, did, rkey) 115 + } 116 + 117 + func (l *loggingNotifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { 118 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "Push")) 119 + l.inner.Push(ctx, repo, ref, oldSha, newSha, committerDid) 120 + }
+32 -31
appview/notify/merged_notifier.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log/slog" 6 - "reflect" 7 5 "sync" 8 6 9 7 "github.com/bluesky-social/indigo/atproto/syntax" 10 8 "tangled.org/core/appview/models" 11 - "tangled.org/core/log" 12 9 ) 13 10 14 11 type mergedNotifier struct { 15 12 notifiers []Notifier 16 - logger *slog.Logger 17 13 } 18 14 19 - func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier { 20 - return &mergedNotifier{notifiers, logger} 15 + func NewMergedNotifier(notifiers []Notifier) Notifier { 16 + return &mergedNotifier{notifiers} 21 17 } 22 18 23 19 var _ Notifier = &mergedNotifier{} 24 20 25 21 // fanout calls the same method on all notifiers concurrently 26 - func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) { 27 - ctx = log.IntoContext(ctx, m.logger.With("method", method)) 22 + func (m *mergedNotifier) fanout(callback func(Notifier)) { 28 23 var wg sync.WaitGroup 29 24 for _, n := range m.notifiers { 30 25 wg.Add(1) 31 26 go func(notifier Notifier) { 32 27 defer wg.Done() 33 - v := reflect.ValueOf(notifier).MethodByName(method) 34 - in := make([]reflect.Value, len(args)+1) 35 - in[0] = reflect.ValueOf(ctx) 36 - for i, arg := range args { 37 - in[i+1] = reflect.ValueOf(arg) 38 - } 39 - v.Call(in) 28 + callback(n) 40 29 }(n) 41 30 } 42 31 } 43 32 44 33 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 45 - m.fanout("NewRepo", ctx, repo) 34 + m.fanout(func(n Notifier) { n.NewRepo(ctx, repo) }) 46 35 } 47 36 48 37 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 49 - m.fanout("NewStar", ctx, star) 38 + m.fanout(func(n Notifier) { n.NewStar(ctx, star) }) 50 39 } 51 40 52 41 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 53 - m.fanout("DeleteStar", ctx, star) 42 + m.fanout(func(n Notifier) { n.DeleteStar(ctx, star) }) 54 43 } 55 44 56 45 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 57 - m.fanout("NewIssue", ctx, issue, mentions) 46 + m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) 58 47 } 59 48 60 49 func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 61 - m.fanout("NewIssueComment", ctx, comment, mentions) 50 + m.fanout(func(n Notifier) { n.NewIssueComment(ctx, comment, mentions) }) 62 51 } 63 52 64 53 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 65 - m.fanout("NewIssueState", ctx, actor, issue) 54 + m.fanout(func(n Notifier) { n.NewIssueState(ctx, actor, issue) }) 66 55 } 67 56 68 57 func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 69 - m.fanout("DeleteIssue", ctx, issue) 58 + m.fanout(func(n Notifier) { n.DeleteIssue(ctx, issue) }) 59 + } 60 + 61 + func (m *mergedNotifier) NewIssueLabelOp(ctx context.Context, issue *models.Issue) { 62 + m.fanout(func(n Notifier) { n.NewIssueLabelOp(ctx, issue) }) 63 + } 64 + 65 + func (m *mergedNotifier) NewPullLabelOp(ctx context.Context, pull *models.Pull) { 66 + m.fanout(func(n Notifier) { n.NewPullLabelOp(ctx, pull) }) 70 67 } 71 68 72 69 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 73 - m.fanout("NewFollow", ctx, follow) 70 + m.fanout(func(n Notifier) { n.NewFollow(ctx, follow) }) 74 71 } 75 72 76 73 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 77 - m.fanout("DeleteFollow", ctx, follow) 74 + m.fanout(func(n Notifier) { n.DeleteFollow(ctx, follow) }) 78 75 } 79 76 80 77 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 81 - m.fanout("NewPull", ctx, pull) 78 + m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) 82 79 } 83 80 84 81 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 85 - m.fanout("NewPullComment", ctx, comment, mentions) 82 + m.fanout(func(n Notifier) { n.NewPullComment(ctx, comment, mentions) }) 86 83 } 87 84 88 85 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 89 - m.fanout("NewPullState", ctx, actor, pull) 86 + m.fanout(func(n Notifier) { n.NewPullState(ctx, actor, pull) }) 90 87 } 91 88 92 89 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 93 - m.fanout("UpdateProfile", ctx, profile) 90 + m.fanout(func(n Notifier) { n.UpdateProfile(ctx, profile) }) 94 91 } 95 92 96 93 func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 97 - m.fanout("NewString", ctx, s) 94 + m.fanout(func(n Notifier) { n.NewString(ctx, s) }) 98 95 } 99 96 100 97 func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 101 - m.fanout("EditString", ctx, s) 98 + m.fanout(func(n Notifier) { n.EditString(ctx, s) }) 102 99 } 103 100 104 101 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 105 - m.fanout("DeleteString", ctx, did, rkey) 102 + m.fanout(func(n Notifier) { n.DeleteString(ctx, did, rkey) }) 103 + } 104 + 105 + func (m *mergedNotifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { 106 + m.fanout(func(n Notifier) { n.Push(ctx, repo, ref, oldSha, newSha, committerDid) }) 106 107 }
+11
appview/notify/notifier.go
··· 25 25 NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 26 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 27 27 28 + NewIssueLabelOp(ctx context.Context, issue *models.Issue) 29 + NewPullLabelOp(ctx context.Context, pull *models.Pull) 30 + 28 31 UpdateProfile(ctx context.Context, profile *models.Profile) 29 32 30 33 NewString(ctx context.Context, s *models.String) 31 34 EditString(ctx context.Context, s *models.String) 32 35 DeleteString(ctx context.Context, did, rkey string) 36 + 37 + Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) 33 38 } 34 39 35 40 // BaseNotifier is a listener that does nothing ··· 48 53 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 54 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 50 55 56 + func (m *BaseNotifier) NewIssueLabelOp(ctx context.Context, issue *models.Issue) {} 57 + func (m *BaseNotifier) NewPullLabelOp(ctx context.Context, pull *models.Pull) {} 58 + 51 59 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 52 60 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 53 61 ··· 61 69 func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {} 62 70 func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {} 63 71 func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {} 72 + 73 + func (m *BaseNotifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { 74 + }
+239
appview/notify/webhook_notifier.go
··· 1 + package notify 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/hmac" 7 + "crypto/sha256" 8 + "encoding/hex" 9 + "encoding/json" 10 + "fmt" 11 + "io" 12 + "log/slog" 13 + "net/http" 14 + "time" 15 + 16 + "github.com/avast/retry-go/v4" 17 + "github.com/google/uuid" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/models" 20 + "tangled.org/core/log" 21 + ) 22 + 23 + type WebhookNotifier struct { 24 + BaseNotifier 25 + db *db.DB 26 + logger *slog.Logger 27 + client *http.Client 28 + } 29 + 30 + func NewWebhookNotifier(database *db.DB) *WebhookNotifier { 31 + return &WebhookNotifier{ 32 + db: database, 33 + logger: log.New("webhook-notifier"), 34 + client: &http.Client{ 35 + Timeout: 30 * time.Second, 36 + }, 37 + } 38 + } 39 + 40 + // Push implements the Notifier interface for git push events 41 + func (w *WebhookNotifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { 42 + webhooks, err := db.GetActiveWebhooksForRepo(w.db, repo.RepoAt()) 43 + if err != nil { 44 + w.logger.Error("failed to get webhooks for repo", "repo", repo.RepoAt(), "err", err) 45 + return 46 + } 47 + 48 + // check if any webhooks are subscribed to push events 49 + var pushWebhooks []models.Webhook 50 + for _, webhook := range webhooks { 51 + if webhook.HasEvent(models.WebhookEventPush) { 52 + pushWebhooks = append(pushWebhooks, webhook) 53 + } 54 + } 55 + 56 + if len(pushWebhooks) == 0 { 57 + return 58 + } 59 + 60 + payload, err := w.buildPushPayload(repo, ref, oldSha, newSha, committerDid) 61 + if err != nil { 62 + w.logger.Error("failed to build push payload", "repo", repo.RepoAt(), "err", err) 63 + return 64 + } 65 + 66 + // Send webhooks 67 + for _, webhook := range pushWebhooks { 68 + go w.sendWebhook(ctx, webhook, string(models.WebhookEventPush), payload) 69 + } 70 + } 71 + 72 + // buildPushPayload creates the webhook payload 73 + func (w *WebhookNotifier) buildPushPayload(repo *models.Repo, ref, oldSha, newSha, committerDid string) (*models.WebhookPayload, error) { 74 + owner := repo.Did 75 + 76 + pusher := committerDid 77 + if committerDid == "" { 78 + pusher = owner 79 + } 80 + 81 + // Build repository object 82 + repository := models.WebhookRepository{ 83 + Name: repo.Name, 84 + FullName: fmt.Sprintf("%s/%s", repo.Did, repo.Name), 85 + Description: repo.Description, 86 + Fork: repo.Source != "", 87 + HtmlUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Name), 88 + CloneUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Name), 89 + SshUrl: fmt.Sprintf("ssh://git@%s/%s/%s", repo.Knot, repo.Did, repo.Name), 90 + CreatedAt: repo.Created.Format(time.RFC3339), 91 + UpdatedAt: repo.Created.Format(time.RFC3339), 92 + Owner: models.WebhookUser{ 93 + Did: owner, 94 + }, 95 + } 96 + 97 + // Add optional fields 98 + if repo.Website != "" { 99 + repository.Website = repo.Website 100 + } 101 + if repo.RepoStats != nil { 102 + repository.StarsCount = repo.RepoStats.StarCount 103 + repository.OpenIssues = repo.RepoStats.IssueCount.Open 104 + } 105 + 106 + // Build payload 107 + payload := &models.WebhookPayload{ 108 + Ref: ref, 109 + Before: oldSha, 110 + After: newSha, 111 + Repository: repository, 112 + Pusher: models.WebhookUser{ 113 + Did: pusher, 114 + }, 115 + } 116 + 117 + return payload, nil 118 + } 119 + 120 + // sendWebhook sends the webhook http request 121 + func (w *WebhookNotifier) sendWebhook(ctx context.Context, webhook models.Webhook, event string, payload *models.WebhookPayload) { 122 + deliveryId := uuid.New().String() 123 + 124 + payloadBytes, err := json.Marshal(payload) 125 + if err != nil { 126 + w.logger.Error("failed to marshal webhook payload", "webhook_id", webhook.Id, "err", err) 127 + return 128 + } 129 + 130 + req, err := http.NewRequestWithContext(ctx, "POST", webhook.Url, bytes.NewReader(payloadBytes)) 131 + if err != nil { 132 + w.logger.Error("failed to create webhook request", "webhook_id", webhook.Id, "err", err) 133 + return 134 + } 135 + 136 + shortSha := payload.After[:7] 137 + 138 + req.Header.Set("Content-Type", "application/json") 139 + req.Header.Set("User-Agent", "Tangled-Hook/"+shortSha) 140 + req.Header.Set("X-Tangled-Event", event) 141 + req.Header.Set("X-Tangled-Hook-ID", fmt.Sprintf("%d", webhook.Id)) 142 + req.Header.Set("X-Tangled-Delivery", deliveryId) 143 + req.Header.Set("X-Tangled-Repo", payload.Repository.FullName) 144 + 145 + if webhook.Secret != "" { 146 + signature := w.computeSignature(payloadBytes, webhook.Secret) 147 + req.Header.Set("X-Tangled-Signature-256", "sha256="+signature) 148 + } 149 + 150 + delivery := &models.WebhookDelivery{ 151 + WebhookId: webhook.Id, 152 + Event: event, 153 + DeliveryId: deliveryId, 154 + Url: webhook.Url, 155 + RequestBody: string(payloadBytes), 156 + } 157 + 158 + // retry webhook delivery with exponential backoff 159 + retryOpts := []retry.Option{ 160 + retry.Attempts(3), 161 + retry.Delay(1 * time.Second), 162 + retry.MaxDelay(10 * time.Second), 163 + retry.DelayType(retry.BackOffDelay), 164 + retry.LastErrorOnly(true), 165 + retry.OnRetry(func(n uint, err error) { 166 + w.logger.Info("retrying webhook delivery", 167 + "webhook_id", webhook.Id, 168 + "attempt", n+1, 169 + "err", err) 170 + }), 171 + retry.Context(ctx), 172 + retry.RetryIf(func(err error) bool { 173 + // only retry on network errors or 5xx responses 174 + if err != nil { 175 + return true 176 + } 177 + return false 178 + }), 179 + } 180 + 181 + var resp *http.Response 182 + err = retry.Do(func() error { 183 + var err error 184 + resp, err = w.client.Do(req) 185 + if err != nil { 186 + return err 187 + } 188 + 189 + // retry on 5xx server errors 190 + if resp.StatusCode >= 500 { 191 + defer resp.Body.Close() 192 + return fmt.Errorf("server error: %d", resp.StatusCode) 193 + } 194 + 195 + return nil 196 + }, retryOpts...) 197 + 198 + if err != nil { 199 + w.logger.Error("webhook request failed after retries", "webhook_id", webhook.Id, "err", err) 200 + delivery.Success = false 201 + delivery.ResponseBody = err.Error() 202 + } else { 203 + defer resp.Body.Close() 204 + 205 + delivery.ResponseCode = resp.StatusCode 206 + delivery.Success = resp.StatusCode >= 200 && resp.StatusCode < 300 207 + 208 + // Read response body (limit to 10KB) 209 + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024)) 210 + if err != nil { 211 + w.logger.Warn("failed to read webhook response body", "webhook_id", webhook.Id, "err", err) 212 + } else { 213 + delivery.ResponseBody = string(bodyBytes) 214 + } 215 + 216 + if !delivery.Success { 217 + w.logger.Warn("webhook delivery failed", 218 + "webhook_id", webhook.Id, 219 + "status", resp.StatusCode, 220 + "url", webhook.Url) 221 + } else { 222 + w.logger.Info("webhook delivered successfully", 223 + "webhook_id", webhook.Id, 224 + "url", webhook.Url, 225 + "delivery_id", deliveryId) 226 + } 227 + } 228 + 229 + if err := db.AddWebhookDelivery(w.db, delivery); err != nil { 230 + w.logger.Error("failed to record webhook delivery", "webhook_id", webhook.Id, "err", err) 231 + } 232 + } 233 + 234 + // computeSignature computes HMAC-SHA256 signature for the payload 235 + func (w *WebhookNotifier) computeSignature(payload []byte, secret string) string { 236 + mac := hmac.New(sha256.New, []byte(secret)) 237 + mac.Write(payload) 238 + return hex.EncodeToString(mac.Sum(nil)) 239 + }
+2 -2
appview/oauth/handler.go
··· 199 199 did := sessData.AccountDID.String() 200 200 l := o.Logger.With("did", did) 201 201 202 - _, err := db.GetProfile(o.Db, did) 203 - if err == nil { 202 + profile, _ := db.GetProfile(o.Db, did) 203 + if profile != nil { 204 204 l.Debug("profile already exists in DB") 205 205 return 206 206 }
+4 -4
appview/oauth/oauth.go
··· 8 8 "time" 9 9 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/atclient" 12 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 13 "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 - atpclient "github.com/bluesky-social/indigo/atproto/client" 13 - atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 xrpc "github.com/bluesky-social/indigo/xrpc" 16 16 "github.com/gorilla/sessions" ··· 43 43 callbackUri := clientUri + "/oauth/callback" 44 44 oauthConfig = oauth.NewLocalhostConfig(callbackUri, TangledScopes) 45 45 } else { 46 - clientUri = config.Core.AppviewHost 46 + clientUri = "https://" + config.Core.AppviewHost 47 47 clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 48 48 callbackUri := clientUri + "/oauth/callback" 49 49 oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, TangledScopes) ··· 256 256 return "" 257 257 } 258 258 259 - func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 259 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atclient.APIClient, error) { 260 260 session, err := o.ResumeSession(r) 261 261 if err != nil { 262 262 return nil, fmt.Errorf("error getting session: %w", err)
+56
appview/ogcard/card.go
··· 257 257 return textWidth, err 258 258 } 259 259 260 + func (c *Card) FontHeight(sizePt float64) int { 261 + ft := freetype.NewContext() 262 + ft.SetDPI(72) 263 + ft.SetFont(c.Font) 264 + ft.SetFontSize(sizePt) 265 + return ft.PointToFixed(sizePt).Ceil() 266 + } 267 + 268 + func (c *Card) TextWidth(text string, sizePt float64) int { 269 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 270 + lineWidth := font.MeasureString(face, text) 271 + textWidth := lineWidth.Ceil() 272 + return textWidth 273 + } 274 + 260 275 // DrawBoldText draws bold text by rendering multiple times with slight offsets 261 276 func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 262 277 // Draw the text multiple times with slight offsets to create bold effect ··· 582 597 func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 583 598 draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 584 599 } 600 + 601 + // drawRoundedRect draws a filled rounded rectangle on the given card 602 + func (card *Card) DrawRoundedRect(x, y, width, height, cornerRadius int, fillColor color.RGBA) { 603 + cardBounds := card.Img.Bounds() 604 + for py := y; py < y+height; py++ { 605 + for px := x; px < x+width; px++ { 606 + // calculate distance from corners 607 + dx := 0 608 + dy := 0 609 + 610 + // check which corner region we're in 611 + if px < x+cornerRadius && py < y+cornerRadius { 612 + // top-left corner 613 + dx = x + cornerRadius - px 614 + dy = y + cornerRadius - py 615 + } else if px >= x+width-cornerRadius && py < y+cornerRadius { 616 + // top-right corner 617 + dx = px - (x + width - cornerRadius - 1) 618 + dy = y + cornerRadius - py 619 + } else if px < x+cornerRadius && py >= y+height-cornerRadius { 620 + // bottom-left corner 621 + dx = x + cornerRadius - px 622 + dy = py - (y + height - cornerRadius - 1) 623 + } else if px >= x+width-cornerRadius && py >= y+height-cornerRadius { 624 + // Bottom-right corner 625 + dx = px - (x + width - cornerRadius - 1) 626 + dy = py - (y + height - cornerRadius - 1) 627 + } 628 + 629 + // if we're in a corner, check if we're within the radius 630 + inCorner := (dx > 0 || dy > 0) 631 + withinRadius := dx*dx+dy*dy <= cornerRadius*cornerRadius 632 + 633 + // draw pixel if not in corner, or in corner and within radius 634 + // check bounds relative to the card's image bounds 635 + if (!inCorner || withinRadius) && px >= 0 && px < cardBounds.Dx() && py >= 0 && py < cardBounds.Dy() { 636 + card.Img.Set(px+cardBounds.Min.X, py+cardBounds.Min.Y, fillColor) 637 + } 638 + } 639 + } 640 + }
+5 -3
appview/pages/funcmap.go
··· 30 30 "tangled.org/core/appview/models" 31 31 "tangled.org/core/appview/oauth" 32 32 "tangled.org/core/appview/pages/markup" 33 + "tangled.org/core/appview/pages/markup/sanitizer" 33 34 "tangled.org/core/crypto" 34 35 ) 35 36 ··· 260 261 "markdown": func(text string) template.HTML { 261 262 p.rctx.RendererType = markup.RendererTypeDefault 262 263 htmlString := p.rctx.RenderMarkdown(text) 263 - sanitized := p.rctx.SanitizeDefault(htmlString) 264 + sanitized := sanitizer.SanitizeDefault(htmlString) 264 265 return template.HTML(sanitized) 265 266 }, 266 267 "description": func(text string) template.HTML { ··· 270 271 emoji.Emoji, 271 272 ), 272 273 )) 273 - sanitized := p.rctx.SanitizeDescription(htmlString) 274 + sanitized := sanitizer.SanitizeDescription(htmlString) 274 275 return template.HTML(sanitized) 275 276 }, 276 277 "readme": func(text string) template.HTML { 277 278 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 278 279 htmlString := p.rctx.RenderMarkdown(text) 279 - sanitized := p.rctx.SanitizeDefault(htmlString) 280 + sanitized := sanitizer.SanitizeDefault(htmlString) 280 281 return template.HTML(sanitized) 281 282 }, 282 283 "code": func(content, path string) string { ··· 466 467 {"Name": "general", "Icon": "sliders-horizontal"}, 467 468 {"Name": "access", "Icon": "users"}, 468 469 {"Name": "pipelines", "Icon": "layers-2"}, 470 + {"Name": "hooks", "Icon": "webhook"}, 469 471 }, 470 472 } 471 473 },
-9
appview/pages/markup/markdown.go
··· 48 48 IsDev bool 49 49 Hostname string 50 50 RendererType RendererType 51 - Sanitizer Sanitizer 52 51 Files fs.FS 53 52 } 54 53 ··· 177 176 } 178 177 default: 179 178 } 180 - } 181 - 182 - func (rctx *RenderContext) SanitizeDefault(html string) string { 183 - return rctx.Sanitizer.SanitizeDefault(html) 184 - } 185 - 186 - func (rctx *RenderContext) SanitizeDescription(html string) string { 187 - return rctx.Sanitizer.SanitizeDescription(html) 188 179 } 189 180 190 181 type MarkdownTransformer struct {
+11 -18
appview/pages/markup/sanitizer.go appview/pages/markup/sanitizer/sanitizer.go
··· 1 - package markup 1 + package sanitizer 2 2 3 3 import ( 4 4 "maps" ··· 10 10 "github.com/microcosm-cc/bluemonday" 11 11 ) 12 12 13 - type Sanitizer struct { 14 - defaultPolicy *bluemonday.Policy 15 - descriptionPolicy *bluemonday.Policy 16 - } 13 + var ( 14 + defaultPolicy = newDefaultPolicy() 15 + descriptionPolicy = newDescriptionPolicy() 16 + ) 17 17 18 - func NewSanitizer() Sanitizer { 19 - return Sanitizer{ 20 - defaultPolicy: defaultPolicy(), 21 - descriptionPolicy: descriptionPolicy(), 22 - } 18 + func SanitizeDefault(html string) string { 19 + return defaultPolicy.Sanitize(html) 23 20 } 24 - 25 - func (s *Sanitizer) SanitizeDefault(html string) string { 26 - return s.defaultPolicy.Sanitize(html) 27 - } 28 - func (s *Sanitizer) SanitizeDescription(html string) string { 29 - return s.descriptionPolicy.Sanitize(html) 21 + func SanitizeDescription(html string) string { 22 + return descriptionPolicy.Sanitize(html) 30 23 } 31 24 32 - func defaultPolicy() *bluemonday.Policy { 25 + func newDefaultPolicy() *bluemonday.Policy { 33 26 policy := bluemonday.UGCPolicy() 34 27 35 28 // Allow generally safe attributes ··· 123 116 return policy 124 117 } 125 118 126 - func descriptionPolicy() *bluemonday.Policy { 119 + func newDescriptionPolicy() *bluemonday.Policy { 127 120 policy := bluemonday.NewPolicy() 128 121 policy.AllowStandardURLs() 129 122
+108 -32
appview/pages/pages.go
··· 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/oauth" 25 25 "tangled.org/core/appview/pages/markup" 26 + "tangled.org/core/appview/pages/markup/sanitizer" 26 27 "tangled.org/core/appview/pages/repoinfo" 27 28 "tangled.org/core/appview/pagination" 28 29 "tangled.org/core/idresolver" ··· 58 59 Hostname: config.Core.AppviewHost, 59 60 CamoUrl: config.Camo.Host, 60 61 CamoSecret: config.Camo.SharedSecret, 61 - Sanitizer: markup.NewSanitizer(), 62 62 Files: Files, 63 63 } 64 64 ··· 178 178 return p.parse(stack...) 179 179 } 180 180 181 + func (p *Pages) parseLoginBase(top string) (*template.Template, error) { 182 + stack := []string{ 183 + "layouts/base", 184 + "layouts/loginbase", 185 + top, 186 + } 187 + return p.parse(stack...) 188 + } 189 + 181 190 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 182 191 tpl, err := p.parse(name) 183 192 if err != nil { ··· 187 196 return tpl.Execute(w, params) 188 197 } 189 198 199 + func (p *Pages) executeLogin(name string, w io.Writer, params any) error { 200 + tpl, err := p.parseLoginBase(name) 201 + if err != nil { 202 + return err 203 + } 204 + 205 + return tpl.ExecuteTemplate(w, "layouts/base", params) 206 + } 207 + 190 208 func (p *Pages) execute(name string, w io.Writer, params any) error { 191 209 tpl, err := p.parseBase(name) 192 210 if err != nil { ··· 237 255 } 238 256 239 257 func (p *Pages) Login(w io.Writer, params LoginParams) error { 240 - return p.executePlain("user/login", w, params) 258 + return p.executeLogin("user/login", w, params) 241 259 } 242 260 243 261 type SignupParams struct { ··· 245 263 } 246 264 247 265 func (p *Pages) Signup(w io.Writer, params SignupParams) error { 248 - return p.executePlain("user/signup", w, params) 266 + return p.executeLogin("user/signup", w, params) 249 267 } 250 268 251 269 func (p *Pages) CompleteSignup(w io.Writer) error { 252 - return p.executePlain("user/completeSignup", w, nil) 270 + return p.executeLogin("user/completeSignup", w, nil) 253 271 } 254 272 255 273 type TermsOfServiceParams struct { ··· 274 292 275 293 p.rctx.RendererType = markup.RendererTypeDefault 276 294 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 277 - sanitized := p.rctx.SanitizeDefault(htmlString) 295 + sanitized := sanitizer.SanitizeDefault(htmlString) 278 296 params.Content = template.HTML(sanitized) 279 297 280 298 return p.execute("legal/terms", w, params) ··· 302 320 303 321 p.rctx.RendererType = markup.RendererTypeDefault 304 322 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 305 - sanitized := p.rctx.SanitizeDefault(htmlString) 323 + sanitized := sanitizer.SanitizeDefault(htmlString) 306 324 params.Content = template.HTML(sanitized) 307 325 308 326 return p.execute("legal/privacy", w, params) ··· 341 359 } 342 360 343 361 type UserProfileSettingsParams struct { 344 - LoggedInUser *oauth.MultiAccountUser 345 - Tab string 362 + LoggedInUser *oauth.MultiAccountUser 363 + Tab string 364 + PunchcardPreference models.PunchcardPreference 346 365 } 347 366 348 367 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { ··· 505 524 506 525 type ProfileCard struct { 507 526 UserDid string 527 + HasProfile bool 508 528 FollowStatus models.FollowStatus 509 529 Punchcard *models.Punchcard 510 530 Profile *models.Profile ··· 538 558 ProfileTimeline *models.ProfileTimeline 539 559 Card *ProfileCard 540 560 Active string 561 + ShowPunchcard bool 541 562 } 542 563 543 564 func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { ··· 676 697 Languages []types.RepoLanguageDetails 677 698 Pipelines map[string]models.Pipeline 678 699 NeedsKnotUpgrade bool 700 + KnotUnreachable bool 679 701 types.RepoIndexResponse 680 702 } 681 703 ··· 689 711 return p.executeRepo("repo/needsUpgrade", w, params) 690 712 } 691 713 714 + if params.KnotUnreachable { 715 + return p.executeRepo("repo/knotUnreachable", w, params) 716 + } 717 + 692 718 p.rctx.RepoInfo = params.RepoInfo 693 719 p.rctx.RepoInfo.Ref = params.Ref 694 720 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 699 725 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 700 726 params.Raw = false 701 727 htmlString := p.rctx.RenderMarkdown(params.Readme) 702 - sanitized := p.rctx.SanitizeDefault(htmlString) 728 + sanitized := sanitizer.SanitizeDefault(htmlString) 703 729 params.HTMLReadme = template.HTML(sanitized) 704 730 default: 705 731 params.Raw = true ··· 746 772 } 747 773 748 774 type RepoTreeParams struct { 749 - LoggedInUser *oauth.MultiAccountUser 750 - RepoInfo repoinfo.RepoInfo 751 - Active string 752 - BreadCrumbs [][]string 753 - TreePath string 754 - Raw bool 755 - HTMLReadme template.HTML 775 + LoggedInUser *oauth.MultiAccountUser 776 + RepoInfo repoinfo.RepoInfo 777 + Active string 778 + BreadCrumbs [][]string 779 + TreePath string 780 + Raw bool 781 + HTMLReadme template.HTML 782 + EmailToDid map[string]string 783 + LastCommitInfo *types.LastCommitInfo 756 784 types.RepoTreeResponse 757 785 } 758 786 ··· 790 818 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 791 819 params.Raw = false 792 820 htmlString := p.rctx.RenderMarkdown(params.Readme) 793 - sanitized := p.rctx.SanitizeDefault(htmlString) 821 + sanitized := sanitizer.SanitizeDefault(htmlString) 794 822 params.HTMLReadme = template.HTML(sanitized) 795 823 default: 796 824 params.Raw = true ··· 826 854 return p.executeRepo("repo/tags", w, params) 827 855 } 828 856 857 + type RepoTagParams struct { 858 + LoggedInUser *oauth.MultiAccountUser 859 + RepoInfo repoinfo.RepoInfo 860 + Active string 861 + types.RepoTagResponse 862 + ArtifactMap map[plumbing.Hash][]models.Artifact 863 + DanglingArtifacts []models.Artifact 864 + } 865 + 866 + func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error { 867 + params.Active = "overview" 868 + return p.executeRepo("repo/tag", w, params) 869 + } 870 + 829 871 type RepoArtifactParams struct { 830 872 LoggedInUser *oauth.MultiAccountUser 831 873 RepoInfo repoinfo.RepoInfo ··· 837 879 } 838 880 839 881 type RepoBlobParams struct { 840 - LoggedInUser *oauth.MultiAccountUser 841 - RepoInfo repoinfo.RepoInfo 842 - Active string 843 - BreadCrumbs [][]string 844 - BlobView models.BlobView 882 + LoggedInUser *oauth.MultiAccountUser 883 + RepoInfo repoinfo.RepoInfo 884 + Active string 885 + BreadCrumbs [][]string 886 + BlobView models.BlobView 887 + EmailToDid map[string]string 888 + LastCommitInfo *types.LastCommitInfo 845 889 *tangled.RepoBlob_Output 846 890 } 847 891 ··· 927 971 return p.executeRepo("repo/settings/pipelines", w, params) 928 972 } 929 973 974 + type RepoWebhooksSettingsParams struct { 975 + LoggedInUser *oauth.MultiAccountUser 976 + RepoInfo repoinfo.RepoInfo 977 + Active string 978 + Tab string 979 + Webhooks []models.Webhook 980 + WebhookDeliveries map[int64][]models.WebhookDelivery 981 + } 982 + 983 + func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsParams) error { 984 + params.Active = "settings" 985 + params.Tab = "hooks" 986 + return p.executeRepo("repo/settings/hooks", w, params) 987 + } 988 + 989 + type WebhookDeliveriesListParams struct { 990 + LoggedInUser *oauth.MultiAccountUser 991 + RepoInfo repoinfo.RepoInfo 992 + Webhook *models.Webhook 993 + Deliveries []models.WebhookDelivery 994 + } 995 + 996 + func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListParams) error { 997 + tpl, err := p.parse("repo/settings/fragments/webhookDeliveries") 998 + if err != nil { 999 + return err 1000 + } 1001 + return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params) 1002 + } 1003 + 930 1004 type RepoIssuesParams struct { 931 - LoggedInUser *oauth.MultiAccountUser 932 - RepoInfo repoinfo.RepoInfo 933 - Active string 934 - Issues []models.Issue 935 - IssueCount int 936 - LabelDefs map[string]*models.LabelDefinition 937 - Page pagination.Page 938 - FilteringByOpen bool 939 - FilterQuery string 1005 + LoggedInUser *oauth.MultiAccountUser 1006 + RepoInfo repoinfo.RepoInfo 1007 + Active string 1008 + Issues []models.Issue 1009 + IssueCount int 1010 + LabelDefs map[string]*models.LabelDefinition 1011 + Page pagination.Page 1012 + FilterState string 1013 + FilterQuery string 940 1014 } 941 1015 942 1016 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 1066 1140 RepoInfo repoinfo.RepoInfo 1067 1141 Pulls []*models.Pull 1068 1142 Active string 1069 - FilteringBy models.PullState 1143 + FilterState string 1070 1144 FilterQuery string 1071 1145 Stacks map[string]models.Stack 1072 1146 Pipelines map[string]models.Pipeline ··· 1316 1390 RepoInfo repoinfo.RepoInfo 1317 1391 Pipelines []models.Pipeline 1318 1392 Active string 1393 + FilterKind string 1394 + Total int64 1319 1395 } 1320 1396 1321 1397 func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
+113
appview/pages/templates/fragments/resizeable.html
··· 1 + {{ define "fragments/resizable" }} 2 + <script> 3 + class ResizablePanel { 4 + constructor(resizerElement) { 5 + this.resizer = resizerElement; 6 + this.isResizing = false; 7 + this.type = resizerElement.dataset.resizer; 8 + this.targetId = resizerElement.dataset.target; 9 + this.target = document.getElementById(this.targetId); 10 + this.min = parseInt(resizerElement.dataset.min) || 100; 11 + this.max = parseInt(resizerElement.dataset.max) || Infinity; 12 + 13 + this.direction = resizerElement.dataset.direction || 'before'; // 'before' or 'after' 14 + 15 + this.handleMouseDown = this.handleMouseDown.bind(this); 16 + this.handleMouseMove = this.handleMouseMove.bind(this); 17 + this.handleMouseUp = this.handleMouseUp.bind(this); 18 + 19 + this.init(); 20 + } 21 + 22 + init() { 23 + this.resizer.addEventListener('mousedown', this.handleMouseDown); 24 + } 25 + 26 + handleMouseDown(e) { 27 + e.preventDefault(); 28 + this.isResizing = true; 29 + this.resizer.classList.add('resizing'); 30 + document.body.style.cursor = this.type === 'vertical' ? 'col-resize' : 'row-resize'; 31 + document.body.style.userSelect = 'none'; 32 + 33 + this.startX = e.clientX; 34 + this.startY = e.clientY; 35 + this.startWidth = this.target.offsetWidth; 36 + this.startHeight = this.target.offsetHeight; 37 + 38 + document.addEventListener('mousemove', this.handleMouseMove); 39 + document.addEventListener('mouseup', this.handleMouseUp); 40 + } 41 + 42 + handleMouseMove(e) { 43 + if (!this.isResizing) return; 44 + 45 + if (this.type === 'vertical') { 46 + let newWidth; 47 + 48 + if (this.direction === 'after') { 49 + const deltaX = this.startX - e.clientX; 50 + newWidth = this.startWidth + deltaX; 51 + } else { 52 + const deltaX = e.clientX - this.startX; 53 + newWidth = this.startWidth + deltaX; 54 + } 55 + 56 + if (newWidth >= this.min && newWidth <= this.max) { 57 + this.target.style.width = newWidth + 'px'; 58 + this.target.style.flexShrink = '0'; 59 + } 60 + } else { 61 + let newHeight; 62 + 63 + if (this.direction === 'after') { 64 + const deltaY = this.startY - e.clientY; 65 + newHeight = this.startHeight + deltaY; 66 + } else { 67 + const deltaY = e.clientY - this.startY; 68 + newHeight = this.startHeight + deltaY; 69 + } 70 + 71 + if (newHeight >= this.min && newHeight <= this.max) { 72 + this.target.style.height = newHeight + 'px'; 73 + } 74 + } 75 + } 76 + 77 + handleMouseUp() { 78 + if (!this.isResizing) return; 79 + 80 + this.isResizing = false; 81 + this.resizer.classList.remove('resizing'); 82 + document.body.style.cursor = ''; 83 + document.body.style.userSelect = ''; 84 + 85 + document.removeEventListener('mousemove', this.handleMouseMove); 86 + document.removeEventListener('mouseup', this.handleMouseUp); 87 + } 88 + 89 + destroy() { 90 + this.resizer.removeEventListener('mousedown', this.handleMouseDown); 91 + document.removeEventListener('mousemove', this.handleMouseMove); 92 + document.removeEventListener('mouseup', this.handleMouseUp); 93 + } 94 + } 95 + 96 + function initializeResizers() { 97 + const resizers = document.querySelectorAll('[data-resizer]'); 98 + const instances = []; 99 + 100 + resizers.forEach(resizer => { 101 + instances.push(new ResizablePanel(resizer)); 102 + }); 103 + 104 + return instances; 105 + } 106 + 107 + if (document.readyState === 'loading') { 108 + document.addEventListener('DOMContentLoaded', initializeResizers); 109 + } else { 110 + initializeResizers(); 111 + } 112 + </script> 113 + {{ end }}
+3 -3
appview/pages/templates/fragments/starBtn.html
··· 15 15 hx-disabled-elt="#starBtn" 16 16 > 17 17 {{ if .IsStarred }} 18 - {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ i "star" "w-4 h-4 fill-current inline group-[.htmx-request]:hidden" }} 19 19 {{ else }} 20 - {{ i "star" "w-4 h-4" }} 20 + {{ i "star" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 21 21 {{ end }} 22 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 22 23 <span class="text-sm"> 23 24 {{ .StarCount }} 24 25 </span> 25 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 26 26 </button> 27 27 {{ end }}
+23 -1
appview/pages/templates/fragments/tabSelector.html
··· 3 3 {{ $all := .Values }} 4 4 {{ $active := .Active }} 5 5 {{ $include := .Include }} 6 + {{ $form := .Form }} 6 7 <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 7 8 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 8 9 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 9 10 {{ range $index, $value := $all }} 10 11 {{ $isActive := eq $value.Key $active }} 12 + {{ if $form }} 13 + <button type="submit" 14 + form="{{ $form }}" 15 + name="{{ $name }}" value="{{ $value.Key }}" 16 + hx-get="?{{ $name }}={{ $value.Key }}" 17 + hx-include="{{ $include }}" 18 + hx-push-url="true" 19 + hx-target="body" 20 + hx-on:htmx:config-request="if(!event.detail.parameters.q) delete event.detail.parameters.q" 21 + class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 22 + {{ if $value.Icon }} 23 + {{ i $value.Icon "size-4" }} 24 + {{ end }} 25 + 26 + {{ with $value.Meta }} 27 + {{ . }} 28 + {{ end }} 29 + 30 + {{ $value.Value }} 31 + </button> 32 + {{ else }} 11 33 <a href="?{{ $name }}={{ $value.Key }}" 12 34 {{ if $include }} 13 35 hx-get="?{{ $name }}={{ $value.Key }}" ··· 27 49 28 50 {{ $value.Value }} 29 51 </a> 52 + {{ end }} 30 53 {{ end }} 31 54 </div> 32 55 {{ end }} 33 -
+15 -2
appview/pages/templates/layouts/base.html
··· 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 7 - <meta name="description" content="Social coding, but for real this time!"/> 7 + <meta name="description" content="The next-generation social coding platform."/> 8 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 9 + 10 + <!-- Open Graph defaults --> 11 + <meta property="og:site_name" content="Tangled" /> 12 + <meta property="og:type" content="website" /> 13 + <meta property="og:locale" content="en_US" /> 14 + 15 + 16 + <!-- Keywords --> 17 + <meta name="keywords" content="git, code collaboration, AT Protocol, open source, version control, social coding, code hosting" /> 18 + 19 + <!-- Author and copyright --> 20 + <meta name="author" content="Tangled" /> 21 + <meta name="robots" content="index, follow" /> 9 22 10 23 <script defer src="/static/htmx.min.js"></script> 11 24 <script defer src="/static/htmx-ext-ws.min.js"></script> ··· 26 39 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 27 40 28 41 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 29 - <title>{{ block "title" . }}{{ end }} · tangled</title> 42 + <title>{{ block "title" . }}{{ end }}</title> 30 43 {{ block "extrameta" . }}{{ end }} 31 44 </head> 32 45 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
+26
appview/pages/templates/layouts/loginbase.html
··· 1 + {{ define "mainLayout" }} 2 + <div class="w-full h-screen flex items-center justify-center bg-white dark:bg-transparent"> 3 + <main class="max-w-md px-7 mt-4"> 4 + {{ template "logo" }} 5 + {{ block "content" . }}{{ end }} 6 + </main> 7 + </div> 8 + {{ end }} 9 + 10 + {{ define "topbarLayout" }} 11 + <div class="hidden"></div> 12 + {{ end }} 13 + 14 + {{ define "footerLayout" }} 15 + <div class="hidden"></div> 16 + {{ end }} 17 + 18 + {{ define "logo" }} 19 + <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 20 + {{ template "fragments/logotype" }} 21 + </h1> 22 + <h2 class="text-center text-xl italic dark:text-white"> 23 + tightly-knit social coding. 24 + </h2> 25 + {{ end }} 26 +
+32 -5
appview/pages/templates/layouts/profilebase.html
··· 3 3 {{ define "extrameta" }} 4 4 {{ $handle := resolve .Card.UserDid }} 5 5 {{ $avatarUrl := profileAvatarUrl .Card.Profile "" }} 6 + {{ $description := or .Card.Profile.Description (printf "%s on Tangled" $handle) }} 7 + {{ $url := printf "https://tangled.org/%s" $handle }} 8 + 9 + <!-- Open Graph Meta Tags --> 6 10 <meta property="og:title" content="{{ $handle }}" /> 7 11 <meta property="og:type" content="profile" /> 8 - <meta property="og:url" content="https://tangled.org/{{ $handle }}?tab={{ .Active }}" /> 9 - <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 12 + <meta property="og:url" content="{{ $url }}" /> 13 + <meta property="og:description" content="{{ $description }}" /> 10 14 <meta property="og:image" content="{{ $avatarUrl }}" /> 11 15 <meta property="og:image:width" content="512" /> 12 16 <meta property="og:image:height" content="512" /> 13 - 17 + <meta property="og:image:alt" content="{{ $handle }}'s avatar" /> 18 + <meta property="profile:username" content="{{ $handle }}" /> 19 + 20 + <!-- Twitter Card Meta Tags --> 14 21 <meta name="twitter:card" content="summary" /> 15 22 <meta name="twitter:title" content="{{ $handle }}" /> 16 - <meta name="twitter:description" content="{{ or .Card.Profile.Description $handle }}" /> 23 + <meta name="twitter:description" content="{{ $description }}" /> 17 24 <meta name="twitter:image" content="{{ $avatarUrl }}" /> 25 + <meta name="twitter:image:alt" content="{{ $handle }}'s avatar" /> 26 + 27 + <!-- Additional SEO --> 28 + <meta name="description" content="{{ $description }}" /> 29 + <link rel="canonical" href="{{ $url }}" /> 18 30 {{ end }} 19 31 20 32 {{ define "content" }} 33 + {{ if not .Card.HasProfile }} 34 + <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm"> 35 + <div class="flex items-center gap-6 p-4"> 36 + <img class="w-28 h-28 shrink-0 object-cover rounded-full" src="{{ profileAvatarUrl .Card.Profile "" }}" /> 37 + <div> 38 + <p class="text-lg font-bold">{{ resolve .Card.UserDid }}</p> 39 + <p class="text-gray-700 dark:text-gray-300 mt-2">This user hasn't joined Tangled yet.</p> 40 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Let them know we're waiting for them!</p> 41 + </div> 42 + </div> 43 + </section> 44 + {{ else }} 21 45 {{ template "profileTabs" . }} 22 46 <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm"> 23 47 <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> ··· 28 52 <div class="{{ $style }} order-1 order-1"> 29 53 <div class="flex flex-col gap-4"> 30 54 {{ template "user/fragments/profileCard" .Card }} 31 - {{ block "punchcard" .Card.Punchcard }} {{ end }} 55 + {{ if .Card.Punchcard }} 56 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 57 + {{ end }} 32 58 </div> 33 59 </div> 34 60 35 61 {{ block "profileContent" . }} {{ end }} 36 62 </div> 37 63 </section> 64 + {{ end }} 38 65 {{ end }} 39 66 40 67 {{ define "profileTabs" }}
+80 -64
appview/pages/templates/layouts/repobase.html
··· 2 2 3 3 {{ define "content" }} 4 4 <section id="repo-header" class="mb-2 py-2 px-4 dark:text-white"> 5 - <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 - <!-- left items --> 5 + <div class="flex flex-col sm:flex-row items-start gap-4 justify-between mb-2"> 7 6 <div class="flex flex-col gap-2"> 8 - <!-- repo owner / repo name --> 9 - <div class="flex items-center gap-2 flex-wrap"> 10 - {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 11 - <span class="select-none">/</span> 12 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 13 - </div> 14 - 15 - {{ if .RepoInfo.Source }} 16 - {{ $sourceOwner := resolve .RepoInfo.Source.Did }} 17 - <div class="flex items-center gap-1 text-sm flex-wrap"> 18 - {{ i "git-fork" "w-3 h-3 shrink-0" }} 19 - <span>forked from</span> 20 - <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}"> 21 - {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 22 - </a> 23 - </div> 24 - {{ end }} 25 - 26 - <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 27 - {{ if .RepoInfo.Description }} 28 - {{ .RepoInfo.Description | description }} 29 - {{ else }} 30 - <span class="italic">this repo has no description</span> 31 - {{ end }} 32 - 33 - {{ with .RepoInfo.Website }} 34 - <span class="flex items-center gap-1"> 35 - <span class="flex-shrink-0">{{ i "globe" "size-4" }}</span> 36 - <a href="{{ . }}">{{ . | trimUriScheme }}</a> 37 - </span> 38 - {{ end }} 39 - 40 - {{ if .RepoInfo.Topics }} 41 - <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300"> 42 - {{ range .RepoInfo.Topics }} 43 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span> 44 - {{ end }} 45 - </div> 46 - {{ end }} 47 - 48 - </span> 7 + {{ template "repoOwnerAndName" . }} 8 + {{ template "repoForkInfo" . }} 49 9 </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" 59 - href="/{{ .RepoInfo.FullName }}/fork" 60 - > 61 - {{ i "git-fork" "w-4 h-4" }} 62 - fork 63 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 64 - </a> 65 - <a 66 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 67 - href="/{{ .RepoInfo.FullName }}/feed.atom"> 68 - {{ i "rss" "size-4" }} 69 - <span class="md:hidden">atom</span> 70 - </a> 10 + <div class="hidden sm:block sm:flex-shrink-0"> 11 + {{ template "repoActions" . }} 71 12 </div> 72 13 </div> 14 + {{ template "repoMetadata" . }} 15 + 16 + <div class="block sm:hidden mt-4"> 17 + {{ template "repoActions" . }} 18 + </div> 73 19 </section> 74 20 75 21 <section class="w-full flex flex-col" > ··· 117 63 {{ end }} 118 64 </section> 119 65 {{ end }} 66 + 67 + {{ define "repoOwnerAndName" }} 68 + <div class="flex items-center gap-2 flex-wrap text-lg"> 69 + {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 70 + <span class="select-none">/</span> 71 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 72 + </div> 73 + {{ end }} 74 + 75 + {{ define "repoForkInfo" }} 76 + {{ if .RepoInfo.Source }} 77 + {{ $sourceOwner := resolve .RepoInfo.Source.Did }} 78 + <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300 mb-2 flex-wrap"> 79 + {{ i "git-fork" "w-3 h-3 shrink-0" }} 80 + <span>forked from</span> 81 + <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}"> 82 + {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 83 + </a> 84 + </div> 85 + {{ end }} 86 + {{ end }} 87 + 88 + {{ define "repoMetadata" }} 89 + <div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 90 + {{ if .RepoInfo.Description }} 91 + {{ .RepoInfo.Description | description }} 92 + {{ else }} 93 + <span class="italic">this repo has no description</span> 94 + {{ end }} 95 + 96 + {{ with .RepoInfo.Website }} 97 + <span class="flex items-center gap-1"> 98 + <span class="flex-shrink-0">{{ i "globe" "size-4" }}</span> 99 + <a href="{{ . }}">{{ . | trimUriScheme }}</a> 100 + </span> 101 + {{ end }} 102 + 103 + {{ if .RepoInfo.Topics }} 104 + <div class="flex items-center gap-1"> 105 + {{ range .RepoInfo.Topics }} 106 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span> 107 + {{ end }} 108 + </div> 109 + {{ end }} 110 + </div> 111 + {{ end }} 112 + 113 + {{ define "repoActions" }} 114 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 115 + {{ template "fragments/starBtn" 116 + (dict "SubjectAt" .RepoInfo.RepoAt 117 + "IsStarred" .RepoInfo.IsStarred 118 + "StarCount" .RepoInfo.Stats.StarCount) }} 119 + <a 120 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 121 + hx-boost="true" 122 + href="/{{ .RepoInfo.FullName }}/fork" 123 + > 124 + {{ i "git-fork" "w-4 h-4" }} 125 + fork 126 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 127 + </a> 128 + <a 129 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 130 + href="/{{ .RepoInfo.FullName }}/feed.atom"> 131 + {{ i "rss" "size-4" }} 132 + <span class="md:hidden">atom</span> 133 + </a> 134 + </div> 135 + {{ end }}
+52 -4
appview/pages/templates/repo/blob.html
··· 12 12 13 13 {{ define "repoContent" }} 14 14 {{ $linkstyle := "no-underline hover:underline" }} 15 - <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 15 + <div class="peer pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 16 16 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 17 17 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 18 18 {{ range $idx, $value := .BreadCrumbs }} ··· 54 54 view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} 55 55 </a> 56 56 {{ end }} 57 + 58 + {{ if .BlobView.ShowingText }} 59 + <div id="toggle-wrap-content" class="flex items-center"> 60 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 61 + <label class="flex lowercase font-normal px-1 py-0 gap-1 text-xs md:text-sm"> 62 + <input id="toggle-wrap-content-checkbox" type="checkbox" name="wrap"/> 63 + wrap content 64 + </label> 65 + </div> 66 + {{ end }} 57 67 </div> 58 68 </div> 59 69 </div> 70 + 71 + {{ if .LastCommitInfo }} 72 + {{ template "repo/fragments/lastCommitPanel" $ }} 73 + {{ end }} 74 + 75 + {{ $wrapContentClasses := "peer-has-[:checked]:*:whitespace-pre-wrap peer-has-[:checked]:*:[overflow-wrap:anywhere]" }} 76 + 60 77 {{ if .BlobView.IsUnsupported }} 61 78 <p class="text-center text-gray-400 dark:text-gray-500"> 62 79 Previews are not supported for this file type. ··· 79 96 </video> 80 97 </div> 81 98 {{ else if .BlobView.ContentType.IsSvg }} 82 - <div class="overflow-auto relative"> 99 + <div class="overflow-auto relative {{ $wrapContentClasses }}"> 83 100 {{ if .BlobView.ShowingRendered }} 84 101 <div class="text-center"> 85 102 <img src="{{ .BlobView.ContentSrc }}" ··· 91 108 {{ end }} 92 109 </div> 93 110 {{ else if .BlobView.ContentType.IsMarkup }} 94 - <div class="overflow-auto relative"> 111 + <div class="overflow-auto relative {{ $wrapContentClasses }}"> 95 112 {{ if .BlobView.ShowingRendered }} 96 113 <div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div> 97 114 {{ else }} ··· 99 116 {{ end }} 100 117 </div> 101 118 {{ else if .BlobView.ContentType.IsCode }} 102 - <div class="overflow-auto relative"> 119 + <div class="overflow-auto relative {{ $wrapContentClasses }}"> 103 120 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 104 121 </div> 105 122 {{ end }} 106 123 {{ template "fragments/multiline-select" }} 124 + <script> 125 + (() => { 126 + const abortController = new AbortController(); 127 + const toggle = document.querySelector('#toggle-wrap-content'); 128 + const toggleCheckbox = document.querySelector('#toggle-wrap-content-checkbox'); 129 + const contents = document.querySelector('#blob-contents'); 130 + 131 + function showWrapContentToggleOnOverflow() { 132 + if(!toggle || !toggleCheckbox || !contents) return; 133 + 134 + const isScrollable = contents.scrollWidth > contents.clientWidth; 135 + const showToggle = isScrollable || toggleCheckbox.checked; 136 + 137 + if(showToggle) { 138 + toggle.classList.remove('hidden'); 139 + } else { 140 + toggle.classList.add('hidden'); 141 + } 142 + } 143 + 144 + window.addEventListener('resize', () => showWrapContentToggleOnOverflow(), {signal: abortController.signal}); 145 + document.body.addEventListener('htmx:afterSettle', () => showWrapContentToggleOnOverflow(), {signal: abortController.signal}); 146 + document.body.addEventListener('htmx:beforeCleanupElement', (e) => { 147 + if(e.target === toggle) { 148 + abortController.abort(); 149 + } 150 + }, {signal: abortController.signal}); 151 + 152 + showWrapContentToggleOnOverflow(); 153 + })(); 154 + </script> 107 155 {{ end }}
+6 -2
appview/pages/templates/repo/commit.html
··· 1 - {{ define "title" }} commit {{ .Diff.Commit.This }} &middot; {{ .RepoInfo.FullName }} {{ end }} 1 + {{ define "title" }} 2 + {{ $messageParts := splitN .Diff.Commit.Message "\n\n" 2 }} 3 + {{ index $messageParts 0 }} &middot; {{ .RepoInfo.FullName }}@{{ slice .Diff.Commit.This 0 7 }} 4 + {{ end }} 2 5 3 6 {{ define "extrameta" }} 4 - {{ $title := printf "commit %s &middot; %s" .Diff.Commit.This .RepoInfo.FullName }} 7 + {{ $messageParts := splitN .Diff.Commit.Message "\n\n" 2 }} 8 + {{ $title := printf "%s &middot; %s@%s" (index $messageParts 0) .RepoInfo.FullName (slice .Diff.Commit.This 0 7) }} 5 9 {{ $url := printf "https://tangled.org/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }} 6 10 7 11 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
+3 -2
appview/pages/templates/repo/fragments/artifact.html
··· 19 19 {{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }} 20 20 <button 21 21 id="delete-{{ $unique }}" 22 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2" 22 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 23 23 title="Delete artifact" 24 24 hx-delete="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/{{ .Artifact.Name | urlquery }}" 25 25 hx-swap="outerHTML" 26 26 hx-target="#artifact-{{ $unique }}" 27 27 hx-disabled-elt="#delete-{{ $unique }}" 28 28 hx-confirm="Are you sure you want to delete the artifact '{{ .Artifact.Name }}'?"> 29 - {{ i "trash-2" "w-4 h-4" }} 29 + {{ i "trash-2" "size-4 inline group-[.htmx-request]:hidden" }} 30 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 30 31 </button> 31 32 {{ end }} 32 33 </div>
+70
appview/pages/templates/repo/fragments/artifactList.html
··· 1 + {{ define "repo/fragments/artifactList" }} 2 + {{ $root := index . 0 }} 3 + {{ $tag := index . 1 }} 4 + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 5 + {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 6 + 7 + <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 8 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 9 + {{ range $artifact := $artifacts }} 10 + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 11 + {{ template "repo/fragments/artifact" $args }} 12 + {{ end }} 13 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 14 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 15 + {{ i "archive" "w-4 h-4" }} 16 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 17 + Source code (.tar.gz) 18 + </a> 19 + </div> 20 + </div> 21 + {{ if $isPushAllowed }} 22 + {{ template "uploadArtifact" (list $root $tag) }} 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + 27 + {{ define "uploadArtifact" }} 28 + {{ $root := index . 0 }} 29 + {{ $tag := index . 1 }} 30 + {{ $unique := $tag.Tag.Target.String }} 31 + <form 32 + id="upload-{{$unique}}" 33 + method="post" 34 + enctype="multipart/form-data" 35 + hx-post="/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload" 36 + hx-on::after-request="if(event.detail.successful) this.reset()" 37 + hx-disabled-elt="#upload-btn-{{$unique}}" 38 + hx-swap="beforebegin" 39 + hx-target="#artifact-git-source" 40 + class="flex items-center gap-2 px-2 group"> 41 + <div class="flex-grow"> 42 + <input type="file" 43 + name="artifact" 44 + required 45 + class="block py-2 px-0 w-full border-none 46 + text-black dark:text-white 47 + bg-white dark:bg-gray-800 48 + file:mr-4 file:px-2 file:py-2 49 + file:rounded file:border-0 50 + file:text-sm file:font-medium 51 + file:text-gray-700 file:dark:text-gray-300 52 + file:bg-gray-200 file:dark:bg-gray-700 53 + file:hover:bg-gray-100 file:hover:dark:bg-gray-600 54 + "> 55 + </input> 56 + </div> 57 + <div class="flex justify-end"> 58 + <button 59 + type="submit" 60 + class="btn-create gap-2" 61 + id="upload-btn-{{$unique}}" 62 + title="Upload artifact"> 63 + {{ i "upload" "size-4 inline group-[.htmx-request]:hidden" }} 64 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 65 + <span class="hidden md:inline">upload</span> 66 + </button> 67 + </div> 68 + </form> 69 + {{ end }} 70 +
+84 -53
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 15 15 popover 16 16 id="clone-dropdown" 17 17 class=" 18 - bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 19 - dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 20 - w-96 p-4 rounded drop-shadow overflow-visible"> 21 - <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-5">Clone this repository</h3> 18 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 19 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 20 + w-96 md:w-[440px] p-4 rounded drop-shadow overflow-visible"> 22 21 23 - <!-- HTTPS Clone --> 24 - <div class="mb-3"> 25 - <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 26 - <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 27 - <code 28 - class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 29 - onclick="window.getSelection().selectAllChildren(this)" 30 - data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 31 - >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 32 - <button 33 - onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 34 - class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 35 - title="Copy to clipboard" 36 - > 37 - {{ i "copy" "w-4 h-4" }} 38 - </button> 39 - </div> 22 + <input 23 + type="checkbox" 24 + id="permalink-toggle" 25 + class="peer hidden m-0"> 26 + 27 + <div class="flex items-center justify-between"> 28 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white">Clone this repository</h3> 29 + <label for="permalink-toggle" class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 cursor-pointer normal-case font-normal"> 30 + {{ i "square-check-big" "checkbox-checked size-4" }} 31 + {{ i "square" "checkbox-unchecked size-4" }} 32 + <span>Use permalink</span> 33 + </label> 40 34 </div> 41 35 42 - <!-- SSH Clone --> 43 - <div class="mb-3"> 44 - {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 45 - <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 46 - <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 47 - <code 48 - class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 49 - onclick="window.getSelection().selectAllChildren(this)" 50 - data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 51 - >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 52 - <button 53 - onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 54 - class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 55 - title="Copy to clipboard" 56 - > 57 - {{ i "copy" "w-4 h-4" }} 58 - </button> 59 - </div> 60 - </div> 36 + {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 37 + 38 + {{ template "cloneUrlItem" ( 39 + dict 40 + "Label" "HTTPS" 41 + "HandleUrl" (printf "https://tangled.org/%s/%s" $repoOwnerHandle .RepoInfo.Name) 42 + "PermaUrl" (printf "https://tangled.org/%s/%s" .RepoInfo.OwnerDid .RepoInfo.Name) 43 + ) }} 44 + 45 + {{ template "cloneUrlItem" ( 46 + dict 47 + "Label" "SSH" 48 + "HandleUrl" (printf "git@%s:%s/%s" (stripPort $knot) $repoOwnerHandle .RepoInfo.Name) 49 + "PermaUrl" (printf "git@%s:%s/%s" (stripPort $knot) .RepoInfo.OwnerDid .RepoInfo.Name) 50 + ) }} 61 51 62 - <!-- Note for self-hosted --> 63 - <p class="text-xs text-gray-500 dark:text-gray-400"> 64 - For self-hosted knots, clone URLs may differ based on your setup. 52 + <p class="text-xs text-gray-500 dark:text-gray-400 mt-2"> 53 + For self-hosted knots, clone URLs may differ based on your setup. 65 54 </p> 66 55 67 - <!-- Download Archive --> 68 - <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 69 - <a 70 - href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 71 - class="flex items-center gap-2 px-3 py-2 text-sm" 72 - > 73 - {{ i "download" "w-4 h-4" }} 74 - Download tar.gz 75 - </a> 76 - </div> 56 + <a 57 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 58 + class="flex items-center btn-create w-full mt-4 gap-2 text-sm hover:no-underline hover:text-gray-100" 59 + > 60 + {{ i "download" "w-4 h-4" }} 61 + Download tar.gz 62 + </a> 77 63 </div> 78 64 65 + <style> 66 + .clone-url-did { 67 + display: none; 68 + } 69 + .checkbox-checked { 70 + display: none; 71 + } 72 + #permalink-toggle:checked ~ * .clone-url-handle { 73 + display: none; 74 + } 75 + #permalink-toggle:checked ~ * .clone-url-did { 76 + display: block; 77 + } 78 + #permalink-toggle:checked ~ * .checkbox-checked { 79 + display: block; 80 + } 81 + #permalink-toggle:checked ~ * .checkbox-unchecked { 82 + display: none; 83 + } 84 + </style> 85 + 79 86 <script> 80 87 function copyToClipboard(button, text) { 81 88 navigator.clipboard.writeText(text).then(() => { ··· 88 95 } 89 96 </script> 90 97 {{ end }} 98 + 99 + {{ define "cloneUrlItem" }} 100 + <div class="mt-4"> 101 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 normal-case">{{ .Label }}</label> 102 + <div class="flex items-stretch border border-gray-300 dark:border-gray-600 divide-x divide-gray-300 dark:divide-gray-600 rounded"> 103 + <span 104 + class="clone-url-handle flex-1 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 select-all cursor-pointer whitespace-nowrap overflow-x-auto font-mono bg-gray-200 dark:bg-gray-700" 105 + onclick="window.getSelection().selectAllChildren(this)" 106 + >{{ .HandleUrl }}</span> 107 + <span 108 + class="clone-url-did flex-1 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 select-all cursor-pointer whitespace-nowrap overflow-x-auto font-mono bg-gray-200 dark:bg-gray-700" 109 + onclick="window.getSelection().selectAllChildren(this)" 110 + >{{ .PermaUrl }}</span> 111 + <button 112 + onclick="copyToClipboard(this, Array.from(this.parentElement.querySelectorAll('span')).find(s => getComputedStyle(s).display !== 'none').textContent)" 113 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" 114 + title="Copy to clipboard" 115 + > 116 + {{ i "copy" "w-4 h-4" }} 117 + </button> 118 + </div> 119 + </div> 120 + {{ end }} 121 +
+19 -2
appview/pages/templates/repo/fragments/diff.html
··· 3 3 #filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; } 4 4 #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 5 5 #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 6 - #filesToggle:checked ~ div div#files { width: fit-content; max-width: 15vw; margin-right: 1rem; } 6 + #filesToggle:checked ~ div div#files { width: fit-content; max-width: 15vw; } 7 7 #filesToggle:not(:checked) ~ div div#files { width: 0; display: none; margin-right: 0; } 8 + #filesToggle:not(:checked) ~ div div#resize-files { display: none; } 8 9 </style> 9 10 10 11 {{ template "diffTopbar" . }} 11 12 {{ block "diffLayout" . }} {{ end }} 13 + {{ template "fragments/resizable" }} 12 14 {{ end }} 13 15 14 16 {{ define "diffTopbar" }} ··· 78 80 79 81 {{ end }} 80 82 83 + {{ define "resize-grip" }} 84 + {{ $id := index . 0 }} 85 + {{ $target := index . 1 }} 86 + {{ $direction := index . 2 }} 87 + <div id="{{ $id }}" 88 + data-resizer="vertical" 89 + data-target="{{ $target }}" 90 + data-direction="{{ $direction }}" 91 + class="resizer-vertical hidden md:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group"> 92 + <div class="w-1 h-16 group-hover:h-24 group-[.resizing]:h-24 transition-all rounded-full bg-gray-400 dark:bg-gray-500 group-hover:bg-gray-500 group-hover:dark:bg-gray-400"></div> 93 + </div> 94 + {{ end }} 95 + 81 96 {{ define "diffLayout" }} 82 97 {{ $diff := index . 0 }} 83 98 {{ $opts := index . 1 }} ··· 90 105 </section> 91 106 </div> 92 107 108 + {{ template "resize-grip" (list "resize-files" "files" "before") }} 109 + 93 110 <!-- main content --> 94 - <div class="flex-1 min-w-0 sticky top-12 pb-12"> 111 + <div id="diff-files" class="flex-1 min-w-0 sticky top-12 pb-12"> 95 112 {{ template "diffFiles" (list $diff $opts) }} 96 113 </div> 97 114
+1 -1
appview/pages/templates/repo/fragments/labelSectionHeader.html
··· 4 4 {{ template "repo/fragments/labelSectionHeaderText" .Name }} 5 5 {{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }} 6 6 <a 7 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 7 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 8 8 hx-get="/{{ .RepoInfo.FullName }}/label/edit" 9 9 hx-vals='{"subject": "{{.Subject}}"}' 10 10 hx-swap="outerHTML"
+29
appview/pages/templates/repo/fragments/lastCommitPanel.html
··· 1 + {{ define "repo/fragments/lastCommitPanel" }} 2 + {{ $messageParts := splitN .LastCommitInfo.Message "\n\n" 2 }} 3 + <div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex flex-col md:flex-row md:items-center md:justify-between text-sm gap-2"> 4 + <div class="flex flex-col md:flex-row md:items-center gap-1"> 5 + {{ if .LastCommitInfo.Author }} 6 + {{ $authorDid := index .EmailToDid .LastCommitInfo.Author.Email }} 7 + <span class="flex items-center gap-1"> 8 + {{ if $authorDid }} 9 + {{ template "user/fragments/picHandleLink" $authorDid }} 10 + {{ else }} 11 + {{ placeholderAvatar "tiny" }} 12 + <a href="mailto:{{ .LastCommitInfo.Author.Email }}" class="no-underline hover:underline">{{ .LastCommitInfo.Author.Name }}</a> 13 + {{ end }} 14 + </span> 15 + <span class="hidden md:inline px-1 select-none before:content-['\00B7']"></span> 16 + {{ end }} 17 + <a href="/{{ .RepoInfo.FullName }}/commit/{{ .LastCommitInfo.Hash }}" 18 + class="inline no-underline hover:underline dark:text-white"> 19 + {{ index $messageParts 0 }} 20 + </a> 21 + <span class="hidden md:inline px-1 select-none before:content-['\00B7']"></span> 22 + <span class="text-gray-400 dark:text-gray-500">{{ template "repo/fragments/time" .LastCommitInfo.When }}</span> 23 + </div> 24 + <a href="/{{ .RepoInfo.FullName }}/commit/{{ .LastCommitInfo.Hash.String }}" 25 + class="no-underline hover:underline text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded font-mono text-xs w-fit"> 26 + {{ slice .LastCommitInfo.Hash.String 0 8 }} 27 + </a> 28 + </div> 29 + {{ end }}
+17 -2
appview/pages/templates/repo/fragments/og.html
··· 1 1 {{ define "repo/fragments/og" }} 2 2 {{ $title := or .Title .RepoInfo.FullName }} 3 - {{ $description := or .Description .RepoInfo.Description }} 3 + {{ $description := or .Description .RepoInfo.Description "A repository on Tangled" }} 4 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 5 {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 6 + {{ $ownerHandle := resolve .RepoInfo.OwnerDid }} 6 7 8 + <!-- Open Graph Meta Tags --> 7 9 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 - <meta property="og:type" content="object" /> 10 + <meta property="og:type" content="article" /> 9 11 <meta property="og:url" content="{{ $url }}" /> 10 12 <meta property="og:description" content="{{ $description }}" /> 11 13 <meta property="og:image" content="{{ $imageUrl }}" /> 12 14 <meta property="og:image:width" content="1200" /> 13 15 <meta property="og:image:height" content="600" /> 16 + <meta property="og:image:alt" content="{{ unescapeHtml $title }}" /> 17 + <meta property="article:author" content="{{ $ownerHandle }}" /> 18 + {{ if .RepoInfo.Topics }} 19 + {{ range .RepoInfo.Topics }} 20 + <meta property="article:tag" content="{{ . }}" /> 21 + {{ end }} 22 + {{ end }} 14 23 24 + <!-- Twitter Card Meta Tags --> 15 25 <meta name="twitter:card" content="summary_large_image" /> 16 26 <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 27 <meta name="twitter:description" content="{{ $description }}" /> 18 28 <meta name="twitter:image" content="{{ $imageUrl }}" /> 29 + <meta name="twitter:image:alt" content="{{ unescapeHtml $title }}" /> 30 + 31 + <!-- Additional SEO --> 32 + <meta name="description" content="{{ $description }}" /> 33 + <link rel="canonical" href="{{ $url }}" /> 19 34 {{ end }}
+67
appview/pages/templates/repo/fragments/singleTag.html
··· 1 + {{ define "repo/fragments/singleTag" }} 2 + {{ $root := index . 0 }} 3 + {{ $item := index . 1 }} 4 + {{ with $item }} 5 + <div class="md:grid md:grid-cols-12 md:items-start flex flex-col"> 6 + <!-- Header column (top on mobile, left on md+) --> 7 + <div class="md:col-span-2 md:border-r border-b md:border-b-0 border-gray-200 dark:border-gray-700 w-full md:h-full"> 8 + <!-- Mobile layout: horizontal --> 9 + <div class="flex md:hidden flex-col py-2 px-2 text-xl"> 10 + <a href="/{{ $root.RepoInfo.FullName }}/tags/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 11 + {{ i "tag" "w-4 h-4" }} 12 + {{ .Name }} 13 + </a> 14 + 15 + <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 16 + {{ if .Tag }} 17 + <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 18 + class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 19 + {{ slice .Tag.Target.String 0 8 }} 20 + </a> 21 + 22 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['·']"></span> 23 + <span>{{ .Tag.Tagger.Name }}</span> 24 + 25 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['·']"></span> 26 + {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 27 + {{ end }} 28 + </div> 29 + </div> 30 + 31 + <!-- Desktop layout: vertical and left-aligned --> 32 + <div class="hidden md:block text-left px-2 pb-6"> 33 + <a href="/{{ $root.RepoInfo.FullName }}/tags/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 34 + {{ i "tag" "w-4 h-4" }} 35 + {{ .Name }} 36 + </a> 37 + <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 38 + {{ if .Tag }} 39 + <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 40 + class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 41 + {{ i "git-commit-horizontal" "w-4 h-4" }} 42 + {{ slice .Tag.Target.String 0 8 }} 43 + </a> 44 + <span>{{ .Tag.Tagger.Name }}</span> 45 + {{ template "repo/fragments/time" .Tag.Tagger.When }} 46 + {{ end }} 47 + </div> 48 + </div> 49 + </div> 50 + 51 + <!-- Content column (bottom on mobile, right on md+) --> 52 + <div class="md:col-span-10 px-2 py-3 md:py-0 md:pb-6"> 53 + {{ if .Tag }} 54 + {{ $messageParts := splitN .Tag.Message "\n\n" 2 }} 55 + <p class="font-bold text-lg">{{ index $messageParts 0 }}</p> 56 + {{ if gt (len $messageParts) 1 }} 57 + <p class="cursor-text py-2">{{ nl2br (index $messageParts 1) }}</p> 58 + {{ end }} 59 + {{ template "repo/fragments/artifactList" (list $root .) }} 60 + {{ else }} 61 + <p class="italic text-gray-500 dark:text-gray-400">no message</p> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + {{ end }} 67 +
+1 -1
appview/pages/templates/repo/index.html
··· 334 334 {{ with $tag }} 335 335 <div> 336 336 <div class="text-base flex items-center gap-2"> 337 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 337 + <a href="/{{ $.RepoInfo.FullName }}/tags/{{ .Reference.Name | urlquery }}" 338 338 class="inline no-underline hover:underline dark:text-white"> 339 339 {{ .Reference.Name }} 340 340 </a>
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
··· 10 10 11 11 {{ define "edit" }} 12 12 <a 13 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 13 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 14 14 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 15 15 hx-swap="outerHTML" 16 16 hx-target="#comment-body-{{.Comment.Id}}"> ··· 21 21 22 22 {{ define "delete" }} 23 23 <a 24 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 24 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 25 25 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 26 26 hx-confirm="Are you sure you want to delete your comment?" 27 27 hx-swap="outerHTML"
+10 -18
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - {{ $active := "closed" }} 12 - {{ if .FilteringByOpen }} 13 - {{ $active = "open" }} 14 - {{ end }} 11 + {{ $active := .FilterState }} 15 12 16 - {{ $open := 13 + {{ $open := 17 14 (dict 18 15 "Key" "open" 19 16 "Value" "open" 20 17 "Icon" "circle-dot" 21 18 "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 - {{ $closed := 19 + {{ $closed := 23 20 (dict 24 21 "Key" "closed" 25 22 "Value" "closed" ··· 28 25 {{ $values := list $open $closed }} 29 26 30 27 <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 }}"> 28 + <form id="search-form" class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 33 29 <div class="flex-1 flex relative"> 34 30 <input 35 31 id="search-q" ··· 40 36 placeholder="search issues..." 41 37 > 42 38 <a 43 - href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 39 + {{ if $active }}href="?q=state:{{ $active }}"{{ else }}href="?"{{ end }} 44 40 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" 45 41 > 46 42 {{ i "x" "w-4 h-4" }} ··· 54 50 </button> 55 51 </form> 56 52 <div class="sm:row-start-1"> 57 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 53 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q" "Form" "search-form") }} 58 54 </div> 59 55 <a 60 56 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 72 68 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 73 69 </div> 74 70 {{if gt .IssueCount .Page.Limit }} 75 - {{ $state := "closed" }} 76 - {{ if .FilteringByOpen }} 77 - {{ $state = "open" }} 78 - {{ end }} 79 - {{ template "fragments/pagination" (dict 80 - "Page" .Page 81 - "TotalCount" .IssueCount 71 + {{ template "fragments/pagination" (dict 72 + "Page" .Page 73 + "TotalCount" .IssueCount 82 74 "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 83 - "QueryParams" (queryParams "state" $state "q" .FilterQuery) 75 + "QueryParams" (queryParams "q" .FilterQuery) 84 76 ) }} 85 77 {{ end }} 86 78 {{ end }}
+18
appview/pages/templates/repo/knotUnreachable.html
··· 1 + {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 + {{ define "extrameta" }} 3 + {{ template "repo/fragments/meta" . }} 4 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 5 + {{ end }} 6 + {{ define "repoContent" }} 7 + <main> 8 + <div class="relative w-full h-96 flex items-center justify-center"> 9 + <div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur"> 10 + <div class="text-center"> 11 + {{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }} 12 + The knot hosting this repository is unreachable. 13 + </div> 14 + </div> 15 + </div> 16 + </main> 17 + {{ end }} 18 +
+2 -1
appview/pages/templates/repo/new.html
··· 110 110 type="text" 111 111 id="description" 112 112 name="description" 113 + maxlength="140" 113 114 class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 115 placeholder="A brief description of your project..." 115 116 /> 116 117 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 - Optional. A short description to help others understand what your project does. 118 + Optional. A short description to help others understand what your project does (max 140 characters). 118 119 </p> 119 120 </div> 120 121 {{ end }}
+18 -6
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
··· 1 1 {{ define "repo/pipelines/fragments/pipelineSymbolLong" }} 2 2 {{ $pipeline := .Pipeline }} 3 3 {{ $repoinfo := .RepoInfo }} 4 + {{ $popoverId := printf "pipeline-status-%d" $pipeline.Id }} 5 + 4 6 <div class="relative inline-block"> 5 - <details class="relative"> 6 - <summary class="cursor-pointer list-none"> 7 - {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 8 - </summary> 7 + <button 8 + type="button" 9 + popovertarget="{{ $popoverId }}" 10 + popovertargetaction="toggle" 11 + class="cursor-pointer" 12 + > 13 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 14 + </button> 15 + 16 + <div 17 + id="{{ $popoverId }}" 18 + popover 19 + class="bg-white dark:bg-gray-900 text-black dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 w-80 rounded shadow-sm p-2 [position:fixed] [inset:0] [margin:auto] [max-height:fit-content]" 20 + > 9 21 {{ template "repo/pipelines/fragments/tooltip" $ }} 10 - </details> 22 + </div> 11 23 </div> 12 - {{ end }} 24 + {{ end }}
+2 -4
appview/pages/templates/repo/pipelines/fragments/tooltip.html
··· 2 2 {{ $repoinfo := .RepoInfo }} 3 3 {{ $pipeline := .Pipeline }} 4 4 {{ $id := $pipeline.Id }} 5 - <div class="absolute z-[9999] bg-white dark:bg-gray-900 text-black dark:text-white rounded shadow-sm w-80 top-full mt-2 p-2"> 6 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700"> 5 + <div class="flex flex-col"> 7 6 {{ range $name, $all := $pipeline.Statuses }} 8 - <a href="/{{ $repoinfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="hover:no-underline"> 7 + <a href="/{{ $repoinfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="hover:no-underline hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors first:rounded-t last:rounded-b"> 9 8 <div class="flex items-center justify-between p-2"> 10 9 {{ $lastStatus := $all.Latest }} 11 10 {{ $kind := $lastStatus.Status.String }} ··· 30 29 Waiting for spindle ... 31 30 </div> 32 31 {{ end }} 33 - </div> 34 32 </div> 35 33 {{ end }}
+123 -82
appview/pages/templates/repo/pipelines/pipelines.html
··· 7 7 {{ end }} 8 8 9 9 {{ define "repoContent" }} 10 - <div class="flex justify-between items-center gap-4"> 11 - <div class="w-full flex flex-col gap-2"> 12 - {{ range .Pipelines }} 13 - {{ block "pipeline" (list $ .) }} {{ end }} 14 - {{ else }} 15 - <div class="py-6 w-fit flex flex-col gap-4 mx-auto"> 16 - <p> 17 - No pipelines have been run for this repository yet. To get started: 18 - </p> 19 - {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 20 - <p> 21 - <span class="{{ $bullet }}">1</span>First, choose a spindle in your 22 - <a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>. 23 - </p> 24 - <p> 25 - <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 - <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>. 27 - </p> 28 - <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 10 + {{ $active := .FilterKind }} 11 + 12 + {{ $all := 13 + (dict 14 + "Key" "all" 15 + "Value" "all" 16 + "Icon" "package" 17 + "Meta" "") }} 18 + {{ $push := 19 + (dict 20 + "Key" "push" 21 + "Value" "push" 22 + "Icon" "git-commit-horizontal" 23 + "Meta" "") }} 24 + {{ $pr := 25 + (dict 26 + "Key" "pull_request" 27 + "Value" "pull request" 28 + "Icon" "git-pull-request" 29 + "Meta" "") }} 30 + {{ $values := list $all $push $pr }} 31 + 32 + <div class="flex justify-between items-center gap-4"> 33 + <div> 34 + {{ template "fragments/tabSelector" (dict "Name" "trigger" "Values" $values "Active" .FilterKind) }} 29 35 </div> 30 - {{ end }} 36 + <div class="text-sm text-gray-600 dark:text-gray-400"> 37 + {{ .Total }} pipeline run{{ if ne .Total 1 }}s{{ end }} 38 + </div> 31 39 </div> 32 - </div> 33 40 {{ end }} 34 41 42 + {{ define "repoAfter" }} 43 + {{ if .Pipelines }} 44 + <div class="flex flex-col gap-2 mt-2"> 45 + {{ range .Pipelines }} 46 + {{ template "pipelineCard" (dict "Root" $ "Pipeline" .) }} 47 + {{ end }} 48 + </div> 49 + {{ else }} 50 + <div class="mt-2 py-12 flex flex-col items-center justify-center gap-6 text-center border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 51 + <div class="flex justify-center"> 52 + {{ i "package" "size-16 text-gray-300 dark:text-gray-700" }} 53 + </div> 35 54 36 - {{ define "pipeline" }} 37 - {{ $root := index . 0 }} 38 - {{ $p := index . 1 }} 39 - <div class="py-2 bg-white dark:bg-gray-800 dark:text-white"> 40 - {{ block "pipelineHeader" $ }} {{ end }} 55 + <div class="flex flex-col gap-2"> 56 + <p class="text-lg font-semibold dark:text-white"> 57 + No pipelines have been run yet 58 + </p> 59 + <p class="text-sm text-gray-600 dark:text-gray-400 max-w-md"> 60 + Get started by configuring CI/CD for this repository 61 + </p> 62 + </div> 63 + 64 + <div class="flex flex-col gap-3 text-left max-w-md"> 65 + <div class="flex items-start gap-3"> 66 + <span class="mt-0.5 text-xs bg-gray-200 dark:bg-gray-700 rounded-full size-6 flex items-center justify-center font-semibold shrink-0">1</span> 67 + <p class="text-sm text-gray-700 dark:text-gray-300"> 68 + Choose a spindle in your 69 + <a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline hover:no-underline">repository settings</a> 70 + </p> 71 + </div> 72 + <div class="flex items-start gap-3"> 73 + <span class="mt-0.5 text-xs bg-gray-200 dark:bg-gray-700 rounded-full size-6 flex items-center justify-center font-semibold shrink-0">2</span> 74 + <p class="text-sm text-gray-700 dark:text-gray-300"> 75 + Configure your CI/CD 76 + <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline hover:no-underline" target="_blank" rel="noopener">pipeline</a> 77 + </p> 78 + </div> 79 + <div class="flex items-start gap-3"> 80 + <span class="mt-0.5 text-xs bg-gray-200 dark:bg-gray-700 rounded-full size-6 flex items-center justify-center font-semibold shrink-0">3</span> 81 + <p class="text-sm text-gray-700 dark:text-gray-300"> 82 + Trigger a workflow with a push or pull request 83 + </p> 84 + </div> 85 + </div> 41 86 </div> 42 87 {{ end }} 88 + {{ end }} 43 89 44 - {{ define "pipelineHeader" }} 45 - {{ $root := index . 0 }} 46 - {{ $p := index . 1 }} 90 + {{ define "pipelineCard" }} 91 + {{ $root := .Root }} 92 + {{ $p := .Pipeline }} 47 93 {{ with $p }} 48 - <div class="grid grid-cols-6 md:grid-cols-12 gap-2 items-center w-full"> 49 - <div class="text-sm md:text-base col-span-1"> 50 - {{ .Trigger.Kind.String }} 51 - </div> 94 + <div class="relative rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800"> 95 + <div class="grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_9rem_7rem] gap-3 md:gap-4 items-start md:items-center"> 96 + <!-- Trigger info --> 97 + <div class="flex items-center gap-2 flex-wrap"> 98 + {{ $target := .Trigger.TargetRef }} 99 + {{ $workflows := .Workflows }} 52 100 53 - <div class="col-span-2 md:col-span-7 flex items-center gap-4"> 54 - {{ $target := .Trigger.TargetRef }} 55 - {{ $workflows := .Workflows }} 56 - {{ $link := "" }} 57 - {{ if .IsResponding }} 58 - {{ $link = printf "/%s/pipelines/%s/workflow/%d" $root.RepoInfo.FullName .Id (index $workflows 0) }} 59 - {{ end }} 60 - {{ if .Trigger.IsPush }} 61 - <span class="font-bold">{{ $target }}</span> 62 - <span class="hidden md:inline-flex gap-2 items-center font-mono text-sm"> 63 - {{ $old := deref .Trigger.PushOldSha }} 64 - {{ $new := deref .Trigger.PushNewSha }} 101 + <!-- Status icon with tooltip --> 102 + <span class="relative z-50 mr-4"> 103 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" . "RepoInfo" $root.RepoInfo) }} 104 + </span> 65 105 66 - <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $new }}">{{ slice $new 0 8 }}</a> 67 - {{ i "arrow-left" "size-4" }} 68 - <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $old }}">{{ slice $old 0 8 }}</a> 69 - </span> 70 - {{ else if .Trigger.IsPullRequest }} 71 - {{ $sha := deref .Trigger.PRSourceSha }} 72 - <span class="inline-flex gap-2 items-center"> 73 - <span class="font-bold">{{ $target }}</span> 74 - {{ i "arrow-left" "size-4" }} 75 - {{ .Trigger.PRSourceBranch }} 76 - <span class="text-sm font-mono"> 77 - @ 78 - <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a> 79 - </span> 80 - </span> 81 - {{ end }} 82 - </div> 106 + {{ if .IsResponding }} 107 + <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}" class="flex items-center gap-2 flex-wrap no-underline hover:underline"> 108 + {{ end }} 109 + {{ if .Trigger.IsPush }} 110 + {{ i "git-commit-horizontal" "size-4 text-gray-500 dark:text-gray-400 shrink-0" }} 111 + <span class="text-sm text-gray-600 dark:text-gray-400">Push to</span> 112 + <span class="font-semibold dark:text-white">{{ $target }}</span> 113 + {{ else if .Trigger.IsPullRequest }} 114 + {{ i "git-pull-request" "size-4 text-gray-500 dark:text-gray-400 shrink-0" }} 115 + <span class="text-sm text-gray-600 dark:text-gray-400">Pull request</span> 116 + <span class="font-semibold dark:text-white">{{ $target }}</span> 117 + {{ i "arrow-left" "size-3 text-gray-500 dark:text-gray-400" }} 118 + <span class="font-semibold dark:text-white">{{ .Trigger.PRSourceBranch }}</span> 119 + {{ end }} 120 + {{ if .IsResponding }} 121 + </a> 122 + {{ end }} 123 + </div> 83 124 84 - <div class="text-sm md:text-base col-span-1"> 85 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" . "RepoInfo" $root.RepoInfo) }} 125 + <!-- Metadata stacked on mobile, separate columns on desktop --> 126 + <div class="flex flex-col gap-2 items-end md:contents"> 127 + <!-- Commit SHA --> 128 + <div class="font-mono text-xs text-gray-500 dark:text-gray-400"> 129 + <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ .Sha }}" class="text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900 no-underline hover:underline px-2 py-1 rounded"> 130 + {{ slice .Sha 0 8 }} 131 + </a> 86 132 </div> 87 133 88 - <div class="text-sm md:text-base col-span-1 text-right"> 89 - {{ template "repo/fragments/shortTimeAgo" .Created }} 134 + <!-- Time --> 135 + <div class="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap text-right"> 136 + {{ template "repo/fragments/time" .Created }} 90 137 </div> 91 138 92 - {{ $t := .TimeTaken }} 93 - <div class="text-sm md:text-base col-span-1 text-right"> 139 + <!-- Duration --> 140 + <div class="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap text-right"> 141 + {{ $t := .TimeTaken }} 94 142 {{ if $t }} 95 - <time title="{{ $t }}">{{ $t | durationFmt }}</time> 143 + <span class="flex items-center gap-1"> 144 + {{ i "clock" "size-3" }} 145 + <time title="{{ $t }}">{{ $t | durationFmt }}</time> 146 + </span> 96 147 {{ else }} 97 - <time>--</time> 148 + <span class="text-gray-400 dark:text-gray-600">--</span> 98 149 {{ end }} 99 150 </div> 100 - 101 - <div class="col-span-1 flex justify-end"> 102 - {{ if $link }} 103 - <a class="md:hidden" href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}"> 104 - {{ i "arrow-up-right" "size-4" }} 105 - </a> 106 - <a class="hidden md:inline underline" href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}"> 107 - view 108 - </a> 109 - {{ end }} 110 - </div> 111 - 112 151 </div> 152 + </div> 153 + </div> 113 154 {{ end }} 114 155 {{ end }}
+2 -2
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 38 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 39 hx-swap="none" 40 40 class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 - {{ i "git-branch" "w-4 h-4" }} 42 - <span>delete branch</span> 41 + {{ i "git-branch" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 43 42 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + delete branch 44 44 </button> 45 45 {{ end }} 46 46 {{ if and $isPushAllowed $isOpen $isLastRound }}
+24 -7
appview/pages/templates/repo/pulls/pull.html
··· 111 111 {{ end }} 112 112 {{ end }} 113 113 114 + {{ define "resize-grip" }} 115 + {{ $id := index . 0 }} 116 + {{ $target := index . 1 }} 117 + {{ $direction := index . 2 }} 118 + <div id="{{ $id }}" 119 + data-resizer="vertical" 120 + data-target="{{ $target }}" 121 + data-direction="{{ $direction }}" 122 + class="resizer-vertical hidden md:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group"> 123 + <div class="w-1 h-16 group-hover:h-24 group-[.resizing]:h-24 transition-all rounded-full bg-gray-400 dark:bg-gray-500 group-hover:bg-gray-500 group-hover:dark:bg-gray-400"></div> 124 + </div> 125 + {{ end }} 126 + 114 127 {{ define "diffLayout" }} 115 128 {{ $diff := index . 0 }} 116 129 {{ $opts := index . 1 }} ··· 124 137 </section> 125 138 </div> 126 139 140 + {{ template "resize-grip" (list "resize-files" "files" "before") }} 141 + 127 142 <!-- main content --> 128 - <div class="flex-1 min-w-0 sticky top-12 pb-12"> 143 + <div id="diff-files" class="flex-1 min-w-0 sticky top-12 pb-12"> 129 144 {{ template "diffFiles" (list $diff $opts) }} 130 145 </div> 146 + 147 + {{ template "resize-grip" (list "resize-subs" "subs" "after") }} 131 148 132 149 <!-- right panel --> 133 150 {{ template "subsPanel" $ }} ··· 187 204 188 205 {{ define "subsToggle" }} 189 206 <style> 190 - /* Mobile: full width */ 191 207 #subsToggle:checked ~ div div#subs { 192 208 width: 100%; 193 209 margin-left: 0; ··· 196 212 #subsToggle:checked ~ div label[for="subsToggle"] .hide-toggle { display: flex; } 197 213 #subsToggle:not(:checked) ~ div label[for="subsToggle"] .hide-toggle { display: none; } 198 214 199 - /* Desktop: 25vw with left margin */ 200 215 @media (min-width: 768px) { 201 216 #subsToggle:checked ~ div div#subs { 202 217 width: 25vw; 203 - margin-left: 1rem; 218 + max-width: 50vw; 204 219 } 205 - /* Unchecked state */ 206 220 #subsToggle:not(:checked) ~ div div#subs { 207 221 width: 0; 208 222 display: none; 209 223 margin-left: 0; 224 + } 225 + #subsToggle:not(:checked) ~ div div#resize-subs { 226 + display: none; 210 227 } 211 228 } 212 229 </style> ··· 298 315 </a> 299 316 </span> 300 317 <div class="flex gap-2 items-center"> 301 - {{ if ne $root.ActiveRound $round }} 318 + {{ if or $root.IsInterdiff (ne $root.ActiveRound $round) }} 302 319 <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 303 320 href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}#round-#{{ $round }}"> 304 321 {{ i "diff" "w-4 h-4" }} 305 322 diff 306 323 </a> 307 324 {{ end }} 308 - {{ if ne $idx 0 }} 325 + {{ if and (ne $idx 0) (or (not $root.IsInterdiff) (ne $root.ActiveRound $round)) }} 309 326 <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 310 327 href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}/interdiff?{{ safeUrl $root.DiffOpts.Encode }}"> 311 328 {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
+11 -17
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - {{ $active := "closed" }} 12 - {{ if .FilteringBy.IsOpen }} 13 - {{ $active = "open" }} 14 - {{ else if .FilteringBy.IsMerged }} 15 - {{ $active = "merged" }} 16 - {{ end }} 17 - {{ $open := 11 + {{ $active := .FilterState }} 12 + {{ $open := 18 13 (dict 19 14 "Key" "open" 20 15 "Value" "open" 21 16 "Icon" "git-pull-request" 22 17 "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 - {{ $merged := 18 + {{ $merged := 24 19 (dict 25 20 "Key" "merged" 26 21 "Value" "merged" 27 22 "Icon" "git-merge" 28 23 "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 - {{ $closed := 24 + {{ $closed := 30 25 (dict 31 26 "Key" "closed" 32 27 "Value" "closed" ··· 34 29 "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} 35 30 {{ $values := list $open $merged $closed }} 36 31 <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 }}"> 32 + <form id="search-form" class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 39 33 <div class="flex-1 flex relative"> 40 34 <input 41 35 id="search-q" ··· 46 40 placeholder="search pulls..." 47 41 > 48 42 <a 49 - href="?state={{ .FilteringBy.String }}" 43 + {{ if $active }}href="?q=state:{{ $active }}"{{ else }}href="?"{{ end }} 50 44 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" 51 45 > 52 46 {{ i "x" "w-4 h-4" }} ··· 60 54 </button> 61 55 </form> 62 56 <div class="sm:row-start-1"> 63 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 57 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q" "Form" "search-form") }} 64 58 </div> 65 59 <a 66 60 href="/{{ .RepoInfo.FullName }}/pulls/new" ··· 162 156 {{ end }} 163 157 </div> 164 158 {{if gt .PullCount .Page.Limit }} 165 - {{ template "fragments/pagination" (dict 166 - "Page" .Page 167 - "TotalCount" .PullCount 159 + {{ template "fragments/pagination" (dict 160 + "Page" .Page 161 + "TotalCount" .PullCount 168 162 "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 169 - "QueryParams" (queryParams "state" .FilteringBy.String "q" .FilterQuery) 163 + "QueryParams" (queryParams "q" .FilterQuery) 170 164 ) }} 171 165 {{ end }} 172 166 {{ end }}
+74
appview/pages/templates/repo/settings/fragments/webhookDeliveries.html
··· 1 + {{define "repo/settings/fragments/webhookDeliveries"}} 2 + <div class="flex flex-col gap-4"> 3 + <div class="flex items-center justify-between"> 4 + <h3 class="text-lg font-bold uppercase text-sm">Recent Deliveries</h3> 5 + <button 6 + type="button" 7 + onclick="this.closest('[popover]').hidePopover()" 8 + class="btn text-sm flex items-center gap-1" 9 + > 10 + {{ i "x" "size-4" }} close 11 + </button> 12 + </div> 13 + 14 + <div class="text-sm text-gray-600 dark:text-gray-400"> 15 + <span class="font-mono break-all">{{ .Webhook.Url }}</span> 16 + </div> 17 + 18 + {{ if .Deliveries }} 19 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded max-h-[60vh] overflow-y-auto"> 20 + {{ range .Deliveries }} 21 + <details class="group"> 22 + <summary class="p-4 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer list-none"> 23 + <div class="flex items-center gap-2 mb-2"> 24 + {{ if .Success }} 25 + <span class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> 26 + {{ i "circle-check" "size-3" }} 27 + {{ .ResponseCode }} 28 + </span> 29 + {{ else }} 30 + <span class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"> 31 + {{ i "circle-x" "size-3" }} 32 + {{ if .ResponseCode }}{{ .ResponseCode }}{{ else }}failed{{ end }} 33 + </span> 34 + {{ end }} 35 + <span class="text-xs text-gray-500 dark:text-gray-400 font-mono"> 36 + {{ .DeliveryId }} 37 + </span> 38 + <span class="ml-auto text-xs text-gray-400 dark:text-gray-500 group-open:rotate-180 transition-transform"> 39 + {{ i "chevron-down" "size-4" }} 40 + </span> 41 + </div> 42 + <div class="text-sm text-gray-700 dark:text-gray-300 mb-1"> 43 + <span class="font-semibold">Event:</span> {{ .Event }} 44 + </div> 45 + <div class="text-xs text-gray-500 dark:text-gray-400"> 46 + {{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }} 47 + </div> 48 + </summary> 49 + 50 + <div class="px-4 pb-4 pt-2 bg-gray-50 dark:bg-gray-800/50"> 51 + <div class="flex flex-col gap-3 text-sm"> 52 + <div class="flex flex-col gap-2"> 53 + <h5 class="font-semibold text-gray-700 dark:text-gray-300">Request Body</h5> 54 + <pre class="bg-white dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700">{{ .RequestBody }}</pre> 55 + </div> 56 + 57 + {{ if .ResponseBody }} 58 + <div class="flex flex-col gap-2"> 59 + <h5 class="font-semibold text-gray-700 dark:text-gray-300">Response Body</h5> 60 + <pre class="bg-white dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700">{{ .ResponseBody }}</pre> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + </details> 66 + {{ end }} 67 + </div> 68 + {{ else }} 69 + <div class="flex items-center justify-center p-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded"> 70 + No deliveries yet 71 + </div> 72 + {{ end }} 73 + </div> 74 + {{end}}
+331
appview/pages/templates/repo/settings/hooks.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "webhooksSettings" . }} 10 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 11 + </div> 12 + </section> 13 + {{ end }} 14 + 15 + {{ define "webhooksSettings" }} 16 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 17 + <div class="col-span-1 md:col-span-2"> 18 + <h2 class="text-sm pb-2 uppercase font-bold">Webhooks</h2> 19 + <p class="text-gray-500 dark:text-gray-400"> 20 + Webhooks allow external services to be notified when certain events happen. 21 + When the specified events happen, we'll send a POST request to each of the URLs you provide. 22 + </p> 23 + </div> 24 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 25 + <button 26 + class="btn flex items-center gap-2" 27 + popovertarget="add-webhook-modal" 28 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 29 + popovertargetaction="toggle"> 30 + {{ i "plus" "size-4" }} 31 + new webhook 32 + </button> 33 + <div 34 + id="add-webhook-modal" 35 + popover 36 + class="bg-white w-full sm:w-[40rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 37 + {{ template "addWebhookModal" . }} 38 + </div> 39 + </div> 40 + </div> 41 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 42 + {{ range .Webhooks }} 43 + <div id="webhook-{{.Id}}" class="flex flex-col gap-2 p-4"> 44 + <div class="flex items-start justify-between"> 45 + <div class="flex-1"> 46 + <div class="flex items-center gap-2"> 47 + <span class="font-mono text-sm break-all">{{ .Url }}</span> 48 + {{ if .Active }} 49 + <span class="inline-flex items-center gap-1 px-2 py-1 text-sm rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> 50 + {{ i "circle-check" "size-4" }} 51 + active 52 + </span> 53 + {{ else }} 54 + <span class="inline-flex items-center gap-1 px-2 py-1 text-sm rounded bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"> 55 + {{ i "circle" "size-4" }} 56 + inactive 57 + </span> 58 + {{ end }} 59 + </div> 60 + <div class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 61 + Events: {{ range $i, $e := .Events }}{{ if $i }}, {{ end }}{{ $e }}{{ end }} 62 + </div> 63 + </div> 64 + {{ if $.RepoInfo.Roles.IsOwner }} 65 + <div class="flex gap-2 items-center"> 66 + <button 67 + class="btn text-sm flex items-center gap-1" 68 + popovertarget="edit-webhook-modal-{{.Id}}" 69 + popovertargetaction="toggle"> 70 + {{ i "pencil" "size-4" }} 71 + edit 72 + </button> 73 + <button 74 + class="btn text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-1 group" 75 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/hooks/{{ .Id }}" 76 + hx-swap="none" 77 + hx-confirm="Are you sure you want to delete this webhook?"> 78 + {{ i "trash-2" "size-4" }} 79 + delete 80 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 81 + </button> 82 + </div> 83 + {{ end }} 84 + </div> 85 + 86 + {{ if $.RepoInfo.Roles.IsOwner }} 87 + <div class="flex items-center mt-2"> 88 + <label class="flex items-center gap-2 cursor-pointer"> 89 + <input 90 + type="checkbox" 91 + {{ if .Active }}checked{{ end }} 92 + hx-post="/{{ $.RepoInfo.FullName }}/settings/hooks/{{ .Id }}/toggle" 93 + hx-swap="none" 94 + class="cursor-pointer" 95 + /> 96 + <span class="text-sm">Active</span> 97 + </label> 98 + </div> 99 + {{ end }} 100 + 101 + <!-- Recent Deliveries --> 102 + {{ $deliveries := index $.WebhookDeliveries .Id }} 103 + {{ if $deliveries }} 104 + <div class="mt-3 border-t border-gray-200 dark:border-gray-700 pt-3"> 105 + <div class="flex items-center justify-between mb-2"> 106 + <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase">Recent Deliveries</h4> 107 + </div> 108 + <div class="flex flex-col gap-2"> 109 + {{ range $deliveries }} 110 + <div class="flex items-center gap-3 text-xs p-2 bg-gray-50 dark:bg-gray-800 rounded"> 111 + {{ if .Success }} 112 + <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> 113 + {{ i "circle-check" "size-3" }} 114 + {{ .ResponseCode }} 115 + </span> 116 + {{ else }} 117 + <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"> 118 + {{ i "circle-x" "size-3" }} 119 + {{ if .ResponseCode }}{{ .ResponseCode }}{{ else }}failed{{ end }} 120 + </span> 121 + {{ end }} 122 + <span class="flex-1 font-mono text-gray-600 dark:text-gray-400 truncate"> 123 + {{ .DeliveryId }} 124 + </span> 125 + <span class="text-gray-500 dark:text-gray-400"> 126 + {{ .Event }} 127 + </span> 128 + <span class="text-gray-500 dark:text-gray-400 whitespace-nowrap"> 129 + {{ .CreatedAt.Format "Jan 02, 15:04" }} 130 + </span> 131 + </div> 132 + {{ end }} 133 + </div> 134 + <div class="mt-2"> 135 + <button 136 + class="btn text-xs flex items-center gap-1" 137 + hx-get="/{{ $.RepoInfo.FullName }}/settings/hooks/{{ .Id }}/deliveries" 138 + hx-target="#webhook-deliveries-modal-{{ .Id }}" 139 + hx-swap="innerHTML" 140 + popovertarget="webhook-deliveries-modal-{{ .Id }}" 141 + popovertargetaction="toggle" 142 + > 143 + {{ i "list" "size-3" }} 144 + show all 145 + </button> 146 + </div> 147 + </div> 148 + {{ else }} 149 + <div class="mt-3 border-t border-gray-200 dark:border-gray-700 pt-3"> 150 + <div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2"> 151 + No deliveries yet 152 + </div> 153 + </div> 154 + {{ end }} 155 + 156 + <!-- Deliveries Modal --> 157 + <div 158 + id="webhook-deliveries-modal-{{ .Id }}" 159 + popover 160 + class="bg-white w-full sm:w-[50rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50" 161 + > 162 + <div class="flex items-center justify-center p-4"> 163 + {{ i "loader-circle" "size-6 animate-spin" }} 164 + </div> 165 + </div> 166 + 167 + {{ if $.RepoInfo.Roles.IsOwner }} 168 + <div 169 + id="edit-webhook-modal-{{.Id}}" 170 + popover 171 + class="bg-white w-full sm:w-[40rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 172 + {{ template "editWebhookModal" (list $ .) }} 173 + </div> 174 + {{ end }} 175 + </div> 176 + {{ else }} 177 + <div class="flex items-center justify-center p-4 text-gray-500"> 178 + no webhooks configured yet 179 + </div> 180 + {{ end }} 181 + </div> 182 + {{ end }} 183 + 184 + {{ define "addWebhookModal" }} 185 + <form 186 + hx-post="/{{ $.RepoInfo.FullName }}/settings/hooks" 187 + hx-swap="none" 188 + class="flex flex-col gap-4" 189 + > 190 + <h3 class="uppercase font-bold">New Webhook</h3> 191 + 192 + <div class="flex flex-col gap-2"> 193 + <label for="webhook-url" class="text-sm font-semibold">Payload URL</label> 194 + <input 195 + type="url" 196 + id="webhook-url" 197 + name="url" 198 + required 199 + placeholder="https://example.com/webhook" 200 + class="w-full" 201 + /> 202 + <p class="text-sm text-gray-500 dark:text-gray-400"> 203 + The URL that will receive the webhook POST requests. 204 + </p> 205 + </div> 206 + 207 + <div class="flex flex-col gap-2"> 208 + <label for="webhook-secret" class="text-sm font-semibold">Secret (optional)</label> 209 + <input 210 + type="text" 211 + id="webhook-secret" 212 + name="secret" 213 + placeholder="Optional: provide a secret for signed webhooks" 214 + class="w-full font-mono text-sm" 215 + /> 216 + <p class="text-sm text-gray-500 dark:text-gray-400"> 217 + If provided, webhook payloads will be signed with HMAC-SHA256. Leave blank to send unsigned webhooks. 218 + </p> 219 + </div> 220 + 221 + <div class="flex flex-col gap-2"> 222 + <label class="text-sm font-semibold">Events</label> 223 + <div class="flex flex-col gap-2 ml-4"> 224 + <div class="flex items-center gap-2"> 225 + <input type="checkbox" name="event_push" value="on" checked /> 226 + <span class="text-sm">Push events</span> 227 + </div> 228 + <p class="text-sm text-gray-500 dark:text-gray-400"> 229 + Additional event types (pull requests, issues) will be available in future updates. 230 + </p> 231 + </div> 232 + </div> 233 + 234 + <div class="flex items-center gap-2"> 235 + <input type="checkbox" id="webhook-active" name="active" value="on" checked /> 236 + <label for="webhook-active" class="text-sm font-semibold">Active</label> 237 + </div> 238 + 239 + <div class="flex gap-2 pt-2 justify-end"> 240 + <button 241 + type="button" 242 + popovertarget="add-webhook-modal" 243 + popovertargetaction="hide" 244 + class="btn flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 245 + > 246 + {{ i "x" "size-4" }} cancel 247 + </button> 248 + <button type="submit" class="btn-create flex items-center gap-2 group"> 249 + {{ i "plus" "size-4" }} add webhook 250 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 251 + </button> 252 + </div> 253 + <div id="webhooks-error" class="text-red-500 dark:text-red-400"></div> 254 + </form> 255 + {{ end }} 256 + 257 + {{ define "editWebhookModal" }} 258 + {{ $ctx := index . 0 }} 259 + {{ $webhook := index . 1 }} 260 + <form 261 + hx-put="/{{ $ctx.RepoInfo.FullName }}/settings/hooks/{{ $webhook.Id }}" 262 + hx-swap="none" 263 + class="flex flex-col gap-4" 264 + > 265 + <h3 class="uppercase font-bold">Edit Webhook</h3> 266 + 267 + <div class="flex flex-col gap-2"> 268 + <label for="edit-webhook-url-{{ $webhook.Id }}" class="text-sm font-semibold">Payload URL</label> 269 + <input 270 + type="url" 271 + id="edit-webhook-url-{{ $webhook.Id }}" 272 + name="url" 273 + required 274 + value="{{ $webhook.Url }}" 275 + class="w-full" 276 + /> 277 + </div> 278 + 279 + <div class="flex flex-col gap-2"> 280 + <label for="edit-webhook-secret-{{ $webhook.Id }}" class="text-sm font-semibold">Secret (optional)</label> 281 + <input 282 + type="text" 283 + id="edit-webhook-secret-{{ $webhook.Id }}" 284 + name="secret" 285 + placeholder="Leave blank to keep current secret" 286 + class="w-full font-mono text-sm" 287 + /> 288 + <p class="text-sm text-gray-500 dark:text-gray-400"> 289 + Leave blank to keep the existing secret. Remove value to disable signing. 290 + </p> 291 + </div> 292 + 293 + <div class="flex flex-col gap-2"> 294 + <label class="text-sm font-semibold">Events</label> 295 + <div class="flex flex-col gap-2 ml-4"> 296 + {{ $hasPush := false }} 297 + {{ range $webhook.Events }} 298 + {{ if eq . "push" }}{{ $hasPush = true }}{{ end }} 299 + {{ end }} 300 + <div class="flex items-center gap-2"> 301 + <input type="checkbox" name="event_push" value="on" {{ if $hasPush }}checked{{ end }} /> 302 + <span class="text-sm">Push events</span> 303 + </div> 304 + <p class="text-sm text-gray-500 dark:text-gray-400"> 305 + Additional event types (pull requests, issues) will be available in future updates. 306 + </p> 307 + </div> 308 + </div> 309 + 310 + <div class="flex items-center gap-2"> 311 + <input type="checkbox" id="edit-webhook-active-{{ $webhook.Id }}" name="active" value="on" {{ if $webhook.Active }}checked{{ end }} /> 312 + <span for="edit-webhook-active-{{ $webhook.Id }}">Enable</span> 313 + </div> 314 + 315 + <div class="flex gap-2 pt-2 justify-end"> 316 + <button 317 + type="button" 318 + popovertarget="edit-webhook-modal-{{ $webhook.Id }}" 319 + popovertargetaction="hide" 320 + class="btn flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 321 + > 322 + {{ i "x" "size-4" }} cancel 323 + </button> 324 + <button type="submit" class="btn-create flex items-center gap-2 group"> 325 + {{ i "save" "size-4" }} save 326 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 327 + </button> 328 + </div> 329 + <div id="webhooks-error" class="text-red-500 dark:text-red-400"></div> 330 + </form> 331 + {{ end }}
+16
appview/pages/templates/repo/tag.html
··· 1 + {{ define "title" }} 2 + tags · {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "extrameta" }} 6 + {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/tag/%s" .RepoInfo.FullName .Tag.Name }} 8 + 9 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 + {{ end }} 11 + 12 + {{ define "repoContent" }} 13 + <section class="flex flex-col py-2 gap-12 md:gap-0"> 14 + {{ template "repo/fragments/singleTag" (list $ .Tag ) }} 15 + </section> 16 + {{ end }}
+1 -129
appview/pages/templates/repo/tags.html
··· 14 14 <h2 class="mb-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">tags</h2> 15 15 <div class="flex flex-col py-2 gap-12 md:gap-0"> 16 16 {{ range .Tags }} 17 - <div class="md:grid md:grid-cols-12 md:items-start flex flex-col"> 18 - <!-- Header column (top on mobile, left on md+) --> 19 - <div class="md:col-span-2 md:border-r border-b md:border-b-0 border-gray-200 dark:border-gray-700 w-full md:h-full"> 20 - <!-- Mobile layout: horizontal --> 21 - <div class="flex md:hidden flex-col py-2 px-2 text-xl"> 22 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 23 - {{ i "tag" "w-4 h-4" }} 24 - {{ .Name }} 25 - </a> 26 - 27 - <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 - {{ if .Tag }} 29 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 - class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 - {{ slice .Tag.Target.String 0 8 }} 32 - </a> 33 - 34 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['·']"></span> 35 - <span>{{ .Tag.Tagger.Name }}</span> 36 - 37 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['·']"></span> 38 - {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 39 - {{ end }} 40 - </div> 41 - </div> 42 - 43 - <!-- Desktop layout: vertical and left-aligned --> 44 - <div class="hidden md:block text-left px-2 pb-6"> 45 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 46 - {{ i "tag" "w-4 h-4" }} 47 - {{ .Name }} 48 - </a> 49 - <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 - {{ if .Tag }} 51 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 - class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 - {{ i "git-commit-horizontal" "w-4 h-4" }} 54 - {{ slice .Tag.Target.String 0 8 }} 55 - </a> 56 - <span>{{ .Tag.Tagger.Name }}</span> 57 - {{ template "repo/fragments/time" .Tag.Tagger.When }} 58 - {{ end }} 59 - </div> 60 - </div> 61 - </div> 62 - 63 - <!-- Content column (bottom on mobile, right on md+) --> 64 - <div class="md:col-span-10 px-2 py-3 md:py-0 md:pb-6"> 65 - {{ if .Tag }} 66 - {{ $messageParts := splitN .Tag.Message "\n\n" 2 }} 67 - <p class="font-bold text-lg">{{ index $messageParts 0 }}</p> 68 - {{ if gt (len $messageParts) 1 }} 69 - <p class="cursor-text py-2">{{ nl2br (index $messageParts 1) }}</p> 70 - {{ end }} 71 - {{ block "artifacts" (list $ .) }} {{ end }} 72 - {{ else }} 73 - <p class="italic text-gray-500 dark:text-gray-400">no message</p> 74 - {{ end }} 75 - </div> 76 - </div> 17 + {{ template "repo/fragments/singleTag" (list $ . ) }} 77 18 {{ else }} 78 19 <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 79 20 This repository does not contain any tags. ··· 89 30 {{ block "dangling" . }} {{ end }} 90 31 </section> 91 32 {{ end }} 92 - {{ end }} 93 - 94 - {{ define "artifacts" }} 95 - {{ $root := index . 0 }} 96 - {{ $tag := index . 1 }} 97 - {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 - {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 - 100 - <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 101 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 102 - {{ range $artifact := $artifacts }} 103 - {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 104 - {{ template "repo/fragments/artifact" $args }} 105 - {{ end }} 106 - <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 - <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 - {{ i "archive" "w-4 h-4" }} 109 - <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 - Source code (.tar.gz) 111 - </a> 112 - </div> 113 - </div> 114 - {{ if $isPushAllowed }} 115 - {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 116 - {{ end }} 117 - </div> 118 - {{ end }} 119 - 120 - {{ define "uploadArtifact" }} 121 - {{ $root := index . 0 }} 122 - {{ $tag := index . 1 }} 123 - {{ $unique := $tag.Tag.Target.String }} 124 - <form 125 - id="upload-{{$unique}}" 126 - method="post" 127 - enctype="multipart/form-data" 128 - hx-post="/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload" 129 - hx-on::after-request="if(event.detail.successful) this.reset()" 130 - hx-disabled-elt="#upload-btn-{{$unique}}" 131 - hx-swap="beforebegin" 132 - hx-target="this" 133 - class="flex items-center gap-2 px-2"> 134 - <div class="flex-grow"> 135 - <input type="file" 136 - name="artifact" 137 - required 138 - class="block py-2 px-0 w-full border-none 139 - text-black dark:text-white 140 - bg-white dark:bg-gray-800 141 - file:mr-4 file:px-2 file:py-2 142 - file:rounded file:border-0 143 - file:text-sm file:font-medium 144 - file:text-gray-700 file:dark:text-gray-300 145 - file:bg-gray-200 file:dark:bg-gray-700 146 - file:hover:bg-gray-100 file:hover:dark:bg-gray-600 147 - "> 148 - </input> 149 - </div> 150 - <div class="flex justify-end"> 151 - <button 152 - type="submit" 153 - class="btn gap-2" 154 - id="upload-btn-{{$unique}}" 155 - title="Upload artifact"> 156 - {{ i "upload" "w-4 h-4" }} 157 - <span class="hidden md:inline">upload</span> 158 - </button> 159 - </div> 160 - </form> 161 33 {{ end }} 162 34 163 35 {{ define "dangling" }}
+4
appview/pages/templates/repo/tree.html
··· 52 52 </div> 53 53 </div> 54 54 55 + {{ if .LastCommitInfo }} 56 + {{ template "repo/fragments/lastCommitPanel" $ }} 57 + {{ end }} 58 + 55 59 {{ range .Files }} 56 60 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 61 <div class="col-span-8 md:col-span-4">
+1 -1
appview/pages/templates/strings/fragments/form.html
··· 31 31 name="content" 32 32 id="content-textarea" 33 33 wrap="off" 34 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 35 rows="20" 36 36 spellcheck="false" 37 37 placeholder="Paste your string here!"
+19 -3
appview/pages/templates/timeline/home.html
··· 1 1 {{ define "title" }}tangled &middot; tightly-knit social coding{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - <meta property="og:title" content="timeline · tangled" /> 5 - <meta property="og:type" content="object" /> 4 + <!-- Open Graph Meta Tags --> 5 + <meta property="og:title" content="tangled · tightly-knit social coding" /> 6 + <meta property="og:type" content="website" /> 6 7 <meta property="og:url" content="https://tangled.org" /> 7 - <meta property="og:description" content="tightly-knit social coding" /> 8 + <meta property="og:description" content="The next-generation social coding platform." /> 9 + <meta property="og:image" content="https://assets.tangled.network/what-is-tangled-repo.png" /> 10 + <meta property="og:image:width" content="1200" /> 11 + <meta property="og:image:height" content="630" /> 12 + 13 + <!-- Twitter Card Meta Tags --> 14 + <meta name="twitter:card" content="summary_large_image" /> 15 + <meta name="twitter:title" content="Tangled" /> 16 + <meta name="twitter:description" content="The next-generation social coding platform." /> 17 + <meta name="twitter:image" content="https://assets.tangled.network/tangled_og.png" /> 18 + 19 + <!-- Additional SEO --> 20 + <meta name="description" content="The next-generation social coding platform. Host repos on your infrastructure with knots, use stacked pull requests, and run CI with spindles." /> 21 + <link rel="canonical" href="https://tangled.org" /> 22 + 23 + 8 24 {{ end }} 9 25 10 26
+62 -99
appview/pages/templates/user/completeSignup.html
··· 1 - {{ define "user/completeSignup" }} 2 - <!doctype html> 3 - <html lang="en" class="dark:bg-gray-900"> 4 - <head> 5 - <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 - <meta 11 - property="og:title" 12 - content="complete signup · tangled" 13 - /> 14 - <meta 15 - property="og:url" 16 - content="https://tangled.org/complete-signup" 17 - /> 18 - <meta 19 - property="og:description" 20 - content="complete your signup for tangled" 21 - /> 22 - <script src="/static/htmx.min.js"></script> 23 - <link rel="manifest" href="/pwa-manifest.json" /> 24 - <link 25 - rel="stylesheet" 26 - href="/static/tw.css?{{ cssContentHash }}" 27 - type="text/css" 28 - /> 29 - <title>complete signup &middot; tangled</title> 30 - </head> 31 - <body class="flex items-center justify-center min-h-screen"> 32 - <main class="max-w-md px-6 -mt-4"> 33 - <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 34 - {{ template "fragments/logotype" }} 35 - </h1> 36 - <h2 class="text-center text-xl italic dark:text-white"> 37 - tightly-knit social coding. 38 - </h2> 39 - <form 40 - class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 41 - hx-post="/signup/complete" 42 - hx-swap="none" 43 - hx-disabled-elt="#complete-signup-button" 44 - > 45 - <div class="flex flex-col"> 46 - <label for="code">verification code</label> 47 - <input 48 - type="text" 49 - id="code" 50 - name="code" 51 - tabindex="1" 52 - required 53 - placeholder="tngl-sh-foo-bar" 54 - /> 55 - <span class="text-sm text-gray-500 mt-1"> 56 - Enter the code sent to your email. 57 - </span> 58 - </div> 1 + {{ define "title" }} complete signup {{ end }} 2 + 3 + {{ define "content" }} 4 + <form 5 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4 group" 6 + hx-post="/signup/complete" 7 + hx-swap="none" 8 + hx-disabled-elt="#complete-signup-button" 9 + > 10 + <div class="flex flex-col"> 11 + <label for="code">verification code</label> 12 + <input 13 + type="text" 14 + id="code" 15 + name="code" 16 + tabindex="1" 17 + required 18 + placeholder="tngl-sh-foo-bar" 19 + /> 20 + <span class="text-sm text-gray-500 mt-1"> 21 + Enter the code sent to your email. 22 + </span> 23 + </div> 59 24 60 - <div class="flex flex-col"> 61 - <label for="username">username</label> 62 - <input 63 - type="text" 64 - id="username" 65 - name="username" 66 - tabindex="2" 67 - required 68 - placeholder="jason" 69 - /> 70 - <span class="text-sm text-gray-500 mt-1"> 71 - Your complete handle will be of the form <code>user.tngl.sh</code>. 72 - </span> 73 - </div> 25 + <div class="flex flex-col"> 26 + <label for="username">username</label> 27 + <input 28 + type="text" 29 + id="username" 30 + name="username" 31 + tabindex="2" 32 + required 33 + placeholder="jason" 34 + /> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + Your complete handle will be of the form <code>user.tngl.sh</code>. 37 + </span> 38 + </div> 74 39 75 - <div class="flex flex-col"> 76 - <label for="password">password</label> 77 - <input 78 - type="password" 79 - id="password" 80 - name="password" 81 - tabindex="3" 82 - required 83 - /> 84 - <span class="text-sm text-gray-500 mt-1"> 85 - Choose a strong password for your account. 86 - </span> 87 - </div> 40 + <div class="flex flex-col"> 41 + <label for="password">password</label> 42 + <input 43 + type="password" 44 + id="password" 45 + name="password" 46 + tabindex="3" 47 + required 48 + /> 49 + <span class="text-sm text-gray-500 mt-1"> 50 + Choose a strong password for your account. 51 + </span> 52 + </div> 88 53 89 - <button 90 - class="btn-create w-full my-2 mt-6 text-base" 91 - type="submit" 92 - id="complete-signup-button" 93 - tabindex="4" 94 - > 95 - <span>complete signup</span> 96 - </button> 97 - </form> 98 - <p id="signup-error" class="error w-full"></p> 99 - <p id="signup-msg" class="dark:text-white w-full"></p> 100 - </main> 101 - </body> 102 - </html> 54 + <button 55 + class="btn-create w-full my-2 mt-6 text-base" 56 + type="submit" 57 + id="complete-signup-button" 58 + tabindex="4" 59 + > 60 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 61 + <span class="inline group-[.htmx-request]:hidden">complete signup</span> 62 + </button> 63 + </form> 64 + <p id="signup-error" class="error w-full"></p> 65 + <p id="signup-msg" class="dark:text-white w-full"></p> 103 66 {{ end }}
+2 -2
appview/pages/templates/user/fragments/editAvatar.html
··· 7 7 <label for="avatar-file" class="uppercase p-0"> 8 8 Upload or Remove Avatar 9 9 </label> 10 - <p class="text-sm text-gray-500 dark:text-gray-400">Upload a new image (PNG or JPEG, max 1MB) or remove your current avatar.</p> 10 + <p class="text-sm text-gray-500 dark:text-gray-400">Upload a new image (PNG or JPEG, max 5MB) or remove your current avatar.</p> 11 11 <input 12 12 type="file" 13 13 id="avatar-file" ··· 23 23 hover:file:bg-gray-200 dark:hover:file:bg-gray-600" /> 24 24 <div class="flex flex-col gap-2 pt-2"> 25 25 <button type="submit" class="btn w-full flex items-center justify-center gap-2"> 26 - {{ i "upload" "size-4 inline group-[.htmx-request]/form:hidden" }} 26 + {{ i "upload" "size-4 inline group-[.htmx-request]/form:hidden" }} 27 27 {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]/form:inline" }} 28 28 upload 29 29 </button>
+2 -1
appview/pages/templates/user/fragments/editBio.html
··· 110 110 {{ $id := index . 0 }} 111 111 {{ $stat := index . 1 }} 112 112 <select class="stat-group w-full p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700 text-sm" id="stat{{$id}}" name="stat{{$id}}"> 113 - <option value="">choose stat</option> 113 + <option value="">Choose Stat</option> 114 114 {{ $stats := assoc 115 115 "merged-pull-request-count" "Merged PR Count" 116 116 "closed-pull-request-count" "Closed PR Count" ··· 118 118 "open-issue-count" "Open Issue Count" 119 119 "closed-issue-count" "Closed Issue Count" 120 120 "repository-count" "Repository Count" 121 + "star-count" "Star Count" 121 122 }} 122 123 {{ range $s := $stats }} 123 124 {{ $value := index $s 0 }}
+6 -3
appview/pages/templates/user/fragments/follow.html
··· 13 13 hx-swap="outerHTML" 14 14 > 15 15 {{ if eq .FollowStatus.String "IsNotFollowing" }} 16 - {{ i "user-round-plus" "w-4 h-4" }} follow 16 + {{ i "user-round-plus" "size-4 inline group-[.htmx-request]:hidden" }} 17 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 18 + follow 17 19 {{ else }} 18 - {{ i "user-round-minus" "w-4 h-4" }} unfollow 20 + {{ i "user-round-minus" "size-4 inline group-[.htmx-request]:hidden" }} 21 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 22 + unfollow 19 23 {{ end }} 20 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 21 24 </button> 22 25 {{ end }}
+111 -132
appview/pages/templates/user/login.html
··· 1 - {{ define "user/login" }} 2 - <!doctype html> 3 - <html lang="en" class="dark:bg-gray-900"> 4 - <head> 5 - <meta charset="UTF-8" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <meta property="og:title" content="login · tangled" /> 8 - <meta property="og:url" content="https://tangled.org/login" /> 9 - <meta property="og:description" content="login to for tangled" /> 10 - <script src="/static/htmx.min.js"></script> 11 - <link rel="manifest" href="/pwa-manifest.json" /> 12 - <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 - <title>login &middot; tangled</title> 14 - </head> 15 - <body class="flex items-center justify-center min-h-screen"> 16 - <main class="max-w-md px-7 mt-4"> 17 - <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 - {{ template "fragments/logotype" }} 19 - </h1> 20 - <h2 class="text-center text-xl italic dark:text-white"> 21 - tightly-knit social coding. 22 - </h2> 1 + {{ define "title" }} login {{ end }} 23 2 24 - {{ if .AddAccount }} 25 - <div class="flex gap-2 my-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-sky-800 rounded px-3 py-2 text-blue-600 dark:text-blue-300"> 26 - <span class="py-1">{{ i "user-plus" "w-4 h-4" }}</span> 27 - <div> 28 - <h5 class="font-medium">Add another account</h5> 29 - <p class="text-sm">Sign in with a different account to add it to your account list.</p> 30 - </div> 31 - </div> 32 - {{ end }} 3 + {{ define "content" }} 4 + {{ if .AddAccount }} 5 + <div class="flex gap-2 my-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-sky-800 rounded px-3 py-2 text-blue-600 dark:text-blue-300"> 6 + <span class="py-1">{{ i "user-plus" "w-4 h-4" }}</span> 7 + <div> 8 + <h5 class="font-medium">Add another account</h5> 9 + <p class="text-sm">Sign in with a different account to add it to your account list.</p> 10 + </div> 11 + </div> 12 + {{ end }} 33 13 34 - {{ if and .LoggedInUser .LoggedInUser.Accounts }} 35 - {{ $accounts := .LoggedInUser.Accounts }} 36 - {{ if $accounts }} 37 - <div class="my-4 border border-gray-200 dark:border-gray-700 rounded overflow-hidden"> 38 - <div class="px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> 39 - <span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Saved accounts</span> 40 - </div> 41 - <div class="divide-y divide-gray-200 dark:divide-gray-700"> 42 - {{ range $accounts }} 43 - <div class="flex items-center justify-between px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"> 44 - <button 45 - type="button" 46 - hx-post="/account/switch" 47 - hx-vals='{"did": "{{ .Did }}"}' 48 - hx-swap="none" 49 - class="flex items-center gap-2 flex-1 text-left min-w-0" 50 - > 51 - {{ template "user/fragments/pic" (list .Did "size-8") }} 52 - <div class="flex flex-col min-w-0"> 53 - <span class="text-sm font-medium dark:text-white truncate">{{ .Did | resolve | truncateAt30 }}</span> 54 - <span class="text-xs text-gray-500 dark:text-gray-400">Click to switch</span> 55 - </div> 56 - </button> 57 - <button 58 - type="button" 59 - hx-delete="/account/{{ .Did }}" 60 - hx-swap="none" 61 - class="p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 flex-shrink-0" 62 - title="Remove account" 63 - > 64 - {{ i "x" "w-4 h-4" }} 65 - </button> 66 - </div> 67 - {{ end }} 68 - </div> 69 - </div> 70 - {{ end }} 71 - {{ end }} 14 + {{ if and .LoggedInUser .LoggedInUser.Accounts }} 15 + {{ $accounts := .LoggedInUser.Accounts }} 16 + {{ if $accounts }} 17 + <div class="my-4 border border-gray-200 dark:border-gray-700 rounded overflow-hidden"> 18 + <div class="px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> 19 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Saved accounts</span> 20 + </div> 21 + <div class="divide-y divide-gray-200 dark:divide-gray-700"> 22 + {{ range $accounts }} 23 + <div class="flex items-center justify-between px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"> 24 + <button 25 + type="button" 26 + hx-post="/account/switch" 27 + hx-vals='{"did": "{{ .Did }}"}' 28 + hx-swap="none" 29 + class="flex items-center gap-2 flex-1 text-left min-w-0" 30 + > 31 + {{ template "user/fragments/pic" (list .Did "size-8") }} 32 + <div class="flex flex-col min-w-0"> 33 + <span class="text-sm font-medium dark:text-white truncate">{{ .Did | resolve | truncateAt30 }}</span> 34 + <span class="text-xs text-gray-500 dark:text-gray-400">Click to switch</span> 35 + </div> 36 + </button> 37 + <button 38 + type="button" 39 + hx-delete="/account/{{ .Did }}" 40 + hx-swap="none" 41 + class="p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 flex-shrink-0" 42 + title="Remove account" 43 + > 44 + {{ i "x" "w-4 h-4" }} 45 + </button> 46 + </div> 47 + {{ end }} 48 + </div> 49 + </div> 50 + {{ end }} 51 + {{ end }} 72 52 73 - <form 74 - class="mt-4" 75 - hx-post="/login" 76 - hx-swap="none" 77 - hx-disabled-elt="#login-button" 78 - > 79 - <div class="flex flex-col"> 80 - <label for="handle">handle</label> 81 - <input 82 - autocapitalize="none" 83 - autocorrect="off" 84 - autocomplete="username" 85 - type="text" 86 - id="handle" 87 - name="handle" 88 - tabindex="1" 89 - required 90 - placeholder="akshay.tngl.sh" 91 - /> 92 - <span class="text-sm text-gray-500 mt-1"> 93 - Use your <a href="https://atproto.com">AT Protocol</a> 94 - handle to log in. If you're unsure, this is likely 95 - your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 96 - </span> 97 - </div> 98 - <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 99 - <input type="hidden" name="add_account" value="{{ if .AddAccount }}true{{ end }}"> 53 + <form 54 + class="mt-4 group" 55 + hx-post="/login" 56 + hx-swap="none" 57 + hx-disabled-elt="#login-button" 58 + > 59 + <div class="flex flex-col"> 60 + <label for="handle">handle</label> 61 + <input 62 + autocapitalize="none" 63 + autocorrect="off" 64 + autocomplete="username" 65 + type="text" 66 + id="handle" 67 + name="handle" 68 + tabindex="1" 69 + required 70 + placeholder="akshay.tngl.sh" 71 + /> 72 + <span class="text-sm text-gray-500 mt-1"> 73 + Use your <a href="https://atproto.com">AT Protocol</a> 74 + handle to log in. If you're unsure, this is likely 75 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 76 + </span> 77 + </div> 78 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 79 + <input type="hidden" name="add_account" value="{{ if .AddAccount }}true{{ end }}"> 100 80 101 - <button 102 - class="btn w-full my-2 mt-6 text-base " 103 - type="submit" 104 - id="login-button" 105 - tabindex="3" 106 - > 107 - <span>login</span> 108 - </button> 109 - </form> 110 - {{ if .ErrorCode }} 111 - <div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300"> 112 - <span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span> 113 - <div> 114 - <h5 class="font-medium">Login error</h5> 115 - <p class="text-sm"> 116 - {{ if eq .ErrorCode "access_denied" }} 117 - You have not authorized the app. 118 - {{ else if eq .ErrorCode "session" }} 119 - Server failed to create user session. 120 - {{ else if eq .ErrorCode "max_accounts" }} 121 - You have reached the maximum of 20 linked accounts. Please remove an account before adding a new one. 122 - {{ else }} 123 - Internal Server error. 124 - {{ end }} 125 - Please try again. 126 - </p> 127 - </div> 128 - </div> 129 - {{ end }} 130 - <p class="text-sm text-gray-500"> 131 - Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 132 - </p> 81 + <button 82 + class="btn w-full my-2 mt-6 text-base" 83 + type="submit" 84 + id="login-button" 85 + tabindex="3" 86 + > 87 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + <span class="inline group-[.htmx-request]:hidden">login</span> 89 + </button> 90 + </form> 91 + {{ if .ErrorCode }} 92 + <div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300"> 93 + <span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span> 94 + <div> 95 + <h5 class="font-medium">Login error</h5> 96 + <p class="text-sm"> 97 + {{ if eq .ErrorCode "access_denied" }} 98 + You have not authorized the app. 99 + {{ else if eq .ErrorCode "session" }} 100 + Server failed to create user session. 101 + {{ else if eq .ErrorCode "max_accounts" }} 102 + You have reached the maximum of 20 linked accounts. Please remove an account before adding a new one. 103 + {{ else }} 104 + Internal Server error. 105 + {{ end }} 106 + Please try again. 107 + </p> 108 + </div> 109 + </div> 110 + {{ end }} 111 + <p class="text-sm text-gray-500"> 112 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 113 + </p> 133 114 134 - <p id="login-msg" class="error w-full"></p> 135 - </main> 136 - </body> 137 - </html> 115 + <p id="login-msg" class="error w-full"></p> 138 116 {{ end }} 117 +
+1 -1
appview/pages/templates/user/settings/fragments/keyListing.html
··· 19 19 <button 20 20 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 21 title="Delete key" 22 - hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}" 23 23 hx-swap="none" 24 24 hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 25 >
+39 -41
appview/pages/templates/user/settings/profile.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 6 </div> 7 - <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="grid grid-cols-1 md:grid-cols-4 gap-6"> 9 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div> 12 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "profileInfo" . }} 13 + {{ template "profile" . }} 14 + {{ template "punchcard" . }} 14 15 </div> 15 16 </section> 16 17 </div> 17 18 {{ end }} 18 19 19 - {{ define "profileInfo" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - Your account information from your AT Protocol identity. 25 - </p> 26 - </div> 27 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 - </div> 29 - </div> 30 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 - <div class="flex items-center justify-between p-4"> 32 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 - <span>Handle</span> 35 - </div> 36 - <span class="font-bold"> 37 - {{ resolve .LoggedInUser.Did }} 38 - </span> 20 + {{ define "profile" }} 21 + <div> 22 + <h2 class="text-sm uppercase font-bold">Profile</h2> 23 + <p class="text-gray-500 dark:text-gray-400 pb-2"> 24 + Your account information from your AT Protocol identity. 25 + </p> 26 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 27 + <div class="flex flex-col gap-1 p-4"> 28 + <span class="text-sm text-gray-500 dark:text-gray-400">Handle</span> 29 + <span class="font-bold">{{ resolve .LoggedInUser.Did }}</span> 39 30 </div> 40 - </div> 41 - <div class="flex items-center justify-between p-4"> 42 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 43 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 44 - <span>Decentralized Identifier (DID)</span> 45 - </div> 46 - <span class="font-mono font-bold"> 47 - {{ .LoggedInUser.Did }} 48 - </span> 31 + <div class="flex flex-col gap-1 p-4"> 32 + <span class="text-sm text-gray-500 dark:text-gray-400">Decentralized Identifier (DID)</span> 33 + <span class="font-mono font-bold">{{ .LoggedInUser.Did }}</span> 49 34 </div> 50 - </div> 51 - <div class="flex items-center justify-between p-4"> 52 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 53 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 54 - <span>Personal Data Server (PDS)</span> 55 - </div> 56 - <span class="font-bold"> 57 - {{ .LoggedInUser.Pds }} 58 - </span> 35 + <div class="flex flex-col gap-1 p-4"> 36 + <span class="text-sm text-gray-500 dark:text-gray-400">Personal Data Server (PDS)</span> 37 + <span class="font-bold">{{ .LoggedInUser.Pds }}</span> 59 38 </div> 60 39 </div> 61 40 </div> 62 41 {{ end }} 42 + 43 + {{ define "punchcard" }} 44 + <div> 45 + <h2 class="text-sm uppercase font-bold">Punchcard</h2> 46 + <p class="text-gray-500 dark:text-gray-400 pb-2 "> 47 + Configure punchcard visibility and preferences. 48 + </p> 49 + <form hx-post="/profile/punchcard" hx-trigger="change" hx-swap="none" class="flex flex-col gap-2"> 50 + <div class="flex items-center gap-2"> 51 + <input type="checkbox" id="hideMine" name="hideMine" value="on" {{ if eq true $.PunchcardPreference.HideMine }}checked{{ end }}> 52 + <label for="hideMine" class="my-0 py-0 normal-case font-normal">Hide mine</label> 53 + </div> 54 + <div class="flex items-center gap-2"> 55 + <input type="checkbox" id="hideOthers" name="hideOthers" value="on" {{ if eq true $.PunchcardPreference.HideOthers }}checked{{ end }}> 56 + <label for="hideOthers" class="my-0 py-0 normal-case font-normal">Hide others from me</label> 57 + </div> 58 + </form> 59 + </div> 60 + {{ end }}
+43 -60
appview/pages/templates/user/signup.html
··· 1 - {{ define "user/signup" }} 2 - <!doctype html> 3 - <html lang="en" class="dark:bg-gray-900"> 4 - <head> 5 - <meta charset="UTF-8" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <meta property="og:title" content="signup · tangled" /> 8 - <meta property="og:url" content="https://tangled.org/signup" /> 9 - <meta property="og:description" content="sign up for tangled" /> 10 - <script src="/static/htmx.min.js"></script> 11 - <link rel="manifest" href="/pwa-manifest.json" /> 12 - <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 - <title>sign up &middot; tangled</title> 1 + {{ define "title" }} signup {{ end }} 14 2 15 - <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 16 - </head> 17 - <body class="flex items-center justify-center min-h-screen"> 18 - <main class="max-w-md px-6 -mt-4"> 19 - <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 20 - {{ template "fragments/logotype" }} 21 - </h1> 22 - <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 23 - <form 24 - class="mt-4 max-w-sm mx-auto" 25 - hx-post="/signup" 26 - hx-swap="none" 27 - hx-disabled-elt="#signup-button" 28 - > 29 - <div class="flex flex-col mt-2"> 30 - <label for="email">email</label> 31 - <input 32 - type="email" 33 - id="email" 34 - name="email" 35 - tabindex="4" 36 - required 37 - placeholder="jason@bourne.co" 38 - /> 39 - </div> 40 - <span class="text-sm text-gray-500 mt-1"> 41 - You will receive an email with an invite code. Enter your 42 - invite code, desired username, and password in the next 43 - page to complete your registration. 44 - </span> 45 - <div class="w-full mt-4 text-center"> 46 - <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 47 - </div> 48 - <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 - <span>join now</span> 50 - </button> 51 - <p class="text-sm text-gray-500"> 52 - Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 53 - </p> 3 + {{ define "extrameta" }} 4 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <form 9 + class="mt-4 max-w-sm mx-auto group" 10 + hx-post="/signup" 11 + hx-swap="none" 12 + hx-disabled-elt="#signup-button" 13 + > 14 + <div class="flex flex-col mt-2"> 15 + <label for="email">email</label> 16 + <input 17 + type="email" 18 + id="email" 19 + name="email" 20 + tabindex="4" 21 + required 22 + placeholder="jason@bourne.co" 23 + /> 24 + </div> 25 + <span class="text-sm text-gray-500 mt-1"> 26 + You will receive an email with an invite code. Enter your 27 + invite code, desired username, and password in the next 28 + page to complete your registration. 29 + </span> 30 + <div class="w-full mt-4 text-center"> 31 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 32 + </div> 33 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 34 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 + <span class="inline group-[.htmx-request]:hidden">join now</span> 36 + </button> 37 + <p class="text-sm text-gray-500"> 38 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 39 + </p> 54 40 55 - <p id="signup-msg" class="error w-full"></p> 56 - <p class="text-sm text-gray-500 pt-4"> 57 - By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>. 58 - </p> 59 - </form> 60 - </main> 61 - </body> 62 - </html> 41 + <p id="signup-msg" class="error w-full"></p> 42 + <p class="text-sm text-gray-500 pt-4"> 43 + By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>. 44 + </p> 45 + </form> 63 46 {{ end }}
+37 -15
appview/pipelines/pipelines.go
··· 86 86 return 87 87 } 88 88 89 + filterKind := r.URL.Query().Get("trigger") 90 + filters := []orm.Filter{ 91 + orm.FilterEq("p.repo_owner", f.Did), 92 + orm.FilterEq("p.repo_name", f.Name), 93 + orm.FilterEq("p.knot", f.Knot), 94 + } 95 + switch filterKind { 96 + case "push": 97 + filters = append(filters, orm.FilterEq("t.kind", "push")) 98 + case "pull_request": 99 + filters = append(filters, orm.FilterEq("t.kind", "pull_request")) 100 + default: 101 + // no filters otherwise, default to "all" 102 + filterKind = "all" 103 + } 104 + 89 105 ps, err := db.GetPipelineStatuses( 90 106 p.db, 91 107 30, 92 - orm.FilterEq("repo_owner", f.Did), 93 - orm.FilterEq("repo_name", f.Name), 94 - orm.FilterEq("knot", f.Knot), 108 + filters..., 95 109 ) 96 110 if err != nil { 97 111 l.Error("failed to query db", "err", err) 98 112 return 99 113 } 100 114 115 + total, err := db.GetTotalPipelineStatuses(p.db, filters...) 116 + if err != nil { 117 + l.Error("failed to query db", "err", err) 118 + return 119 + } 120 + 101 121 p.pages.Pipelines(w, pages.PipelinesParams{ 102 122 LoggedInUser: user, 103 123 RepoInfo: p.repoResolver.GetRepoInfo(r, user), 104 124 Pipelines: ps, 125 + FilterKind: filterKind, 126 + Total: total, 105 127 }) 106 128 } 107 129 ··· 130 152 ps, err := db.GetPipelineStatuses( 131 153 p.db, 132 154 1, 133 - orm.FilterEq("repo_owner", f.Did), 134 - orm.FilterEq("repo_name", f.Name), 135 - orm.FilterEq("knot", f.Knot), 136 - orm.FilterEq("id", pipelineId), 155 + orm.FilterEq("p.repo_owner", f.Did), 156 + orm.FilterEq("p.repo_name", f.Name), 157 + orm.FilterEq("p.knot", f.Knot), 158 + orm.FilterEq("p.id", pipelineId), 137 159 ) 138 160 if err != nil { 139 161 l.Error("failed to query db", "err", err) ··· 197 219 ps, err := db.GetPipelineStatuses( 198 220 p.db, 199 221 1, 200 - orm.FilterEq("repo_owner", f.Did), 201 - orm.FilterEq("repo_name", f.Name), 202 - orm.FilterEq("knot", f.Knot), 203 - orm.FilterEq("id", pipelineId), 222 + orm.FilterEq("p.repo_owner", f.Did), 223 + orm.FilterEq("p.repo_name", f.Name), 224 + orm.FilterEq("p.knot", f.Knot), 225 + orm.FilterEq("p.id", pipelineId), 204 226 ) 205 227 if err != nil || len(ps) != 1 { 206 228 l.Error("pipeline query failed", "err", err, "count", len(ps)) ··· 346 368 ps, err := db.GetPipelineStatuses( 347 369 p.db, 348 370 1, 349 - orm.FilterEq("repo_owner", f.Did), 350 - orm.FilterEq("repo_name", f.Name), 351 - orm.FilterEq("knot", f.Knot), 352 - orm.FilterEq("id", pipelineId), 371 + orm.FilterEq("p.repo_owner", f.Did), 372 + orm.FilterEq("p.repo_name", f.Name), 373 + orm.FilterEq("p.knot", f.Knot), 374 + orm.FilterEq("p.id", pipelineId), 353 375 ) 354 376 if err != nil { 355 377 return models.Pipeline{}, err
+31 -31
appview/pulls/opengraph.go
··· 10 10 "log" 11 11 "net/http" 12 12 13 - "tangled.org/core/appview/db" 14 13 "tangled.org/core/appview/models" 15 14 "tangled.org/core/appview/ogcard" 16 - "tangled.org/core/orm" 17 15 "tangled.org/core/patchutil" 18 16 "tangled.org/core/types" 19 17 ) 20 18 21 - func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { 19 + func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { 22 20 width, height := ogcard.DefaultSize() 23 21 mainCard, err := ogcard.NewCard(width, height) 24 22 if err != nil { ··· 128 126 } 129 127 130 128 // Split stats area: left side for status/stats (80%), right side for dolly (20%) 131 - statusStatsArea, dollyArea := statsArea.Split(true, 80) 129 + statusArea, dollyArea := statsArea.Split(true, 80) 132 130 133 131 // Draw status and stats 134 - statsBounds := statusStatsArea.Img.Bounds() 132 + statsBounds := statusArea.Img.Bounds() 135 133 statsX := statsBounds.Min.X + 60 // left padding 136 134 statsY := statsBounds.Min.Y 137 135 ··· 157 155 } else { 158 156 statusIcon = "git-pull-request-closed" 159 157 statusText = "closed" 160 - statusColor = color.RGBA{128, 128, 128, 255} // gray 158 + statusColor = color.RGBA{52, 58, 64, 255} // dark gray 161 159 } 162 160 163 - statusIconSize := 36 161 + statusTextWidth := statusArea.TextWidth(statusText, textSize) 162 + badgePadding := 12 163 + badgeHeight := int(textSize) + (badgePadding * 2) 164 + badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) 165 + cornerRadius := 8 166 + badgeX := 60 167 + badgeY := 0 168 + 169 + statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) 164 170 165 - // Draw icon with status color 166 - err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 171 + whiteColor := color.RGBA{255, 255, 255, 255} 172 + iconX := statsX + badgePadding 173 + iconY := statsY + (badgeHeight-iconSize)/2 174 + err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) 167 175 if err != nil { 168 176 log.Printf("failed to draw status icon: %v", err) 169 177 } 170 178 171 - // Draw text with status color 172 - textX := statsX + statusIconSize + 12 173 - statusTextSize := 32.0 174 - err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left) 179 + textX := statsX + badgePadding + iconSize + badgePadding 180 + textY := statsY + (badgeHeight-int(textSize))/2 - 5 181 + err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) 175 182 if err != nil { 176 183 log.Printf("failed to draw status text: %v", err) 177 184 } 178 185 179 - statusTextWidth := len(statusText) * 20 180 - currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 186 + currentX := statsX + badgeWidth + 50 181 187 182 188 // Draw comment count 183 - err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 + err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) 184 190 if err != nil { 185 191 log.Printf("failed to draw comment icon: %v", err) 186 192 } 187 193 188 194 currentX += iconSize + 15 195 + commentCount := pull.TotalComments() 189 196 commentText := fmt.Sprintf("%d comments", commentCount) 190 197 if commentCount == 1 { 191 198 commentText = "1 comment" 192 199 } 193 - err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 200 + err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 194 201 if err != nil { 195 202 log.Printf("failed to draw comment text: %v", err) 196 203 } ··· 199 206 currentX += commentTextWidth + 40 200 207 201 208 // Draw files changed 202 - err = statusStatsArea.DrawLucideIcon("file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 209 + err = statusArea.DrawLucideIcon("file-diff", currentX, iconY, iconSize, iconColor) 203 210 if err != nil { 204 211 log.Printf("failed to draw file diff icon: %v", err) 205 212 } ··· 209 216 if filesChanged == 1 { 210 217 filesText = "1 file" 211 218 } 212 - err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 219 + err = statusArea.DrawTextAt(filesText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 213 220 if err != nil { 214 221 log.Printf("failed to draw files text: %v", err) 215 222 } ··· 220 227 // Draw additions (green +) 221 228 greenColor := color.RGBA{34, 139, 34, 255} 222 229 additionsText := fmt.Sprintf("+%d", diffStats.Insertions) 223 - err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left) 230 + err = statusArea.DrawTextAt(additionsText, currentX, textY, greenColor, textSize, ogcard.Top, ogcard.Left) 224 231 if err != nil { 225 232 log.Printf("failed to draw additions text: %v", err) 226 233 } ··· 231 238 // Draw deletions (red -) right next to additions 232 239 redColor := color.RGBA{220, 20, 60, 255} 233 240 deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) 234 - err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left) 241 + err = statusArea.DrawTextAt(deletionsText, currentX, textY, redColor, textSize, ogcard.Top, ogcard.Left) 235 242 if err != nil { 236 243 log.Printf("failed to draw deletions text: %v", err) 237 244 } ··· 254 261 openedDate := pull.Created.Format("Jan 2, 2006") 255 262 metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 256 263 257 - err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 264 + err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 258 265 if err != nil { 259 266 log.Printf("failed to draw metadata: %v", err) 260 267 } ··· 276 283 return 277 284 } 278 285 279 - // Get comment count from database 280 - comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 281 - if err != nil { 282 - log.Printf("failed to get pull comments: %v", err) 283 - } 284 - commentCount := len(comments) 285 - 286 286 // Calculate diff stats from latest submission using patchutil 287 287 var diffStats types.DiffFileStat 288 288 filesChanged := 0 289 289 if len(pull.Submissions) > 0 { 290 - latestSubmission := pull.Submissions[len(pull.Submissions)-1] 290 + latestSubmission := pull.LatestSubmission() 291 291 niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 292 292 diffStats.Insertions = int64(niceDiff.Stat.Insertions) 293 293 diffStats.Deletions = int64(niceDiff.Stat.Deletions) 294 294 filesChanged = niceDiff.Stat.FilesChanged 295 295 } 296 296 297 - card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged) 297 + card, err := s.drawPullSummaryCard(pull, f, diffStats, filesChanged) 298 298 if err != nil { 299 299 log.Println("failed to draw pull summary card", err) 300 300 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+195 -67
appview/pulls/pulls.go
··· 27 27 "tangled.org/core/appview/notify" 28 28 "tangled.org/core/appview/oauth" 29 29 "tangled.org/core/appview/pages" 30 - "tangled.org/core/appview/pages/markup" 30 + "tangled.org/core/appview/pages/markup/sanitizer" 31 31 "tangled.org/core/appview/pages/repoinfo" 32 32 "tangled.org/core/appview/pagination" 33 33 "tangled.org/core/appview/reporesolver" 34 - "tangled.org/core/appview/validator" 34 + "tangled.org/core/appview/searchquery" 35 35 "tangled.org/core/appview/xrpcclient" 36 36 "tangled.org/core/idresolver" 37 37 "tangled.org/core/orm" ··· 39 39 "tangled.org/core/rbac" 40 40 "tangled.org/core/tid" 41 41 "tangled.org/core/types" 42 + "tangled.org/core/xrpc" 42 43 43 44 comatproto "github.com/bluesky-social/indigo/api/atproto" 44 45 "github.com/bluesky-social/indigo/atproto/syntax" ··· 48 49 "github.com/google/uuid" 49 50 ) 50 51 52 + const ApplicationGzip = "application/gzip" 53 + 51 54 type Pulls struct { 52 55 oauth *oauth.OAuth 53 56 repoResolver *reporesolver.RepoResolver ··· 59 62 notifier notify.Notifier 60 63 enforcer *rbac.Enforcer 61 64 logger *slog.Logger 62 - validator *validator.Validator 63 65 indexer *pulls_indexer.Indexer 64 66 } 65 67 ··· 73 75 config *config.Config, 74 76 notifier notify.Notifier, 75 77 enforcer *rbac.Enforcer, 76 - validator *validator.Validator, 77 78 indexer *pulls_indexer.Indexer, 78 79 logger *slog.Logger, 79 80 ) *Pulls { ··· 88 89 notifier: notifier, 89 90 enforcer: enforcer, 90 91 logger: logger, 91 - validator: validator, 92 92 indexer: indexer, 93 93 } 94 94 } ··· 211 211 ps, err := db.GetPipelineStatuses( 212 212 s.db, 213 213 len(shas), 214 - orm.FilterEq("repo_owner", f.Did), 215 - orm.FilterEq("repo_name", f.Name), 216 - orm.FilterEq("knot", f.Knot), 217 - orm.FilterIn("sha", shas), 214 + orm.FilterEq("p.repo_owner", f.Did), 215 + orm.FilterEq("p.repo_name", f.Name), 216 + orm.FilterEq("p.knot", f.Knot), 217 + orm.FilterIn("p.sha", shas), 218 218 ) 219 219 if err != nil { 220 220 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 227 227 228 228 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 229 229 if err != nil { 230 - log.Println("failed to get pull reactions") 230 + s.logger.Error("failed to get pull reaction status", "err", err) 231 231 } 232 232 233 233 userReactions := map[models.ReactionKind]bool{} 234 234 if user != nil { 235 - userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 235 + userReactions, err = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 236 + if err != nil { 237 + s.logger.Error("failed to get pull reaction status", "err", err) 238 + } 236 239 } 237 240 238 241 labelDefs, err := db.GetLabelDefinitions( ··· 521 524 522 525 user := s.oauth.GetMultiAccountUser(r) 523 526 params := r.URL.Query() 524 - 525 - state := models.PullOpen 526 - switch params.Get("state") { 527 - case "closed": 528 - state = models.PullClosed 529 - case "merged": 530 - state = models.PullMerged 531 - } 532 - 533 527 page := pagination.FromContext(r.Context()) 534 528 535 529 f, err := s.repoResolver.Resolve(r) ··· 538 532 return 539 533 } 540 534 541 - var totalPulls int 542 - switch state { 543 - case models.PullOpen: 544 - totalPulls = f.RepoStats.PullCount.Open 545 - case models.PullMerged: 546 - totalPulls = f.RepoStats.PullCount.Merged 547 - case models.PullClosed: 548 - totalPulls = f.RepoStats.PullCount.Closed 535 + query := searchquery.Parse(params.Get("q")) 536 + 537 + var state *models.PullState 538 + if urlState := params.Get("state"); urlState != "" { 539 + switch urlState { 540 + case "open": 541 + state = ptrPullState(models.PullOpen) 542 + case "closed": 543 + state = ptrPullState(models.PullClosed) 544 + case "merged": 545 + state = ptrPullState(models.PullMerged) 546 + } 547 + query.Set("state", urlState) 548 + } else if queryState := query.Get("state"); queryState != nil { 549 + switch *queryState { 550 + case "open": 551 + state = ptrPullState(models.PullOpen) 552 + case "closed": 553 + state = ptrPullState(models.PullClosed) 554 + case "merged": 555 + state = ptrPullState(models.PullMerged) 556 + } 557 + } else if _, hasQ := params["q"]; !hasQ { 558 + state = ptrPullState(models.PullOpen) 559 + query.Set("state", "open") 560 + } 561 + 562 + var authorDid string 563 + if authorHandle := query.Get("author"); authorHandle != nil { 564 + identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) 565 + if err != nil { 566 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 567 + } else { 568 + authorDid = identity.DID.String() 569 + } 570 + } 571 + 572 + var negatedAuthorDid string 573 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 574 + identity, err := s.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 575 + if err != nil { 576 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 577 + } else { 578 + negatedAuthorDid = identity.DID.String() 579 + } 549 580 } 550 581 551 - keyword := params.Get("q") 582 + labels := query.GetAll("label") 583 + negatedLabels := query.GetAllNegated("label") 584 + 585 + var keywords, negatedKeywords []string 586 + var phrases, negatedPhrases []string 587 + for _, item := range query.Items() { 588 + switch item.Kind { 589 + case searchquery.KindKeyword: 590 + if item.Negated { 591 + negatedKeywords = append(negatedKeywords, item.Value) 592 + } else { 593 + keywords = append(keywords, item.Value) 594 + } 595 + case searchquery.KindQuoted: 596 + if item.Negated { 597 + negatedPhrases = append(negatedPhrases, item.Value) 598 + } else { 599 + phrases = append(phrases, item.Value) 600 + } 601 + } 602 + } 552 603 553 - var pulls []*models.Pull 554 604 searchOpts := models.PullSearchOptions{ 555 - Keyword: keyword, 556 - RepoAt: f.RepoAt().String(), 557 - State: state, 558 - Page: page, 605 + Keywords: keywords, 606 + Phrases: phrases, 607 + RepoAt: f.RepoAt().String(), 608 + State: state, 609 + AuthorDid: authorDid, 610 + Labels: labels, 611 + NegatedKeywords: negatedKeywords, 612 + NegatedPhrases: negatedPhrases, 613 + NegatedLabels: negatedLabels, 614 + NegatedAuthorDid: negatedAuthorDid, 615 + Page: page, 559 616 } 560 - l.Debug("searching with", "searchOpts", searchOpts) 561 - if keyword != "" { 617 + 618 + var totalPulls int 619 + if state == nil { 620 + totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 621 + } else { 622 + switch *state { 623 + case models.PullOpen: 624 + totalPulls = f.RepoStats.PullCount.Open 625 + case models.PullMerged: 626 + totalPulls = f.RepoStats.PullCount.Merged 627 + case models.PullClosed: 628 + totalPulls = f.RepoStats.PullCount.Closed 629 + } 630 + } 631 + 632 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 633 + 634 + var pulls []*models.Pull 635 + 636 + if searchOpts.HasSearchFilters() { 562 637 res, err := s.indexer.Search(r.Context(), searchOpts) 563 638 if err != nil { 564 639 l.Error("failed to search for pulls", "err", err) ··· 567 642 totalPulls = int(res.Total) 568 643 l.Debug("searched pulls with indexer", "count", len(res.Hits)) 569 644 570 - pulls, err = db.GetPulls( 571 - s.db, 572 - orm.FilterIn("id", res.Hits), 573 - ) 574 - if err != nil { 575 - log.Println("failed to get pulls", err) 576 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 577 - return 645 + // update tab counts to reflect filtered results 646 + countOpts := searchOpts 647 + countOpts.Page = pagination.Page{Limit: 1} 648 + for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 649 + ps := ps 650 + countOpts.State = &ps 651 + countRes, err := s.indexer.Search(r.Context(), countOpts) 652 + if err != nil { 653 + continue 654 + } 655 + switch ps { 656 + case models.PullOpen: 657 + repoInfo.Stats.PullCount.Open = int(countRes.Total) 658 + case models.PullMerged: 659 + repoInfo.Stats.PullCount.Merged = int(countRes.Total) 660 + case models.PullClosed: 661 + repoInfo.Stats.PullCount.Closed = int(countRes.Total) 662 + } 663 + } 664 + 665 + if len(res.Hits) > 0 { 666 + pulls, err = db.GetPulls( 667 + s.db, 668 + orm.FilterIn("id", res.Hits), 669 + ) 670 + if err != nil { 671 + l.Error("failed to get pulls", "err", err) 672 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 673 + return 674 + } 578 675 } 579 676 } else { 677 + filters := []orm.Filter{ 678 + orm.FilterEq("repo_at", f.RepoAt()), 679 + } 680 + if state != nil { 681 + filters = append(filters, orm.FilterEq("state", *state)) 682 + } 580 683 pulls, err = db.GetPullsPaginated( 581 684 s.db, 582 685 page, 583 - orm.FilterEq("repo_at", f.RepoAt()), 584 - orm.FilterEq("state", searchOpts.State), 686 + filters..., 585 687 ) 586 688 if err != nil { 587 - log.Println("failed to get pulls", err) 689 + l.Error("failed to get pulls", "err", err) 588 690 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 589 691 return 590 692 } ··· 633 735 ps, err := db.GetPipelineStatuses( 634 736 s.db, 635 737 len(shas), 636 - orm.FilterEq("repo_owner", f.Did), 637 - orm.FilterEq("repo_name", f.Name), 638 - orm.FilterEq("knot", f.Knot), 639 - orm.FilterIn("sha", shas), 738 + orm.FilterEq("p.repo_owner", f.Did), 739 + orm.FilterEq("p.repo_name", f.Name), 740 + orm.FilterEq("p.knot", f.Knot), 741 + orm.FilterIn("p.sha", shas), 640 742 ) 641 743 if err != nil { 642 744 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 653 755 orm.FilterContains("scope", tangled.RepoPullNSID), 654 756 ) 655 757 if err != nil { 656 - log.Println("failed to fetch labels", err) 758 + l.Error("failed to fetch labels", "err", err) 657 759 s.pages.Error503(w) 658 760 return 659 761 } ··· 663 765 defs[l.AtUri().String()] = &l 664 766 } 665 767 768 + filterState := "" 769 + if state != nil { 770 + filterState = state.String() 771 + } 772 + 666 773 s.pages.RepoPulls(w, pages.RepoPullsParams{ 667 774 LoggedInUser: s.oauth.GetMultiAccountUser(r), 668 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 775 + RepoInfo: repoInfo, 669 776 Pulls: pulls, 670 777 LabelDefs: defs, 671 - FilteringBy: state, 672 - FilterQuery: keyword, 778 + FilterState: filterState, 779 + FilterQuery: query.String(), 673 780 Stacks: stacks, 674 781 Pipelines: m, 675 782 Page: page, ··· 868 975 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 869 976 return 870 977 } 871 - sanitizer := markup.NewSanitizer() 872 978 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 873 979 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 874 980 return ··· 996 1102 patch := comparison.FormatPatchRaw 997 1103 combined := comparison.CombinedPatchRaw 998 1104 999 - if err := s.validator.ValidatePatch(&patch); err != nil { 1105 + if err := validatePatch(&patch); err != nil { 1000 1106 s.logger.Error("failed to validate patch", "err", err) 1001 1107 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1002 1108 return ··· 1014 1120 } 1015 1121 1016 1122 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1017 - if err := s.validator.ValidatePatch(&patch); err != nil { 1123 + if err := validatePatch(&patch); err != nil { 1018 1124 s.logger.Error("patch validation failed", "err", err) 1019 1125 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1020 1126 return ··· 1106 1212 patch := comparison.FormatPatchRaw 1107 1213 combined := comparison.CombinedPatchRaw 1108 1214 1109 - if err := s.validator.ValidatePatch(&patch); err != nil { 1215 + if err := validatePatch(&patch); err != nil { 1110 1216 s.logger.Error("failed to validate patch", "err", err) 1111 1217 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1112 1218 return ··· 1227 1333 return 1228 1334 } 1229 1335 1230 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1336 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1231 1337 if err != nil { 1232 1338 log.Println("failed to upload patch", err) 1233 1339 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1321 1427 // apply all record creations at once 1322 1428 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1323 1429 for _, p := range stack { 1324 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch())) 1430 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 1325 1431 if err != nil { 1326 1432 log.Println("failed to upload patch blob", err) 1327 1433 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1399 1505 return 1400 1506 } 1401 1507 1402 - if err := s.validator.ValidatePatch(&patch); err != nil { 1508 + if err := validatePatch(&patch); err != nil { 1403 1509 s.logger.Error("faield to validate patch", "err", err) 1404 1510 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1405 1511 return ··· 1820 1926 return 1821 1927 } 1822 1928 1823 - if err := s.validator.ValidatePatch(&patch); err != nil { 1929 + if err := validatePatch(&patch); err != nil { 1824 1930 s.pages.Notice(w, "resubmit-error", err.Error()) 1825 1931 return 1826 1932 } ··· 1871 1977 return 1872 1978 } 1873 1979 1874 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1980 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1875 1981 if err != nil { 1876 1982 log.Println("failed to upload patch blob", err) 1877 1983 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") ··· 1880 1986 record := pull.AsRecord() 1881 1987 record.PatchBlob = blob.Blob 1882 1988 record.CreatedAt = time.Now().Format(time.RFC3339) 1883 - record.Source.Sha = newSourceRev 1989 + 1990 + if record.Source != nil { 1991 + record.Source.Sha = newSourceRev 1992 + } 1884 1993 1885 1994 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1886 1995 Collection: tangled.RepoPullNSID, ··· 2014 2123 return 2015 2124 } 2016 2125 2017 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2126 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2018 2127 if err != nil { 2019 2128 log.Println("failed to upload patch blob", err) 2020 2129 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") ··· 2056 2165 return 2057 2166 } 2058 2167 2059 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2168 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2060 2169 if err != nil { 2061 2170 log.Println("failed to upload patch blob", err) 2062 2171 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") ··· 2447 2556 w.Close() 2448 2557 return &b 2449 2558 } 2559 + 2560 + func ptrPullState(s models.PullState) *models.PullState { return &s } 2561 + 2562 + func validatePatch(patch *string) error { 2563 + if patch == nil || *patch == "" { 2564 + return fmt.Errorf("patch is empty") 2565 + } 2566 + 2567 + // add newline if not present to diff style patches 2568 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 2569 + *patch = *patch + "\n" 2570 + } 2571 + 2572 + if err := patchutil.IsPatchValid(*patch); err != nil { 2573 + return err 2574 + } 2575 + 2576 + return nil 2577 + }
+44 -34
appview/repo/artifact.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "io" 8 - "log" 9 8 "net/http" 10 9 "net/url" 11 10 "time" ··· 18 17 "tangled.org/core/orm" 19 18 "tangled.org/core/tid" 20 19 "tangled.org/core/types" 20 + "tangled.org/core/xrpc" 21 21 22 22 comatproto "github.com/bluesky-social/indigo/api/atproto" 23 23 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 30 30 31 31 // TODO: proper statuses here on early exit 32 32 func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { 33 + l := rp.logger.With("handler", "AttachArtifact") 34 + 33 35 user := rp.oauth.GetMultiAccountUser(r) 34 36 tagParam := chi.URLParam(r, "tag") 35 37 f, err := rp.repoResolver.Resolve(r) 36 38 if err != nil { 37 - log.Println("failed to get repo and knot", err) 39 + l.Error("failed to get repo and knot", "err", err) 38 40 rp.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution") 39 41 return 40 42 } 41 43 42 44 tag, err := rp.resolveTag(r.Context(), f, tagParam) 43 45 if err != nil { 44 - log.Println("failed to resolve tag", err) 46 + l.Error("failed to resolve tag", "err", err) 45 47 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 46 48 return 47 49 } 48 50 49 - file, handler, err := r.FormFile("artifact") 51 + file, header, err := r.FormFile("artifact") 50 52 if err != nil { 51 - log.Println("failed to upload artifact", err) 53 + l.Error("failed to upload artifact", "err", err) 52 54 rp.pages.Notice(w, "upload", "failed to upload artifact") 53 55 return 54 56 } ··· 56 58 57 59 client, err := rp.oauth.AuthorizedClient(r) 58 60 if err != nil { 59 - log.Println("failed to get authorized client", err) 61 + l.Error("failed to get authorized client", "err", err) 60 62 rp.pages.Notice(w, "upload", "failed to get authorized client") 61 63 return 62 64 } 63 65 64 - uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 66 + uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type")) 65 67 if err != nil { 66 - log.Println("failed to upload blob", err) 68 + l.Error("failed to upload blob", "err", err) 67 69 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") 68 70 return 69 71 } 70 72 71 - log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 73 + l.Info("uploaded blob", "size", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), "blobRef", uploadBlobResp.Blob.Ref.String()) 72 74 73 75 rkey := tid.TID() 74 76 createdAt := time.Now() ··· 81 83 Val: &tangled.RepoArtifact{ 82 84 Artifact: uploadBlobResp.Blob, 83 85 CreatedAt: createdAt.Format(time.RFC3339), 84 - Name: handler.Filename, 86 + Name: header.Filename, 85 87 Repo: f.RepoAt().String(), 86 88 Tag: tag.Tag.Hash[:], 87 89 }, 88 90 }, 89 91 }) 90 92 if err != nil { 91 - log.Println("failed to create record", err) 93 + l.Error("failed to create record", "err", err) 92 94 rp.pages.Notice(w, "upload", "Failed to create artifact record. Try again later.") 93 95 return 94 96 } 95 97 96 - log.Println(putRecordResp.Uri) 98 + l.Debug("created record for blob", "aturi", putRecordResp.Uri) 97 99 98 100 tx, err := rp.db.BeginTx(r.Context(), nil) 99 101 if err != nil { 100 - log.Println("failed to start tx") 102 + l.Error("failed to start tx") 101 103 rp.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 102 104 return 103 105 } ··· 110 112 Tag: tag.Tag.Hash, 111 113 CreatedAt: createdAt, 112 114 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), 113 - Name: handler.Filename, 115 + Name: header.Filename, 114 116 Size: uint64(uploadBlobResp.Blob.Size), 115 117 MimeType: uploadBlobResp.Blob.MimeType, 116 118 } 117 119 118 120 err = db.AddArtifact(tx, artifact) 119 121 if err != nil { 120 - log.Println("failed to add artifact record to db", err) 122 + l.Error("failed to add artifact record to db", "err", err) 121 123 rp.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 122 124 return 123 125 } 124 126 125 127 err = tx.Commit() 126 128 if err != nil { 127 - log.Println("failed to add artifact record to db") 129 + l.Error("failed to add artifact record to db") 128 130 rp.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 129 131 return 130 132 } ··· 137 139 } 138 140 139 141 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 142 + l := rp.logger.With("handler", "DownloadArtifact") 143 + 140 144 f, err := rp.repoResolver.Resolve(r) 141 145 if err != nil { 142 - log.Println("failed to get repo and knot", err) 146 + l.Error("failed to get repo and knot", "err", err) 143 147 http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 144 148 return 145 149 } ··· 149 153 150 154 tag, err := rp.resolveTag(r.Context(), f, tagParam) 151 155 if err != nil { 152 - log.Println("failed to resolve tag", err) 156 + l.Error("failed to resolve tag", "err", err) 153 157 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 154 158 return 155 159 } ··· 161 165 orm.FilterEq("name", filename), 162 166 ) 163 167 if err != nil { 164 - log.Println("failed to get artifacts", err) 168 + l.Error("failed to get artifacts", "err", err) 165 169 http.Error(w, "failed to get artifact", http.StatusInternalServerError) 166 170 return 167 171 } 168 172 169 173 if len(artifacts) != 1 { 170 - log.Printf("too many or too few artifacts found") 174 + l.Error("too many or too few artifacts found") 171 175 http.Error(w, "artifact not found", http.StatusNotFound) 172 176 return 173 177 } ··· 176 180 177 181 ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 178 182 if err != nil { 179 - log.Println("failed to resolve repo owner did", f.Did, err) 183 + l.Error("failed to resolve repo owner did", "did", f.Did, "err", err) 180 184 http.Error(w, "repository owner not found", http.StatusNotFound) 181 185 return 182 186 } ··· 190 194 191 195 req, err := http.NewRequest(http.MethodGet, url.String(), nil) 192 196 if err != nil { 193 - log.Println("failed to create request", err) 197 + l.Error("failed to create request", "err", err) 194 198 http.Error(w, "failed to create request", http.StatusInternalServerError) 195 199 return 196 200 } ··· 198 202 199 203 resp, err := http.DefaultClient.Do(req) 200 204 if err != nil { 201 - log.Println("failed to make request", err) 205 + l.Error("failed to make request", "err", err) 202 206 http.Error(w, "failed to make request to PDS", http.StatusInternalServerError) 203 207 return 204 208 } ··· 214 218 215 219 // stream the body directly to the client 216 220 if _, err := io.Copy(w, resp.Body); err != nil { 217 - log.Println("error streaming response to client:", err) 221 + l.Error("error streaming response to client:", "err", err) 218 222 } 219 223 } 220 224 221 225 // TODO: proper statuses here on early exit 222 226 func (rp *Repo) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 227 + l := rp.logger.With("handler", "DeleteArtifact") 228 + 223 229 user := rp.oauth.GetMultiAccountUser(r) 224 230 tagParam := chi.URLParam(r, "tag") 225 231 filename := chi.URLParam(r, "file") 226 232 f, err := rp.repoResolver.Resolve(r) 227 233 if err != nil { 228 - log.Println("failed to get repo and knot", err) 234 + l.Error("failed to get repo and knot", "err", err) 229 235 return 230 236 } 231 237 ··· 240 246 orm.FilterEq("name", filename), 241 247 ) 242 248 if err != nil { 243 - log.Println("failed to get artifacts", err) 249 + l.Error("failed to get artifacts", "err", err) 244 250 rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 245 251 return 246 252 } ··· 252 258 artifact := artifacts[0] 253 259 254 260 if user.Active.Did != artifact.Did { 255 - log.Println("user not authorized to delete artifact", err) 261 + l.Error("user not authorized to delete artifact", "err", err) 256 262 rp.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 257 263 return 258 264 } ··· 263 269 Rkey: artifact.Rkey, 264 270 }) 265 271 if err != nil { 266 - log.Println("failed to get blob from pds", err) 272 + l.Error("failed to get blob from pds", "err", err) 267 273 rp.pages.Notice(w, "remove", "Failed to remove blob from PDS.") 268 274 return 269 275 } 270 276 271 277 tx, err := rp.db.BeginTx(r.Context(), nil) 272 278 if err != nil { 273 - log.Println("failed to start tx") 279 + l.Error("failed to start tx") 274 280 rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 275 281 return 276 282 } ··· 282 288 orm.FilterEq("name", filename), 283 289 ) 284 290 if err != nil { 285 - log.Println("failed to remove artifact record from db", err) 291 + l.Error("failed to remove artifact record from db", "err", err) 286 292 rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 287 293 return 288 294 } 289 295 290 296 err = tx.Commit() 291 297 if err != nil { 292 - log.Println("failed to remove artifact record from db") 298 + l.Error("failed to remove artifact record from db") 293 299 rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 294 300 return 295 301 } 296 302 303 + l.Info("successfully deleted artifact", "tag", tagParam, "file", filename) 304 + 297 305 w.Write([]byte{}) 298 306 } 299 307 300 308 func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) { 309 + l := rp.logger.With("handler", "resolveTag") 310 + 301 311 tagParam, err := url.QueryUnescape(tagParam) 302 312 if err != nil { 303 313 return nil, err ··· 316 326 xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 317 327 if err != nil { 318 328 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 319 - log.Println("failed to call XRPC repo.tags", xrpcerr) 329 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 320 330 return nil, xrpcerr 321 331 } 322 - log.Println("failed to reach knotserver", err) 332 + l.Error("failed to reach knotserver", "err", err) 323 333 return nil, err 324 334 } 325 335 326 336 var result types.RepoTagsResponse 327 337 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 328 - log.Println("failed to decode XRPC tags response", err) 338 + l.Error("failed to decode XRPC tags response", "err", err) 329 339 return nil, err 330 340 } 331 341
+32
appview/repo/blob.go
··· 9 9 "path/filepath" 10 10 "slices" 11 11 "strings" 12 + "time" 12 13 13 14 "tangled.org/core/api/tangled" 14 15 "tangled.org/core/appview/config" 16 + "tangled.org/core/appview/db" 15 17 "tangled.org/core/appview/models" 16 18 "tangled.org/core/appview/pages" 17 19 "tangled.org/core/appview/pages/markup" 18 20 "tangled.org/core/appview/reporesolver" 19 21 xrpcclient "tangled.org/core/appview/xrpcclient" 22 + "tangled.org/core/types" 20 23 21 24 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 25 "github.com/go-chi/chi/v5" 26 + "github.com/go-git/go-git/v5/plumbing" 23 27 ) 24 28 25 29 // the content can be one of the following: ··· 78 82 79 83 user := rp.oauth.GetMultiAccountUser(r) 80 84 85 + // Get email to DID mapping for commit author 86 + var emails []string 87 + if resp.LastCommit != nil && resp.LastCommit.Author != nil { 88 + emails = append(emails, resp.LastCommit.Author.Email) 89 + } 90 + emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 91 + if err != nil { 92 + l.Error("failed to get email to did mapping", "err", err) 93 + emailToDidMap = make(map[string]string) 94 + } 95 + 96 + var lastCommitInfo *types.LastCommitInfo 97 + if resp.LastCommit != nil { 98 + when, _ := time.Parse(time.RFC3339, resp.LastCommit.When) 99 + lastCommitInfo = &types.LastCommitInfo{ 100 + Hash: plumbing.NewHash(resp.LastCommit.Hash), 101 + Message: resp.LastCommit.Message, 102 + When: when, 103 + } 104 + if resp.LastCommit.Author != nil { 105 + lastCommitInfo.Author.Name = resp.LastCommit.Author.Name 106 + lastCommitInfo.Author.Email = resp.LastCommit.Author.Email 107 + lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) 108 + } 109 + } 110 + 81 111 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 112 LoggedInUser: user, 83 113 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 84 114 BreadCrumbs: breadcrumbs, 85 115 BlobView: blobView, 116 + EmailToDid: emailToDidMap, 117 + LastCommitInfo: lastCommitInfo, 86 118 RepoBlob_Output: resp, 87 119 }) 88 120 }
+14 -16
appview/repo/index.go
··· 64 64 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 65 65 }) 66 66 return 67 + } else { 68 + l.Error("failed to build index response", "err", err) 69 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 70 + LoggedInUser: user, 71 + KnotUnreachable: true, 72 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 73 + }) 74 + return 67 75 } 68 - 69 - rp.pages.Error503(w) 70 - l.Error("failed to build index response", "err", err) 71 - return 72 76 } 73 77 74 78 tagMap := make(map[string][]string) ··· 299 303 ) 300 304 301 305 // tags 302 - wg.Add(1) 303 - go func() { 304 - defer wg.Done() 306 + wg.Go(func() { 305 307 tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo) 306 308 if err != nil { 307 309 errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) ··· 311 313 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 312 314 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err)) 313 315 } 314 - }() 316 + }) 315 317 316 318 // tree/files 317 - wg.Add(1) 318 - go func() { 319 - defer wg.Done() 319 + wg.Go(func() { 320 320 resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo) 321 321 if err != nil { 322 322 errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 323 323 return 324 324 } 325 325 treeResp = resp 326 - }() 326 + }) 327 327 328 328 // commits 329 - wg.Add(1) 330 - go func() { 331 - defer wg.Done() 329 + wg.Go(func() { 332 330 logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo) 333 331 if err != nil { 334 332 errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) ··· 338 336 if err := json.Unmarshal(logBytes, &logResp); err != nil { 339 337 errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err)) 340 338 } 341 - }() 339 + }) 342 340 343 341 wg.Wait() 344 342
+3 -7
appview/repo/repo.go
··· 20 20 "tangled.org/core/appview/oauth" 21 21 "tangled.org/core/appview/pages" 22 22 "tangled.org/core/appview/reporesolver" 23 - "tangled.org/core/appview/validator" 24 23 xrpcclient "tangled.org/core/appview/xrpcclient" 25 24 "tangled.org/core/eventconsumer" 26 25 "tangled.org/core/idresolver" ··· 30 29 "tangled.org/core/xrpc/serviceauth" 31 30 32 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 33 - atpclient "github.com/bluesky-social/indigo/atproto/client" 32 + "github.com/bluesky-social/indigo/atproto/atclient" 34 33 "github.com/bluesky-social/indigo/atproto/syntax" 35 34 lexutil "github.com/bluesky-social/indigo/lex/util" 36 35 securejoin "github.com/cyphar/filepath-securejoin" ··· 49 48 notifier notify.Notifier 50 49 logger *slog.Logger 51 50 serviceAuth *serviceauth.ServiceAuth 52 - validator *validator.Validator 53 51 } 54 52 55 53 func New( ··· 63 61 notifier notify.Notifier, 64 62 enforcer *rbac.Enforcer, 65 63 logger *slog.Logger, 66 - validator *validator.Validator, 67 64 ) *Repo { 68 65 return &Repo{oauth: oauth, 69 66 repoResolver: repoResolver, ··· 75 72 notifier: notifier, 76 73 enforcer: enforcer, 77 74 logger: logger, 78 - validator: validator, 79 75 } 80 76 } 81 77 ··· 225 221 Multiple: multiple, 226 222 Created: time.Now(), 227 223 } 228 - if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 224 + if err := label.Validate(); err != nil { 229 225 fail(err.Error(), err) 230 226 return 231 227 } ··· 1198 1194 // this is used to rollback changes made to the PDS 1199 1195 // 1200 1196 // it is a no-op if the provided ATURI is empty 1201 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1197 + func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 1202 1198 if aturi == "" { 1203 1199 return nil 1204 1200 }
+4 -4
appview/repo/repo_util.go
··· 103 103 ps, err := db.GetPipelineStatuses( 104 104 d, 105 105 len(shas), 106 - orm.FilterEq("repo_owner", repo.Did), 107 - orm.FilterEq("repo_name", repo.Name), 108 - orm.FilterEq("knot", repo.Knot), 109 - orm.FilterIn("sha", shas), 106 + orm.FilterEq("p.repo_owner", repo.Did), 107 + orm.FilterEq("p.repo_name", repo.Name), 108 + orm.FilterEq("p.knot", repo.Knot), 109 + orm.FilterIn("p.sha", shas), 110 110 ) 111 111 if err != nil { 112 112 return nil, err
+9
appview/repo/router.go
··· 23 23 r.Route("/tags", func(r chi.Router) { 24 24 r.Get("/", rp.Tags) 25 25 r.Route("/{tag}", func(r chi.Router) { 26 + r.Get("/", rp.Tag) 26 27 r.Get("/download/{file}", rp.DownloadArtifact) 27 28 28 29 // require repo:push to upload or delete artifacts ··· 86 87 r.Put("/branches/default", rp.SetDefaultBranch) 87 88 r.Put("/secrets", rp.Secrets) 88 89 r.Delete("/secrets", rp.Secrets) 90 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { 91 + r.Get("/", rp.Webhooks) 92 + r.Post("/", rp.AddWebhook) 93 + r.Put("/{id}", rp.UpdateWebhook) 94 + r.Delete("/{id}", rp.DeleteWebhook) 95 + r.Post("/{id}/toggle", rp.ToggleWebhook) 96 + r.Get("/{id}/deliveries", rp.WebhookDeliveries) 97 + }) 89 98 }) 90 99 }) 91 100
+71 -12
appview/repo/settings.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 8 + "regexp" 7 9 "slices" 8 10 "strings" 9 11 "time" ··· 15 17 "tangled.org/core/appview/pages" 16 18 xrpcclient "tangled.org/core/appview/xrpcclient" 17 19 "tangled.org/core/orm" 20 + "tangled.org/core/sets" 18 21 "tangled.org/core/types" 19 22 20 23 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 167 170 168 171 case "pipelines": 169 172 rp.pipelineSettings(w, r) 173 + 174 + case "hooks": 175 + rp.Webhooks(w, r) 170 176 } 171 177 } 172 178 ··· 187 193 188 194 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 189 195 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 196 + var result types.RepoBranchesResponse 190 197 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 191 198 l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 192 - rp.pages.Error503(w) 193 - return 194 - } 195 - 196 - var result types.RepoBranchesResponse 197 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 199 + } else if err := json.Unmarshal(xrpcBytes, &result); err != nil { 198 200 l.Error("failed to decode XRPC response", "err", err) 199 201 rp.pages.Error503(w) 200 202 return ··· 385 387 topicStr = r.FormValue("topics") 386 388 ) 387 389 388 - err = rp.validator.ValidateURI(website) 389 - if website != "" && err != nil { 390 - l.Error("invalid uri", "err", err) 391 - rp.pages.Notice(w, noticeId, err.Error()) 392 - return 390 + if website != "" { 391 + if err := validateURI(website); err != nil { 392 + l.Error("invalid uri", "err", err) 393 + rp.pages.Notice(w, noticeId, err.Error()) 394 + return 395 + } 393 396 } 394 397 395 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 398 + topics, err := parseRepoTopicStr(topicStr) 396 399 if err != nil { 397 400 l.Error("invalid topics", "err", err) 398 401 rp.pages.Notice(w, noticeId, err.Error()) ··· 452 455 453 456 rp.pages.HxRefresh(w) 454 457 } 458 + 459 + const ( 460 + maxTopicLen = 50 461 + maxTopics = 20 462 + ) 463 + 464 + var ( 465 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 466 + ) 467 + 468 + // parseRepoTopicStr parses and validates whitespace-separated topic string. 469 + // 470 + // Rules: 471 + // - topics are separated by whitespace 472 + // - each topic may contain lowercase letters, digits, and hyphens only 473 + // - each topic must be <= 50 characters long 474 + // - no more than 20 topics allowed 475 + // - duplicates are removed 476 + func parseRepoTopicStr(topicStr string) ([]string, error) { 477 + topicStr = strings.TrimSpace(topicStr) 478 + if topicStr == "" { 479 + return nil, nil 480 + } 481 + parts := strings.Fields(topicStr) 482 + if len(parts) > maxTopics { 483 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 484 + } 485 + 486 + topicSet := sets.New[string]() 487 + 488 + for _, t := range parts { 489 + if topicSet.Contains(t) { 490 + continue 491 + } 492 + if len(t) > maxTopicLen { 493 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 494 + } 495 + if !topicRE.MatchString(t) { 496 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 497 + } 498 + topicSet.Insert(t) 499 + } 500 + return slices.Collect(topicSet.All()), nil 501 + } 502 + 503 + // TODO(boltless): move this to models.Repo instead 504 + func validateURI(uri string) error { 505 + parsed, err := url.Parse(uri) 506 + if err != nil { 507 + return fmt.Errorf("invalid uri format") 508 + } 509 + if parsed.Scheme == "" { 510 + return fmt.Errorf("uri scheme missing") 511 + } 512 + return nil 513 + }
+84
appview/repo/tags.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 7 8 8 9 "tangled.org/core/api/tangled" 9 10 "tangled.org/core/appview/db" 10 11 "tangled.org/core/appview/models" 11 12 "tangled.org/core/appview/pages" 13 + "tangled.org/core/appview/reporesolver" 12 14 xrpcclient "tangled.org/core/appview/xrpcclient" 13 15 "tangled.org/core/orm" 14 16 "tangled.org/core/types" 15 17 16 18 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-chi/chi/v5" 17 20 "github.com/go-git/go-git/v5/plumbing" 18 21 ) 19 22 ··· 70 73 } 71 74 } 72 75 user := rp.oauth.GetMultiAccountUser(r) 76 + 73 77 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 78 LoggedInUser: user, 75 79 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 78 82 DanglingArtifacts: danglingArtifacts, 79 83 }) 80 84 } 85 + 86 + func (rp *Repo) Tag(w http.ResponseWriter, r *http.Request) { 87 + l := rp.logger.With("handler", "RepoTag") 88 + f, err := rp.repoResolver.Resolve(r) 89 + if err != nil { 90 + l.Error("failed to get repo and knot", "err", err) 91 + return 92 + } 93 + scheme := "http" 94 + if !rp.config.Core.Dev { 95 + scheme = "https" 96 + } 97 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 98 + xrpcc := &indigoxrpc.Client{ 99 + Host: host, 100 + } 101 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 102 + tag := chi.URLParam(r, "tag") 103 + 104 + xrpcBytes, err := tangled.RepoTag(r.Context(), xrpcc, repo, tag) 105 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 106 + // if we don't match an existing tag, and the tag we're trying 107 + // to match is "latest", resolve to the most recent tag 108 + if tag == "latest" { 109 + tagsBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 1, repo) 110 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 111 + l.Error("failed to call XRPC repo.tags for latest", "err", xrpcerr) 112 + rp.pages.Error503(w) 113 + return 114 + } 115 + var tagsResult types.RepoTagsResponse 116 + if err := json.Unmarshal(tagsBytes, &tagsResult); err != nil { 117 + l.Error("failed to decode XRPC response", "err", err) 118 + rp.pages.Error503(w) 119 + return 120 + } 121 + if len(tagsResult.Tags) == 0 { 122 + rp.pages.Error503(w) 123 + return 124 + } 125 + latestTag := tagsResult.Tags[0].Name 126 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 127 + http.Redirect(w, r, fmt.Sprintf("/%s/tags/%s", ownerSlashRepo, url.PathEscape(latestTag)), http.StatusTemporaryRedirect) 128 + return 129 + } 130 + l.Error("failed to call XRPC repo.tag", "err", xrpcerr) 131 + rp.pages.Error503(w) 132 + return 133 + } 134 + var result types.RepoTagResponse 135 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 136 + l.Error("failed to decode XRPC response", "err", err) 137 + rp.pages.Error503(w) 138 + return 139 + } 140 + 141 + filters := []orm.Filter{orm.FilterEq("repo_at", f.RepoAt())} 142 + if result.Tag.Tag != nil { 143 + filters = append(filters, orm.FilterEq("tag", result.Tag.Tag.Hash[:])) 144 + } 145 + 146 + artifacts, err := db.GetArtifact(rp.db, filters...) 147 + if err != nil { 148 + l.Error("failed grab artifacts", "err", err) 149 + return 150 + } 151 + // convert artifacts to map for easy UI building 152 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 153 + for _, a := range artifacts { 154 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 155 + } 156 + 157 + user := rp.oauth.GetMultiAccountUser(r) 158 + rp.pages.RepoTag(w, pages.RepoTagParams{ 159 + LoggedInUser: user, 160 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 161 + RepoTagResponse: result, 162 + ArtifactMap: artifactMap, 163 + }) 164 + }
+29
appview/repo/tree.go
··· 8 8 "time" 9 9 10 10 "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 11 12 "tangled.org/core/appview/pages" 12 13 "tangled.org/core/appview/reporesolver" 13 14 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 98 99 } 99 100 sortFiles(result.Files) 100 101 102 + // Get email to DID mapping for commit author 103 + var emails []string 104 + if xrpcResp.LastCommit != nil && xrpcResp.LastCommit.Author != nil { 105 + emails = append(emails, xrpcResp.LastCommit.Author.Email) 106 + } 107 + emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 108 + if err != nil { 109 + l.Error("failed to get email to did mapping", "err", err) 110 + emailToDidMap = make(map[string]string) 111 + } 112 + 113 + var lastCommitInfo *types.LastCommitInfo 114 + if xrpcResp.LastCommit != nil { 115 + when, _ := time.Parse(time.RFC3339, xrpcResp.LastCommit.When) 116 + lastCommitInfo = &types.LastCommitInfo{ 117 + Hash: plumbing.NewHash(xrpcResp.LastCommit.Hash), 118 + Message: xrpcResp.LastCommit.Message, 119 + When: when, 120 + } 121 + if xrpcResp.LastCommit.Author != nil { 122 + lastCommitInfo.Author.Name = xrpcResp.LastCommit.Author.Name 123 + lastCommitInfo.Author.Email = xrpcResp.LastCommit.Author.Email 124 + lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, xrpcResp.LastCommit.Author.When) 125 + } 126 + } 127 + 101 128 rp.pages.RepoTree(w, pages.RepoTreeParams{ 102 129 LoggedInUser: user, 103 130 BreadCrumbs: breadcrumbs, 104 131 TreePath: treePath, 105 132 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 133 + EmailToDid: emailToDidMap, 134 + LastCommitInfo: lastCommitInfo, 106 135 RepoTreeResponse: result, 107 136 }) 108 137 }
+367
appview/repo/webhooks.go
··· 1 + package repo 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + "strings" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages" 12 + ) 13 + 14 + // Webhooks displays the webhooks settings page 15 + func (rp *Repo) Webhooks(w http.ResponseWriter, r *http.Request) { 16 + l := rp.logger.With("handler", "Webhooks") 17 + 18 + f, err := rp.repoResolver.Resolve(r) 19 + if err != nil { 20 + l.Error("failed to get repo and knot", "err", err) 21 + w.WriteHeader(http.StatusBadRequest) 22 + return 23 + } 24 + 25 + user := rp.oauth.GetMultiAccountUser(r) 26 + 27 + webhooks, err := db.GetWebhooksForRepo(rp.db, f.RepoAt()) 28 + if err != nil { 29 + l.Error("failed to get webhooks", "err", err) 30 + rp.pages.Notice(w, "webhooks-error", "Failed to load webhooks") 31 + return 32 + } 33 + 34 + // fetch recent deliveries for each webhook 35 + deliveriesMap := make(map[int64][]models.WebhookDelivery) 36 + for _, webhook := range webhooks { 37 + deliveries, err := db.GetWebhookDeliveries(rp.db, webhook.Id, 4) 38 + if err != nil { 39 + l.Error("failed to get webhook deliveries", "webhook_id", webhook.Id, "err", err) 40 + // continue even if we can't get deliveries for one webhook 41 + continue 42 + } 43 + deliveriesMap[webhook.Id] = deliveries 44 + } 45 + 46 + rp.pages.RepoWebhooksSettings(w, pages.RepoWebhooksSettingsParams{ 47 + LoggedInUser: user, 48 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 49 + Webhooks: webhooks, 50 + WebhookDeliveries: deliveriesMap, 51 + }) 52 + } 53 + 54 + // AddWebhook creates a new webhook 55 + func (rp *Repo) AddWebhook(w http.ResponseWriter, r *http.Request) { 56 + l := rp.logger.With("handler", "AddWebhook") 57 + 58 + f, err := rp.repoResolver.Resolve(r) 59 + if err != nil { 60 + l.Error("failed to get repo and knot", "err", err) 61 + w.WriteHeader(http.StatusBadRequest) 62 + return 63 + } 64 + 65 + url := strings.TrimSpace(r.FormValue("url")) 66 + if url == "" { 67 + rp.pages.Notice(w, "webhooks-error", "Webhook URL is required") 68 + return 69 + } 70 + 71 + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { 72 + rp.pages.Notice(w, "webhooks-error", "Webhook URL must start with http:// or https://") 73 + return 74 + } 75 + 76 + secret := strings.TrimSpace(r.FormValue("secret")) 77 + // if secret is empty, we don't sign 78 + 79 + active := r.FormValue("active") == "on" 80 + 81 + events := []string{} 82 + if r.FormValue("event_push") == "on" { 83 + events = append(events, string(models.WebhookEventPush)) 84 + } 85 + 86 + if len(events) == 0 { 87 + rp.pages.Notice(w, "webhooks-error", "Push events must be enabled") 88 + return 89 + } 90 + 91 + webhook := &models.Webhook{ 92 + RepoAt: f.RepoAt(), 93 + Url: url, 94 + Secret: secret, 95 + Active: active, 96 + Events: events, 97 + } 98 + 99 + tx, err := rp.db.Begin() 100 + if err != nil { 101 + l.Error("failed to start transaction", "err", err) 102 + rp.pages.Notice(w, "webhooks-error", "Failed to create webhook") 103 + return 104 + } 105 + defer tx.Rollback() 106 + 107 + if err := db.AddWebhook(tx, webhook); err != nil { 108 + l.Error("failed to add webhook", "err", err) 109 + rp.pages.Notice(w, "webhooks-error", "Failed to create webhook") 110 + return 111 + } 112 + 113 + if err := tx.Commit(); err != nil { 114 + l.Error("failed to commit transaction", "err", err) 115 + rp.pages.Notice(w, "webhooks-error", "Failed to create webhook") 116 + return 117 + } 118 + 119 + rp.pages.HxRefresh(w) 120 + } 121 + 122 + // UpdateWebhook updates an existing webhook 123 + func (rp *Repo) UpdateWebhook(w http.ResponseWriter, r *http.Request) { 124 + l := rp.logger.With("handler", "UpdateWebhook") 125 + 126 + f, err := rp.repoResolver.Resolve(r) 127 + if err != nil { 128 + l.Error("failed to get repo and knot", "err", err) 129 + w.WriteHeader(http.StatusBadRequest) 130 + return 131 + } 132 + 133 + idStr := chi.URLParam(r, "id") 134 + id, err := strconv.ParseInt(idStr, 10, 64) 135 + if err != nil { 136 + l.Error("invalid webhook id", "err", err) 137 + w.WriteHeader(http.StatusBadRequest) 138 + return 139 + } 140 + 141 + webhook, err := db.GetWebhook(rp.db, id) 142 + if err != nil { 143 + l.Error("failed to get webhook", "err", err) 144 + rp.pages.Notice(w, "webhooks-error", "Webhook not found") 145 + return 146 + } 147 + 148 + // Verify webhook belongs to this repo 149 + if webhook.RepoAt != f.RepoAt() { 150 + l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoAt, "current_repo", f.RepoAt()) 151 + w.WriteHeader(http.StatusForbidden) 152 + return 153 + } 154 + 155 + url := strings.TrimSpace(r.FormValue("url")) 156 + if url != "" { 157 + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { 158 + rp.pages.Notice(w, "webhooks-error", "Webhook URL must start with http:// or https://") 159 + return 160 + } 161 + webhook.Url = url 162 + } 163 + 164 + secret := strings.TrimSpace(r.FormValue("secret")) 165 + if secret != "" { 166 + webhook.Secret = secret 167 + } 168 + 169 + webhook.Active = r.FormValue("active") == "on" 170 + 171 + // Parse events - only push events are supported for now 172 + events := []string{} 173 + if r.FormValue("event_push") == "on" { 174 + events = append(events, string(models.WebhookEventPush)) 175 + } 176 + 177 + if len(events) > 0 { 178 + webhook.Events = events 179 + } 180 + 181 + tx, err := rp.db.Begin() 182 + if err != nil { 183 + l.Error("failed to start transaction", "err", err) 184 + rp.pages.Notice(w, "webhooks-error", "Failed to update webhook") 185 + return 186 + } 187 + defer tx.Rollback() 188 + 189 + if err := db.UpdateWebhook(tx, webhook); err != nil { 190 + l.Error("failed to update webhook", "err", err) 191 + rp.pages.Notice(w, "webhooks-error", "Failed to update webhook") 192 + return 193 + } 194 + 195 + if err := tx.Commit(); err != nil { 196 + l.Error("failed to commit transaction", "err", err) 197 + rp.pages.Notice(w, "webhooks-error", "Failed to update webhook") 198 + return 199 + } 200 + 201 + rp.pages.HxRefresh(w) 202 + } 203 + 204 + // DeleteWebhook deletes a webhook 205 + func (rp *Repo) DeleteWebhook(w http.ResponseWriter, r *http.Request) { 206 + l := rp.logger.With("handler", "DeleteWebhook") 207 + 208 + f, err := rp.repoResolver.Resolve(r) 209 + if err != nil { 210 + l.Error("failed to get repo and knot", "err", err) 211 + w.WriteHeader(http.StatusBadRequest) 212 + return 213 + } 214 + 215 + idStr := chi.URLParam(r, "id") 216 + id, err := strconv.ParseInt(idStr, 10, 64) 217 + if err != nil { 218 + l.Error("invalid webhook id", "err", err) 219 + w.WriteHeader(http.StatusBadRequest) 220 + return 221 + } 222 + 223 + webhook, err := db.GetWebhook(rp.db, id) 224 + if err != nil { 225 + l.Error("failed to get webhook", "err", err) 226 + w.WriteHeader(http.StatusNotFound) 227 + return 228 + } 229 + 230 + // Verify webhook belongs to this repo 231 + if webhook.RepoAt != f.RepoAt() { 232 + l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoAt, "current_repo", f.RepoAt()) 233 + w.WriteHeader(http.StatusForbidden) 234 + return 235 + } 236 + 237 + tx, err := rp.db.Begin() 238 + if err != nil { 239 + l.Error("failed to start transaction", "err", err) 240 + rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook") 241 + return 242 + } 243 + defer tx.Rollback() 244 + 245 + if err := db.DeleteWebhook(tx, id); err != nil { 246 + l.Error("failed to delete webhook", "err", err) 247 + rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook") 248 + return 249 + } 250 + 251 + if err := tx.Commit(); err != nil { 252 + l.Error("failed to commit transaction", "err", err) 253 + rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook") 254 + return 255 + } 256 + 257 + rp.pages.HxRefresh(w) 258 + } 259 + 260 + // ToggleWebhook toggles the active state of a webhook 261 + func (rp *Repo) ToggleWebhook(w http.ResponseWriter, r *http.Request) { 262 + l := rp.logger.With("handler", "ToggleWebhook") 263 + 264 + f, err := rp.repoResolver.Resolve(r) 265 + if err != nil { 266 + l.Error("failed to get repo and knot", "err", err) 267 + w.WriteHeader(http.StatusBadRequest) 268 + return 269 + } 270 + 271 + idStr := chi.URLParam(r, "id") 272 + id, err := strconv.ParseInt(idStr, 10, 64) 273 + if err != nil { 274 + l.Error("invalid webhook id", "err", err) 275 + w.WriteHeader(http.StatusBadRequest) 276 + return 277 + } 278 + 279 + webhook, err := db.GetWebhook(rp.db, id) 280 + if err != nil { 281 + l.Error("failed to get webhook", "err", err) 282 + w.WriteHeader(http.StatusNotFound) 283 + return 284 + } 285 + 286 + // Verify webhook belongs to this repo 287 + if webhook.RepoAt != f.RepoAt() { 288 + l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoAt, "current_repo", f.RepoAt()) 289 + w.WriteHeader(http.StatusForbidden) 290 + return 291 + } 292 + 293 + // Toggle the active state 294 + webhook.Active = !webhook.Active 295 + 296 + tx, err := rp.db.Begin() 297 + if err != nil { 298 + l.Error("failed to start transaction", "err", err) 299 + rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook") 300 + return 301 + } 302 + defer tx.Rollback() 303 + 304 + if err := db.UpdateWebhook(tx, webhook); err != nil { 305 + l.Error("failed to update webhook", "err", err) 306 + rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook") 307 + return 308 + } 309 + 310 + if err := tx.Commit(); err != nil { 311 + l.Error("failed to commit transaction", "err", err) 312 + rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook") 313 + return 314 + } 315 + 316 + rp.pages.HxRefresh(w) 317 + } 318 + 319 + // WebhookDeliveries returns all deliveries for a webhook (for modal display) 320 + func (rp *Repo) WebhookDeliveries(w http.ResponseWriter, r *http.Request) { 321 + l := rp.logger.With("handler", "WebhookDeliveries") 322 + 323 + f, err := rp.repoResolver.Resolve(r) 324 + if err != nil { 325 + l.Error("failed to get repo and knot", "err", err) 326 + w.WriteHeader(http.StatusBadRequest) 327 + return 328 + } 329 + 330 + idStr := chi.URLParam(r, "id") 331 + id, err := strconv.ParseInt(idStr, 10, 64) 332 + if err != nil { 333 + l.Error("invalid webhook id", "err", err) 334 + w.WriteHeader(http.StatusBadRequest) 335 + return 336 + } 337 + 338 + webhook, err := db.GetWebhook(rp.db, id) 339 + if err != nil { 340 + l.Error("failed to get webhook", "err", err) 341 + w.WriteHeader(http.StatusNotFound) 342 + return 343 + } 344 + 345 + // Verify webhook belongs to this repo 346 + if webhook.RepoAt != f.RepoAt() { 347 + l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoAt, "current_repo", f.RepoAt()) 348 + w.WriteHeader(http.StatusForbidden) 349 + return 350 + } 351 + 352 + deliveries, err := db.GetWebhookDeliveries(rp.db, webhook.Id, 100) 353 + if err != nil { 354 + l.Error("failed to get webhook deliveries", "err", err) 355 + rp.pages.Notice(w, "webhooks-error", "Failed to load deliveries") 356 + return 357 + } 358 + 359 + user := rp.oauth.GetMultiAccountUser(r) 360 + 361 + rp.pages.WebhookDeliveriesList(w, pages.WebhookDeliveriesListParams{ 362 + LoggedInUser: user, 363 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 364 + Webhook: webhook, 365 + Deliveries: deliveries, 366 + }) 367 + }
+204
appview/searchquery/searchquery.go
··· 1 + package searchquery 2 + 3 + import ( 4 + "strings" 5 + "unicode" 6 + ) 7 + 8 + type ItemKind int 9 + 10 + const ( 11 + KindKeyword ItemKind = iota 12 + KindQuoted 13 + KindTagValue 14 + ) 15 + 16 + type Item struct { 17 + Kind ItemKind 18 + Negated bool 19 + Raw string 20 + Key string 21 + Value string 22 + } 23 + 24 + type Query struct { 25 + items []Item 26 + } 27 + 28 + func Parse(input string) *Query { 29 + q := &Query{} 30 + runes := []rune(strings.TrimSpace(input)) 31 + if len(runes) == 0 { 32 + return q 33 + } 34 + 35 + i := 0 36 + for i < len(runes) { 37 + for i < len(runes) && unicode.IsSpace(runes[i]) { 38 + i++ 39 + } 40 + if i >= len(runes) { 41 + break 42 + } 43 + 44 + negated := false 45 + if runes[i] == '-' && i+1 < len(runes) && runes[i+1] == '"' { 46 + negated = true 47 + i++ // skip '-' 48 + } 49 + 50 + if runes[i] == '"' { 51 + start := i 52 + if negated { 53 + start-- // include the '-' in Raw 54 + } 55 + i++ // skip opening quote 56 + inner := i 57 + for i < len(runes) && runes[i] != '"' { 58 + if runes[i] == '\\' && i+1 < len(runes) { 59 + i++ 60 + } 61 + i++ 62 + } 63 + value := unescapeQuoted(runes[inner:i]) 64 + if i < len(runes) { 65 + i++ // skip closing quote 66 + } 67 + q.items = append(q.items, Item{ 68 + Kind: KindQuoted, 69 + Negated: negated, 70 + Raw: string(runes[start:i]), 71 + Value: value, 72 + }) 73 + continue 74 + } 75 + 76 + start := i 77 + for i < len(runes) && !unicode.IsSpace(runes[i]) && runes[i] != '"' { 78 + i++ 79 + } 80 + token := string(runes[start:i]) 81 + 82 + negated = false 83 + subject := token 84 + if len(subject) > 1 && subject[0] == '-' { 85 + negated = true 86 + subject = subject[1:] 87 + } 88 + 89 + colonIdx := strings.Index(subject, ":") 90 + if colonIdx > 0 { 91 + key := subject[:colonIdx] 92 + value := subject[colonIdx+1:] 93 + q.items = append(q.items, Item{ 94 + Kind: KindTagValue, 95 + Negated: negated, 96 + Raw: token, 97 + Key: key, 98 + Value: value, 99 + }) 100 + } else { 101 + q.items = append(q.items, Item{ 102 + Kind: KindKeyword, 103 + Negated: negated, 104 + Raw: token, 105 + Value: subject, 106 + }) 107 + } 108 + } 109 + 110 + return q 111 + } 112 + 113 + func unescapeQuoted(runes []rune) string { 114 + var b strings.Builder 115 + for i := 0; i < len(runes); i++ { 116 + if runes[i] == '\\' && i+1 < len(runes) { 117 + i++ 118 + } 119 + b.WriteRune(runes[i]) 120 + } 121 + return b.String() 122 + } 123 + 124 + func (q *Query) Items() []Item { 125 + return q.items 126 + } 127 + 128 + func (q *Query) Get(key string) *string { 129 + for i, item := range q.items { 130 + if item.Kind == KindTagValue && !item.Negated && item.Key == key { 131 + return &q.items[i].Value 132 + } 133 + } 134 + return nil 135 + } 136 + 137 + func (q *Query) GetAll(key string) []string { 138 + var result []string 139 + for _, item := range q.items { 140 + if item.Kind == KindTagValue && !item.Negated && item.Key == key { 141 + result = append(result, item.Value) 142 + } 143 + } 144 + return result 145 + } 146 + 147 + func (q *Query) GetAllNegated(key string) []string { 148 + var result []string 149 + for _, item := range q.items { 150 + if item.Kind == KindTagValue && item.Negated && item.Key == key { 151 + result = append(result, item.Value) 152 + } 153 + } 154 + return result 155 + } 156 + 157 + func (q *Query) Has(key string) bool { 158 + return q.Get(key) != nil 159 + } 160 + 161 + func (q *Query) Set(key, value string) { 162 + raw := key + ":" + value 163 + found := false 164 + newItems := make([]Item, 0, len(q.items)) 165 + 166 + for _, item := range q.items { 167 + if item.Kind == KindTagValue && !item.Negated && item.Key == key { 168 + if !found { 169 + newItems = append(newItems, Item{ 170 + Kind: KindTagValue, 171 + Raw: raw, 172 + Key: key, 173 + Value: value, 174 + }) 175 + found = true 176 + } 177 + } else { 178 + newItems = append(newItems, item) 179 + } 180 + } 181 + 182 + if !found { 183 + newItems = append(newItems, Item{ 184 + Kind: KindTagValue, 185 + Raw: raw, 186 + Key: key, 187 + Value: value, 188 + }) 189 + } 190 + 191 + q.items = newItems 192 + } 193 + 194 + func (q *Query) String() string { 195 + if len(q.items) == 0 { 196 + return "" 197 + } 198 + 199 + parts := make([]string, len(q.items)) 200 + for i, item := range q.items { 201 + parts[i] = item.Raw 202 + } 203 + return strings.Join(parts, " ") 204 + }
+252
appview/searchquery/searchquery_test.go
··· 1 + package searchquery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestParseMixed(t *testing.T) { 10 + q := Parse(`state:open bug "critical issue" label:good-first-issue fix`) 11 + items := q.Items() 12 + assert.Equal(t, 5, len(items)) 13 + 14 + assert.Equal(t, KindTagValue, items[0].Kind) 15 + assert.Equal(t, "state", items[0].Key) 16 + assert.Equal(t, "open", items[0].Value) 17 + 18 + assert.Equal(t, KindKeyword, items[1].Kind) 19 + assert.Equal(t, "bug", items[1].Raw) 20 + 21 + assert.Equal(t, KindQuoted, items[2].Kind) 22 + assert.Equal(t, `"critical issue"`, items[2].Raw) 23 + 24 + assert.Equal(t, KindTagValue, items[3].Kind) 25 + assert.Equal(t, "label", items[3].Key) 26 + assert.Equal(t, "good-first-issue", items[3].Value) 27 + 28 + assert.Equal(t, KindKeyword, items[4].Kind) 29 + 30 + assert.Equal(t, `state:open bug "critical issue" label:good-first-issue fix`, q.String()) 31 + } 32 + 33 + func TestGetSetLifecycle(t *testing.T) { 34 + q := Parse("label:bug state:open keyword label:feature label:urgent") 35 + 36 + // Get returns first match 37 + val := q.Get("state") 38 + assert.NotNil(t, val) 39 + assert.Equal(t, "open", *val) 40 + 41 + // Get returns nil for missing key 42 + assert.Nil(t, q.Get("author")) 43 + 44 + // Has 45 + assert.True(t, q.Has("state")) 46 + assert.False(t, q.Has("author")) 47 + 48 + // GetAll 49 + assert.Equal(t, []string{"bug", "feature", "urgent"}, q.GetAll("label")) 50 + assert.Equal(t, 0, len(q.GetAll("missing"))) 51 + 52 + // Set updates existing, preserving position 53 + q.Set("state", "closed") 54 + assert.Equal(t, "label:bug state:closed keyword label:feature label:urgent", q.String()) 55 + 56 + // Set deduplicates 57 + q.Set("label", "single") 58 + assert.Equal(t, "label:single state:closed keyword", q.String()) 59 + 60 + // Set appends new tag 61 + q.Set("author", "bob") 62 + assert.Equal(t, "label:single state:closed keyword author:bob", q.String()) 63 + } 64 + 65 + func TestParseEmpty(t *testing.T) { 66 + q := Parse(" ") 67 + assert.Equal(t, 0, len(q.Items())) 68 + assert.Equal(t, "", q.String()) 69 + } 70 + 71 + func TestParseUnclosedQuote(t *testing.T) { 72 + q := Parse(`"hello world`) 73 + items := q.Items() 74 + assert.Equal(t, 1, len(items)) 75 + assert.Equal(t, KindQuoted, items[0].Kind) 76 + assert.Equal(t, `"hello world`, items[0].Raw) 77 + assert.Equal(t, "hello world", items[0].Value) 78 + } 79 + 80 + func TestParseLeadingColon(t *testing.T) { 81 + q := Parse(":value") 82 + items := q.Items() 83 + assert.Equal(t, 1, len(items)) 84 + assert.Equal(t, KindKeyword, items[0].Kind) 85 + assert.Equal(t, ":value", items[0].Raw) 86 + } 87 + 88 + func TestParseColonInValue(t *testing.T) { 89 + q := Parse("key:value:with:colons") 90 + items := q.Items() 91 + assert.Equal(t, 1, len(items)) 92 + assert.Equal(t, "key", items[0].Key) 93 + assert.Equal(t, "value:with:colons", items[0].Value) 94 + } 95 + 96 + func TestParseEmptyValue(t *testing.T) { 97 + q := Parse("state:") 98 + items := q.Items() 99 + assert.Equal(t, 1, len(items)) 100 + assert.Equal(t, KindTagValue, items[0].Kind) 101 + assert.Equal(t, "state", items[0].Key) 102 + assert.Equal(t, "", items[0].Value) 103 + } 104 + 105 + func TestQuotedKeyValueIsNotTag(t *testing.T) { 106 + q := Parse(`"state:open"`) 107 + items := q.Items() 108 + assert.Equal(t, 1, len(items)) 109 + assert.Equal(t, KindQuoted, items[0].Kind) 110 + assert.Equal(t, "state:open", items[0].Value) 111 + assert.False(t, q.Has("state")) 112 + } 113 + 114 + func TestConsecutiveQuotes(t *testing.T) { 115 + q := Parse(`"one""two"`) 116 + items := q.Items() 117 + assert.Equal(t, 2, len(items)) 118 + assert.Equal(t, `"one"`, items[0].Raw) 119 + assert.Equal(t, `"two"`, items[1].Raw) 120 + } 121 + 122 + func TestEscapedQuotes(t *testing.T) { 123 + q := Parse(`"hello \"world\""`) 124 + items := q.Items() 125 + assert.Equal(t, 1, len(items)) 126 + assert.Equal(t, KindQuoted, items[0].Kind) 127 + assert.Equal(t, `"hello \"world\""`, items[0].Raw) 128 + assert.Equal(t, `hello "world"`, items[0].Value) 129 + } 130 + 131 + func TestEscapedBackslash(t *testing.T) { 132 + q := Parse(`"hello\\"`) 133 + items := q.Items() 134 + assert.Equal(t, 1, len(items)) 135 + assert.Equal(t, KindQuoted, items[0].Kind) 136 + assert.Equal(t, `hello\`, items[0].Value) 137 + } 138 + 139 + func TestNegatedTag(t *testing.T) { 140 + q := Parse("state:open -label:bug keyword -label:wontfix") 141 + items := q.Items() 142 + assert.Equal(t, 4, len(items)) 143 + 144 + assert.False(t, items[0].Negated) 145 + assert.Equal(t, "state", items[0].Key) 146 + 147 + assert.True(t, items[1].Negated) 148 + assert.Equal(t, KindTagValue, items[1].Kind) 149 + assert.Equal(t, "label", items[1].Key) 150 + assert.Equal(t, "bug", items[1].Value) 151 + assert.Equal(t, "-label:bug", items[1].Raw) 152 + 153 + assert.True(t, items[3].Negated) 154 + assert.Equal(t, "wontfix", items[3].Value) 155 + 156 + // Get/GetAll/Has skip negated tags 157 + assert.False(t, q.Has("label")) 158 + assert.Equal(t, 0, len(q.GetAll("label"))) 159 + 160 + // Set doesn't touch negated tags 161 + q.Set("label", "feature") 162 + assert.Equal(t, "state:open -label:bug keyword -label:wontfix label:feature", q.String()) 163 + } 164 + 165 + func TestNegatedBareWordIsKeyword(t *testing.T) { 166 + q := Parse("-keyword") 167 + items := q.Items() 168 + assert.Equal(t, 1, len(items)) 169 + assert.Equal(t, KindKeyword, items[0].Kind) 170 + assert.Equal(t, "-keyword", items[0].Raw) 171 + } 172 + 173 + func TestNegatedQuotedPhrase(t *testing.T) { 174 + q := Parse(`-"critical bug" state:open`) 175 + items := q.Items() 176 + assert.Equal(t, 2, len(items)) 177 + 178 + assert.Equal(t, KindQuoted, items[0].Kind) 179 + assert.True(t, items[0].Negated) 180 + assert.Equal(t, `-"critical bug"`, items[0].Raw) 181 + assert.Equal(t, "critical bug", items[0].Value) 182 + 183 + assert.Equal(t, KindTagValue, items[1].Kind) 184 + assert.Equal(t, "state", items[1].Key) 185 + } 186 + 187 + func TestNegatedQuotedPhraseAmongOthers(t *testing.T) { 188 + q := Parse(`"good phrase" -"bad phrase" keyword`) 189 + items := q.Items() 190 + assert.Equal(t, 3, len(items)) 191 + 192 + assert.Equal(t, KindQuoted, items[0].Kind) 193 + assert.False(t, items[0].Negated) 194 + assert.Equal(t, "good phrase", items[0].Value) 195 + 196 + assert.Equal(t, KindQuoted, items[1].Kind) 197 + assert.True(t, items[1].Negated) 198 + assert.Equal(t, "bad phrase", items[1].Value) 199 + 200 + assert.Equal(t, KindKeyword, items[2].Kind) 201 + } 202 + 203 + func TestWhitespaceNormalization(t *testing.T) { 204 + q := Parse(" state:open keyword ") 205 + assert.Equal(t, "state:open keyword", q.String()) 206 + } 207 + 208 + func TestParseConsecutiveColons(t *testing.T) { 209 + q := Parse("foo:::bar") 210 + items := q.Items() 211 + assert.Equal(t, 1, len(items)) 212 + assert.Equal(t, KindTagValue, items[0].Kind) 213 + assert.Equal(t, "foo", items[0].Key) 214 + assert.Equal(t, "::bar", items[0].Value) 215 + } 216 + 217 + func TestParseEmptyQuotes(t *testing.T) { 218 + q := Parse(`""`) 219 + items := q.Items() 220 + assert.Equal(t, 1, len(items)) 221 + assert.Equal(t, KindQuoted, items[0].Kind) 222 + assert.Equal(t, `""`, items[0].Raw) 223 + assert.Equal(t, "", items[0].Value) 224 + } 225 + 226 + func TestParseBareDash(t *testing.T) { 227 + q := Parse("-") 228 + items := q.Items() 229 + assert.Equal(t, 1, len(items)) 230 + assert.Equal(t, KindKeyword, items[0].Kind) 231 + assert.False(t, items[0].Negated) 232 + assert.Equal(t, "-", items[0].Raw) 233 + } 234 + 235 + func TestParseDashColon(t *testing.T) { 236 + q := Parse("-:value") 237 + items := q.Items() 238 + assert.Equal(t, 1, len(items)) 239 + assert.Equal(t, KindKeyword, items[0].Kind) 240 + assert.True(t, items[0].Negated) 241 + assert.Equal(t, ":value", items[0].Value) 242 + } 243 + 244 + func TestParseDoubleHyphen(t *testing.T) { 245 + q := Parse("--label:bug") 246 + items := q.Items() 247 + assert.Equal(t, 1, len(items)) 248 + assert.Equal(t, KindTagValue, items[0].Kind) 249 + assert.True(t, items[0].Negated) 250 + assert.Equal(t, "-label", items[0].Key) 251 + assert.Equal(t, "bug", items[0].Value) 252 + }
+46 -37
appview/settings/settings.go
··· 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 25 "github.com/bluesky-social/indigo/atproto/syntax" 26 26 lexutil "github.com/bluesky-social/indigo/lex/util" 27 - "github.com/gliderlabs/ssh" 28 27 "github.com/google/uuid" 29 28 ) 30 29 ··· 70 69 func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 71 70 user := s.OAuth.GetMultiAccountUser(r) 72 71 72 + punchcardPreferences, err := db.GetPunchcardPreference(s.Db, user.Did()) 73 + if err != nil { 74 + log.Printf("failed to get users punchcard preferences: %s", err) 75 + } 76 + 73 77 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 74 - LoggedInUser: user, 78 + LoggedInUser: user, 79 + PunchcardPreference: punchcardPreferences, 75 80 }) 76 81 } 77 82 ··· 418 423 log.Println("unimplemented") 419 424 return 420 425 case http.MethodPut: 421 - did := s.OAuth.GetDid(r) 422 - key := r.FormValue("key") 423 - key = strings.TrimSpace(key) 424 - name := r.FormValue("name") 425 - client, err := s.OAuth.AuthorizedClient(r) 426 - if err != nil { 427 - s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.") 428 - return 426 + created := time.Now() 427 + pubKey := models.PublicKey{ 428 + Did: s.OAuth.GetDid(r), 429 + Rkey: tid.TID(), 430 + Name: r.FormValue("name"), 431 + Key: strings.TrimSpace(r.FormValue("key")), 432 + Created: &created, 429 433 } 430 434 431 - _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 432 - if err != nil { 435 + if err := pubKey.Validate(); err != nil { 433 436 log.Printf("parsing public key: %s", err) 434 437 s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 435 438 return 436 439 } 437 440 438 - rkey := tid.TID() 439 - 440 441 tx, err := s.Db.Begin() 441 442 if err != nil { 442 443 log.Printf("failed to start tx; adding public key: %s", err) ··· 445 446 } 446 447 defer tx.Rollback() 447 448 448 - if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 449 + if err = db.UpsertPublicKey(s.Db, pubKey); err != nil { 449 450 log.Printf("adding public key: %s", err) 450 451 s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 452 + return 453 + } 454 + 455 + client, err := s.OAuth.AuthorizedClient(r) 456 + if err != nil { 457 + s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.") 451 458 return 452 459 } 453 460 454 461 // store in pds too 462 + record := pubKey.AsRecord() 455 463 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 456 464 Collection: tangled.PublicKeyNSID, 457 - Repo: did, 458 - Rkey: rkey, 465 + Repo: pubKey.Did, 466 + Rkey: pubKey.Rkey, 459 467 Record: &lexutil.LexiconTypeDecoder{ 460 - Val: &tangled.PublicKey{ 461 - CreatedAt: time.Now().Format(time.RFC3339), 462 - Key: key, 463 - Name: name, 464 - }}, 468 + Val: &record, 469 + }, 465 470 }) 466 471 // invalid record 467 472 if err != nil { ··· 488 493 489 494 name := q.Get("name") 490 495 rkey := q.Get("rkey") 491 - key := q.Get("key") 492 496 493 497 log.Println(name) 494 498 log.Println(rkey) 495 - log.Println(key) 496 499 497 - client, err := s.OAuth.AuthorizedClient(r) 498 - if err != nil { 499 - log.Printf("failed to authorize client: %s", err) 500 - s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 501 - return 502 - } 500 + if rkey == "" { 501 + if err := db.DeletePublicKeyLegacy(s.Db, did, name); err != nil { 502 + log.Printf("removing public key: %s", err) 503 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 504 + return 505 + } 506 + } else { 507 + if err := db.DeletePublicKeyByRkey(s.Db, did, rkey); err != nil { 508 + log.Printf("removing public key: %s", err) 509 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 510 + return 511 + } 503 512 504 - if err := db.DeletePublicKey(s.Db, did, name, key); err != nil { 505 - log.Printf("removing public key: %s", err) 506 - s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 507 - return 508 - } 513 + client, err := s.OAuth.AuthorizedClient(r) 514 + if err != nil { 515 + log.Printf("failed to authorize client: %s", err) 516 + s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 517 + return 518 + } 509 519 510 - if rkey != "" { 511 520 // remove from pds too 512 - _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 521 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 513 522 Collection: tangled.PublicKeyNSID, 514 523 Repo: did, 515 524 Rkey: rkey,
+59 -34
appview/state/follow.go
··· 6 6 "time" 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 lexutil "github.com/bluesky-social/indigo/lex/util" 10 11 "tangled.org/core/api/tangled" 11 12 "tangled.org/core/appview/db" ··· 42 43 43 44 switch r.Method { 44 45 case http.MethodPost: 45 - createdAt := time.Now().Format(time.RFC3339) 46 - rkey := tid.TID() 46 + follow := models.Follow{ 47 + UserDid: currentUser.Active.Did, 48 + SubjectDid: subjectIdent.DID.String(), 49 + Rkey: tid.TID(), 50 + FollowedAt: time.Now(), 51 + } 52 + 53 + tx, err := s.db.BeginTx(r.Context(), nil) 54 + if err != nil { 55 + s.logger.Error("failed to start transaction", "err", err) 56 + return 57 + } 58 + defer tx.Rollback() 59 + 60 + if err := db.UpsertFollow(tx, follow); err != nil { 61 + s.logger.Error("failed to follow", "err", err) 62 + return 63 + } 64 + 65 + record := follow.AsRecord() 47 66 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 67 Collection: tangled.GraphFollowNSID, 49 68 Repo: currentUser.Active.Did, 50 - Rkey: rkey, 69 + Rkey: follow.Rkey, 51 70 Record: &lexutil.LexiconTypeDecoder{ 52 - Val: &tangled.GraphFollow{ 53 - Subject: subjectIdent.DID.String(), 54 - CreatedAt: createdAt, 55 - }}, 71 + Val: &record, 72 + }, 56 73 }) 57 74 if err != nil { 58 75 log.Println("failed to create atproto record", err) 59 76 return 60 77 } 61 - 62 78 log.Println("created atproto record: ", resp.Uri) 63 79 64 - follow := &models.Follow{ 65 - UserDid: currentUser.Active.Did, 66 - SubjectDid: subjectIdent.DID.String(), 67 - Rkey: rkey, 80 + if err := tx.Commit(); err != nil { 81 + s.logger.Error("failed to commit transaction", "err", err) 82 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 68 83 } 69 84 70 - err = db.AddFollow(s.db, follow) 71 - if err != nil { 72 - log.Println("failed to follow", err) 73 - return 74 - } 75 - 76 - s.notifier.NewFollow(r.Context(), follow) 85 + s.notifier.NewFollow(r.Context(), &follow) 77 86 78 87 followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 79 88 if err != nil { ··· 88 97 89 98 return 90 99 case http.MethodDelete: 91 - // find the record in the db 92 - follow, err := db.GetFollow(s.db, currentUser.Active.Did, subjectIdent.DID.String()) 100 + tx, err := s.db.BeginTx(r.Context(), nil) 93 101 if err != nil { 94 - log.Println("failed to get follow relationship") 102 + s.logger.Error("failed to start transaction", "err", err) 103 + } 104 + defer tx.Rollback() 105 + 106 + follows, err := db.DeleteFollow(tx, syntax.DID(currentUser.Active.Did), subjectIdent.DID) 107 + if err != nil { 108 + s.logger.Error("failed to delete follows from db", "err", err) 95 109 return 96 110 } 97 111 98 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 99 - Collection: tangled.GraphFollowNSID, 100 - Repo: currentUser.Active.Did, 101 - Rkey: follow.Rkey, 112 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 113 + for _, followAt := range follows { 114 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 115 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 116 + Collection: tangled.GraphFollowNSID, 117 + Rkey: followAt.RecordKey().String(), 118 + }, 119 + }) 120 + } 121 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 122 + Repo: currentUser.Active.Did, 123 + Writes: writes, 102 124 }) 103 - 104 125 if err != nil { 105 - log.Println("failed to unfollow") 126 + s.logger.Error("failed to delete follows from PDS", "err", err) 106 127 return 107 128 } 108 129 109 - err = db.DeleteFollowByRkey(s.db, currentUser.Active.Did, follow.Rkey) 110 - if err != nil { 111 - log.Println("failed to delete follow from DB") 112 - // this is not an issue, the firehose event might have already done this 130 + if err := tx.Commit(); err != nil { 131 + s.logger.Error("failed to commit transaction", "err", err) 132 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 113 133 } 134 + 135 + s.notifier.DeleteFollow(r.Context(), &models.Follow{ 136 + UserDid: currentUser.Active.Did, 137 + SubjectDid: subjectIdent.DID.String(), 138 + // Rkey 139 + // FollowedAt 140 + }) 114 141 115 142 followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 116 143 if err != nil { ··· 122 149 FollowStatus: models.IsNotFollowing, 123 150 FollowersCount: followStats.Followers, 124 151 }) 125 - 126 - s.notifier.DeleteFollow(r.Context(), follow) 127 152 128 153 return 129 154 }
+42 -97
appview/state/knotstream.go
··· 8 8 "slices" 9 9 "time" 10 10 11 + "tangled.org/core/appview/notify" 12 + 11 13 "tangled.org/core/api/tangled" 12 14 "tangled.org/core/appview/cache" 13 15 "tangled.org/core/appview/config" ··· 18 20 "tangled.org/core/log" 19 21 "tangled.org/core/orm" 20 22 "tangled.org/core/rbac" 21 - "tangled.org/core/workflow" 22 23 23 - "github.com/bluesky-social/indigo/atproto/syntax" 24 24 "github.com/go-git/go-git/v5/plumbing" 25 25 "github.com/posthog/posthog-go" 26 26 ) 27 27 28 - func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 + func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, notifier notify.Notifier) (*ec.Consumer, error) { 29 29 logger := log.FromContext(ctx) 30 30 logger = log.SubLogger(logger, "knotstream") 31 31 ··· 48 48 49 49 cfg := ec.ConsumerConfig{ 50 50 Sources: srcs, 51 - ProcessFunc: knotIngester(d, enforcer, posthog, c.Core.Dev), 51 + ProcessFunc: knotIngester(d, enforcer, posthog, notifier, c.Core.Dev), 52 52 RetryInterval: c.Knotstream.RetryInterval, 53 53 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 54 54 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 62 62 return ec.NewConsumer(cfg), nil 63 63 } 64 64 65 - func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 65 + func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, notifier notify.Notifier, dev bool) ec.ProcessFunc { 66 66 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 67 67 switch msg.Nsid { 68 68 case tangled.GitRefUpdateNSID: 69 - return ingestRefUpdate(d, enforcer, posthog, dev, source, msg) 70 - case tangled.PipelineNSID: 71 - return ingestPipeline(d, source, msg) 69 + return ingestRefUpdate(d, enforcer, posthog, notifier, dev, source, msg, ctx) 72 70 } 73 71 74 72 return nil 75 73 } 76 74 } 77 75 78 - func ingestRefUpdate(d *db.DB, enforcer *rbac.Enforcer, pc posthog.Client, dev bool, source ec.Source, msg ec.Message) error { 76 + func ingestRefUpdate(d *db.DB, enforcer *rbac.Enforcer, pc posthog.Client, notifier notify.Notifier, dev bool, source ec.Source, msg ec.Message, ctx context.Context) error { 77 + logger := log.FromContext(ctx) 78 + 79 79 var record tangled.GitRefUpdate 80 80 err := json.Unmarshal(msg.EventJson, &record) 81 81 if err != nil { ··· 90 90 return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key()) 91 91 } 92 92 93 - err1 := populatePunchcard(d, record) 94 - err2 := updateRepoLanguages(d, record) 93 + logger.Info("processing gitRefUpdate event", 94 + "repo_did", record.RepoDid, 95 + "repo_name", record.RepoName, 96 + "ref", record.Ref, 97 + "old_sha", record.OldSha, 98 + "new_sha", record.NewSha) 95 99 96 - var err3 error 97 - if !dev { 98 - err3 = pc.Enqueue(posthog.Capture{ 100 + // trigger webhook notifications first (before other ops that might fail) 101 + var errWebhook error 102 + repos, err := db.GetRepos( 103 + d, 104 + 0, 105 + orm.FilterEq("did", record.RepoDid), 106 + orm.FilterEq("name", record.RepoName), 107 + ) 108 + if err != nil { 109 + errWebhook = fmt.Errorf("failed to lookup repo for webhooks: %w", err) 110 + } else if len(repos) == 1 { 111 + notifier.Push(ctx, &repos[0], record.Ref, record.OldSha, record.NewSha, record.CommitterDid) 112 + } else if len(repos) == 0 { 113 + errWebhook = fmt.Errorf("no repo found for webhooks: %s/%s", record.RepoDid, record.RepoName) 114 + } 115 + 116 + errPunchcard := populatePunchcard(d, record) 117 + errLanguages := updateRepoLanguages(d, record) 118 + 119 + var errPosthog error 120 + if !dev && record.CommitterDid != "" { 121 + errPosthog = pc.Enqueue(posthog.Capture{ 99 122 DistinctId: record.CommitterDid, 100 123 Event: "git_ref_update", 101 124 }) 102 125 } 103 126 104 - return errors.Join(err1, err2, err3) 127 + return errors.Join(errWebhook, errPunchcard, errLanguages, errPosthog) 105 128 } 106 129 107 130 func populatePunchcard(d *db.DB, record tangled.GitRefUpdate) error { 131 + if record.CommitterDid == "" { 132 + return nil 133 + } 134 + 108 135 knownEmails, err := db.GetAllEmails(d, record.CommitterDid) 109 136 if err != nil { 110 137 return err ··· 190 217 191 218 return tx.Commit() 192 219 } 193 - 194 - func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { 195 - var record tangled.Pipeline 196 - err := json.Unmarshal(msg.EventJson, &record) 197 - if err != nil { 198 - return err 199 - } 200 - 201 - if record.TriggerMetadata == nil { 202 - return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 203 - } 204 - 205 - if record.TriggerMetadata.Repo == nil { 206 - return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 207 - } 208 - 209 - // does this repo have a spindle configured? 210 - repos, err := db.GetRepos( 211 - d, 212 - 0, 213 - orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 - orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 215 - ) 216 - if err != nil { 217 - return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 218 - } 219 - if len(repos) != 1 { 220 - return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 221 - } 222 - if repos[0].Spindle == "" { 223 - return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 224 - } 225 - 226 - // trigger info 227 - var trigger models.Trigger 228 - var sha string 229 - trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 230 - switch trigger.Kind { 231 - case workflow.TriggerKindPush: 232 - trigger.PushRef = &record.TriggerMetadata.Push.Ref 233 - trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 234 - trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 235 - sha = *trigger.PushNewSha 236 - case workflow.TriggerKindPullRequest: 237 - trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 238 - trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 239 - trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 240 - trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 241 - sha = *trigger.PRSourceSha 242 - } 243 - 244 - tx, err := d.Begin() 245 - if err != nil { 246 - return fmt.Errorf("failed to start txn: %w", err) 247 - } 248 - 249 - triggerId, err := db.AddTrigger(tx, trigger) 250 - if err != nil { 251 - return fmt.Errorf("failed to add trigger entry: %w", err) 252 - } 253 - 254 - pipeline := models.Pipeline{ 255 - Rkey: msg.Rkey, 256 - Knot: source.Key(), 257 - RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 258 - RepoName: record.TriggerMetadata.Repo.Repo, 259 - TriggerId: int(triggerId), 260 - Sha: sha, 261 - } 262 - 263 - err = db.AddPipeline(tx, pipeline) 264 - if err != nil { 265 - return fmt.Errorf("failed to add pipeline: %w", err) 266 - } 267 - 268 - err = tx.Commit() 269 - if err != nil { 270 - return fmt.Errorf("failed to commit txn: %w", err) 271 - } 272 - 273 - return nil 274 - }
+8 -1
appview/state/login.go
··· 39 39 returnURL := r.FormValue("return_url") 40 40 addAccount := r.FormValue("add_account") == "true" 41 41 42 + // remove spaces around the handle, handles can't have spaces around them 43 + handle = strings.TrimSpace(handle) 44 + 42 45 // when users copy their handle from bsky.app, it tends to have these characters around it: 43 46 // 44 47 // @nelind.dk: ··· 68 71 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 69 72 if err != nil { 70 73 l.Error("failed to start auth", "err", err) 71 - http.Error(w, err.Error(), http.StatusInternalServerError) 74 + s.pages.Notice( 75 + w, 76 + "login-msg", 77 + fmt.Sprintf("Failed to start auth flow: %v", err), 78 + ) 72 79 return 73 80 } 74 81
+116 -28
appview/state/profile.go
··· 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/pages" 22 22 "tangled.org/core/orm" 23 + "tangled.org/core/xrpc" 23 24 ) 24 25 25 26 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 57 58 return nil, fmt.Errorf("failed to get profile: %w", err) 58 59 } 59 60 61 + hasProfile := profile != nil 62 + if !hasProfile { 63 + profile = &models.Profile{Did: did} 64 + } 65 + 60 66 repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did)) 61 67 if err != nil { 62 68 return nil, fmt.Errorf("failed to get repo count: %w", err) ··· 83 89 followStatus = db.GetFollowStatus(s.db, loggedInUser.Active.Did, did) 84 90 } 85 91 86 - now := time.Now() 87 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 88 - punchcard, err := db.MakePunchcard( 89 - s.db, 90 - orm.FilterEq("did", did), 91 - orm.FilterGte("date", startOfYear.Format(time.DateOnly)), 92 - orm.FilterLte("date", now.Format(time.DateOnly)), 93 - ) 94 - if err != nil { 95 - return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 92 + showPunchcard := s.shouldShowPunchcard(did, loggedInUser.Did()) 93 + 94 + var punchcard *models.Punchcard 95 + if showPunchcard { 96 + now := time.Now() 97 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 98 + punchcard, err = db.MakePunchcard( 99 + s.db, 100 + orm.FilterEq("did", did), 101 + orm.FilterGte("date", startOfYear.Format(time.DateOnly)), 102 + orm.FilterLte("date", now.Format(time.DateOnly)), 103 + ) 104 + if err != nil { 105 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 106 + } 96 107 } 97 108 98 109 return &pages.ProfileCard{ 99 110 UserDid: did, 111 + HasProfile: hasProfile, 100 112 Profile: profile, 101 113 FollowStatus: followStatus, 102 114 Stats: pages.ProfileStats{ ··· 169 181 CollaboratingRepos: pinnedCollaboratingRepos, 170 182 ProfileTimeline: timeline, 171 183 }) 184 + } 185 + 186 + func (s *State) shouldShowPunchcard(targetDid, requesterDid string) bool { 187 + l := s.logger.With("helper", "shouldShowPunchcard") 188 + 189 + targetPunchcardPreferences, err := db.GetPunchcardPreference(s.db, targetDid) 190 + if err != nil { 191 + l.Error("failed to get target users punchcard preferences", "err", err) 192 + return true 193 + } 194 + 195 + requesterPunchcardPreferences, err := db.GetPunchcardPreference(s.db, requesterDid) 196 + if err != nil { 197 + l.Error("failed to get requester users punchcard preferences", "err", err) 198 + return true 199 + } 200 + 201 + showPunchcard := true 202 + 203 + // looking at their own profile 204 + if targetDid == requesterDid { 205 + if targetPunchcardPreferences.HideMine { 206 + return false 207 + } 208 + return true 209 + } 210 + 211 + if targetPunchcardPreferences.HideMine || requesterPunchcardPreferences.HideOthers { 212 + showPunchcard = false 213 + } 214 + return showPunchcard 172 215 } 173 216 174 217 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { ··· 532 575 if err != nil { 533 576 log.Printf("getting profile data for %s: %s", user.Active.Did, err) 534 577 } 578 + if profile == nil { 579 + profile = &models.Profile{Did: user.Active.Did} 580 + } 535 581 536 582 profile.Description = r.FormValue("description") 537 583 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" ··· 549 595 stat0 := r.FormValue("stat0") 550 596 stat1 := r.FormValue("stat1") 551 597 552 - if stat0 != "" { 553 - profile.Stats[0].Kind = models.VanityStatKind(stat0) 554 - } 555 - 556 - if stat1 != "" { 557 - profile.Stats[1].Kind = models.VanityStatKind(stat1) 558 - } 598 + profile.Stats[0].Kind = models.ParseVanityStatKind(stat0) 599 + profile.Stats[1].Kind = models.ParseVanityStatKind(stat1) 559 600 560 601 if err := db.ValidateProfile(s.db, profile); err != nil { 561 602 log.Println("invalid profile", err) ··· 579 620 profile, err := db.GetProfile(s.db, user.Active.Did) 580 621 if err != nil { 581 622 log.Printf("getting profile data for %s: %s", user.Active.Did, err) 623 + } 624 + if profile == nil { 625 + profile = &models.Profile{Did: user.Active.Did} 582 626 } 583 627 584 628 i := 0 ··· 613 657 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 614 658 return 615 659 } 660 + defer tx.Rollback() 661 + 662 + err = db.UpsertProfile(tx, profile) 663 + if err != nil { 664 + log.Println("failed to update profile", err) 665 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 666 + return 667 + } 616 668 617 669 client, err := s.oauth.AuthorizedClient(r) 618 670 if err != nil { ··· 661 713 return 662 714 } 663 715 664 - err = db.UpsertProfile(tx, profile) 665 - if err != nil { 666 - log.Println("failed to update profile", err) 667 - s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 668 - return 716 + if err := tx.Commit(); err != nil { 717 + s.logger.Error("failed to commit transaction", "err", err) 718 + // db failed, but PDS operation succeed. 719 + // log error and continue 669 720 } 670 721 671 722 s.notifier.UpdateProfile(r.Context(), profile) ··· 680 731 if err != nil { 681 732 log.Printf("getting profile data for %s: %s", user.Active.Did, err) 682 733 } 734 + if profile == nil { 735 + profile = &models.Profile{Did: user.Active.Did} 736 + } 683 737 684 738 s.pages.EditBioFragment(w, pages.EditBioParams{ 685 739 LoggedInUser: user, ··· 693 747 profile, err := db.GetProfile(s.db, user.Active.Did) 694 748 if err != nil { 695 749 log.Printf("getting profile data for %s: %s", user.Active.Did, err) 750 + } 751 + if profile == nil { 752 + profile = &models.Profile{Did: user.Active.Did} 696 753 } 697 754 698 755 repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Active.Did)) ··· 741 798 return 742 799 } 743 800 744 - file, handler, err := r.FormFile("avatar") 801 + file, header, err := r.FormFile("avatar") 745 802 if err != nil { 746 803 l.Error("failed to read avatar file", "err", err) 747 804 s.pages.Notice(w, "avatar-error", "Failed to read avatar file") ··· 749 806 } 750 807 defer file.Close() 751 808 752 - if handler.Size > 1000000 { 753 - l.Warn("avatar file too large", "size", handler.Size) 754 - s.pages.Notice(w, "avatar-error", "Avatar file too large (max 1MB)") 809 + if header.Size > 5000000 { 810 + l.Warn("avatar file too large", "size", header.Size) 811 + s.pages.Notice(w, "avatar-error", "Avatar file too large (max 5MB)") 755 812 return 756 813 } 757 814 758 - contentType := handler.Header.Get("Content-Type") 815 + contentType := header.Header.Get("Content-Type") 759 816 if contentType != "image/png" && contentType != "image/jpeg" { 760 817 l.Warn("invalid image type", "contentType", contentType) 761 818 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)") ··· 769 826 return 770 827 } 771 828 772 - uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 829 + uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type")) 773 830 if err != nil { 774 831 l.Error("failed to upload avatar blob", "err", err) 775 832 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS") ··· 820 877 profile, err := db.GetProfile(s.db, user.Did) 821 878 if err != nil { 822 879 l.Warn("getting profile data from DB", "err", err) 880 + } 881 + if profile == nil { 823 882 profile = &models.Profile{Did: user.Did} 824 883 } 825 884 profile.Avatar = uploadBlobResp.Blob.Ref.String() ··· 896 955 profile, err := db.GetProfile(s.db, user.Did) 897 956 if err != nil { 898 957 l.Warn("getting profile data from DB", "err", err) 958 + } 959 + if profile == nil { 899 960 profile = &models.Profile{Did: user.Did} 900 961 } 901 962 profile.Avatar = "" ··· 918 979 919 980 s.pages.HxRedirect(w, r.Header.Get("Referer")) 920 981 } 982 + 983 + func (s *State) UpdateProfilePunchcardSetting(w http.ResponseWriter, r *http.Request) { 984 + err := r.ParseForm() 985 + if err != nil { 986 + log.Println("invalid profile update form", err) 987 + return 988 + } 989 + user := s.oauth.GetUser(r) 990 + 991 + hideOthers := false 992 + hideMine := false 993 + 994 + if r.Form.Get("hideMine") == "on" { 995 + hideMine = true 996 + } 997 + if r.Form.Get("hideOthers") == "on" { 998 + hideOthers = true 999 + } 1000 + 1001 + err = db.UpsertPunchcardPreference(s.db, user.Did, hideMine, hideOthers) 1002 + if err != nil { 1003 + log.Println("failed to update punchcard preferences", err) 1004 + return 1005 + } 1006 + 1007 + s.pages.HxRefresh(w) 1008 + }
+51 -26
appview/state/reaction.go
··· 45 45 46 46 switch r.Method { 47 47 case http.MethodPost: 48 - createdAt := time.Now().Format(time.RFC3339) 49 - rkey := tid.TID() 48 + reaction := models.Reaction{ 49 + ReactedByDid: currentUser.Active.Did, 50 + Rkey: tid.TID(), 51 + Kind: reactionKind, 52 + ThreadAt: subjectUri, 53 + Created: time.Now(), 54 + } 55 + 56 + tx, err := s.db.BeginTx(r.Context(), nil) 57 + if err != nil { 58 + s.logger.Error("failed to start transaction", "err", err) 59 + return 60 + } 61 + defer tx.Rollback() 62 + 63 + if err := db.UpsertReaction(tx, reaction); err != nil { 64 + log.Println("failed to react", err) 65 + return 66 + } 67 + 68 + record := reaction.AsRecord() 50 69 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 70 Collection: tangled.FeedReactionNSID, 52 71 Repo: currentUser.Active.Did, 53 - Rkey: rkey, 72 + Rkey: reaction.Rkey, 54 73 Record: &lexutil.LexiconTypeDecoder{ 55 - Val: &tangled.FeedReaction{ 56 - Subject: subjectUri.String(), 57 - Reaction: reactionKind.String(), 58 - CreatedAt: createdAt, 59 - }, 74 + Val: &record, 60 75 }, 61 76 }) 62 77 if err != nil { 63 78 log.Println("failed to create atproto record", err) 64 79 return 65 80 } 81 + log.Println("created atproto record: ", resp.Uri) 66 82 67 - err = db.AddReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind, rkey) 68 - if err != nil { 69 - log.Println("failed to react", err) 70 - return 83 + if err := tx.Commit(); err != nil { 84 + s.logger.Error("failed to commit transaction", "err", err) 85 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 71 86 } 72 87 73 88 reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) ··· 75 90 log.Println("failed to get reactions for ", subjectUri) 76 91 } 77 92 78 - log.Println("created atproto record: ", resp.Uri) 79 - 80 93 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 94 ThreadAt: subjectUri, 82 95 Kind: reactionKind, ··· 87 100 88 101 return 89 102 case http.MethodDelete: 90 - reaction, err := db.GetReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind) 103 + tx, err := s.db.BeginTx(r.Context(), nil) 104 + if err != nil { 105 + s.logger.Error("failed to start transaction", "err", err) 106 + } 107 + defer tx.Rollback() 108 + 109 + reactions, err := db.DeleteReaction(tx, syntax.DID(currentUser.Active.Did), subjectUri, reactionKind) 91 110 if err != nil { 92 - log.Println("failed to get reaction relationship for", currentUser.Active.Did, subjectUri) 111 + s.logger.Error("failed to delete reactions from db", "err", err) 93 112 return 94 113 } 95 114 96 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 - Collection: tangled.FeedReactionNSID, 98 - Repo: currentUser.Active.Did, 99 - Rkey: reaction.Rkey, 115 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 116 + for _, reactionAt := range reactions { 117 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 118 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 119 + Collection: tangled.FeedReactionNSID, 120 + Rkey: reactionAt.RecordKey().String(), 121 + }, 122 + }) 123 + } 124 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 125 + Repo: currentUser.Active.Did, 126 + Writes: writes, 100 127 }) 101 - 102 128 if err != nil { 103 - log.Println("failed to remove reaction") 129 + s.logger.Error("failed to delete reactions from PDS", "err", err) 104 130 return 105 131 } 106 132 107 - err = db.DeleteReactionByRkey(s.db, currentUser.Active.Did, reaction.Rkey) 108 - if err != nil { 109 - log.Println("failed to delete reaction from DB") 110 - // this is not an issue, the firehose event might have already done this 133 + if err := tx.Commit(); err != nil { 134 + s.logger.Error("failed to commit transaction", "err", err) 135 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 111 136 } 112 137 113 138 reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
+2 -4
appview/state/router.go
··· 167 167 r.Post("/pins", s.UpdateProfilePins) 168 168 r.Post("/avatar", s.UploadProfileAvatar) 169 169 r.Delete("/avatar", s.RemoveProfileAvatar) 170 + r.Post("/punchcard", s.UpdateProfilePunchcardSetting) 170 171 }) 171 172 172 173 r.Mount("/settings", s.SettingsRouter()) ··· 276 277 s.db, 277 278 s.config, 278 279 s.notifier, 279 - s.validator, 280 280 s.indexer.Issues, 281 281 log.SubLogger(s.logger, "issues"), 282 282 ) ··· 294 294 s.config, 295 295 s.notifier, 296 296 s.enforcer, 297 - s.validator, 298 297 s.indexer.Pulls, 299 298 log.SubLogger(s.logger, "pulls"), 300 299 ) ··· 313 312 s.notifier, 314 313 s.enforcer, 315 314 log.SubLogger(s.logger, "repo"), 316 - s.validator, 317 315 ) 318 316 return repo.Router(mw) 319 317 } ··· 338 336 s.oauth, 339 337 s.pages, 340 338 s.db, 341 - s.validator, 342 339 s.enforcer, 340 + s.notifier, 343 341 log.SubLogger(s.logger, "labels"), 344 342 ) 345 343 return ls.Router()
+89
appview/state/spindlestream.go
··· 20 20 "tangled.org/core/orm" 21 21 "tangled.org/core/rbac" 22 22 spindle "tangled.org/core/spindle/models" 23 + "tangled.org/core/workflow" 23 24 ) 24 25 25 26 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 62 63 func spindleIngester(ctx context.Context, logger *slog.Logger, d *db.DB) ec.ProcessFunc { 63 64 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 64 65 switch msg.Nsid { 66 + case tangled.PipelineNSID: 67 + return ingestPipeline(logger, d, source, msg) 65 68 case tangled.PipelineStatusNSID: 66 69 return ingestPipelineStatus(ctx, logger, d, source, msg) 67 70 } 68 71 69 72 return nil 70 73 } 74 + } 75 + 76 + func ingestPipeline(l *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error { 77 + var record tangled.Pipeline 78 + err := json.Unmarshal(msg.EventJson, &record) 79 + if err != nil { 80 + return err 81 + } 82 + 83 + if record.TriggerMetadata == nil { 84 + return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 85 + } 86 + 87 + if record.TriggerMetadata.Repo == nil { 88 + return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 89 + } 90 + 91 + // does this repo have a spindle configured? 92 + repos, err := db.GetRepos( 93 + d, 94 + 0, 95 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 96 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 97 + ) 98 + if err != nil { 99 + return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 100 + } 101 + if len(repos) != 1 { 102 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 103 + } 104 + if repos[0].Spindle == "" { 105 + return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 106 + } 107 + 108 + // trigger info 109 + var trigger models.Trigger 110 + var sha string 111 + trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 112 + switch trigger.Kind { 113 + case workflow.TriggerKindPush: 114 + trigger.PushRef = &record.TriggerMetadata.Push.Ref 115 + trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 116 + trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 117 + sha = *trigger.PushNewSha 118 + case workflow.TriggerKindPullRequest: 119 + trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 120 + trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 121 + trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 122 + trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 123 + sha = *trigger.PRSourceSha 124 + } 125 + 126 + tx, err := d.Begin() 127 + if err != nil { 128 + return fmt.Errorf("failed to start txn: %w", err) 129 + } 130 + 131 + triggerId, err := db.AddTrigger(tx, trigger) 132 + if err != nil { 133 + return fmt.Errorf("failed to add trigger entry: %w", err) 134 + } 135 + 136 + // TODO: we shouldn't even use knot to identify pipelines 137 + knot := record.TriggerMetadata.Repo.Knot 138 + pipeline := models.Pipeline{ 139 + Rkey: msg.Rkey, 140 + Knot: knot, 141 + RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 142 + RepoName: record.TriggerMetadata.Repo.Repo, 143 + TriggerId: int(triggerId), 144 + Sha: sha, 145 + } 146 + 147 + err = db.AddPipeline(tx, pipeline) 148 + if err != nil { 149 + return fmt.Errorf("failed to add pipeline: %w", err) 150 + } 151 + 152 + err = tx.Commit() 153 + if err != nil { 154 + return fmt.Errorf("failed to commit txn: %w", err) 155 + } 156 + 157 + l.Info("added pipeline", "pipeline", pipeline) 158 + 159 + return nil 71 160 } 72 161 73 162 func ingestPipelineStatus(ctx context.Context, logger *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
+58 -33
appview/state/star.go
··· 38 38 39 39 switch r.Method { 40 40 case http.MethodPost: 41 - createdAt := time.Now().Format(time.RFC3339) 42 - rkey := tid.TID() 41 + star := models.Star{ 42 + Did: currentUser.Active.Did, 43 + Rkey: tid.TID(), 44 + RepoAt: subjectUri, 45 + Created: time.Now(), 46 + } 47 + 48 + tx, err := s.db.BeginTx(r.Context(), nil) 49 + if err != nil { 50 + s.logger.Error("failed to start transaction", "err", err) 51 + return 52 + } 53 + defer tx.Rollback() 54 + 55 + if err := db.UpsertStar(tx, star); err != nil { 56 + s.logger.Error("failed to star", "err", err) 57 + return 58 + } 59 + 60 + record := star.AsRecord() 43 61 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 62 Collection: tangled.FeedStarNSID, 45 63 Repo: currentUser.Active.Did, 46 - Rkey: rkey, 64 + Rkey: star.Rkey, 47 65 Record: &lexutil.LexiconTypeDecoder{ 48 - Val: &tangled.FeedStar{ 49 - Subject: subjectUri.String(), 50 - CreatedAt: createdAt, 51 - }}, 66 + Val: &record, 67 + }, 52 68 }) 53 69 if err != nil { 54 70 log.Println("failed to create atproto record", err) ··· 56 72 } 57 73 log.Println("created atproto record: ", resp.Uri) 58 74 59 - star := &models.Star{ 60 - Did: currentUser.Active.Did, 61 - RepoAt: subjectUri, 62 - Rkey: rkey, 75 + if err := tx.Commit(); err != nil { 76 + s.logger.Error("failed to commit transaction", "err", err) 77 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 63 78 } 64 79 65 - err = db.AddStar(s.db, star) 66 - if err != nil { 67 - log.Println("failed to star", err) 68 - return 69 - } 80 + s.notifier.NewStar(r.Context(), &star) 70 81 71 82 starCount, err := db.GetStarCount(s.db, subjectUri) 72 83 if err != nil { 73 84 log.Println("failed to get star count for ", subjectUri) 74 85 } 75 - 76 - s.notifier.NewStar(r.Context(), star) 77 86 78 87 s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 79 88 IsStarred: true, ··· 83 92 84 93 return 85 94 case http.MethodDelete: 86 - // find the record in the db 87 - star, err := db.GetStar(s.db, currentUser.Active.Did, subjectUri) 95 + tx, err := s.db.BeginTx(r.Context(), nil) 88 96 if err != nil { 89 - log.Println("failed to get star relationship") 97 + s.logger.Error("failed to start transaction", "err", err) 98 + } 99 + defer tx.Rollback() 100 + 101 + stars, err := db.DeleteStar(tx, syntax.DID(currentUser.Active.Did), subjectUri) 102 + if err != nil { 103 + s.logger.Error("failed to delete stars from db", "err", err) 90 104 return 91 105 } 92 106 93 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 94 - Collection: tangled.FeedStarNSID, 95 - Repo: currentUser.Active.Did, 96 - Rkey: star.Rkey, 107 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 108 + for _, starAt := range stars { 109 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 110 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 111 + Collection: tangled.FeedStarNSID, 112 + Rkey: starAt.RecordKey().String(), 113 + }, 114 + }) 115 + } 116 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 117 + Repo: currentUser.Active.Did, 118 + Writes: writes, 97 119 }) 98 - 99 120 if err != nil { 100 - log.Println("failed to unstar") 121 + s.logger.Error("failed to delete stars from PDS", "err", err) 101 122 return 102 123 } 103 124 104 - err = db.DeleteStarByRkey(s.db, currentUser.Active.Did, star.Rkey) 105 - if err != nil { 106 - log.Println("failed to delete star from DB") 107 - // this is not an issue, the firehose event might have already done this 125 + if err := tx.Commit(); err != nil { 126 + s.logger.Error("failed to commit transaction", "err", err) 127 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 108 128 } 109 129 130 + s.notifier.DeleteStar(r.Context(), &models.Star{ 131 + Did: currentUser.Active.Did, 132 + RepoAt: subjectUri, 133 + // Rkey 134 + // Created 135 + }) 136 + 110 137 starCount, err := db.GetStarCount(s.db, subjectUri) 111 138 if err != nil { 112 139 log.Println("failed to get star count for ", subjectUri) 113 140 return 114 141 } 115 - 116 - s.notifier.DeleteStar(r.Context(), star) 117 142 118 143 s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 119 144 IsStarred: false,
+33 -22
appview/state/state.go
··· 23 23 "tangled.org/core/appview/oauth" 24 24 "tangled.org/core/appview/pages" 25 25 "tangled.org/core/appview/reporesolver" 26 - "tangled.org/core/appview/validator" 27 26 xrpcclient "tangled.org/core/appview/xrpcclient" 28 27 "tangled.org/core/eventconsumer" 29 28 "tangled.org/core/idresolver" ··· 35 34 "tangled.org/core/tid" 36 35 37 36 comatproto "github.com/bluesky-social/indigo/api/atproto" 38 - atpclient "github.com/bluesky-social/indigo/atproto/client" 37 + "github.com/bluesky-social/indigo/atproto/atclient" 39 38 "github.com/bluesky-social/indigo/atproto/syntax" 40 39 lexutil "github.com/bluesky-social/indigo/lex/util" 41 40 securejoin "github.com/cyphar/filepath-securejoin" ··· 59 58 knotstream *eventconsumer.Consumer 60 59 spindlestream *eventconsumer.Consumer 61 60 logger *slog.Logger 62 - validator *validator.Validator 63 61 } 64 62 65 63 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 97 95 if err != nil { 98 96 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 99 97 } 100 - validator := validator.New(d, res, enforcer) 101 98 102 99 repoResolver := reporesolver.New(config, enforcer, d) 103 100 ··· 126 123 wrapper, 127 124 false, 128 125 129 - // in-memory filter is inapplicalble to appview so 126 + // in-memory filter is inapplicable to appview so 130 127 // we'll never log dids anyway. 131 128 false, 132 129 ) ··· 144 141 IdResolver: res, 145 142 Config: config, 146 143 Logger: log.SubLogger(logger, "ingester"), 147 - Validator: validator, 148 144 } 149 145 err = jc.StartJetstream(ctx, ingester.Ingest()) 150 146 if err != nil { 151 147 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 152 148 } 153 149 154 - knotstream, err := Knotstream(ctx, config, d, enforcer, posthog) 150 + var notifiers []notify.Notifier 151 + 152 + // Always add the database notifier 153 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 154 + 155 + // Add other notifiers in production only 156 + if !config.Core.Dev { 157 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 158 + } 159 + notifiers = append(notifiers, indexer) 160 + 161 + // Add webhook notifier 162 + notifiers = append(notifiers, notify.NewWebhookNotifier(d)) 163 + 164 + notifier := notify.NewMergedNotifier(notifiers) 165 + notifier = notify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) 166 + 167 + knotstream, err := Knotstream(ctx, config, d, enforcer, posthog, notifier) 155 168 if err != nil { 156 169 return nil, fmt.Errorf("failed to start knotstream consumer: %w", err) 157 170 } ··· 163 176 } 164 177 spindlestream.Start(ctx) 165 178 166 - var notifiers []notify.Notifier 167 - 168 - // Always add the database notifier 169 - notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 170 - 171 - // Add other notifiers in production only 172 - if !config.Core.Dev { 173 - notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 174 - } 175 - notifiers = append(notifiers, indexer) 176 - notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 177 - 178 179 state := &State{ 179 180 d, 180 181 notifier, ··· 191 192 knotstream, 192 193 spindlestream, 193 194 logger, 194 - validator, 195 195 } 196 196 197 197 return state, nil ··· 206 206 w.Header().Set("Content-Type", "text/plain") 207 207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day 208 208 209 - robotsTxt := `User-agent: * 209 + robotsTxt := `# Hello, Tanglers! 210 + User-agent: * 210 211 Allow: / 212 + Disallow: /*/*/settings 213 + Disallow: /settings 214 + Disallow: /*/*/compare 215 + Disallow: /*/*/fork 216 + 217 + Crawl-delay: 1 211 218 ` 212 219 w.Write([]byte(robotsTxt)) 213 220 } ··· 457 464 l = l.With("defaultBranch", defaultBranch) 458 465 459 466 description := r.FormValue("description") 467 + if len([]rune(description)) > 140 { 468 + s.pages.Notice(w, "repo", "Description must be 140 characters or fewer.") 469 + return 470 + } 460 471 461 472 // ACL validation 462 473 ok, err := s.enforcer.E.Enforce(user.Active.Did, domain, domain, "repo:create") ··· 610 621 // this is used to rollback changes made to the PDS 611 622 // 612 623 // it is a no-op if the provided ATURI is empty 613 - func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 624 + func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 614 625 if aturi == "" { 615 626 return nil 616 627 }
-55
appview/validator/issue.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/orm" 10 - ) 11 - 12 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 - // if comments have parents, only ingest ones that are 1 level deep 14 - if comment.ReplyTo != nil { 15 - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 - if err != nil { 17 - return fmt.Errorf("failed to fetch parent comment: %w", err) 18 - } 19 - if len(parents) != 1 { 20 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 - } 22 - 23 - // depth check 24 - parent := parents[0] 25 - if parent.ReplyTo != nil { 26 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 - } 28 - } 29 - 30 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 - return fmt.Errorf("body is empty after HTML sanitization") 32 - } 33 - 34 - return nil 35 - } 36 - 37 - func (v *Validator) ValidateIssue(issue *models.Issue) error { 38 - if issue.Title == "" { 39 - return fmt.Errorf("issue title is empty") 40 - } 41 - 42 - if issue.Body == "" { 43 - return fmt.Errorf("issue body is empty") 44 - } 45 - 46 - if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 47 - return fmt.Errorf("title is empty after HTML sanitization") 48 - } 49 - 50 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 51 - return fmt.Errorf("body is empty after HTML sanitization") 52 - } 53 - 54 - return nil 55 - }
-217
appview/validator/label.go
··· 1 - package validator 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "regexp" 7 - "strings" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "golang.org/x/exp/slices" 11 - "tangled.org/core/api/tangled" 12 - "tangled.org/core/appview/models" 13 - ) 14 - 15 - var ( 16 - // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 17 - labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 18 - // Color should be a valid hex color 19 - colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 20 - // You can only label issues and pulls presently 21 - validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 - ) 23 - 24 - func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 - if label.Name == "" { 26 - return fmt.Errorf("label name is empty") 27 - } 28 - if len(label.Name) > 40 { 29 - return fmt.Errorf("label name too long (max 40 graphemes)") 30 - } 31 - if len(label.Name) < 1 { 32 - return fmt.Errorf("label name too short (min 1 grapheme)") 33 - } 34 - if !labelNameRegex.MatchString(label.Name) { 35 - return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 36 - } 37 - 38 - if !label.ValueType.IsConcreteType() { 39 - return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) 40 - } 41 - 42 - // null type checks: cannot be enums, multiple or explicit format 43 - if label.ValueType.IsNull() && label.ValueType.IsEnum() { 44 - return fmt.Errorf("null type cannot be used in conjunction with enum type") 45 - } 46 - if label.ValueType.IsNull() && label.Multiple { 47 - return fmt.Errorf("null type labels cannot be multiple") 48 - } 49 - if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { 50 - return fmt.Errorf("format cannot be used in conjunction with null type") 51 - } 52 - 53 - // format checks: cannot be used with enum, or integers 54 - if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { 55 - return fmt.Errorf("enum types cannot be used in conjunction with format specification") 56 - } 57 - 58 - if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { 59 - return fmt.Errorf("format specifications are only permitted on string types") 60 - } 61 - 62 - // validate scope (nsid format) 63 - if label.Scope == nil { 64 - return fmt.Errorf("scope is required") 65 - } 66 - for _, s := range label.Scope { 67 - if _, err := syntax.ParseNSID(s); err != nil { 68 - return fmt.Errorf("failed to parse scope: %w", err) 69 - } 70 - if !slices.Contains(validScopes, s) { 71 - return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 72 - } 73 - } 74 - 75 - // validate color if provided 76 - if label.Color != nil { 77 - color := strings.TrimSpace(*label.Color) 78 - if color == "" { 79 - // empty color is fine, set to nil 80 - label.Color = nil 81 - } else { 82 - if !colorRegex.MatchString(color) { 83 - return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 84 - } 85 - // expand 3-digit hex to 6-digit hex 86 - if len(color) == 4 { // #ABC 87 - color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 88 - } 89 - // convert to uppercase for consistency 90 - color = strings.ToUpper(color) 91 - label.Color = &color 92 - } 93 - } 94 - 95 - return nil 96 - } 97 - 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 - if labelDef == nil { 100 - return fmt.Errorf("label definition is required") 101 - } 102 - if repo == nil { 103 - return fmt.Errorf("repo is required") 104 - } 105 - if labelOp == nil { 106 - return fmt.Errorf("label operation is required") 107 - } 108 - 109 - // validate permissions: only collaborators can apply labels currently 110 - // 111 - // TODO: introduce a repo:triage permission 112 - ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 - if err != nil { 114 - return fmt.Errorf("failed to enforce permissions: %w", err) 115 - } 116 - if !ok { 117 - return fmt.Errorf("unauhtorized label operation") 118 - } 119 - 120 - expectedKey := labelDef.AtUri().String() 121 - if labelOp.OperandKey != expectedKey { 122 - return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 123 - } 124 - 125 - if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 126 - return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 127 - } 128 - 129 - if labelOp.Subject == "" { 130 - return fmt.Errorf("subject URI is required") 131 - } 132 - if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { 133 - return fmt.Errorf("invalid subject URI: %w", err) 134 - } 135 - 136 - if err := v.validateOperandValue(labelDef, labelOp); err != nil { 137 - return fmt.Errorf("invalid operand value: %w", err) 138 - } 139 - 140 - // Validate performed time is not zero/invalid 141 - if labelOp.PerformedAt.IsZero() { 142 - return fmt.Errorf("performed_at timestamp is required") 143 - } 144 - 145 - return nil 146 - } 147 - 148 - func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 149 - valueType := labelDef.ValueType 150 - 151 - // this is permitted, it "unsets" a label 152 - if labelOp.OperandValue == "" { 153 - labelOp.Operation = models.LabelOperationDel 154 - return nil 155 - } 156 - 157 - switch valueType.Type { 158 - case models.ConcreteTypeNull: 159 - // For null type, value should be empty 160 - if labelOp.OperandValue != "null" { 161 - return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 162 - } 163 - 164 - case models.ConcreteTypeString: 165 - // For string type, validate enum constraints if present 166 - if valueType.IsEnum() { 167 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 168 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 169 - } 170 - } 171 - 172 - switch valueType.Format { 173 - case models.ValueTypeFormatDid: 174 - id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 175 - if err != nil { 176 - return fmt.Errorf("failed to resolve did/handle: %w", err) 177 - } 178 - 179 - labelOp.OperandValue = id.DID.String() 180 - 181 - case models.ValueTypeFormatAny, "": 182 - default: 183 - return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 184 - } 185 - 186 - case models.ConcreteTypeInt: 187 - if labelOp.OperandValue == "" { 188 - return fmt.Errorf("integer type requires non-empty value") 189 - } 190 - if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { 191 - return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) 192 - } 193 - 194 - if valueType.IsEnum() { 195 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 196 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 197 - } 198 - } 199 - 200 - case models.ConcreteTypeBool: 201 - if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 202 - return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 203 - } 204 - 205 - // validate enum constraints if present (though uncommon for booleans) 206 - if valueType.IsEnum() { 207 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 208 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 209 - } 210 - } 211 - 212 - default: 213 - return fmt.Errorf("unsupported value type: %q", valueType.Type) 214 - } 215 - 216 - return nil 217 - }
-25
appview/validator/patch.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/patchutil" 8 - ) 9 - 10 - func (v *Validator) ValidatePatch(patch *string) error { 11 - if patch == nil || *patch == "" { 12 - return fmt.Errorf("patch is empty") 13 - } 14 - 15 - // add newline if not present to diff style patches 16 - if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 17 - *patch = *patch + "\n" 18 - } 19 - 20 - if err := patchutil.IsPatchValid(*patch); err != nil { 21 - return err 22 - } 23 - 24 - return nil 25 - }
-53
appview/validator/repo_topics.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "maps" 6 - "regexp" 7 - "slices" 8 - "strings" 9 - ) 10 - 11 - const ( 12 - maxTopicLen = 50 13 - maxTopics = 20 14 - ) 15 - 16 - var ( 17 - topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 18 - ) 19 - 20 - // ValidateRepoTopicStr parses and validates whitespace-separated topic string. 21 - // 22 - // Rules: 23 - // - topics are separated by whitespace 24 - // - each topic may contain lowercase letters, digits, and hyphens only 25 - // - each topic must be <= 50 characters long 26 - // - no more than 20 topics allowed 27 - // - duplicates are removed 28 - func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { 29 - topicsStr = strings.TrimSpace(topicsStr) 30 - if topicsStr == "" { 31 - return nil, nil 32 - } 33 - parts := strings.Fields(topicsStr) 34 - if len(parts) > maxTopics { 35 - return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 36 - } 37 - 38 - topicSet := make(map[string]struct{}) 39 - 40 - for _, t := range parts { 41 - if _, exists := topicSet[t]; exists { 42 - continue 43 - } 44 - if len(t) > maxTopicLen { 45 - return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 46 - } 47 - if !topicRE.MatchString(t) { 48 - return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 49 - } 50 - topicSet[t] = struct{}{} 51 - } 52 - return slices.Collect(maps.Keys(topicSet)), nil 53 - }
-27
appview/validator/string.go
··· 1 - package validator 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "unicode/utf8" 7 - 8 - "tangled.org/core/appview/models" 9 - ) 10 - 11 - func (v *Validator) ValidateString(s *models.String) error { 12 - var err error 13 - 14 - if utf8.RuneCountInString(s.Filename) > 140 { 15 - err = errors.Join(err, fmt.Errorf("filename too long")) 16 - } 17 - 18 - if utf8.RuneCountInString(s.Description) > 280 { 19 - err = errors.Join(err, fmt.Errorf("description too long")) 20 - } 21 - 22 - if len(s.Contents) == 0 { 23 - err = errors.Join(err, fmt.Errorf("contents is empty")) 24 - } 25 - 26 - return err 27 - }
-17
appview/validator/uri.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "net/url" 6 - ) 7 - 8 - func (v *Validator) ValidateURI(uri string) error { 9 - parsed, err := url.Parse(uri) 10 - if err != nil { 11 - return fmt.Errorf("invalid uri format") 12 - } 13 - if parsed.Scheme == "" { 14 - return fmt.Errorf("uri scheme missing") 15 - } 16 - return nil 17 - }
-24
appview/validator/validator.go
··· 1 - package validator 2 - 3 - import ( 4 - "tangled.org/core/appview/db" 5 - "tangled.org/core/appview/pages/markup" 6 - "tangled.org/core/idresolver" 7 - "tangled.org/core/rbac" 8 - ) 9 - 10 - type Validator struct { 11 - db *db.DB 12 - sanitizer markup.Sanitizer 13 - resolver *idresolver.Resolver 14 - enforcer *rbac.Enforcer 15 - } 16 - 17 - func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 - return &Validator{ 19 - db: db, 20 - sanitizer: markup.NewSanitizer(), 21 - resolver: res, 22 - enforcer: enforcer, 23 - } 24 - }
+8 -8
avatar/src/index.js
··· 244 244 let avatarResponse; 245 245 if (resizeToTiny) { 246 246 avatarResponse = await fetch(avatarUrl, { 247 - cf: { 248 - image: { 249 - width: 32, 250 - height: 32, 251 - fit: "cover", 252 - format: "webp", 253 - }, 254 - }, 247 + // cf: { 248 + // image: { 249 + // width: 32, 250 + // height: 32, 251 + // fit: "cover", 252 + // format: "webp", 253 + // }, 254 + // }, 255 255 }); 256 256 } else { 257 257 avatarResponse = await fetch(avatarUrl);
+242
cmd/populatepipelines/populate_pipelines.go
··· 1 + package main 2 + 3 + import ( 4 + "database/sql" 5 + "flag" 6 + "fmt" 7 + "log" 8 + "math/rand" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + _ "github.com/mattn/go-sqlite3" 13 + ) 14 + 15 + var ( 16 + dbPath = flag.String("db", "appview.db", "Path to SQLite database") 17 + count = flag.Int("count", 10, "Number of pipeline runs to generate") 18 + repo = flag.String("repo", "", "Repository name (e.g., 'did:plc:xyz/myrepo')") 19 + knot = flag.String("knot", "localhost:8100", "Knot hostname") 20 + ) 21 + 22 + // StatusKind represents the status of a workflow 23 + type StatusKind string 24 + 25 + const ( 26 + StatusKindPending StatusKind = "pending" 27 + StatusKindRunning StatusKind = "running" 28 + StatusKindFailed StatusKind = "failed" 29 + StatusKindTimeout StatusKind = "timeout" 30 + StatusKindCancelled StatusKind = "cancelled" 31 + StatusKindSuccess StatusKind = "success" 32 + ) 33 + 34 + var finishStatuses = []StatusKind{ 35 + StatusKindFailed, 36 + StatusKindTimeout, 37 + StatusKindCancelled, 38 + StatusKindSuccess, 39 + } 40 + 41 + // generateRandomSha generates a random 40-character SHA 42 + func generateRandomSha() string { 43 + const hexChars = "0123456789abcdef" 44 + sha := make([]byte, 40) 45 + for i := range sha { 46 + sha[i] = hexChars[rand.Intn(len(hexChars))] 47 + } 48 + return string(sha) 49 + } 50 + 51 + // generateRkey generates a TID-like rkey 52 + func generateRkey() string { 53 + // Simple timestamp-based rkey 54 + now := time.Now().UnixMicro() 55 + return fmt.Sprintf("%d", now) 56 + } 57 + 58 + func main() { 59 + flag.Parse() 60 + 61 + if *repo == "" { 62 + log.Fatal("--repo is required (format: did:plc:xyz/reponame)") 63 + } 64 + 65 + // Parse repo into owner and name 66 + did, repoName, ok := parseRepo(*repo) 67 + if !ok { 68 + log.Fatalf("Invalid repo format: %s (expected: did:plc:xyz/reponame)", *repo) 69 + } 70 + 71 + db, err := sql.Open("sqlite3", *dbPath) 72 + if err != nil { 73 + log.Fatalf("Failed to open database: %v", err) 74 + } 75 + defer db.Close() 76 + 77 + rand.Seed(time.Now().UnixNano()) 78 + 79 + branches := []string{"main", "develop", "feature/auth", "fix/bugs"} 80 + workflows := []string{"test", "build", "lint", "deploy"} 81 + 82 + log.Printf("Generating %d pipeline runs for %s...\n", *count, *repo) 83 + 84 + for i := 0; i < *count; i++ { 85 + // Random trigger type 86 + isPush := rand.Float32() > 0.3 // 70% push, 30% PR 87 + 88 + var triggerId int64 89 + if isPush { 90 + triggerId, err = createPushTrigger(db, branches) 91 + } else { 92 + triggerId, err = createPRTrigger(db, branches) 93 + } 94 + if err != nil { 95 + log.Fatalf("Failed to create trigger: %v", err) 96 + } 97 + 98 + // Create pipeline 99 + pipelineRkey := generateRkey() 100 + sha := generateRandomSha() 101 + createdTime := time.Now().Add(-time.Duration(rand.Intn(7*24*60)) * time.Minute) // Random time in last week 102 + 103 + _, err = db.Exec(` 104 + INSERT INTO pipelines (knot, rkey, repo_owner, repo_name, sha, created, trigger_id) 105 + VALUES (?, ?, ?, ?, ?, ?, ?) 106 + `, *knot, pipelineRkey, did, repoName, sha, createdTime.Format(time.RFC3339), triggerId) 107 + 108 + if err != nil { 109 + log.Fatalf("Failed to create pipeline: %v", err) 110 + } 111 + 112 + // Create workflow statuses 113 + numWorkflows := rand.Intn(len(workflows)-1) + 2 // 2-4 workflows 114 + selectedWorkflows := make([]string, numWorkflows) 115 + perm := rand.Perm(len(workflows)) 116 + for j := 0; j < numWorkflows; j++ { 117 + selectedWorkflows[j] = workflows[perm[j]] 118 + } 119 + 120 + for _, workflow := range selectedWorkflows { 121 + err = createWorkflowStatuses(db, *knot, pipelineRkey, workflow, createdTime) 122 + if err != nil { 123 + log.Fatalf("Failed to create workflow statuses: %v", err) 124 + } 125 + } 126 + 127 + log.Printf("Created pipeline %d/%d (rkey: %s)\n", i+1, *count, pipelineRkey) 128 + 129 + // Small delay to ensure unique rkeys 130 + time.Sleep(2 * time.Millisecond) 131 + } 132 + 133 + log.Println("✓ Pipeline population complete!") 134 + } 135 + 136 + func parseRepo(repo string) (syntax.DID, string, bool) { 137 + // Simple parser for "did:plc:xyz/reponame" 138 + for i := 0; i < len(repo); i++ { 139 + if repo[i] == '/' { 140 + did := syntax.DID(repo[:i]) 141 + name := repo[i+1:] 142 + if did != "" && name != "" { 143 + return did, name, true 144 + } 145 + } 146 + } 147 + return "", "", false 148 + } 149 + 150 + func createPushTrigger(db *sql.DB, branches []string) (int64, error) { 151 + branch := branches[rand.Intn(len(branches))] 152 + oldSha := generateRandomSha() 153 + newSha := generateRandomSha() 154 + 155 + result, err := db.Exec(` 156 + INSERT INTO triggers (kind, push_ref, push_new_sha, push_old_sha) 157 + VALUES (?, ?, ?, ?) 158 + `, "push", "refs/heads/"+branch, newSha, oldSha) 159 + 160 + if err != nil { 161 + return 0, err 162 + } 163 + 164 + return result.LastInsertId() 165 + } 166 + 167 + func createPRTrigger(db *sql.DB, branches []string) (int64, error) { 168 + targetBranch := branches[0] // Usually main 169 + sourceBranch := branches[rand.Intn(len(branches)-1)+1] 170 + sourceSha := generateRandomSha() 171 + actions := []string{"opened", "synchronize", "reopened"} 172 + action := actions[rand.Intn(len(actions))] 173 + 174 + result, err := db.Exec(` 175 + INSERT INTO triggers (kind, pr_source_branch, pr_target_branch, pr_source_sha, pr_action) 176 + VALUES (?, ?, ?, ?, ?) 177 + `, "pull_request", sourceBranch, targetBranch, sourceSha, action) 178 + 179 + if err != nil { 180 + return 0, err 181 + } 182 + 183 + return result.LastInsertId() 184 + } 185 + 186 + func createWorkflowStatuses(db *sql.DB, knot, pipelineRkey, workflow string, startTime time.Time) error { 187 + // Generate a progression of statuses for the workflow 188 + statusProgression := []StatusKind{StatusKindPending, StatusKindRunning} 189 + 190 + // Randomly choose a final status (80% success, 10% failed, 5% timeout, 5% cancelled) 191 + roll := rand.Float32() 192 + var finalStatus StatusKind 193 + switch { 194 + case roll < 0.80: 195 + finalStatus = StatusKindSuccess 196 + case roll < 0.90: 197 + finalStatus = StatusKindFailed 198 + case roll < 0.95: 199 + finalStatus = StatusKindTimeout 200 + default: 201 + finalStatus = StatusKindCancelled 202 + } 203 + 204 + statusProgression = append(statusProgression, finalStatus) 205 + 206 + currentTime := startTime 207 + for i, status := range statusProgression { 208 + rkey := fmt.Sprintf("%s-%s-%d", pipelineRkey, workflow, i) 209 + 210 + // Add some realistic time progression (10-60 seconds between statuses) 211 + if i > 0 { 212 + currentTime = currentTime.Add(time.Duration(rand.Intn(50)+10) * time.Second) 213 + } 214 + 215 + var errorMsg *string 216 + var exitCode int 217 + 218 + if status == StatusKindFailed { 219 + msg := "Command exited with non-zero status" 220 + errorMsg = &msg 221 + exitCode = rand.Intn(100) + 1 222 + } else if status == StatusKindTimeout { 223 + msg := "Workflow exceeded maximum execution time" 224 + errorMsg = &msg 225 + exitCode = 124 226 + } 227 + 228 + _, err := db.Exec(` 229 + INSERT INTO pipeline_statuses ( 230 + spindle, rkey, pipeline_knot, pipeline_rkey, 231 + created, workflow, status, error, exit_code 232 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 233 + `, "spindle.example.com", rkey, knot, pipelineRkey, 234 + currentTime.Format(time.RFC3339), workflow, string(status), errorMsg, exitCode) 235 + 236 + if err != nil { 237 + return err 238 + } 239 + } 240 + 241 + return nil 242 + }
+11
contrib/certs/root.crt
··· 1 + -----BEGIN CERTIFICATE----- 2 + MIIBozCCAUmgAwIBAgIQRnYoKs3BuihlLFeydgURVzAKBggqhkjOPQQDAjAwMS4w 3 + LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X 4 + DTI2MDEwODEzNTk1MloXDTM1MTExNzEzNTk1MlowMDEuMCwGA1UEAxMlQ2FkZHkg 5 + TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG 6 + SM49AwEHA0IABCQlYShhxLaX8/ZP7rcBtD5xL4u3wYMe77JS/lRFjjpAUGmJPxUE 7 + ctsNvukG1hU4MeLMSqAEIqFWjs8dQBxLjGSjRTBDMA4GA1UdDwEB/wQEAwIBBjAS 8 + BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBQ7Mt/6izTOOXCSWDS6HrwrqMDB 9 + vzAKBggqhkjOPQQDAgNIADBFAiEA9QAYIuHR5qsGJ1JMZnuAAQpEwaqewhUICsKO 10 + e2fWj4ACICPgj9Kh9++8FH5eVyDI1AD/BLwmMmiaqs1ojZT7QJqb 11 + -----END CERTIFICATE-----
+31
contrib/example.env
··· 1 + # NOTE: put actual DIDs here 2 + alice_did=did:plc:alice-did 3 + tangled_did=did:plc:tangled-did 4 + 5 + #core 6 + export TANGLED_DEV=true 7 + export TANGLED_APPVIEW_HOST=127.0.0.1:3000 8 + # plc 9 + export TANGLED_PLC_URL=https://plc.tngl.boltless.dev 10 + # jetstream 11 + export TANGLED_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe 12 + # label 13 + export TANGLED_LABEL_GFI=at://${tangled_did}/sh.tangled.label.definition/good-first-issue 14 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_GFI 15 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/assignee 16 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/documentation 17 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/duplicate 18 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/wontfix 19 + 20 + # vm settings 21 + export TANGLED_VM_PLC_URL=https://plc.tngl.boltless.dev 22 + export TANGLED_VM_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe 23 + export TANGLED_VM_KNOT_HOST=knot.tngl.boltless.dev 24 + export TANGLED_VM_KNOT_OWNER=$alice_did 25 + export TANGLED_VM_SPINDLE_HOST=spindle.tngl.boltless.dev 26 + export TANGLED_VM_SPINDLE_OWNER=$alice_did 27 + 28 + if [ -n "${TANGLED_RESEND_API_KEY:-}" ] && [ -n "${TANGLED_RESEND_SENT_FROM:-}" ]; then 29 + export TANGLED_VM_PDS_EMAIL_SMTP_URL=smtps://resend:$TANGLED_RESEND_API_KEY@smtp.resend.com:465/ 30 + export TANGLED_VM_PDS_EMAIL_FROM_ADDRESS=$TANGLED_RESEND_SENT_FROM 31 + fi
+12
contrib/pds.env
··· 1 + LOG_ENABLED=true 2 + 3 + PDS_JWT_SECRET=8cae8bffcc73d9932819650791e4e89a 4 + PDS_ADMIN_PASSWORD=d6a902588cd93bee1af83f924f60cfd3 5 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7 6 + 7 + PDS_DATA_DIRECTORY=/pds 8 + PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks 9 + 10 + PDS_DID_PLC_URL=http://localhost:8080 11 + PDS_HOSTNAME=pds.tngl.boltless.dev 12 + PDS_PORT=3000
+25
contrib/readme.md
··· 1 + # how to setup local appview dev environment 2 + 3 + Appview requires several microservices from knot and spindle to entire atproto infra. This test environment is implemented under nixos vm. 4 + 5 + 1. copy `contrib/example.env` to `.env`, fill it and source it 6 + 2. run vm 7 + ```bash 8 + nix run --impure .#vm 9 + ``` 10 + 3. trust the generated cert from host machine 11 + ```bash 12 + # for macos 13 + sudo security add-trusted-cert -d -r trustRoot \ 14 + -k /Library/Keychains/System.keychain \ 15 + ./nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/root.crt 16 + ``` 17 + 4. create test accounts with valid emails (use [`create-test-account.sh`](./scripts/create-test-account.sh)) 18 + 5. create default labels (use [`setup-const-records`](./scripts/setup-const-records.sh)) 19 + 6. restart vm with correct owner-did 20 + 21 + for git-https, you should change your local git config: 22 + ``` 23 + [http "https://knot.tngl.boltless.dev"] 24 + sslCAPath = /Users/boltless/repo/tangled/nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/ 25 + ```
+68
contrib/scripts/create-test-account.sh
··· 1 + #!/bin/bash 2 + set -o errexit 3 + set -o nounset 4 + set -o pipefail 5 + 6 + source "$(dirname "$0")/../pds.env" 7 + 8 + # PDS_HOSTNAME= 9 + # PDS_ADMIN_PASSWORD= 10 + 11 + # curl a URL and fail if the request fails. 12 + function curl_cmd_get { 13 + curl --fail --silent --show-error "$@" 14 + } 15 + 16 + # curl a URL and fail if the request fails. 17 + function curl_cmd_post { 18 + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" 19 + } 20 + 21 + # curl a URL but do not fail if the request fails. 22 + function curl_cmd_post_nofail { 23 + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" 24 + } 25 + 26 + USERNAME="${1:-}" 27 + 28 + if [[ "${USERNAME}" == "" ]]; then 29 + read -p "Enter a username: " USERNAME 30 + fi 31 + 32 + if [[ "${USERNAME}" == "" ]]; then 33 + echo "ERROR: missing USERNAME parameter." >/dev/stderr 34 + echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr 35 + exit 1 36 + fi 37 + 38 + EMAIL=${USERNAME}@${PDS_HOSTNAME} 39 + 40 + PASSWORD="password" 41 + INVITE_CODE="$(curl_cmd_post \ 42 + --user "admin:${PDS_ADMIN_PASSWORD}" \ 43 + --data '{"useCount": 1}' \ 44 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' 45 + )" 46 + RESULT="$(curl_cmd_post_nofail \ 47 + --data "{\"email\":\"${EMAIL}\", \"handle\":\"${USERNAME}.${PDS_HOSTNAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \ 48 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount" 49 + )" 50 + 51 + DID="$(echo $RESULT | jq --raw-output '.did')" 52 + if [[ "${DID}" != did:* ]]; then 53 + ERR="$(echo ${RESULT} | jq --raw-output '.message')" 54 + echo "ERROR: ${ERR}" >/dev/stderr 55 + echo "Usage: $0 <EMAIL> <HANDLE>" >/dev/stderr 56 + exit 1 57 + fi 58 + 59 + echo 60 + echo "Account created successfully!" 61 + echo "-----------------------------" 62 + echo "Handle : ${USERNAME}.${PDS_HOSTNAME}" 63 + echo "DID : ${DID}" 64 + echo "Password : ${PASSWORD}" 65 + echo "-----------------------------" 66 + echo "This is a test account with an insecure password." 67 + echo "Make sure it's only used for development." 68 + echo
+106
contrib/scripts/setup-const-records.sh
··· 1 + #!/bin/bash 2 + set -o errexit 3 + set -o nounset 4 + set -o pipefail 5 + 6 + source "$(dirname "$0")/../pds.env" 7 + 8 + # PDS_HOSTNAME= 9 + 10 + # curl a URL and fail if the request fails. 11 + function curl_cmd_get { 12 + curl --fail --silent --show-error "$@" 13 + } 14 + 15 + # curl a URL and fail if the request fails. 16 + function curl_cmd_post { 17 + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" 18 + } 19 + 20 + # curl a URL but do not fail if the request fails. 21 + function curl_cmd_post_nofail { 22 + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" 23 + } 24 + 25 + USERNAME="${1:-}" 26 + 27 + if [[ "${USERNAME}" == "" ]]; then 28 + read -p "Enter a username: " USERNAME 29 + fi 30 + 31 + if [[ "${USERNAME}" == "" ]]; then 32 + echo "ERROR: missing USERNAME parameter." >/dev/stderr 33 + echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr 34 + exit 1 35 + fi 36 + 37 + SESS_RESULT="$(curl_cmd_post \ 38 + --data "$(cat <<EOF 39 + { 40 + "identifier": "$USERNAME", 41 + "password": "password" 42 + } 43 + EOF 44 + )" \ 45 + https://pds.tngl.boltless.dev/xrpc/com.atproto.server.createSession 46 + )" 47 + 48 + echo $SESS_RESULT | jq 49 + 50 + DID="$(echo $SESS_RESULT | jq --raw-output '.did')" 51 + ACCESS_JWT="$(echo $SESS_RESULT | jq --raw-output '.accessJwt')" 52 + 53 + function add_label_def { 54 + local color=$1 55 + local name=$2 56 + echo $color 57 + echo $name 58 + local json_payload=$(cat <<EOF 59 + { 60 + "repo": "$DID", 61 + "collection": "sh.tangled.label.definition", 62 + "rkey": "$name", 63 + "record": { 64 + "name": "$name", 65 + "color": "$color", 66 + "scope": ["sh.tangled.repo.issue"], 67 + "multiple": false, 68 + "createdAt": "2025-09-22T11:14:35+01:00", 69 + "valueType": {"type": "null", "format": "any"} 70 + } 71 + } 72 + EOF 73 + ) 74 + echo $json_payload 75 + echo $json_payload | jq 76 + RESULT="$(curl_cmd_post \ 77 + --data "$json_payload" \ 78 + -H "Authorization: Bearer ${ACCESS_JWT}" \ 79 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord")" 80 + echo $RESULT | jq 81 + } 82 + 83 + add_label_def '#64748b' 'wontfix' 84 + add_label_def '#8B5CF6' 'good-first-issue' 85 + add_label_def '#ef4444' 'duplicate' 86 + add_label_def '#06b6d4' 'documentation' 87 + json_payload=$(cat <<EOF 88 + { 89 + "repo": "$DID", 90 + "collection": "sh.tangled.label.definition", 91 + "rkey": "assignee", 92 + "record": { 93 + "name": "assignee", 94 + "color": "#10B981", 95 + "scope": ["sh.tangled.repo.issue", "sh.tangled.repo.pull"], 96 + "multiple": false, 97 + "createdAt": "2025-09-22T11:14:35+01:00", 98 + "valueType": {"type": "string", "format": "did"} 99 + } 100 + } 101 + EOF 102 + ) 103 + curl_cmd_post \ 104 + --data "$json_payload" \ 105 + -H "Authorization: Bearer ${ACCESS_JWT}" \ 106 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord"
+346 -99
docs/DOCS.md
··· 3 3 author: The Tangled Contributors 4 4 date: 21 Sun, Dec 2025 5 5 abstract: | 6 - Tangled is a decentralized code hosting and collaboration 7 - platform. Every component of Tangled is open-source and 8 - self-hostable. [tangled.org](https://tangled.org) also 9 - provides hosting and CI services that are free to use. 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 10 10 11 - There are several models for decentralized code 12 - collaboration platforms, ranging from ActivityPub’s 13 - (Forgejo) federated model, to Radicle’s entirely P2P model. 14 - Our approach attempts to be the best of both worlds by 15 - adopting the AT Protocol—a protocol for building decentralized 16 - social applications with a central identity 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPub’s 13 + (Forgejo) federated model, to Radicle’s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocol—a protocol for building decentralized 16 + social applications with a central identity 17 17 18 - Our approach to this is the idea of “knots”. Knots are 19 - lightweight, headless servers that enable users to host Git 20 - repositories with ease. Knots are designed for either single 21 - or multi-tenant use which is perfect for self-hosting on a 22 - Raspberry Pi at home, or larger “community” servers. By 23 - default, Tangled provides managed knots where you can host 24 - your repositories for free. 18 + Our approach to this is the idea of “knots”. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger “community” servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 25 25 26 - The appview at tangled.org acts as a consolidated "view" 27 - into the whole network, allowing users to access, clone and 28 - contribute to repositories hosted across different knots 29 - seamlessly. 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 30 --- 31 31 32 32 # Quick start guide ··· 131 131 cd my-project 132 132 133 133 git init 134 - echo "# My Project" > README.md 134 + echo "# My Project" > README.md 135 135 ``` 136 136 137 137 Add some content and push! ··· 313 313 and operation tool. For the purpose of this guide, we're 314 314 only concerned with these subcommands: 315 315 316 - * `knot server`: the main knot server process, typically 317 - run as a supervised service 318 - * `knot guard`: handles role-based access control for git 319 - over SSH (you'll never have to run this yourself) 320 - * `knot keys`: fetches SSH keys associated with your knot; 321 - we'll use this to generate the SSH 322 - `AuthorizedKeysCommand` 316 + - `knot server`: the main knot server process, typically 317 + run as a supervised service 318 + - `knot guard`: handles role-based access control for git 319 + over SSH (you'll never have to run this yourself) 320 + - `knot keys`: fetches SSH keys associated with your knot; 321 + we'll use this to generate the SSH 322 + `AuthorizedKeysCommand` 323 323 324 324 ``` 325 325 cd core ··· 432 432 can move these paths if you'd like to store them in another folder. Be careful 433 433 when adjusting these paths: 434 434 435 - * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 436 - any possible side effects. Remember to restart it once you're done. 437 - * Make backups before moving in case something goes wrong. 438 - * Make sure the `git` user can read and write from the new paths. 435 + - Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 436 + any possible side effects. Remember to restart it once you're done. 437 + - Make backups before moving in case something goes wrong. 438 + - Make sure the `git` user can read and write from the new paths. 439 439 440 440 #### Database 441 441 ··· 502 502 Note that you should add a newline at the end if setting a non-empty message 503 503 since the knot won't do this for you. 504 504 505 + ## Troubleshooting 506 + 507 + If you run your own knot, you may run into some of these 508 + common issues. You can always join the 509 + [IRC](https://web.libera.chat/#tangled) or 510 + [Discord](https://chat.tangled.org/) if this section does 511 + not help. 512 + 513 + ### Unable to push 514 + 515 + If you are unable to push to your knot or repository: 516 + 517 + 1. First, ensure that you have added your SSH public key to 518 + your account 519 + 2. Check to see that your knot has synced the key by running 520 + `knot keys` 521 + 3. Check to see if git is supplying the correct private key 522 + when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...` 523 + 4. Check to see if `sshd` on the knot is rejecting the push 524 + for some reason: `journalctl -xeu ssh` (or `sshd`, 525 + depending on your machine). These logs are unavailable if 526 + using docker. 527 + 5. Check to see if the knot itself is rejecting the push, 528 + depending on your setup, the logs might be in one of the 529 + following paths: 530 + - `/tmp/knotguard.log` 531 + - `/home/git/log` 532 + - `/home/git/guard.log` 533 + 505 534 # Spindles 506 535 507 536 ## Pipelines ··· 650 679 key-value map, with the key being the registry to fetch 651 680 dependencies from, and the value being the list of 652 681 dependencies to fetch. 682 + 683 + The registry URL syntax can be found [on the nix 684 + manual](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-registry-add). 653 685 654 686 Say you want to fetch Node.js and Go from `nixpkgs`, and a 655 687 package called `my_pkg` you've made from your own registry ··· 818 850 819 851 ### Prerequisites 820 852 821 - * Go 822 - * Docker (the only supported backend currently) 853 + - Go 854 + - Docker (the only supported backend currently) 823 855 824 856 ### Configuration 825 857 826 858 Spindle is configured using environment variables. The following environment variables are available: 827 859 828 - * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 829 - * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 830 - * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 831 - * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 832 - * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 833 - * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 834 - * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 835 - * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 836 - * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 860 + - `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 861 + - `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 862 + - `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 863 + - `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 864 + - `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 865 + - `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 866 + - `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 867 + - `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 868 + - `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 837 869 838 870 ### Running spindle 839 871 840 - 1. **Set the environment variables.** For example: 872 + 1. **Set the environment variables.** For example: 841 873 842 874 ```shell 843 875 export SPINDLE_SERVER_HOSTNAME="your-hostname" ··· 871 903 872 904 Spindle is a small CI runner service. Here's a high-level overview of how it operates: 873 905 874 - * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 875 - [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 876 - * When a new repo record comes through (typically when you add a spindle to a 877 - repo from the settings), spindle then resolves the underlying knot and 878 - subscribes to repo events (see: 879 - [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 880 - * The spindle engine then handles execution of the pipeline, with results and 881 - logs beamed on the spindle event stream over WebSocket 906 + - Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 907 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 908 + - When a new repo record comes through (typically when you add a spindle to a 909 + repo from the settings), spindle then resolves the underlying knot and 910 + subscribes to repo events (see: 911 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 912 + - The spindle engine then handles execution of the pipeline, with results and 913 + logs beamed on the spindle event stream over WebSocket 882 914 883 915 ### The engine 884 916 ··· 1192 1224 secret_id="$(cat /tmp/openbao/secret-id)" 1193 1225 ``` 1194 1226 1227 + # Webhooks 1228 + 1229 + Webhooks allow you to receive HTTP POST notifications when events occur in your repositories. This enables you to integrate Tangled with external services, trigger CI/CD pipelines, send notifications, or automate workflows. 1230 + 1231 + ## Overview 1232 + 1233 + Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push events, with more event types coming soon. 1234 + 1235 + ## Configuring webhooks 1236 + 1237 + To set up a webhook for your repository: 1238 + 1239 + 1. Navigate to your repository settings 1240 + 2. Click the "hooks" tab 1241 + 3. Click "add webhook" 1242 + 4. Configure your webhook: 1243 + - **Payload URL**: The endpoint that will receive the webhook POST requests 1244 + - **Secret**: An optional secret key for verifying webhook authenticity (auto-generated if left blank) 1245 + - **Events**: Select which events trigger the webhook (currently only push events) 1246 + - **Active**: Toggle whether the webhook is enabled 1247 + 1248 + ## Webhook payload 1249 + 1250 + ### Push 1251 + 1252 + When a push event occurs, Tangled sends a POST request with a JSON payload of the format: 1253 + 1254 + ```json 1255 + { 1256 + "after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5", 1257 + "before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e", 1258 + "pusher": { 1259 + "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 1260 + }, 1261 + "ref": "refs/heads/main", 1262 + "repository": { 1263 + "clone_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 1264 + "created_at": "2025-09-15T08:57:23Z", 1265 + "description": "an example repository", 1266 + "fork": false, 1267 + "full_name": "did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 1268 + "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 1269 + "name": "some-repo", 1270 + "open_issues_count": 5, 1271 + "owner": { 1272 + "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 1273 + }, 1274 + "ssh_url": "ssh://git@tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 1275 + "stars_count": 1, 1276 + "updated_at": "2025-09-15T08:57:23Z" 1277 + } 1278 + } 1279 + ``` 1280 + 1281 + ## HTTP headers 1282 + 1283 + Each webhook request includes the following headers: 1284 + 1285 + - `Content-Type: application/json` 1286 + - `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit 1287 + - `X-Tangled-Event: push` — The event type 1288 + - `X-Tangled-Hook-ID: <webhook-id>` — The webhook ID 1289 + - `X-Tangled-Delivery: <uuid>` — Unique delivery ID 1290 + - `X-Tangled-Signature-256: sha256=<hmac>` — HMAC-SHA256 signature (if secret configured) 1291 + 1292 + ## Verifying webhook signatures 1293 + 1294 + If you configured a secret, you should verify the webhook signature to ensure requests are authentic. For example, in Go: 1295 + 1296 + ```go 1297 + package main 1298 + 1299 + import ( 1300 + "crypto/hmac" 1301 + "crypto/sha256" 1302 + "encoding/hex" 1303 + "io" 1304 + "net/http" 1305 + "strings" 1306 + ) 1307 + 1308 + func verifySignature(payload []byte, signatureHeader, secret string) bool { 1309 + // Remove 'sha256=' prefix from signature header 1310 + signature := strings.TrimPrefix(signatureHeader, "sha256=") 1311 + 1312 + // Compute expected signature 1313 + mac := hmac.New(sha256.New, []byte(secret)) 1314 + mac.Write(payload) 1315 + expected := hex.EncodeToString(mac.Sum(nil)) 1316 + 1317 + // Use constant-time comparison to prevent timing attacks 1318 + return hmac.Equal([]byte(signature), []byte(expected)) 1319 + } 1320 + 1321 + func webhookHandler(w http.ResponseWriter, r *http.Request) { 1322 + // Read the request body 1323 + payload, err := io.ReadAll(r.Body) 1324 + if err != nil { 1325 + http.Error(w, "Bad request", http.StatusBadRequest) 1326 + return 1327 + } 1328 + 1329 + // Get signature from header 1330 + signatureHeader := r.Header.Get("X-Tangled-Signature-256") 1331 + 1332 + // Verify signature 1333 + if signatureHeader != "" && verifySignature(payload, signatureHeader, yourSecret) { 1334 + // Webhook is authentic, process it 1335 + processWebhook(payload) 1336 + w.WriteHeader(http.StatusOK) 1337 + } else { 1338 + http.Error(w, "Invalid signature", http.StatusUnauthorized) 1339 + } 1340 + } 1341 + ``` 1342 + 1343 + ## Delivery retries 1344 + 1345 + Webhooks are automatically retried on failure: 1346 + 1347 + - **3 total attempts** (1 initial + 2 retries) 1348 + - **Exponential backoff** starting at 1 second, max 10 seconds 1349 + - **Retried on**: 1350 + - Network errors 1351 + - HTTP 5xx server errors 1352 + - **Not retried on**: 1353 + - HTTP 4xx client errors (bad request, unauthorized, etc.) 1354 + 1355 + ### Timeouts 1356 + 1357 + Webhook requests timeout after 30 seconds. If your endpoint needs more time: 1358 + 1359 + 1. Respond with 200 OK immediately 1360 + 2. Process the webhook asynchronously in the background 1361 + 1362 + ## Example integrations 1363 + 1364 + ### Discord notifications 1365 + 1366 + ```javascript 1367 + app.post("/webhook", (req, res) => { 1368 + const payload = req.body; 1369 + 1370 + fetch("https://discord.com/api/webhooks/...", { 1371 + method: "POST", 1372 + headers: { "Content-Type": "application/json" }, 1373 + body: JSON.stringify({ 1374 + content: `New push to ${payload.repository.full_name}`, 1375 + embeds: [ 1376 + { 1377 + title: `${payload.pusher.did} pushed to ${payload.ref}`, 1378 + url: payload.repository.html_url, 1379 + color: 0x00ff00, 1380 + }, 1381 + ], 1382 + }), 1383 + }); 1384 + 1385 + res.status(200).send("OK"); 1386 + }); 1387 + ``` 1388 + 1195 1389 # Migrating knots and spindles 1196 1390 1197 1391 Sometimes, non-backwards compatible changes are made to the ··· 1327 1521 <details> 1328 1522 <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1329 1523 1330 - In order to build Tangled's dev VM on macOS, you will 1331 - first need to set up a Linux Nix builder. The recommended 1332 - way to do so is to run a [`darwin.linux-builder` 1333 - VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1334 - and to register it in `nix.conf` as a builder for Linux 1335 - with the same architecture as your Mac (`linux-aarch64` if 1336 - you are using Apple Silicon). 1524 + In order to build Tangled's dev VM on macOS, you will 1525 + first need to set up a Linux Nix builder. The recommended 1526 + way to do so is to run a [`darwin.linux-builder` 1527 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1528 + and to register it in `nix.conf` as a builder for Linux 1529 + with the same architecture as your Mac (`linux-aarch64` if 1530 + you are using Apple Silicon). 1337 1531 1338 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1339 - > the Tangled repo so that it doesn't conflict with the other VM. For example, 1340 - > you can do 1341 - > 1342 - > ```shell 1343 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1344 - > ``` 1345 - > 1346 - > to store the builder VM in a temporary dir. 1347 - > 1348 - > You should read and follow [all the other intructions][darwin builder vm] to 1349 - > avoid subtle problems. 1532 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1533 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1534 + > you can do 1535 + > 1536 + > ```shell 1537 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1538 + > ``` 1539 + > 1540 + > to store the builder VM in a temporary dir. 1541 + > 1542 + > You should read and follow [all the other intructions][darwin builder vm] to 1543 + > avoid subtle problems. 1350 1544 1351 - Alternatively, you can use any other method to set up a 1352 - Linux machine with Nix installed that you can `sudo ssh` 1353 - into (in other words, root user on your Mac has to be able 1354 - to ssh into the Linux machine without entering a password) 1355 - and that has the same architecture as your Mac. See 1356 - [remote builder 1357 - instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1358 - for how to register such a builder in `nix.conf`. 1545 + Alternatively, you can use any other method to set up a 1546 + Linux machine with Nix installed that you can `sudo ssh` 1547 + into (in other words, root user on your Mac has to be able 1548 + to ssh into the Linux machine without entering a password) 1549 + and that has the same architecture as your Mac. See 1550 + [remote builder 1551 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1552 + for how to register such a builder in `nix.conf`. 1359 1553 1360 - > WARNING: If you'd like to use 1361 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1362 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1363 - > ssh` works can be tricky. It seems to be [possible with 1364 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1554 + > WARNING: If you'd like to use 1555 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1556 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1557 + ssh` works can be tricky. It seems to be [possible with 1558 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1365 1559 1366 1560 </details> 1367 1561 ··· 1434 1628 1435 1629 We follow a commit style similar to the Go project. Please keep commits: 1436 1630 1437 - * **atomic**: each commit should represent one logical change 1438 - * **descriptive**: the commit message should clearly describe what the 1439 - change does and why it's needed 1631 + - **atomic**: each commit should represent one logical change 1632 + - **descriptive**: the commit message should clearly describe what the 1633 + change does and why it's needed 1440 1634 1441 1635 ### Message format 1442 1636 ··· 1462 1656 knotserver/git/service: improve error checking in upload-pack 1463 1657 ``` 1464 1658 1465 - 1466 1659 ### General notes 1467 1660 1468 1661 - PRs get merged "as-is" (fast-forward)—like applying a patch-series 1469 - using `git am`. At present, there is no squashing—so please author 1470 - your commits as they would appear on `master`, following the above 1471 - guidelines. 1662 + using `git am`. At present, there is no squashing—so please author 1663 + your commits as they would appear on `master`, following the above 1664 + guidelines. 1472 1665 - If there is a lot of nesting, for example "appview: 1473 - pages/templates/repo/fragments: ...", these can be truncated down to 1474 - just "appview: repo/fragments: ...". If the change affects a lot of 1475 - subdirectories, you may abbreviate to just the top-level names, e.g. 1476 - "appview: ..." or "knotserver: ...". 1666 + pages/templates/repo/fragments: ...", these can be truncated down to 1667 + just "appview: repo/fragments: ...". If the change affects a lot of 1668 + subdirectories, you may abbreviate to just the top-level names, e.g. 1669 + "appview: ..." or "knotserver: ...". 1477 1670 - Keep commits lowercased with no trailing period. 1478 1671 - Use the imperative mood in the summary line (e.g., "fix bug" not 1479 - "fixed bug" or "fixes bug"). 1672 + "fixed bug" or "fixes bug"). 1480 1673 - Try to keep the summary line under 72 characters, but we aren't too 1481 - fussed about this. 1674 + fussed about this. 1482 1675 - Follow the same formatting for PR titles if filled manually. 1483 1676 - Don't include unrelated changes in the same commit. 1484 1677 - Avoid noisy commit messages like "wip" or "final fix"—rewrite history 1485 - before submitting if necessary. 1678 + before submitting if necessary. 1486 1679 1487 1680 ## Code formatting 1488 1681 ··· 1561 1754 Refer to the [jujutsu 1562 1755 documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1563 1756 for more information. 1757 + 1758 + # Troubleshooting guide 1759 + 1760 + ## Login issues 1761 + 1762 + Owing to the distributed nature of OAuth on AT Protocol, you 1763 + may run into issues with logging in. If you run a 1764 + self-hosted PDS: 1765 + 1766 + - You may need to ensure that your PDS is timesynced using 1767 + NTP: 1768 + - Enable the `ntpd` service 1769 + - Run `ntpd -qg` to synchronize your clock 1770 + - You may need to increase the default request timeout: 1771 + `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"` 1772 + 1773 + ## Empty punchcard 1774 + 1775 + For Tangled to register commits that you make across the 1776 + network, you need to setup one of following: 1777 + 1778 + - The committer email should be a verified email associated 1779 + to your account. You can add and verify emails on the 1780 + settings page. 1781 + - Or, the committer email should be set to your account's 1782 + DID: `git config user.email "did:plc:foobar"`. You can find 1783 + your account's DID on the settings page 1784 + 1785 + ## Commit is not marked as verified 1786 + 1787 + Presently, Tangled only supports SSH commit signatures. 1788 + 1789 + To sign commits using an SSH key with git: 1790 + 1791 + ``` 1792 + git config --global gpg.format ssh 1793 + git config --global user.signingkey ~/.ssh/tangled-key 1794 + ``` 1795 + 1796 + To sign commits using an SSH key with jj, add this to your 1797 + config: 1798 + 1799 + ``` 1800 + [signing] 1801 + behavior = "own" 1802 + backend = "ssh" 1803 + key = "~/.ssh/tangled-key" 1804 + ``` 1805 + 1806 + ## Self-hosted knot issues 1807 + 1808 + If you need help troubleshooting a self-hosted knot, check 1809 + out the [knot troubleshooting 1810 + guide](/knot-self-hosting-guide.html#troubleshooting).
+34 -2
flake.nix
··· 95 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 96 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 97 dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 98 + did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; 99 + bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; 100 + bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; 101 + tap = self.callPackage ./nix/pkgs/tap.nix {}; 98 102 }); 99 103 in { 100 104 overlays.default = final: prev: { 101 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 105 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly did-method-plc bluesky-jetstream bluesky-relay tap; 102 106 }; 103 107 104 108 packages = forAllSystems (system: let ··· 119 123 sqlite-lib 120 124 docs 121 125 dolly 126 + did-method-plc 127 + bluesky-jetstream 128 + bluesky-relay 129 + tap 122 130 ; 123 131 124 132 pkgsStatic-appview = staticPackages.appview; ··· 248 256 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 249 257 cd "$rootDir" 250 258 251 - mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 259 + mkdir -p nix/vm-data/{caddy,knot,repos,spindle,spindle-logs} 252 260 253 261 export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 254 262 exec ${pkgs.lib.getExe ··· 323 331 imports = [./nix/modules/spindle.nix]; 324 332 325 333 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 334 + }; 335 + nixosModules.did-method-plc = { 336 + lib, 337 + pkgs, 338 + ... 339 + }: { 340 + imports = [./nix/modules/did-method-plc.nix]; 341 + services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 342 + }; 343 + nixosModules.bluesky-relay = { 344 + lib, 345 + pkgs, 346 + ... 347 + }: { 348 + imports = [./nix/modules/bluesky-relay.nix]; 349 + services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 350 + }; 351 + nixosModules.bluesky-jetstream = { 352 + lib, 353 + pkgs, 354 + ... 355 + }: { 356 + imports = [./nix/modules/bluesky-jetstream.nix]; 357 + services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 326 358 }; 327 359 }; 328 360 }
+34 -4
go.mod
··· 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/blevesearch/bleve/v2 v2.5.3 11 11 github.com/bluekeyes/go-gitdiff v0.8.1 12 - github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 13 - github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 12 + github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab 13 + github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 14 14 github.com/bmatcuk/doublestar/v4 v4.9.1 15 15 github.com/carlmjohnson/versioninfo v0.22.5 16 16 github.com/casbin/casbin/v2 v2.103.0 ··· 29 29 github.com/gorilla/feeds v1.2.0 30 30 github.com/gorilla/sessions v1.4.0 31 31 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 32 + github.com/hashicorp/go-version v1.8.0 32 33 github.com/hiddeco/sshsig v0.2.0 33 34 github.com/hpcloud/tail v1.0.0 34 35 github.com/ipfs/go-cid v0.5.0 ··· 42 43 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 43 44 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 44 45 github.com/stretchr/testify v1.10.0 45 - github.com/urfave/cli/v3 v3.3.3 46 + github.com/urfave/cli/v3 v3.4.1 46 47 github.com/whyrusleeping/cbor-gen v0.3.1 47 48 github.com/yuin/goldmark v1.7.13 48 49 github.com/yuin/goldmark-emoji v1.0.6 49 50 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 51 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 52 golang.org/x/crypto v0.40.0 52 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 53 golang.org/x/image v0.31.0 54 54 golang.org/x/net v0.42.0 55 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ··· 61 61 github.com/Microsoft/go-winio v0.6.2 // indirect 62 62 github.com/ProtonMail/go-crypto v1.3.0 // indirect 63 63 github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 64 + github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 64 65 github.com/alecthomas/repr v0.5.2 // indirect 65 66 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 66 67 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect ··· 102 103 github.com/dlclark/regexp2 v1.11.5 // indirect 103 104 github.com/docker/go-connections v0.5.0 // indirect 104 105 github.com/docker/go-units v0.5.0 // indirect 106 + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 105 107 github.com/emirpasic/gods v1.18.1 // indirect 106 108 github.com/felixge/httpsnoop v1.0.4 // indirect 107 109 github.com/fsnotify/fsnotify v1.6.0 // indirect ··· 116 118 github.com/go-test/deep v1.1.1 // indirect 117 119 github.com/goccy/go-json v0.10.5 // indirect 118 120 github.com/gogo/protobuf v1.3.2 // indirect 121 + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 119 122 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 120 123 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 121 124 github.com/golang/mock v1.6.0 // indirect ··· 138 141 github.com/ipfs/bbloom v0.0.4 // indirect 139 142 github.com/ipfs/boxo v0.33.0 // indirect 140 143 github.com/ipfs/go-block-format v0.2.2 // indirect 144 + github.com/ipfs/go-blockservice v0.5.2 // indirect 141 145 github.com/ipfs/go-datastore v0.8.2 // indirect 142 146 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 143 147 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 148 + github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect 149 + github.com/ipfs/go-ipfs-util v0.0.3 // indirect 144 150 github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 145 151 github.com/ipfs/go-ipld-format v0.6.2 // indirect 152 + github.com/ipfs/go-ipld-legacy v0.2.2 // indirect 146 153 github.com/ipfs/go-log v1.0.5 // indirect 147 154 github.com/ipfs/go-log/v2 v2.6.0 // indirect 155 + github.com/ipfs/go-merkledag v0.11.0 // indirect 148 156 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 157 + github.com/ipfs/go-verifcid v0.0.3 // indirect 158 + github.com/ipld/go-car v0.6.2 // indirect 159 + github.com/ipld/go-codec-dagpb v1.7.0 // indirect 160 + github.com/ipld/go-ipld-prime v0.21.0 // indirect 161 + github.com/jackc/pgpassfile v1.0.0 // indirect 162 + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 163 + github.com/jackc/pgx/v5 v5.5.0 // indirect 164 + github.com/jackc/puddle/v2 v2.2.1 // indirect 165 + github.com/jinzhu/inflection v1.0.0 // indirect 166 + github.com/jinzhu/now v1.1.5 // indirect 149 167 github.com/json-iterator/go v1.1.12 // indirect 150 168 github.com/kevinburke/ssh_config v1.2.0 // indirect 151 169 github.com/klauspost/compress v1.18.0 // indirect 152 170 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 171 + github.com/labstack/echo/v4 v4.11.3 // indirect 172 + github.com/labstack/gommon v0.4.1 // indirect 153 173 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 174 + github.com/mattn/go-colorable v0.1.14 // indirect 154 175 github.com/mattn/go-isatty v0.0.20 // indirect 155 176 github.com/mattn/go-runewidth v0.0.16 // indirect 156 177 github.com/minio/sha256-simd v1.0.1 // indirect ··· 182 203 github.com/prometheus/client_model v0.6.2 // indirect 183 204 github.com/prometheus/common v0.64.0 // indirect 184 205 github.com/prometheus/procfs v0.16.1 // indirect 206 + github.com/puzpuzpuz/xsync/v4 v4.2.0 // indirect 185 207 github.com/rivo/uniseg v0.4.7 // indirect 186 208 github.com/ryanuber/go-glob v1.0.0 // indirect 187 209 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 188 210 github.com/spaolacci/murmur3 v1.1.0 // indirect 211 + github.com/valyala/bytebufferpool v1.0.0 // indirect 212 + github.com/valyala/fasttemplate v1.2.2 // indirect 189 213 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 190 214 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 191 215 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect ··· 203 227 go.uber.org/atomic v1.11.0 // indirect 204 228 go.uber.org/multierr v1.11.0 // indirect 205 229 go.uber.org/zap v1.27.0 // indirect 230 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 206 231 golang.org/x/sync v0.17.0 // indirect 207 232 golang.org/x/sys v0.34.0 // indirect 208 233 golang.org/x/text v0.29.0 // indirect ··· 214 239 gopkg.in/fsnotify.v1 v1.4.7 // indirect 215 240 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 216 241 gopkg.in/warnings.v0 v0.1.2 // indirect 242 + gorm.io/driver/postgres v1.5.7 // indirect 243 + gorm.io/driver/sqlite v1.5.5 // indirect 244 + gorm.io/gorm v1.25.9 // indirect 217 245 gotest.tools/v3 v3.5.2 // indirect 218 246 lukechampine.com/blake3 v1.4.1 // indirect 219 247 ) ··· 225 253 replace github.com/bluekeyes/go-gitdiff => tangled.sh/oppi.li/go-gitdiff v0.8.2 226 254 227 255 replace github.com/alecthomas/chroma/v2 => github.com/oppiliappan/chroma/v2 v2.24.2 256 + 257 + replace github.com/bluesky-social/indigo => github.com/boltlessengineer/indigo v0.0.0-20260302045703-861eeb80e873 228 258 229 259 // from bluesky-social/indigo 230 260 replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4
+188 -6
go.sum
··· 11 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 12 github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 13 13 github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 14 + github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 15 + github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 14 16 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 15 17 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 16 18 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= ··· 26 28 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 27 29 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 28 30 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 31 + github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= 32 + github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 29 33 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 30 34 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 31 35 github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= ··· 67 71 github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= 68 72 github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= 69 73 github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= 70 - github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 71 - github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 72 - github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 73 - github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 74 + github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 h1:OK76FcHhZp8ohjRB0OMWgti0oYAWFlt3KDQcIkH1pfI= 75 + github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654/go.mod h1:vt8kVRKtvrBspt9G38wDD8+BotjIMO8u8IYoVnyE4zY= 74 76 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 75 77 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 76 78 github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 77 79 github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 80 + github.com/boltlessengineer/indigo v0.0.0-20260302045703-861eeb80e873 h1:dMpEw+TYJMQYAQI+jWw/34+kNqPaOxMR9wxnzuSrJIQ= 81 + github.com/boltlessengineer/indigo v0.0.0-20260302045703-861eeb80e873/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 78 82 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 79 83 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 80 84 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 120 124 github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 121 125 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 122 126 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 127 + github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 128 + github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 123 129 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 124 130 github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 125 131 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 126 132 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 127 133 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 128 134 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 135 + github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= 136 + github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= 137 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 138 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 129 139 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 130 140 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 131 141 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 145 155 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 146 156 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 147 157 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 158 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 159 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 148 160 github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 149 161 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 150 162 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= ··· 153 165 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 154 166 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 155 167 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 168 + github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= 169 + github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= 170 + github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= 171 + github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= 172 + github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= 173 + github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= 174 + github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 175 + github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 156 176 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 157 177 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 158 178 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= ··· 185 205 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 186 206 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 187 207 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 208 + github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= 209 + github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 210 + github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= 188 211 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 212 + github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 213 + github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 189 214 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 190 215 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 191 216 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= ··· 195 220 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 196 221 github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 197 222 github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 223 + github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 224 + github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 198 225 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 199 226 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 200 227 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 229 256 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 230 257 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 231 258 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 259 + github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 260 + github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 232 261 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 262 + github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= 263 + github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= 233 264 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 234 265 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 235 266 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= ··· 265 296 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 266 297 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 267 298 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 299 + github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 300 + github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 268 301 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 269 302 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 270 303 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= ··· 277 310 github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE= 278 311 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 279 312 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 313 + github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= 314 + github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= 280 315 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 281 316 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 282 317 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 283 318 github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw= 284 319 github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM= 320 + github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ= 321 + github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk= 285 322 github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ= 286 323 github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8= 324 + github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8= 325 + github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk= 287 326 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 288 327 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 289 328 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 292 331 github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 293 332 github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 294 333 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 334 + github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ= 335 + github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk= 336 + github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ= 337 + github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw= 295 338 github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 296 339 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 340 + github.com/ipfs/go-ipfs-exchange-interface v0.2.1 h1:jMzo2VhLKSHbVe+mHNzYgs95n0+t0Q69GQ5WhRDZV/s= 341 + github.com/ipfs/go-ipfs-exchange-interface v0.2.1/go.mod h1:MUsYn6rKbG6CTtsDp+lKJPmVt3ZrCViNyH3rfPGsZ2E= 342 + github.com/ipfs/go-ipfs-exchange-offline v0.3.0 h1:c/Dg8GDPzixGd0MC8Jh6mjOwU57uYokgWRFidfvEkuA= 343 + github.com/ipfs/go-ipfs-exchange-offline v0.3.0/go.mod h1:MOdJ9DChbb5u37M1IcbrRB02e++Z7521fMxqCNRrz9s= 344 + github.com/ipfs/go-ipfs-pq v0.0.3 h1:YpoHVJB+jzK15mr/xsWC574tyDLkezVrDNeaalQBsTE= 345 + github.com/ipfs/go-ipfs-pq v0.0.3/go.mod h1:btNw5hsHBpRcSSgZtiNm/SLj5gYIZ18AKtv3kERkRb4= 346 + github.com/ipfs/go-ipfs-routing v0.3.0 h1:9W/W3N+g+y4ZDeffSgqhgo7BsBSJwPMcyssET9OWevc= 347 + github.com/ipfs/go-ipfs-routing v0.3.0/go.mod h1:dKqtTFIql7e1zYsEuWLyuOU+E0WJWW8JjbTPLParDWo= 297 348 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 298 349 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 299 350 github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 300 351 github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 301 352 github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU= 302 353 github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk= 354 + github.com/ipfs/go-ipld-legacy v0.2.2 h1:DThbqCPVLpWBcGtU23KDLiY2YRZZnTkXQyfz8aOfBkQ= 355 + github.com/ipfs/go-ipld-legacy v0.2.2/go.mod h1:hhkj+b3kG9b2BcUNw8IFYAsfeNo8E3U7eYlWeAOPyDU= 303 356 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 304 357 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 305 358 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 306 359 github.com/ipfs/go-log/v2 v2.6.0 h1:2Nu1KKQQ2ayonKp4MPo6pXCjqw1ULc9iohRqWV5EYqg= 307 360 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 361 + github.com/ipfs/go-merkledag v0.11.0 h1:DgzwK5hprESOzS4O1t/wi6JDpyVQdvm9Bs59N/jqfBY= 362 + github.com/ipfs/go-merkledag v0.11.0/go.mod h1:Q4f/1ezvBiJV0YCIXvt51W/9/kqJGH4I1LsA7+djsM4= 308 363 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 309 364 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 365 + github.com/ipfs/go-peertaskqueue v0.8.2 h1:PaHFRaVFdxQk1Qo3OKiHPYjmmusQy7gKQUaL8JDszAU= 366 + github.com/ipfs/go-peertaskqueue v0.8.2/go.mod h1:L6QPvou0346c2qPJNiJa6BvOibxDfaiPlqHInmzg0FA= 367 + github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs= 368 + github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw= 369 + github.com/ipld/go-car v0.6.2 h1:Hlnl3Awgnq8icK+ze3iRghk805lu8YNq3wlREDTF2qc= 370 + github.com/ipld/go-car v0.6.2/go.mod h1:oEGXdwp6bmxJCZ+rARSkDliTeYnVzv3++eXajZ+Bmr8= 371 + github.com/ipld/go-codec-dagpb v1.7.0 h1:hpuvQjCSVSLnTnHXn+QAMR0mLmb1gA6wl10LExo2Ts0= 372 + github.com/ipld/go-codec-dagpb v1.7.0/go.mod h1:rD3Zg+zub9ZnxcLwfol/OTQRVjaLzXypgy4UqHQvilM= 373 + github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= 374 + github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ= 375 + github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 376 + github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 377 + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 378 + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 379 + github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw= 380 + github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= 381 + github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 382 + github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 383 + github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= 384 + github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= 385 + github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= 386 + github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= 387 + github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 388 + github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 389 + github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 390 + github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 391 + github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 392 + github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 310 393 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 311 394 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 312 395 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 320 403 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 321 404 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 322 405 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 406 + github.com/koron/go-ssdp v0.0.6 h1:Jb0h04599eq/CY7rB5YEqPS83HmRfHP2azkxMN2rFtU= 407 + github.com/koron/go-ssdp v0.0.6/go.mod h1:0R9LfRJGek1zWTjN3JUNlm5INCDYGpRDfAptnct63fI= 323 408 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 324 409 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 325 410 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 328 413 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 329 414 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 330 415 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 416 + github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM= 417 + github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= 418 + github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk= 419 + github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM= 420 + github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= 421 + github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= 422 + github.com/libp2p/go-libp2p v0.42.0 h1:A8foZk+ZEhZTv0Jb++7xUFlrFhBDv4j2Vh/uq4YX+KE= 423 + github.com/libp2p/go-libp2p v0.42.0/go.mod h1:4NGcjbD9OIvFiSRb0XueCO19zJ4kSPK5vkyyOUYmMro= 424 + github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= 425 + github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= 426 + github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= 427 + github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= 428 + github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= 429 + github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= 430 + github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= 431 + github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= 432 + github.com/libp2p/go-netroute v0.2.2 h1:Dejd8cQ47Qx2kRABg6lPwknU7+nBnFRpko45/fFPuZ8= 433 + github.com/libp2p/go-netroute v0.2.2/go.mod h1:Rntq6jUAH0l9Gg17w5bFGhcC9a+vk4KNXs6s7IljKYE= 331 434 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 332 435 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 333 436 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 369 472 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 370 473 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 371 474 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 475 + github.com/multiformats/go-multiaddr v0.16.0 h1:oGWEVKioVQcdIOBlYM8BH1rZDWOGJSqr9/BKl6zQ4qc= 476 + github.com/multiformats/go-multiaddr v0.16.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= 477 + github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= 478 + github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= 372 479 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 373 480 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 481 + github.com/multiformats/go-multicodec v0.9.2 h1:YrlXCuqxjqm3bXl+vBq5LKz5pz4mvAsugdqy78k0pXQ= 482 + github.com/multiformats/go-multicodec v0.9.2/go.mod h1:LLWNMtyV5ithSBUo3vFIMaeDy+h3EbkMTek1m+Fybbo= 374 483 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 375 484 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 485 + github.com/multiformats/go-multistream v0.6.1 h1:4aoX5v6T+yWmc2raBHsTvzmFhOI8WVOer28DeBBEYdQ= 486 + github.com/multiformats/go-multistream v0.6.1/go.mod h1:ksQf6kqHAb6zIsyw7Zm+gAuVo57Qbq84E27YlYqavqw= 376 487 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 377 488 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 378 489 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= ··· 392 503 github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= 393 504 github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 394 505 github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= 506 + github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= 507 + github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= 395 508 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 396 509 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 397 510 github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= ··· 417 530 github.com/oppiliappan/chroma/v2 v2.24.2/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= 418 531 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= 419 532 github.com/oppiliappan/go-git/v5 v5.17.0/go.mod h1:q/FE8C3SPMoRN7LoH9vRFiBzidAOBWJPS1CqVS8DN+w= 533 + github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= 534 + github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= 535 + github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= 536 + github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= 537 + github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= 538 + github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= 539 + github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= 540 + github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= 541 + github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= 542 + github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= 543 + github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= 544 + github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= 545 + github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= 546 + github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= 547 + github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= 548 + github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= 549 + github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= 550 + github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= 551 + github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c= 552 + github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= 553 + github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= 554 + github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= 555 + github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4= 556 + github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= 557 + github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= 558 + github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= 559 + github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= 560 + github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= 561 + github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= 562 + github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= 563 + github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= 564 + github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= 565 + github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= 566 + github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= 567 + github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= 568 + github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= 569 + github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= 570 + github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= 420 571 github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 421 572 github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 422 573 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ··· 437 588 github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 438 589 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 439 590 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 591 + github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0= 592 + github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= 593 + github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 594 + github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 595 + github.com/quic-go/quic-go v0.52.0 h1:/SlHrCRElyaU6MaEPKqKr9z83sBg2v4FLLvWM+Z47pA= 596 + github.com/quic-go/quic-go v0.52.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= 597 + github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg= 598 + github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw= 440 599 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 441 600 github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 442 601 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= ··· 483 642 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 484 643 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 485 644 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 486 - github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 487 - github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 645 + github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= 646 + github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 647 + github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 648 + github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 649 + github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 650 + github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 488 651 github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= 489 652 github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= 490 653 github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= ··· 492 655 github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 493 656 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 494 657 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 658 + github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s= 659 + github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y= 495 660 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 496 661 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 497 662 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 498 663 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 664 + github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= 665 + github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= 499 666 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 500 667 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 501 668 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 542 709 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 543 710 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 544 711 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 712 + go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 713 + go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 545 714 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 546 715 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 716 + go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= 717 + go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= 547 718 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 548 719 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 549 720 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 575 746 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 576 747 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 577 748 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 749 + golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 750 + golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 578 751 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 579 752 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 580 753 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 680 853 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 681 854 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 682 855 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 856 + golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 857 + golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 683 858 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 684 859 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 685 860 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= ··· 719 894 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 720 895 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 721 896 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 897 + gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 722 898 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 723 899 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 724 900 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 725 901 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 726 902 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 903 + gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= 904 + gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= 905 + gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= 906 + gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= 907 + gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= 908 + gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 727 909 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= 728 910 gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 729 911 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+3 -3
idresolver/resolver.go
··· 60 60 base := BaseDirectory(plcUrl) 61 61 cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 62 62 return &Resolver{ 63 - directory: &cached, 63 + directory: cached, 64 64 } 65 65 } 66 66 ··· 80 80 return nil, err 81 81 } 82 82 83 - return r.directory.Lookup(ctx, *id) 83 + return r.directory.Lookup(ctx, id) 84 84 } 85 85 86 86 func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { ··· 117 117 return err 118 118 } 119 119 120 - return r.directory.Purge(ctx, *id) 120 + return r.directory.Purge(ctx, id) 121 121 } 122 122 123 123 func (r *Resolver) Directory() identity.Directory {
+12
input.css
··· 99 99 border border-gray-300 dark:border-gray-600 100 100 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; 101 101 } 102 + textarea { 103 + @apply font-mono; 104 + } 102 105 details summary::-webkit-details-marker { 103 106 display: none; 104 107 } ··· 173 176 174 177 .prose .heading .anchor:hover { 175 178 @apply opacity-70; 179 + } 180 + 181 + .prose h1:target, 182 + .prose h2:target, 183 + .prose h3:target, 184 + .prose h4:target, 185 + .prose h5:target, 186 + .prose h6:target { 187 + @apply bg-yellow-200/30 dark:bg-yellow-600/30; 176 188 } 177 189 178 190 .prose a.footnote-backref {
+2 -1
jetstream/jetstream.go
··· 159 159 j.cancelMu.Unlock() 160 160 161 161 if err := j.client.ConnectAndRead(connCtx, cursor); err != nil { 162 - l.Error("error reading jetstream", "error", err) 162 + l.Error("error reading jetstream, retry in 3s", "error", err) 163 163 cancel() 164 + time.Sleep(3 * time.Second) 164 165 continue 165 166 } 166 167
+46 -3
knotserver/git/branch.go
··· 12 12 "tangled.org/core/types" 13 13 ) 14 14 15 - func (g *GitRepo) Branches() ([]types.Branch, error) { 15 + type BranchesOptions struct { 16 + Limit int 17 + Offset int 18 + } 19 + 20 + func (g *GitRepo) Branches(opts *BranchesOptions) ([]types.Branch, error) { 21 + if opts == nil { 22 + opts = &BranchesOptions{} 23 + } 24 + 16 25 fields := []string{ 17 26 "refname:short", 18 27 "objectname", ··· 33 42 if i != 0 { 34 43 outFormat.WriteString(fieldSeparator) 35 44 } 36 - outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 45 + fmt.Fprintf(&outFormat, "%%(%s)", f) 37 46 } 38 47 outFormat.WriteString("") 39 48 outFormat.WriteString(recordSeparator) 40 49 41 - output, err := g.forEachRef(outFormat.String(), "refs/heads") 50 + args := []string{outFormat.String(), "--sort=-creatordate"} 51 + 52 + // only add the count if the limit is a non-zero value, 53 + // if it is zero, get as many tags as we can 54 + if opts.Limit > 0 { 55 + args = append(args, fmt.Sprintf("--count=%d", opts.Offset+opts.Limit)) 56 + } 57 + 58 + args = append(args, "refs/heads") 59 + 60 + output, err := g.forEachRef(args...) 42 61 if err != nil { 43 62 return nil, fmt.Errorf("failed to get branches: %w", err) 44 63 } ··· 48 67 return nil, nil 49 68 } 50 69 70 + startIdx := opts.Offset 71 + if startIdx >= len(records) { 72 + return nil, nil 73 + } 74 + 75 + endIdx := len(records) 76 + if opts.Limit > 0 { 77 + endIdx = min(startIdx+opts.Limit, len(records)) 78 + } 79 + 80 + records = records[startIdx:endIdx] 51 81 branches := make([]types.Branch, 0, len(records)) 52 82 53 83 // ignore errors here ··· 109 139 110 140 slices.Reverse(branches) 111 141 return branches, nil 142 + } 143 + 144 + func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 145 + ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 146 + if err != nil { 147 + return nil, fmt.Errorf("branch: %w", err) 148 + } 149 + 150 + if !ref.Name().IsBranch() { 151 + return nil, fmt.Errorf("branch: %s is not a branch", ref.Name()) 152 + } 153 + 154 + return ref, nil 112 155 } 113 156 114 157 func (g *GitRepo) DeleteBranch(branch string) error {
+355
knotserver/git/branch_test.go
··· 1 + package git 2 + 3 + import ( 4 + "path/filepath" 5 + "slices" 6 + "testing" 7 + 8 + gogit "github.com/go-git/go-git/v5" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/stretchr/testify/assert" 11 + "github.com/stretchr/testify/require" 12 + "github.com/stretchr/testify/suite" 13 + 14 + "tangled.org/core/sets" 15 + ) 16 + 17 + type BranchSuite struct { 18 + suite.Suite 19 + *RepoSuite 20 + } 21 + 22 + func TestBranchSuite(t *testing.T) { 23 + t.Parallel() 24 + suite.Run(t, new(BranchSuite)) 25 + } 26 + 27 + func (s *BranchSuite) SetupTest() { 28 + s.RepoSuite = NewRepoSuite(s.T()) 29 + } 30 + 31 + func (s *BranchSuite) TearDownTest() { 32 + s.RepoSuite.cleanup() 33 + } 34 + 35 + func (s *BranchSuite) setupRepoWithBranches() { 36 + s.init() 37 + 38 + // get the initial commit on master 39 + head, err := s.repo.r.Head() 40 + require.NoError(s.T(), err) 41 + initialCommit := head.Hash() 42 + 43 + // create multiple branches with commits 44 + // branch-1 45 + s.createBranch("branch-1", initialCommit) 46 + s.checkoutBranch("branch-1") 47 + _ = s.commitFile("file1.txt", "content 1", "Add file1 on branch-1") 48 + 49 + // branch-2 50 + s.createBranch("branch-2", initialCommit) 51 + s.checkoutBranch("branch-2") 52 + _ = s.commitFile("file2.txt", "content 2", "Add file2 on branch-2") 53 + 54 + // branch-3 55 + s.createBranch("branch-3", initialCommit) 56 + s.checkoutBranch("branch-3") 57 + _ = s.commitFile("file3.txt", "content 3", "Add file3 on branch-3") 58 + 59 + // branch-4 60 + s.createBranch("branch-4", initialCommit) 61 + s.checkoutBranch("branch-4") 62 + s.commitFile("file4.txt", "content 4", "Add file4 on branch-4") 63 + 64 + // back to master and make a commit 65 + s.checkoutBranch("master") 66 + s.commitFile("master-file.txt", "master content", "Add file on master") 67 + 68 + // verify we have multiple branches 69 + refs, err := s.repo.r.References() 70 + require.NoError(s.T(), err) 71 + 72 + branchCount := 0 73 + err = refs.ForEach(func(ref *plumbing.Reference) error { 74 + if ref.Name().IsBranch() { 75 + branchCount++ 76 + } 77 + return nil 78 + }) 79 + require.NoError(s.T(), err) 80 + 81 + // we should have 5 branches: master, branch-1, branch-2, branch-3, branch-4 82 + assert.Equal(s.T(), 5, branchCount, "expected 5 branches") 83 + } 84 + 85 + func (s *BranchSuite) TestBranches_All() { 86 + s.setupRepoWithBranches() 87 + 88 + branches, err := s.repo.Branches(&BranchesOptions{}) 89 + require.NoError(s.T(), err) 90 + 91 + assert.Len(s.T(), branches, 5, "expected 5 branches") 92 + 93 + expectedBranches := sets.Collect(slices.Values([]string{ 94 + "master", 95 + "branch-1", 96 + "branch-2", 97 + "branch-3", 98 + "branch-4", 99 + })) 100 + 101 + for _, branch := range branches { 102 + assert.True(s.T(), expectedBranches.Contains(branch.Reference.Name), 103 + "unexpected branch: %s", branch.Reference.Name) 104 + assert.NotEmpty(s.T(), branch.Reference.Hash, "branch hash should not be empty") 105 + assert.NotNil(s.T(), branch.Commit, "branch commit should not be nil") 106 + } 107 + } 108 + 109 + func (s *BranchSuite) TestBranches_WithLimit() { 110 + s.setupRepoWithBranches() 111 + 112 + tests := []struct { 113 + name string 114 + limit int 115 + expectedCount int 116 + }{ 117 + { 118 + name: "limit 1", 119 + limit: 1, 120 + expectedCount: 1, 121 + }, 122 + { 123 + name: "limit 2", 124 + limit: 2, 125 + expectedCount: 2, 126 + }, 127 + { 128 + name: "limit 3", 129 + limit: 3, 130 + expectedCount: 3, 131 + }, 132 + { 133 + name: "limit 10 (more than available)", 134 + limit: 10, 135 + expectedCount: 5, 136 + }, 137 + } 138 + 139 + for _, tt := range tests { 140 + s.Run(tt.name, func() { 141 + branches, err := s.repo.Branches(&BranchesOptions{ 142 + Limit: tt.limit, 143 + }) 144 + require.NoError(s.T(), err) 145 + assert.Len(s.T(), branches, tt.expectedCount, "expected %d branches", tt.expectedCount) 146 + }) 147 + } 148 + } 149 + 150 + func (s *BranchSuite) TestBranches_WithOffset() { 151 + s.setupRepoWithBranches() 152 + 153 + tests := []struct { 154 + name string 155 + offset int 156 + expectedCount int 157 + }{ 158 + { 159 + name: "offset 0", 160 + offset: 0, 161 + expectedCount: 5, 162 + }, 163 + { 164 + name: "offset 1", 165 + offset: 1, 166 + expectedCount: 4, 167 + }, 168 + { 169 + name: "offset 2", 170 + offset: 2, 171 + expectedCount: 3, 172 + }, 173 + { 174 + name: "offset 4", 175 + offset: 4, 176 + expectedCount: 1, 177 + }, 178 + { 179 + name: "offset 5 (all skipped)", 180 + offset: 5, 181 + expectedCount: 0, 182 + }, 183 + { 184 + name: "offset 10 (more than available)", 185 + offset: 10, 186 + expectedCount: 0, 187 + }, 188 + } 189 + 190 + for _, tt := range tests { 191 + s.Run(tt.name, func() { 192 + branches, err := s.repo.Branches(&BranchesOptions{ 193 + Offset: tt.offset, 194 + }) 195 + require.NoError(s.T(), err) 196 + assert.Len(s.T(), branches, tt.expectedCount, "expected %d branches", tt.expectedCount) 197 + }) 198 + } 199 + } 200 + 201 + func (s *BranchSuite) TestBranches_WithLimitAndOffset() { 202 + s.setupRepoWithBranches() 203 + 204 + tests := []struct { 205 + name string 206 + limit int 207 + offset int 208 + expectedCount int 209 + }{ 210 + { 211 + name: "limit 2, offset 0", 212 + limit: 2, 213 + offset: 0, 214 + expectedCount: 2, 215 + }, 216 + { 217 + name: "limit 2, offset 1", 218 + limit: 2, 219 + offset: 1, 220 + expectedCount: 2, 221 + }, 222 + { 223 + name: "limit 2, offset 3", 224 + limit: 2, 225 + offset: 3, 226 + expectedCount: 2, 227 + }, 228 + { 229 + name: "limit 2, offset 4", 230 + limit: 2, 231 + offset: 4, 232 + expectedCount: 1, 233 + }, 234 + { 235 + name: "limit 3, offset 2", 236 + limit: 3, 237 + offset: 2, 238 + expectedCount: 3, 239 + }, 240 + { 241 + name: "limit 10, offset 3", 242 + limit: 10, 243 + offset: 3, 244 + expectedCount: 2, 245 + }, 246 + } 247 + 248 + for _, tt := range tests { 249 + s.Run(tt.name, func() { 250 + branches, err := s.repo.Branches(&BranchesOptions{ 251 + Limit: tt.limit, 252 + Offset: tt.offset, 253 + }) 254 + require.NoError(s.T(), err) 255 + assert.Len(s.T(), branches, tt.expectedCount, "expected %d branches", tt.expectedCount) 256 + }) 257 + } 258 + } 259 + 260 + func (s *BranchSuite) TestBranches_EmptyRepo() { 261 + repoPath := filepath.Join(s.tempDir, "empty-repo") 262 + 263 + _, err := gogit.PlainInit(repoPath, false) 264 + require.NoError(s.T(), err) 265 + 266 + gitRepo, err := PlainOpen(repoPath) 267 + require.NoError(s.T(), err) 268 + 269 + branches, err := gitRepo.Branches(&BranchesOptions{}) 270 + require.NoError(s.T(), err) 271 + 272 + if branches != nil { 273 + assert.Empty(s.T(), branches, "expected no branches in empty repo") 274 + } 275 + } 276 + 277 + func (s *BranchSuite) TestBranches_Pagination() { 278 + s.setupRepoWithBranches() 279 + 280 + allBranches, err := s.repo.Branches(&BranchesOptions{}) 281 + require.NoError(s.T(), err) 282 + assert.Len(s.T(), allBranches, 5, "expected 5 branches") 283 + 284 + pageSize := 2 285 + var paginatedBranches []string 286 + 287 + for offset := 0; offset < len(allBranches); offset += pageSize { 288 + branches, err := s.repo.Branches(&BranchesOptions{ 289 + Limit: pageSize, 290 + Offset: offset, 291 + }) 292 + require.NoError(s.T(), err) 293 + for _, branch := range branches { 294 + paginatedBranches = append(paginatedBranches, branch.Reference.Name) 295 + } 296 + } 297 + 298 + assert.Len(s.T(), paginatedBranches, len(allBranches), "pagination should return all branches") 299 + 300 + // create sets to verify all branches are present 301 + allBranchNames := sets.New[string]() 302 + for _, branch := range allBranches { 303 + allBranchNames.Insert(branch.Reference.Name) 304 + } 305 + 306 + paginatedBranchNames := sets.New[string]() 307 + for _, name := range paginatedBranches { 308 + paginatedBranchNames.Insert(name) 309 + } 310 + 311 + assert.EqualValues(s.T(), allBranchNames, paginatedBranchNames, 312 + "pagination should return the same set of branches") 313 + } 314 + 315 + func (s *BranchSuite) TestBranches_VerifyBranchFields() { 316 + s.setupRepoWithBranches() 317 + 318 + branches, err := s.repo.Branches(&BranchesOptions{}) 319 + require.NoError(s.T(), err) 320 + 321 + found := false 322 + for i := range branches { 323 + if branches[i].Reference.Name == "master" { 324 + found = true 325 + assert.Equal(s.T(), "master", branches[i].Reference.Name) 326 + assert.NotEmpty(s.T(), branches[i].Reference.Hash) 327 + assert.NotNil(s.T(), branches[i].Commit) 328 + assert.NotEmpty(s.T(), branches[i].Commit.Author.Name) 329 + assert.NotEmpty(s.T(), branches[i].Commit.Author.Email) 330 + assert.False(s.T(), branches[i].Commit.Hash.IsZero()) 331 + break 332 + } 333 + } 334 + 335 + assert.True(s.T(), found, "master branch not found") 336 + } 337 + 338 + func (s *BranchSuite) TestBranches_NilOptions() { 339 + s.setupRepoWithBranches() 340 + 341 + branches, err := s.repo.Branches(nil) 342 + require.NoError(s.T(), err) 343 + assert.Len(s.T(), branches, 5, "nil options should return all branches") 344 + } 345 + 346 + func (s *BranchSuite) TestBranches_ZeroLimitAndOffset() { 347 + s.setupRepoWithBranches() 348 + 349 + branches, err := s.repo.Branches(&BranchesOptions{ 350 + Limit: 0, 351 + Offset: 0, 352 + }) 353 + require.NoError(s.T(), err) 354 + assert.Len(s.T(), branches, 5, "zero limit should return all branches") 355 + }
+1 -14
knotserver/git/git.go
··· 122 122 func (g *GitRepo) TotalCommits() (int, error) { 123 123 output, err := g.revList( 124 124 g.h.String(), 125 - fmt.Sprintf("--count"), 125 + "--count", 126 126 ) 127 127 if err != nil { 128 128 return 0, fmt.Errorf("failed to run rev-list: %w", err) ··· 250 250 251 251 // path is not a submodule 252 252 return nil, ErrNotSubmodule 253 - } 254 - 255 - func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 256 - ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 257 - if err != nil { 258 - return nil, fmt.Errorf("branch: %w", err) 259 - } 260 - 261 - if !ref.Name().IsBranch() { 262 - return nil, fmt.Errorf("branch: %s is not a branch", ref.Name()) 263 - } 264 - 265 - return ref, nil 266 253 } 267 254 268 255 func (g *GitRepo) SetDefaultBranch(branch string) error {
+94 -31
knotserver/git/last_commit.go
··· 6 6 "crypto/sha256" 7 7 "fmt" 8 8 "io" 9 + "iter" 9 10 "os/exec" 10 11 "path" 12 + "strconv" 11 13 "strings" 12 14 "time" 13 15 14 16 "github.com/dgraph-io/ristretto" 15 17 "github.com/go-git/go-git/v5/plumbing" 16 - "github.com/go-git/go-git/v5/plumbing/object" 18 + "tangled.org/core/sets" 19 + "tangled.org/core/types" 17 20 ) 18 21 19 22 var ( ··· 72 75 type commit struct { 73 76 hash plumbing.Hash 74 77 when time.Time 75 - files []string 78 + files sets.Set[string] 76 79 message string 77 80 } 78 81 82 + func newCommit() commit { 83 + return commit{ 84 + files: sets.New[string](), 85 + } 86 + } 87 + 88 + type lastCommitDir struct { 89 + dir string 90 + entries []string 91 + } 92 + 93 + func (l lastCommitDir) children() iter.Seq[string] { 94 + return func(yield func(string) bool) { 95 + for _, child := range l.entries { 96 + if !yield(path.Join(l.dir, child)) { 97 + return 98 + } 99 + } 100 + } 101 + } 102 + 79 103 func cacheKey(g *GitRepo, path string) string { 80 104 sep := byte(':') 81 105 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, path)) 82 106 return fmt.Sprintf("%x", hash) 83 107 } 84 108 85 - func (g *GitRepo) calculateCommitTimeIn(ctx context.Context, subtree *object.Tree, parent string, timeout time.Duration) (map[string]commit, error) { 109 + func (g *GitRepo) lastCommitDirIn(ctx context.Context, parent lastCommitDir, timeout time.Duration) (map[string]commit, error) { 86 110 ctx, cancel := context.WithTimeout(ctx, timeout) 87 111 defer cancel() 88 - return g.calculateCommitTime(ctx, subtree, parent) 112 + return g.lastCommitDir(ctx, parent) 89 113 } 90 114 91 - func (g *GitRepo) calculateCommitTime(ctx context.Context, subtree *object.Tree, parent string) (map[string]commit, error) { 92 - filesToDo := make(map[string]struct{}) 115 + func (g *GitRepo) lastCommitDir(ctx context.Context, parent lastCommitDir) (map[string]commit, error) { 116 + filesToDo := sets.Collect(parent.children()) 93 117 filesDone := make(map[string]commit) 94 - for _, e := range subtree.Entries { 95 - fpath := path.Clean(path.Join(parent, e.Name)) 96 - filesToDo[fpath] = struct{}{} 97 - } 98 118 99 - for _, e := range subtree.Entries { 100 - f := path.Clean(path.Join(parent, e.Name)) 101 - cacheKey := cacheKey(g, f) 119 + for p := range filesToDo.All() { 120 + cacheKey := cacheKey(g, p) 102 121 if cached, ok := commitCache.Get(cacheKey); ok { 103 - filesDone[f] = cached.(commit) 104 - delete(filesToDo, f) 122 + filesDone[p] = cached.(commit) 123 + filesToDo.Remove(p) 105 124 } else { 106 - filesToDo[f] = struct{}{} 125 + filesToDo.Insert(p) 107 126 } 108 127 } 109 128 110 - if len(filesToDo) == 0 { 129 + if filesToDo.IsEmpty() { 111 130 return filesDone, nil 112 131 } 113 132 ··· 115 134 defer cancel() 116 135 117 136 pathSpec := "." 118 - if parent != "" { 119 - pathSpec = parent 137 + if parent.dir != "" { 138 + pathSpec = parent.dir 139 + } 140 + if filesToDo.Len() == 1 { 141 + // this is an optimization for the scenario where we want to calculate 142 + // the last commit for just one path, we can directly set the pathspec to that path 143 + for s := range filesToDo.All() { 144 + pathSpec = s 145 + } 120 146 } 121 - output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%s", "--date=iso", "--name-only", "--", pathSpec) 147 + 148 + output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%s", "--date=unix", "--name-only", "--", pathSpec) 122 149 if err != nil { 123 150 return nil, err 124 151 } 125 152 defer output.Close() // Ensure the git process is properly cleaned up 126 153 127 154 reader := bufio.NewReader(output) 128 - var current commit 155 + current := newCommit() 129 156 for { 130 157 line, err := reader.ReadString('\n') 131 158 if err != nil && err != io.EOF { ··· 136 163 if line == "" { 137 164 if !current.hash.IsZero() { 138 165 // we have a fully parsed commit 139 - for _, f := range current.files { 140 - if _, ok := filesToDo[f]; ok { 166 + for f := range current.files.All() { 167 + if filesToDo.Contains(f) { 141 168 filesDone[f] = current 142 - delete(filesToDo, f) 169 + filesToDo.Remove(f) 143 170 commitCache.Set(cacheKey(g, f), current, 0) 144 171 } 145 172 } 146 173 147 - if len(filesToDo) == 0 { 148 - cancel() 174 + if filesToDo.IsEmpty() { 149 175 break 150 176 } 151 - current = commit{} 177 + current = newCommit() 152 178 } 153 179 } else if current.hash.IsZero() { 154 180 parts := strings.SplitN(line, ",", 3) 155 181 if len(parts) == 3 { 156 182 current.hash = plumbing.NewHash(parts[0]) 157 - current.when, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[1]) 183 + epochTime, _ := strconv.ParseInt(parts[1], 10, 64) 184 + current.when = time.Unix(epochTime, 0) 158 185 current.message = parts[2] 159 186 } 160 187 } else { 161 188 // all ancestors along this path should also be included 162 189 file := path.Clean(line) 163 - ancestors := ancestors(file) 164 - current.files = append(current.files, file) 165 - current.files = append(current.files, ancestors...) 190 + current.files.Insert(file) 191 + for _, a := range ancestors(file) { 192 + current.files.Insert(a) 193 + } 166 194 } 167 195 168 196 if err == io.EOF { ··· 171 199 } 172 200 173 201 return filesDone, nil 202 + } 203 + 204 + // LastCommitFile returns the last commit information for a specific file path 205 + func (g *GitRepo) LastCommitFile(ctx context.Context, filePath string) (*types.LastCommitInfo, error) { 206 + parent, child := path.Split(filePath) 207 + parent = path.Clean(parent) 208 + if parent == "." { 209 + parent = "" 210 + } 211 + 212 + lastCommitDir := lastCommitDir{ 213 + dir: parent, 214 + entries: []string{child}, 215 + } 216 + 217 + times, err := g.lastCommitDirIn(ctx, lastCommitDir, 2*time.Second) 218 + if err != nil { 219 + return nil, fmt.Errorf("calculate commit time: %w", err) 220 + } 221 + 222 + // extract the only element of the map, the commit info of the current path 223 + var commitInfo *commit 224 + for _, c := range times { 225 + commitInfo = &c 226 + } 227 + 228 + if commitInfo == nil { 229 + return nil, fmt.Errorf("no commit found for path: %s", filePath) 230 + } 231 + 232 + return &types.LastCommitInfo{ 233 + Hash: commitInfo.hash, 234 + Message: commitInfo.message, 235 + When: commitInfo.when, 236 + }, nil 174 237 } 175 238 176 239 func ancestors(p string) []string {
+30 -30
knotserver/git/merge.go
··· 107 107 return fmt.Sprintf("merge failed: %s", e.Message) 108 108 } 109 109 110 - func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 110 + func createTemp(data string) (string, error) { 111 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 112 112 if err != nil { 113 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 114 114 } 115 115 116 - if _, err := tmpFile.Write([]byte(patchData)); err != nil { 116 + if _, err := tmpFile.Write([]byte(data)); err != nil { 117 117 tmpFile.Close() 118 118 os.Remove(tmpFile.Name()) 119 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 127 127 return tmpFile.Name(), nil 128 128 } 129 129 130 - func (g *GitRepo) cloneRepository(targetBranch string) (string, error) { 130 + func (g *GitRepo) cloneTemp(targetBranch string) (string, error) { 131 131 tmpDir, err := os.MkdirTemp("", "git-clone-") 132 132 if err != nil { 133 133 return "", fmt.Errorf("failed to create temporary directory: %w", err) ··· 147 147 return tmpDir, nil 148 148 } 149 149 150 - func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 151 - var stderr bytes.Buffer 152 - 153 - cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 154 - cmd.Stderr = &stderr 155 - 156 - if err := cmd.Run(); err != nil { 157 - conflicts := parseGitApplyErrors(stderr.String()) 158 - return &ErrMerge{ 159 - Message: "patch cannot be applied cleanly", 160 - Conflicts: conflicts, 161 - HasConflict: len(conflicts) > 0, 162 - OtherError: err, 163 - } 164 - } 165 - return nil 166 - } 167 - 168 150 func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 169 151 var stderr bytes.Buffer 170 152 var cmd *exec.Cmd ··· 173 155 exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 156 exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 157 exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 158 + exec.Command("git", "-C", g.path, "config", "advice.amWorkDir", "false").Run() 176 159 177 160 // if patch is a format-patch, apply using 'git am' 178 161 if opts.FormatPatch { ··· 213 196 cmd.Stderr = &stderr 214 197 215 198 if err := cmd.Run(); err != nil { 216 - return fmt.Errorf("patch application failed: %s", stderr.String()) 199 + conflicts := parseGitApplyErrors(stderr.String()) 200 + return &ErrMerge{ 201 + Message: "patch cannot be applied cleanly", 202 + Conflicts: conflicts, 203 + HasConflict: len(conflicts) > 0, 204 + OtherError: err, 205 + } 217 206 } 218 207 219 208 return nil ··· 241 230 } 242 231 243 232 func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 - tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 233 + tmpPatch, err := createTemp(singlePatch.Raw) 245 234 if err != nil { 246 235 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 236 } ··· 257 246 log.Println("head before apply", head.Hash().String()) 258 247 259 248 if err := cmd.Run(); err != nil { 260 - return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 249 + conflicts := parseGitApplyErrors(stderr.String()) 250 + return plumbing.ZeroHash, &ErrMerge{ 251 + Message: "patch cannot be applied cleanly", 252 + Conflicts: conflicts, 253 + HasConflict: len(conflicts) > 0, 254 + OtherError: err, 255 + } 261 256 } 262 257 263 258 if err := g.Refresh(); err != nil { ··· 324 319 return newHash, nil 325 320 } 326 321 327 - func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 322 + func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error { 328 323 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 329 324 return val 330 325 } 331 326 332 - patchFile, err := g.createTempFileWithPatch(patchData) 327 + patchFile, err := createTemp(patchData) 333 328 if err != nil { 334 329 return &ErrMerge{ 335 330 Message: err.Error(), ··· 338 333 } 339 334 defer os.Remove(patchFile) 340 335 341 - tmpDir, err := g.cloneRepository(targetBranch) 336 + tmpDir, err := g.cloneTemp(targetBranch) 342 337 if err != nil { 343 338 return &ErrMerge{ 344 339 Message: err.Error(), ··· 347 342 } 348 343 defer os.RemoveAll(tmpDir) 349 344 350 - result := g.checkPatch(tmpDir, patchFile) 345 + tmpRepo, err := PlainOpen(tmpDir) 346 + if err != nil { 347 + return err 348 + } 349 + 350 + result := tmpRepo.applyPatch(patchData, patchFile, mo) 351 351 mergeCheckCache.Set(g, patchData, targetBranch, result) 352 352 return result 353 353 } 354 354 355 355 func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 356 - patchFile, err := g.createTempFileWithPatch(patchData) 356 + patchFile, err := createTemp(patchData) 357 357 if err != nil { 358 358 return &ErrMerge{ 359 359 Message: err.Error(), ··· 362 362 } 363 363 defer os.Remove(patchFile) 364 364 365 - tmpDir, err := g.cloneRepository(targetBranch) 365 + tmpDir, err := g.cloneTemp(targetBranch) 366 366 if err != nil { 367 367 return &ErrMerge{ 368 368 Message: err.Error(),
+706
knotserver/git/merge_test.go
··· 1 + package git 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/go-git/go-git/v5" 10 + "github.com/go-git/go-git/v5/config" 11 + "github.com/go-git/go-git/v5/plumbing" 12 + "github.com/go-git/go-git/v5/plumbing/object" 13 + "github.com/stretchr/testify/assert" 14 + "github.com/stretchr/testify/require" 15 + ) 16 + 17 + type Helper struct { 18 + t *testing.T 19 + tempDir string 20 + repo *GitRepo 21 + } 22 + 23 + func helper(t *testing.T) *Helper { 24 + tempDir, err := os.MkdirTemp("", "git-merge-test-*") 25 + require.NoError(t, err) 26 + 27 + return &Helper{ 28 + t: t, 29 + tempDir: tempDir, 30 + } 31 + } 32 + 33 + func (h *Helper) cleanup() { 34 + if h.tempDir != "" { 35 + os.RemoveAll(h.tempDir) 36 + } 37 + } 38 + 39 + // initRepo initializes a git repository with an initial commit 40 + func (h *Helper) initRepo() *GitRepo { 41 + repoPath := filepath.Join(h.tempDir, "test-repo") 42 + 43 + // initialize repository 44 + r, err := git.PlainInit(repoPath, false) 45 + require.NoError(h.t, err) 46 + 47 + // configure git user 48 + cfg, err := r.Config() 49 + require.NoError(h.t, err) 50 + cfg.User.Name = "Test User" 51 + cfg.User.Email = "test@example.com" 52 + err = r.SetConfig(cfg) 53 + require.NoError(h.t, err) 54 + 55 + // create initial commit with a file 56 + w, err := r.Worktree() 57 + require.NoError(h.t, err) 58 + 59 + // create initial file 60 + initialFile := filepath.Join(repoPath, "README.md") 61 + err = os.WriteFile(initialFile, []byte("# Test Repository\n\nInitial content.\n"), 0644) 62 + require.NoError(h.t, err) 63 + 64 + _, err = w.Add("README.md") 65 + require.NoError(h.t, err) 66 + 67 + _, err = w.Commit("Initial commit", &git.CommitOptions{ 68 + Author: &object.Signature{ 69 + Name: "Test User", 70 + Email: "test@example.com", 71 + }, 72 + }) 73 + require.NoError(h.t, err) 74 + 75 + gitRepo, err := PlainOpen(repoPath) 76 + require.NoError(h.t, err) 77 + 78 + h.repo = gitRepo 79 + return gitRepo 80 + } 81 + 82 + // addFile creates a file in the repository 83 + func (h *Helper) addFile(filename, content string) { 84 + filePath := filepath.Join(h.repo.path, filename) 85 + dir := filepath.Dir(filePath) 86 + 87 + err := os.MkdirAll(dir, 0755) 88 + require.NoError(h.t, err) 89 + 90 + err = os.WriteFile(filePath, []byte(content), 0644) 91 + require.NoError(h.t, err) 92 + } 93 + 94 + // commitFile adds and commits a file 95 + func (h *Helper) commitFile(filename, content, message string) plumbing.Hash { 96 + h.addFile(filename, content) 97 + 98 + w, err := h.repo.r.Worktree() 99 + require.NoError(h.t, err) 100 + 101 + _, err = w.Add(filename) 102 + require.NoError(h.t, err) 103 + 104 + hash, err := w.Commit(message, &git.CommitOptions{ 105 + Author: &object.Signature{ 106 + Name: "Test User", 107 + Email: "test@example.com", 108 + }, 109 + }) 110 + require.NoError(h.t, err) 111 + 112 + return hash 113 + } 114 + 115 + // readFile reads a file from the repository 116 + func (h *Helper) readFile(filename string) string { 117 + content, err := os.ReadFile(filepath.Join(h.repo.path, filename)) 118 + require.NoError(h.t, err) 119 + return string(content) 120 + } 121 + 122 + // fileExists checks if a file exists in the repository 123 + func (h *Helper) fileExists(filename string) bool { 124 + _, err := os.Stat(filepath.Join(h.repo.path, filename)) 125 + return err == nil 126 + } 127 + 128 + func TestApplyPatch_Success(t *testing.T) { 129 + h := helper(t) 130 + defer h.cleanup() 131 + 132 + repo := h.initRepo() 133 + 134 + // modify README.md 135 + patch := `diff --git a/README.md b/README.md 136 + index 1234567..abcdefg 100644 137 + --- a/README.md 138 + +++ b/README.md 139 + @@ -1,3 +1,3 @@ 140 + # Test Repository 141 + 142 + -Initial content. 143 + +Modified content. 144 + ` 145 + 146 + patchFile, err := createTemp(patch) 147 + require.NoError(t, err) 148 + defer os.Remove(patchFile) 149 + 150 + opts := MergeOptions{ 151 + CommitMessage: "Apply test patch", 152 + CommitterName: "Test Committer", 153 + CommitterEmail: "committer@example.com", 154 + FormatPatch: false, 155 + } 156 + 157 + err = repo.applyPatch(patch, patchFile, opts) 158 + assert.NoError(t, err) 159 + 160 + // verify the file was modified 161 + content := h.readFile("README.md") 162 + assert.Contains(t, content, "Modified content.") 163 + } 164 + 165 + func TestApplyPatch_AddNewFile(t *testing.T) { 166 + h := helper(t) 167 + defer h.cleanup() 168 + 169 + repo := h.initRepo() 170 + 171 + // add a new file 172 + patch := `diff --git a/newfile.txt b/newfile.txt 173 + new file mode 100644 174 + index 0000000..ce01362 175 + --- /dev/null 176 + +++ b/newfile.txt 177 + @@ -0,0 +1 @@ 178 + +hello 179 + ` 180 + 181 + patchFile, err := createTemp(patch) 182 + require.NoError(t, err) 183 + defer os.Remove(patchFile) 184 + 185 + opts := MergeOptions{ 186 + CommitMessage: "Add new file", 187 + CommitterName: "Test Committer", 188 + CommitterEmail: "committer@example.com", 189 + FormatPatch: false, 190 + } 191 + 192 + err = repo.applyPatch(patch, patchFile, opts) 193 + assert.NoError(t, err) 194 + 195 + assert.True(t, h.fileExists("newfile.txt")) 196 + content := h.readFile("newfile.txt") 197 + assert.Equal(t, "hello\n", content) 198 + } 199 + 200 + func TestApplyPatch_DeleteFile(t *testing.T) { 201 + h := helper(t) 202 + defer h.cleanup() 203 + 204 + repo := h.initRepo() 205 + 206 + // add a file 207 + h.commitFile("deleteme.txt", "content to delete\n", "Add file to delete") 208 + 209 + // delete the file 210 + patch := `diff --git a/deleteme.txt b/deleteme.txt 211 + deleted file mode 100644 212 + index 1234567..0000000 213 + --- a/deleteme.txt 214 + +++ /dev/null 215 + @@ -1 +0,0 @@ 216 + -content to delete 217 + ` 218 + 219 + patchFile, err := createTemp(patch) 220 + require.NoError(t, err) 221 + defer os.Remove(patchFile) 222 + 223 + opts := MergeOptions{ 224 + CommitMessage: "Delete file", 225 + CommitterName: "Test Committer", 226 + CommitterEmail: "committer@example.com", 227 + FormatPatch: false, 228 + } 229 + 230 + err = repo.applyPatch(patch, patchFile, opts) 231 + assert.NoError(t, err) 232 + 233 + assert.False(t, h.fileExists("deleteme.txt")) 234 + } 235 + 236 + func TestApplyPatch_WithAuthor(t *testing.T) { 237 + h := helper(t) 238 + defer h.cleanup() 239 + 240 + repo := h.initRepo() 241 + 242 + patch := `diff --git a/README.md b/README.md 243 + index 1234567..abcdefg 100644 244 + --- a/README.md 245 + +++ b/README.md 246 + @@ -1,3 +1,4 @@ 247 + # Test Repository 248 + 249 + Initial content. 250 + +New line. 251 + ` 252 + 253 + patchFile, err := createTemp(patch) 254 + require.NoError(t, err) 255 + defer os.Remove(patchFile) 256 + 257 + opts := MergeOptions{ 258 + CommitMessage: "Patch with author", 259 + AuthorName: "Patch Author", 260 + AuthorEmail: "author@example.com", 261 + CommitterName: "Test Committer", 262 + CommitterEmail: "committer@example.com", 263 + FormatPatch: false, 264 + } 265 + 266 + err = repo.applyPatch(patch, patchFile, opts) 267 + assert.NoError(t, err) 268 + 269 + head, err := repo.r.Head() 270 + require.NoError(t, err) 271 + 272 + commit, err := repo.r.CommitObject(head.Hash()) 273 + require.NoError(t, err) 274 + 275 + assert.Equal(t, "Patch Author", commit.Author.Name) 276 + assert.Equal(t, "author@example.com", commit.Author.Email) 277 + } 278 + 279 + func TestApplyPatch_MissingFile(t *testing.T) { 280 + h := helper(t) 281 + defer h.cleanup() 282 + 283 + repo := h.initRepo() 284 + 285 + // patch that modifies a non-existent file 286 + patch := `diff --git a/nonexistent.txt b/nonexistent.txt 287 + index 1234567..abcdefg 100644 288 + --- a/nonexistent.txt 289 + +++ b/nonexistent.txt 290 + @@ -1 +1 @@ 291 + -old content 292 + +new content 293 + ` 294 + 295 + patchFile, err := createTemp(patch) 296 + require.NoError(t, err) 297 + defer os.Remove(patchFile) 298 + 299 + opts := MergeOptions{ 300 + CommitMessage: "Should fail", 301 + CommitterName: "Test Committer", 302 + CommitterEmail: "committer@example.com", 303 + FormatPatch: false, 304 + } 305 + 306 + err = repo.applyPatch(patch, patchFile, opts) 307 + assert.Error(t, err) 308 + assert.Contains(t, err.Error(), "patch application failed") 309 + } 310 + 311 + func TestApplyPatch_Conflict(t *testing.T) { 312 + h := helper(t) 313 + defer h.cleanup() 314 + 315 + repo := h.initRepo() 316 + 317 + // modify the file to create a conflict 318 + h.commitFile("README.md", "# Test Repository\n\nDifferent content.\n", "Modify README") 319 + 320 + // patch that expects different content 321 + patch := `diff --git a/README.md b/README.md 322 + index 1234567..abcdefg 100644 323 + --- a/README.md 324 + +++ b/README.md 325 + @@ -1,3 +1,3 @@ 326 + # Test Repository 327 + 328 + -Initial content. 329 + +Modified content. 330 + ` 331 + 332 + patchFile, err := createTemp(patch) 333 + require.NoError(t, err) 334 + defer os.Remove(patchFile) 335 + 336 + opts := MergeOptions{ 337 + CommitMessage: "Should conflict", 338 + CommitterName: "Test Committer", 339 + CommitterEmail: "committer@example.com", 340 + FormatPatch: false, 341 + } 342 + 343 + err = repo.applyPatch(patch, patchFile, opts) 344 + assert.Error(t, err) 345 + } 346 + 347 + func TestApplyPatch_MissingDirectory(t *testing.T) { 348 + h := helper(t) 349 + defer h.cleanup() 350 + 351 + repo := h.initRepo() 352 + 353 + // patch that adds a file in a non-existent directory 354 + patch := `diff --git a/subdir/newfile.txt b/subdir/newfile.txt 355 + new file mode 100644 356 + index 0000000..ce01362 357 + --- /dev/null 358 + +++ b/subdir/newfile.txt 359 + @@ -0,0 +1 @@ 360 + +content 361 + ` 362 + 363 + patchFile, err := createTemp(patch) 364 + require.NoError(t, err) 365 + defer os.Remove(patchFile) 366 + 367 + opts := MergeOptions{ 368 + CommitMessage: "Add file in subdir", 369 + CommitterName: "Test Committer", 370 + CommitterEmail: "committer@example.com", 371 + FormatPatch: false, 372 + } 373 + 374 + // git apply should create the directory automatically 375 + err = repo.applyPatch(patch, patchFile, opts) 376 + assert.NoError(t, err) 377 + 378 + // Verify the file and directory were created 379 + assert.True(t, h.fileExists("subdir/newfile.txt")) 380 + } 381 + 382 + func TestApplyMailbox_Single(t *testing.T) { 383 + h := helper(t) 384 + defer h.cleanup() 385 + 386 + repo := h.initRepo() 387 + 388 + // format-patch mailbox format 389 + patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 390 + From: Patch Author <author@example.com> 391 + Date: Mon, 1 Jan 2024 12:00:00 +0000 392 + Subject: [PATCH] Add new feature 393 + 394 + This is a test patch. 395 + --- 396 + newfile.txt | 1 + 397 + 1 file changed, 1 insertion(+) 398 + create mode 100644 newfile.txt 399 + 400 + diff --git a/newfile.txt b/newfile.txt 401 + new file mode 100644 402 + index 0000000..ce01362 403 + --- /dev/null 404 + +++ b/newfile.txt 405 + @@ -0,0 +1 @@ 406 + +hello 407 + -- 408 + 2.40.0 409 + ` 410 + 411 + err := repo.applyMailbox(patch) 412 + assert.NoError(t, err) 413 + 414 + assert.True(t, h.fileExists("newfile.txt")) 415 + content := h.readFile("newfile.txt") 416 + assert.Equal(t, "hello\n", content) 417 + } 418 + 419 + func TestApplyMailbox_Multiple(t *testing.T) { 420 + h := helper(t) 421 + defer h.cleanup() 422 + 423 + repo := h.initRepo() 424 + 425 + // multiple patches in mailbox format 426 + patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 427 + From: Patch Author <author@example.com> 428 + Date: Mon, 1 Jan 2024 12:00:00 +0000 429 + Subject: [PATCH 1/2] Add first file 430 + 431 + --- 432 + file1.txt | 1 + 433 + 1 file changed, 1 insertion(+) 434 + create mode 100644 file1.txt 435 + 436 + diff --git a/file1.txt b/file1.txt 437 + new file mode 100644 438 + index 0000000..ce01362 439 + --- /dev/null 440 + +++ b/file1.txt 441 + @@ -0,0 +1 @@ 442 + +first 443 + -- 444 + 2.40.0 445 + 446 + From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 447 + From: Patch Author <author@example.com> 448 + Date: Mon, 1 Jan 2024 12:01:00 +0000 449 + Subject: [PATCH 2/2] Add second file 450 + 451 + --- 452 + file2.txt | 1 + 453 + 1 file changed, 1 insertion(+) 454 + create mode 100644 file2.txt 455 + 456 + diff --git a/file2.txt b/file2.txt 457 + new file mode 100644 458 + index 0000000..ce01362 459 + --- /dev/null 460 + +++ b/file2.txt 461 + @@ -0,0 +1 @@ 462 + +second 463 + -- 464 + 2.40.0 465 + ` 466 + 467 + err := repo.applyMailbox(patch) 468 + assert.NoError(t, err) 469 + 470 + assert.True(t, h.fileExists("file1.txt")) 471 + assert.True(t, h.fileExists("file2.txt")) 472 + 473 + content1 := h.readFile("file1.txt") 474 + assert.Equal(t, "first\n", content1) 475 + 476 + content2 := h.readFile("file2.txt") 477 + assert.Equal(t, "second\n", content2) 478 + } 479 + 480 + func TestApplyMailbox_Conflict(t *testing.T) { 481 + h := helper(t) 482 + defer h.cleanup() 483 + 484 + repo := h.initRepo() 485 + 486 + h.commitFile("README.md", "# Test Repository\n\nConflicting content.\n", "Create conflict") 487 + 488 + patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 489 + From: Patch Author <author@example.com> 490 + Date: Mon, 1 Jan 2024 12:00:00 +0000 491 + Subject: [PATCH] Modify README 492 + 493 + --- 494 + README.md | 2 +- 495 + 1 file changed, 1 insertion(+), 1 deletion(-) 496 + 497 + diff --git a/README.md b/README.md 498 + index 1234567..abcdefg 100644 499 + --- a/README.md 500 + +++ b/README.md 501 + @@ -1,3 +1,3 @@ 502 + # Test Repository 503 + 504 + -Initial content. 505 + +Different content. 506 + -- 507 + 2.40.0 508 + ` 509 + 510 + err := repo.applyMailbox(patch) 511 + assert.Error(t, err) 512 + 513 + var mergeErr *ErrMerge 514 + assert.ErrorAs(t, err, &mergeErr) 515 + } 516 + 517 + func TestParseGitApplyErrors(t *testing.T) { 518 + tests := []struct { 519 + name string 520 + errorOutput string 521 + expectedCount int 522 + expectedReason string 523 + }{ 524 + { 525 + name: "file already exists", 526 + errorOutput: `error: path/to/file.txt: already exists in working directory`, 527 + expectedCount: 1, 528 + expectedReason: "file already exists", 529 + }, 530 + { 531 + name: "file does not exist", 532 + errorOutput: `error: path/to/file.txt: does not exist in working tree`, 533 + expectedCount: 1, 534 + expectedReason: "file does not exist", 535 + }, 536 + { 537 + name: "patch does not apply", 538 + errorOutput: `error: patch failed: file.txt:10 539 + error: file.txt: patch does not apply`, 540 + expectedCount: 1, 541 + expectedReason: "patch does not apply", 542 + }, 543 + { 544 + name: "multiple conflicts", 545 + errorOutput: `error: patch failed: file1.txt:5 546 + error: file1.txt:5: some error 547 + error: patch failed: file2.txt:10 548 + error: file2.txt:10: another error`, 549 + expectedCount: 2, 550 + }, 551 + } 552 + 553 + for _, tt := range tests { 554 + t.Run(tt.name, func(t *testing.T) { 555 + conflicts := parseGitApplyErrors(tt.errorOutput) 556 + assert.Len(t, conflicts, tt.expectedCount) 557 + 558 + if tt.expectedReason != "" && len(conflicts) > 0 { 559 + assert.Equal(t, tt.expectedReason, conflicts[0].Reason) 560 + } 561 + }) 562 + } 563 + } 564 + 565 + func TestErrMerge_Error(t *testing.T) { 566 + tests := []struct { 567 + name string 568 + err ErrMerge 569 + expectedMsg string 570 + }{ 571 + { 572 + name: "with conflicts", 573 + err: ErrMerge{ 574 + Message: "test merge failed", 575 + HasConflict: true, 576 + Conflicts: []ConflictInfo{ 577 + {Filename: "file1.txt", Reason: "conflict 1"}, 578 + {Filename: "file2.txt", Reason: "conflict 2"}, 579 + }, 580 + }, 581 + expectedMsg: "merge failed due to conflicts: test merge failed (2 conflicts)", 582 + }, 583 + { 584 + name: "with other error", 585 + err: ErrMerge{ 586 + Message: "command failed", 587 + OtherError: assert.AnError, 588 + }, 589 + expectedMsg: "merge failed: command failed:", 590 + }, 591 + { 592 + name: "message only", 593 + err: ErrMerge{ 594 + Message: "simple failure", 595 + }, 596 + expectedMsg: "merge failed: simple failure", 597 + }, 598 + } 599 + 600 + for _, tt := range tests { 601 + t.Run(tt.name, func(t *testing.T) { 602 + errMsg := tt.err.Error() 603 + assert.Contains(t, errMsg, tt.expectedMsg) 604 + }) 605 + } 606 + } 607 + 608 + func TestMergeWithOptions_Integration(t *testing.T) { 609 + h := helper(t) 610 + defer h.cleanup() 611 + 612 + // create a repository first with initial content 613 + workRepoPath := filepath.Join(h.tempDir, "work-repo") 614 + workRepo, err := git.PlainInit(workRepoPath, false) 615 + require.NoError(t, err) 616 + 617 + // configure git user 618 + cfg, err := workRepo.Config() 619 + require.NoError(t, err) 620 + cfg.User.Name = "Test User" 621 + cfg.User.Email = "test@example.com" 622 + err = workRepo.SetConfig(cfg) 623 + require.NoError(t, err) 624 + 625 + // Create initial commit 626 + w, err := workRepo.Worktree() 627 + require.NoError(t, err) 628 + 629 + err = os.WriteFile(filepath.Join(workRepoPath, "README.md"), []byte("# Initial\n"), 0644) 630 + require.NoError(t, err) 631 + 632 + _, err = w.Add("README.md") 633 + require.NoError(t, err) 634 + 635 + _, err = w.Commit("Initial commit", &git.CommitOptions{ 636 + Author: &object.Signature{ 637 + Name: "Test User", 638 + Email: "test@example.com", 639 + }, 640 + }) 641 + require.NoError(t, err) 642 + 643 + // create a bare repository (like production) 644 + bareRepoPath := filepath.Join(h.tempDir, "bare-repo") 645 + err = InitBare(bareRepoPath, "main") 646 + require.NoError(t, err) 647 + 648 + // add bare repo as remote and push to it 649 + _, err = workRepo.CreateRemote(&config.RemoteConfig{ 650 + Name: "origin", 651 + URLs: []string{"file://" + bareRepoPath}, 652 + }) 653 + require.NoError(t, err) 654 + 655 + err = workRepo.Push(&git.PushOptions{ 656 + RemoteName: "origin", 657 + RefSpecs: []config.RefSpec{"refs/heads/master:refs/heads/main"}, 658 + }) 659 + require.NoError(t, err) 660 + 661 + // now merge a patch into the bare repo 662 + gitRepo, err := PlainOpen(bareRepoPath) 663 + require.NoError(t, err) 664 + 665 + patch := `diff --git a/feature.txt b/feature.txt 666 + new file mode 100644 667 + index 0000000..5e1c309 668 + --- /dev/null 669 + +++ b/feature.txt 670 + @@ -0,0 +1 @@ 671 + +Hello World 672 + ` 673 + 674 + opts := MergeOptions{ 675 + CommitMessage: "Add feature", 676 + CommitterName: "Test Committer", 677 + CommitterEmail: "committer@example.com", 678 + FormatPatch: false, 679 + } 680 + 681 + err = gitRepo.MergeWithOptions(patch, "main", opts) 682 + assert.NoError(t, err) 683 + 684 + // Clone again and verify the changes were merged 685 + verifyRepoPath := filepath.Join(h.tempDir, "verify-repo") 686 + verifyRepo, err := git.PlainClone(verifyRepoPath, false, &git.CloneOptions{ 687 + URL: "file://" + bareRepoPath, 688 + }) 689 + require.NoError(t, err) 690 + 691 + // check that feature.txt exists 692 + featureFile := filepath.Join(verifyRepoPath, "feature.txt") 693 + assert.FileExists(t, featureFile) 694 + 695 + content, err := os.ReadFile(featureFile) 696 + require.NoError(t, err) 697 + assert.Equal(t, "Hello World\n", string(content)) 698 + 699 + // verify commit message 700 + head, err := verifyRepo.Head() 701 + require.NoError(t, err) 702 + 703 + commit, err := verifyRepo.CommitObject(head.Hash()) 704 + require.NoError(t, err) 705 + assert.Equal(t, "Add feature", strings.TrimSpace(commit.Message)) 706 + }
+1 -1
knotserver/git/post_receive.go
··· 95 95 // git rev-list <newsha> ^other-branches --not ^this-branch 96 96 args = append(args, line.NewSha.String()) 97 97 98 - branches, _ := g.Branches() 98 + branches, _ := g.Branches(nil) 99 99 for _, b := range branches { 100 100 if !strings.Contains(line.Ref, b.Name) { 101 101 args = append(args, fmt.Sprintf("^%s", b.Name))
+58 -8
knotserver/git/tag.go
··· 10 10 "github.com/go-git/go-git/v5/plumbing/object" 11 11 ) 12 12 13 - func (g *GitRepo) Tags() ([]object.Tag, error) { 13 + type TagsOptions struct { 14 + Limit int 15 + Offset int 16 + Pattern string 17 + } 18 + 19 + func (g *GitRepo) Tags(opts *TagsOptions) ([]object.Tag, error) { 20 + if opts == nil { 21 + opts = &TagsOptions{} 22 + } 23 + 24 + if opts.Pattern == "" { 25 + opts.Pattern = "refs/tags" 26 + } 27 + 14 28 fields := []string{ 15 29 "refname:short", 16 30 "objectname", ··· 20 34 "taggername", 21 35 "taggeremail", 22 36 "taggerdate:unix", 23 - "contents", 37 + "contents:subject", 38 + "contents:body", 39 + "contents:signature", 24 40 } 25 41 26 42 var outFormat strings.Builder ··· 29 45 if i != 0 { 30 46 outFormat.WriteString(fieldSeparator) 31 47 } 32 - outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 48 + fmt.Fprintf(&outFormat, "%%(%s)", f) 33 49 } 34 50 outFormat.WriteString("") 35 51 outFormat.WriteString(recordSeparator) 36 52 37 - output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 53 + args := []string{outFormat.String(), "--sort=-creatordate"} 54 + 55 + // only add the count if the limit is a non-zero value, 56 + // if it is zero, get as many tags as we can 57 + if opts.Limit > 0 { 58 + args = append(args, fmt.Sprintf("--count=%d", opts.Offset+opts.Limit)) 59 + } 60 + 61 + args = append(args, opts.Pattern) 62 + 63 + output, err := g.forEachRef(args...) 38 64 if err != nil { 39 65 return nil, fmt.Errorf("failed to get tags: %w", err) 40 66 } ··· 44 70 return nil, nil 45 71 } 46 72 73 + startIdx := opts.Offset 74 + if startIdx >= len(records) { 75 + return nil, nil 76 + } 77 + 78 + endIdx := len(records) 79 + if opts.Limit > 0 { 80 + endIdx = min(startIdx+opts.Limit, len(records)) 81 + } 82 + 83 + records = records[startIdx:endIdx] 47 84 tags := make([]object.Tag, 0, len(records)) 48 85 49 86 for _, line := range records { ··· 60 97 taggerName := parts[5] 61 98 taggerEmail := parts[6] 62 99 taggerDate := parts[7] 63 - message := parts[8] 100 + subject := parts[8] 101 + body := parts[9] 102 + signature := parts[10] 103 + 104 + // combine subject and body for the message 105 + var message string 106 + if subject != "" && body != "" { 107 + message = subject + "\n\n" + body 108 + } else if subject != "" { 109 + message = subject 110 + } else { 111 + message = body 112 + } 64 113 65 114 // parse creation time 66 115 var createdAt time.Time ··· 85 134 Email: taggerEmail, 86 135 When: createdAt, 87 136 }, 88 - Message: message, 89 - TargetType: typ, 90 - Target: plumbing.NewHash(targetHash), 137 + Message: message, 138 + PGPSignature: signature, 139 + TargetType: typ, 140 + Target: plumbing.NewHash(targetHash), 91 141 } 92 142 93 143 tags = append(tags, tag)
+386
knotserver/git/tag_test.go
··· 1 + package git 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + "time" 7 + 8 + gogit "github.com/go-git/go-git/v5" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/go-git/go-git/v5/plumbing/object" 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + "github.com/stretchr/testify/suite" 14 + ) 15 + 16 + type TagSuite struct { 17 + suite.Suite 18 + *RepoSuite 19 + } 20 + 21 + func TestTagSuite(t *testing.T) { 22 + t.Parallel() 23 + suite.Run(t, new(TagSuite)) 24 + } 25 + 26 + func (s *TagSuite) SetupTest() { 27 + s.RepoSuite = NewRepoSuite(s.T()) 28 + } 29 + 30 + func (s *TagSuite) TearDownTest() { 31 + s.RepoSuite.cleanup() 32 + } 33 + 34 + func (s *TagSuite) setupRepoWithTags() { 35 + s.init() 36 + 37 + // create commits for tagging 38 + commit1 := s.commitFile("file1.txt", "content 1", "Add file1") 39 + commit2 := s.commitFile("file2.txt", "content 2", "Add file2") 40 + commit3 := s.commitFile("file3.txt", "content 3", "Add file3") 41 + commit4 := s.commitFile("file4.txt", "content 4", "Add file4") 42 + commit5 := s.commitFile("file5.txt", "content 5", "Add file5") 43 + 44 + // create annotated tags 45 + s.createAnnotatedTag( 46 + "v1.0.0", 47 + commit1, 48 + "Tagger One", 49 + "tagger1@example.com", 50 + "Release version 1.0.0\n\nThis is the first stable release.", 51 + s.baseTime.Add(1*time.Hour), 52 + ) 53 + 54 + s.createAnnotatedTag( 55 + "v1.1.0", 56 + commit2, 57 + "Tagger Two", 58 + "tagger2@example.com", 59 + "Release version 1.1.0", 60 + s.baseTime.Add(2*time.Hour), 61 + ) 62 + 63 + // create lightweight tags 64 + s.createLightweightTag("v2.0.0", commit3) 65 + s.createLightweightTag("v2.1.0", commit4) 66 + 67 + // create another annotated tag 68 + s.createAnnotatedTag( 69 + "v3.0.0", 70 + commit5, 71 + "Tagger Three", 72 + "tagger3@example.com", 73 + "Major version 3.0.0\n\nBreaking changes included.", 74 + s.baseTime.Add(3*time.Hour), 75 + ) 76 + } 77 + 78 + func (s *TagSuite) TestTags_All() { 79 + s.setupRepoWithTags() 80 + 81 + tags, err := s.repo.Tags(nil) 82 + require.NoError(s.T(), err) 83 + 84 + // we created 5 tags total (3 annotated, 2 lightweight) 85 + assert.Len(s.T(), tags, 5, "expected 5 tags") 86 + 87 + // verify tags are sorted by creation date (newest first) 88 + expectedAnnotated := map[string]bool{ 89 + "v1.0.0": true, 90 + "v1.1.0": true, 91 + "v3.0.0": true, 92 + } 93 + 94 + expectedLightweight := map[string]bool{ 95 + "v2.0.0": true, 96 + "v2.1.0": true, 97 + } 98 + 99 + for _, tag := range tags { 100 + if expectedAnnotated[tag.Name] { 101 + // annotated tags should have tagger info 102 + assert.NotEmpty(s.T(), tag.Tagger.Name, "annotated tag %s should have tagger name", tag.Name) 103 + assert.NotEmpty(s.T(), tag.Message, "annotated tag %s should have message", tag.Name) 104 + } else if expectedLightweight[tag.Name] { 105 + // lightweight tags won't have tagger info or message (they'll have empty values) 106 + } else { 107 + s.T().Errorf("unexpected tag name: %s", tag.Name) 108 + } 109 + } 110 + } 111 + 112 + func (s *TagSuite) TestTags_WithLimit() { 113 + s.setupRepoWithTags() 114 + 115 + tests := []struct { 116 + name string 117 + limit int 118 + expectedCount int 119 + }{ 120 + { 121 + name: "limit 1", 122 + limit: 1, 123 + expectedCount: 1, 124 + }, 125 + { 126 + name: "limit 2", 127 + limit: 2, 128 + expectedCount: 2, 129 + }, 130 + { 131 + name: "limit 3", 132 + limit: 3, 133 + expectedCount: 3, 134 + }, 135 + { 136 + name: "limit 10 (more than available)", 137 + limit: 10, 138 + expectedCount: 5, 139 + }, 140 + } 141 + 142 + for _, tt := range tests { 143 + s.Run(tt.name, func() { 144 + tags, err := s.repo.Tags(&TagsOptions{ 145 + Limit: tt.limit, 146 + }) 147 + require.NoError(s.T(), err) 148 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 149 + }) 150 + } 151 + } 152 + 153 + func (s *TagSuite) TestTags_WithOffset() { 154 + s.setupRepoWithTags() 155 + 156 + tests := []struct { 157 + name string 158 + offset int 159 + expectedCount int 160 + }{ 161 + { 162 + name: "offset 0", 163 + offset: 0, 164 + expectedCount: 5, 165 + }, 166 + { 167 + name: "offset 1", 168 + offset: 1, 169 + expectedCount: 4, 170 + }, 171 + { 172 + name: "offset 2", 173 + offset: 2, 174 + expectedCount: 3, 175 + }, 176 + { 177 + name: "offset 4", 178 + offset: 4, 179 + expectedCount: 1, 180 + }, 181 + { 182 + name: "offset 5 (all skipped)", 183 + offset: 5, 184 + expectedCount: 0, 185 + }, 186 + { 187 + name: "offset 10 (more than available)", 188 + offset: 10, 189 + expectedCount: 0, 190 + }, 191 + } 192 + 193 + for _, tt := range tests { 194 + s.Run(tt.name, func() { 195 + tags, err := s.repo.Tags(&TagsOptions{ 196 + Offset: tt.offset, 197 + }) 198 + require.NoError(s.T(), err) 199 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 200 + }) 201 + } 202 + } 203 + 204 + func (s *TagSuite) TestTags_WithLimitAndOffset() { 205 + s.setupRepoWithTags() 206 + 207 + tests := []struct { 208 + name string 209 + limit int 210 + offset int 211 + expectedCount int 212 + }{ 213 + { 214 + name: "limit 2, offset 0", 215 + limit: 2, 216 + offset: 0, 217 + expectedCount: 2, 218 + }, 219 + { 220 + name: "limit 2, offset 1", 221 + limit: 2, 222 + offset: 1, 223 + expectedCount: 2, 224 + }, 225 + { 226 + name: "limit 2, offset 3", 227 + limit: 2, 228 + offset: 3, 229 + expectedCount: 2, 230 + }, 231 + { 232 + name: "limit 2, offset 4", 233 + limit: 2, 234 + offset: 4, 235 + expectedCount: 1, 236 + }, 237 + { 238 + name: "limit 3, offset 2", 239 + limit: 3, 240 + offset: 2, 241 + expectedCount: 3, 242 + }, 243 + { 244 + name: "limit 10, offset 3", 245 + limit: 10, 246 + offset: 3, 247 + expectedCount: 2, 248 + }, 249 + } 250 + 251 + for _, tt := range tests { 252 + s.Run(tt.name, func() { 253 + tags, err := s.repo.Tags(&TagsOptions{ 254 + Limit: tt.limit, 255 + Offset: tt.offset, 256 + }) 257 + require.NoError(s.T(), err) 258 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 259 + }) 260 + } 261 + } 262 + 263 + func (s *TagSuite) TestTags_EmptyRepo() { 264 + repoPath := filepath.Join(s.tempDir, "empty-repo") 265 + 266 + _, err := gogit.PlainInit(repoPath, false) 267 + require.NoError(s.T(), err) 268 + 269 + gitRepo, err := PlainOpen(repoPath) 270 + require.NoError(s.T(), err) 271 + 272 + tags, err := gitRepo.Tags(nil) 273 + require.NoError(s.T(), err) 274 + 275 + if tags != nil { 276 + assert.Empty(s.T(), tags, "expected no tags in empty repo") 277 + } 278 + } 279 + 280 + func (s *TagSuite) TestTags_Pagination() { 281 + s.setupRepoWithTags() 282 + 283 + allTags, err := s.repo.Tags(nil) 284 + require.NoError(s.T(), err) 285 + assert.Len(s.T(), allTags, 5, "expected 5 tags") 286 + 287 + pageSize := 2 288 + var paginatedTags []object.Tag 289 + 290 + for offset := 0; offset < len(allTags); offset += pageSize { 291 + tags, err := s.repo.Tags(&TagsOptions{ 292 + Limit: pageSize, 293 + Offset: offset, 294 + }) 295 + require.NoError(s.T(), err) 296 + paginatedTags = append(paginatedTags, tags...) 297 + } 298 + 299 + assert.Len(s.T(), paginatedTags, len(allTags), "pagination should return all tags") 300 + 301 + for i := range allTags { 302 + assert.Equal(s.T(), allTags[i].Name, paginatedTags[i].Name, 303 + "tag at index %d differs", i) 304 + } 305 + } 306 + 307 + func (s *TagSuite) TestTags_VerifyAnnotatedTagFields() { 308 + s.setupRepoWithTags() 309 + 310 + tags, err := s.repo.Tags(nil) 311 + require.NoError(s.T(), err) 312 + 313 + var v1Tag *object.Tag 314 + for i := range tags { 315 + if tags[i].Name == "v1.0.0" { 316 + v1Tag = &tags[i] 317 + break 318 + } 319 + } 320 + 321 + require.NotNil(s.T(), v1Tag, "v1.0.0 tag not found") 322 + 323 + assert.Equal(s.T(), "Tagger One", v1Tag.Tagger.Name, "tagger name should match") 324 + assert.Equal(s.T(), "tagger1@example.com", v1Tag.Tagger.Email, "tagger email should match") 325 + 326 + assert.Equal(s.T(), "Release version 1.0.0\n\nThis is the first stable release.\n", 327 + v1Tag.Message, "tag message should match") 328 + 329 + assert.Equal(s.T(), plumbing.TagObject, v1Tag.TargetType, 330 + "target type should be CommitObject") 331 + 332 + assert.False(s.T(), v1Tag.Hash.IsZero(), "tag hash should be set") 333 + 334 + assert.False(s.T(), v1Tag.Target.IsZero(), "target hash should be set") 335 + } 336 + 337 + func (s *TagSuite) TestTags_NilOptions() { 338 + s.setupRepoWithTags() 339 + 340 + tags, err := s.repo.Tags(nil) 341 + require.NoError(s.T(), err) 342 + assert.Len(s.T(), tags, 5, "nil options should return all tags") 343 + } 344 + 345 + func (s *TagSuite) TestTags_ZeroLimitAndOffset() { 346 + s.setupRepoWithTags() 347 + 348 + tags, err := s.repo.Tags(&TagsOptions{ 349 + Limit: 0, 350 + Offset: 0, 351 + }) 352 + require.NoError(s.T(), err) 353 + assert.Len(s.T(), tags, 5, "zero limit should return all tags") 354 + } 355 + 356 + func (s *TagSuite) TestTags_OrderedNewestFirst() { 357 + s.setupRepoWithTags() 358 + 359 + tags, err := s.repo.Tags(nil) 360 + require.NoError(s.T(), err) 361 + require.Len(s.T(), tags, 5) 362 + 363 + // v3.0.0 has the latest tagger date (baseTime+3h), should be first 364 + assert.Equal(s.T(), "v3.0.0", tags[0].Name, "newest tag should be first") 365 + } 366 + 367 + func (s *TagSuite) TestTags_LatestWithLimit1() { 368 + s.setupRepoWithTags() 369 + 370 + tags, err := s.repo.Tags(&TagsOptions{Limit: 1}) 371 + require.NoError(s.T(), err) 372 + require.Len(s.T(), tags, 1) 373 + 374 + assert.Equal(s.T(), "v3.0.0", tags[0].Name, "limit=1 should return the newest tag") 375 + } 376 + 377 + func (s *TagSuite) TestTags_Pattern() { 378 + s.setupRepoWithTags() 379 + 380 + v1tag, err := s.repo.Tags(&TagsOptions{ 381 + Pattern: "refs/tags/v1.0.0", 382 + }) 383 + 384 + require.NoError(s.T(), err) 385 + assert.Len(s.T(), v1tag, 1, "expected 1 tag") 386 + }
+141
knotserver/git/test_common.go
··· 1 + package git 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + 9 + gogit "github.com/go-git/go-git/v5" 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + type RepoSuite struct { 16 + t *testing.T 17 + tempDir string 18 + repo *GitRepo 19 + baseTime time.Time 20 + } 21 + 22 + func NewRepoSuite(t *testing.T) *RepoSuite { 23 + tempDir, err := os.MkdirTemp("", "git-test-*") 24 + require.NoError(t, err) 25 + 26 + return &RepoSuite{ 27 + t: t, 28 + tempDir: tempDir, 29 + baseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 30 + } 31 + } 32 + 33 + func (h *RepoSuite) cleanup() { 34 + if h.tempDir != "" { 35 + os.RemoveAll(h.tempDir) 36 + } 37 + } 38 + 39 + func (h *RepoSuite) init() *GitRepo { 40 + repoPath := filepath.Join(h.tempDir, "test-repo") 41 + 42 + // initialize repository 43 + r, err := gogit.PlainInit(repoPath, false) 44 + require.NoError(h.t, err) 45 + 46 + // configure git user 47 + cfg, err := r.Config() 48 + require.NoError(h.t, err) 49 + cfg.User.Name = "Test User" 50 + cfg.User.Email = "test@example.com" 51 + err = r.SetConfig(cfg) 52 + require.NoError(h.t, err) 53 + 54 + // create initial commit with a file 55 + w, err := r.Worktree() 56 + require.NoError(h.t, err) 57 + 58 + // create initial file 59 + initialFile := filepath.Join(repoPath, "README.md") 60 + err = os.WriteFile(initialFile, []byte("# Test Repository\n\nInitial content.\n"), 0644) 61 + require.NoError(h.t, err) 62 + 63 + _, err = w.Add("README.md") 64 + require.NoError(h.t, err) 65 + 66 + _, err = w.Commit("Initial commit", &gogit.CommitOptions{ 67 + Author: &object.Signature{ 68 + Name: "Test User", 69 + Email: "test@example.com", 70 + When: h.baseTime, 71 + }, 72 + }) 73 + require.NoError(h.t, err) 74 + 75 + gitRepo, err := PlainOpen(repoPath) 76 + require.NoError(h.t, err) 77 + 78 + h.repo = gitRepo 79 + return gitRepo 80 + } 81 + 82 + func (h *RepoSuite) commitFile(filename, content, message string) plumbing.Hash { 83 + filePath := filepath.Join(h.repo.path, filename) 84 + dir := filepath.Dir(filePath) 85 + 86 + err := os.MkdirAll(dir, 0755) 87 + require.NoError(h.t, err) 88 + 89 + err = os.WriteFile(filePath, []byte(content), 0644) 90 + require.NoError(h.t, err) 91 + 92 + w, err := h.repo.r.Worktree() 93 + require.NoError(h.t, err) 94 + 95 + _, err = w.Add(filename) 96 + require.NoError(h.t, err) 97 + 98 + hash, err := w.Commit(message, &gogit.CommitOptions{ 99 + Author: &object.Signature{ 100 + Name: "Test User", 101 + Email: "test@example.com", 102 + }, 103 + }) 104 + require.NoError(h.t, err) 105 + 106 + return hash 107 + } 108 + 109 + func (h *RepoSuite) createAnnotatedTag(name string, commit plumbing.Hash, taggerName, taggerEmail, message string, when time.Time) { 110 + _, err := h.repo.r.CreateTag(name, commit, &gogit.CreateTagOptions{ 111 + Tagger: &object.Signature{ 112 + Name: taggerName, 113 + Email: taggerEmail, 114 + When: when, 115 + }, 116 + Message: message, 117 + }) 118 + require.NoError(h.t, err) 119 + } 120 + 121 + func (h *RepoSuite) createLightweightTag(name string, commit plumbing.Hash) { 122 + ref := plumbing.NewReferenceFromStrings("refs/tags/"+name, commit.String()) 123 + err := h.repo.r.Storer.SetReference(ref) 124 + require.NoError(h.t, err) 125 + } 126 + 127 + func (h *RepoSuite) createBranch(name string, commit plumbing.Hash) { 128 + ref := plumbing.NewReferenceFromStrings("refs/heads/"+name, commit.String()) 129 + err := h.repo.r.Storer.SetReference(ref) 130 + require.NoError(h.t, err) 131 + } 132 + 133 + func (h *RepoSuite) checkoutBranch(name string) { 134 + w, err := h.repo.r.Worktree() 135 + require.NoError(h.t, err) 136 + 137 + err = w.Checkout(&gogit.CheckoutOptions{ 138 + Branch: plumbing.NewBranchReferenceName(name), 139 + }) 140 + require.NoError(h.t, err) 141 + }
+11 -1
knotserver/git/tree.go
··· 48 48 func (g *GitRepo) makeNiceTree(ctx context.Context, subtree *object.Tree, parent string) []types.NiceTree { 49 49 nts := []types.NiceTree{} 50 50 51 - times, err := g.calculateCommitTimeIn(ctx, subtree, parent, 2*time.Second) 51 + entries := make([]string, len(subtree.Entries)) 52 + for _, e := range subtree.Entries { 53 + entries = append(entries, e.Name) 54 + } 55 + 56 + lastCommitDir := lastCommitDir{ 57 + dir: parent, 58 + entries: entries, 59 + } 60 + 61 + times, err := g.lastCommitDirIn(ctx, lastCommitDir, 2*time.Second) 52 62 if err != nil { 53 63 return nts 54 64 }
-136
knotserver/ingester.go
··· 7 7 "io" 8 8 "net/http" 9 9 "net/url" 10 - "path/filepath" 11 10 "strings" 12 11 13 12 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 17 16 securejoin "github.com/cyphar/filepath-securejoin" 18 17 "tangled.org/core/api/tangled" 19 18 "tangled.org/core/knotserver/db" 20 - "tangled.org/core/knotserver/git" 21 19 "tangled.org/core/log" 22 20 "tangled.org/core/rbac" 23 - "tangled.org/core/workflow" 24 21 ) 25 22 26 23 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 85 82 return nil 86 83 } 87 84 88 - func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 89 - raw := json.RawMessage(event.Commit.Record) 90 - did := event.Did 91 - 92 - var record tangled.RepoPull 93 - if err := json.Unmarshal(raw, &record); err != nil { 94 - return fmt.Errorf("failed to unmarshal record: %w", err) 95 - } 96 - 97 - l := log.FromContext(ctx) 98 - l = l.With("handler", "processPull") 99 - l = l.With("did", did) 100 - 101 - if record.Target == nil { 102 - return fmt.Errorf("ignoring pull record: target repo is nil") 103 - } 104 - 105 - l = l.With("target_repo", record.Target.Repo) 106 - l = l.With("target_branch", record.Target.Branch) 107 - 108 - if record.Source == nil { 109 - return fmt.Errorf("ignoring pull record: not a branch-based pull request") 110 - } 111 - 112 - if record.Source.Repo != nil { 113 - return fmt.Errorf("ignoring pull record: fork based pull") 114 - } 115 - 116 - repoAt, err := syntax.ParseATURI(record.Target.Repo) 117 - if err != nil { 118 - return fmt.Errorf("failed to parse ATURI: %w", err) 119 - } 120 - 121 - // resolve this aturi to extract the repo record 122 - ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 - if err != nil || ident.Handle.IsInvalidHandle() { 124 - return fmt.Errorf("failed to resolve handle: %w", err) 125 - } 126 - 127 - xrpcc := xrpc.Client{ 128 - Host: ident.PDSEndpoint(), 129 - } 130 - 131 - resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 132 - if err != nil { 133 - return fmt.Errorf("failed to resolver repo: %w", err) 134 - } 135 - 136 - repo := resp.Value.Val.(*tangled.Repo) 137 - 138 - if repo.Knot != h.c.Server.Hostname { 139 - return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 140 - } 141 - 142 - didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 143 - if err != nil { 144 - return fmt.Errorf("failed to construct relative repo path: %w", err) 145 - } 146 - 147 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 148 - if err != nil { 149 - return fmt.Errorf("failed to construct absolute repo path: %w", err) 150 - } 151 - 152 - gr, err := git.Open(repoPath, record.Source.Sha) 153 - if err != nil { 154 - return fmt.Errorf("failed to open git repository: %w", err) 155 - } 156 - 157 - workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 158 - if err != nil { 159 - return fmt.Errorf("failed to open workflow directory: %w", err) 160 - } 161 - 162 - var pipeline workflow.RawPipeline 163 - for _, e := range workflowDir { 164 - if !e.IsFile() { 165 - continue 166 - } 167 - 168 - fpath := filepath.Join(workflow.WorkflowDir, e.Name) 169 - contents, err := gr.RawContent(fpath) 170 - if err != nil { 171 - continue 172 - } 173 - 174 - pipeline = append(pipeline, workflow.RawWorkflow{ 175 - Name: e.Name, 176 - Contents: contents, 177 - }) 178 - } 179 - 180 - trigger := tangled.Pipeline_PullRequestTriggerData{ 181 - Action: "create", 182 - SourceBranch: record.Source.Branch, 183 - SourceSha: record.Source.Sha, 184 - TargetBranch: record.Target.Branch, 185 - } 186 - 187 - compiler := workflow.Compiler{ 188 - Trigger: tangled.Pipeline_TriggerMetadata{ 189 - Kind: string(workflow.TriggerKindPullRequest), 190 - PullRequest: &trigger, 191 - Repo: &tangled.Pipeline_TriggerRepo{ 192 - Did: ident.DID.String(), 193 - Knot: repo.Knot, 194 - Repo: repo.Name, 195 - }, 196 - }, 197 - } 198 - 199 - cp := compiler.Compile(compiler.Parse(pipeline)) 200 - eventJson, err := json.Marshal(cp) 201 - if err != nil { 202 - return fmt.Errorf("failed to marshal pipeline event: %w", err) 203 - } 204 - 205 - // do not run empty pipelines 206 - if cp.Workflows == nil { 207 - return nil 208 - } 209 - 210 - ev := db.Event{ 211 - Rkey: TID(), 212 - Nsid: tangled.PipelineNSID, 213 - EventJson: string(eventJson), 214 - } 215 - 216 - return h.db.InsertEvent(ev, h.n) 217 - } 218 - 219 85 // duplicated from add collaborator 220 86 func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 221 87 raw := json.RawMessage(event.Commit.Record) ··· 338 204 err = h.processPublicKey(ctx, event) 339 205 case tangled.KnotMemberNSID: 340 206 err = h.processKnotMember(ctx, event) 341 - case tangled.RepoPullNSID: 342 - err = h.processPull(ctx, event) 343 207 case tangled.RepoCollaboratorNSID: 344 208 err = h.processCollaborator(ctx, event) 345 209 }
+1 -109
knotserver/internal.go
··· 23 23 "tangled.org/core/log" 24 24 "tangled.org/core/notifier" 25 25 "tangled.org/core/rbac" 26 - "tangled.org/core/workflow" 27 26 ) 28 27 29 28 type InternalHandle struct { ··· 176 175 } 177 176 178 177 for _, line := range lines { 178 + // TODO: pass pushOptions to refUpdate 179 179 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 180 180 if err != nil { 181 181 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 185 185 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 186 if err != nil { 187 187 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 - // non-fatal 189 - } 190 - 191 - err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 192 - if err != nil { 193 - l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 194 188 // non-fatal 195 189 } 196 190 } ··· 241 235 } 242 236 243 237 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 244 - } 245 - 246 - func (h *InternalHandle) triggerPipeline( 247 - clientMsgs *[]string, 248 - line git.PostReceiveLine, 249 - gitUserDid string, 250 - repoDid string, 251 - repoName string, 252 - pushOptions PushOptions, 253 - ) error { 254 - if pushOptions.skipCi { 255 - return nil 256 - } 257 - 258 - didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 259 - if err != nil { 260 - return err 261 - } 262 - 263 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 264 - if err != nil { 265 - return err 266 - } 267 - 268 - gr, err := git.Open(repoPath, line.Ref) 269 - if err != nil { 270 - return err 271 - } 272 - 273 - workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 274 - if err != nil { 275 - return err 276 - } 277 - 278 - var pipeline workflow.RawPipeline 279 - for _, e := range workflowDir { 280 - if !e.IsFile() { 281 - continue 282 - } 283 - 284 - fpath := filepath.Join(workflow.WorkflowDir, e.Name) 285 - contents, err := gr.RawContent(fpath) 286 - if err != nil { 287 - continue 288 - } 289 - 290 - pipeline = append(pipeline, workflow.RawWorkflow{ 291 - Name: e.Name, 292 - Contents: contents, 293 - }) 294 - } 295 - 296 - trigger := tangled.Pipeline_PushTriggerData{ 297 - Ref: line.Ref, 298 - OldSha: line.OldSha.String(), 299 - NewSha: line.NewSha.String(), 300 - } 301 - 302 - compiler := workflow.Compiler{ 303 - Trigger: tangled.Pipeline_TriggerMetadata{ 304 - Kind: string(workflow.TriggerKindPush), 305 - Push: &trigger, 306 - Repo: &tangled.Pipeline_TriggerRepo{ 307 - Did: repoDid, 308 - Knot: h.c.Server.Hostname, 309 - Repo: repoName, 310 - }, 311 - }, 312 - } 313 - 314 - cp := compiler.Compile(compiler.Parse(pipeline)) 315 - eventJson, err := json.Marshal(cp) 316 - if err != nil { 317 - return err 318 - } 319 - 320 - for _, e := range compiler.Diagnostics.Errors { 321 - *clientMsgs = append(*clientMsgs, e.String()) 322 - } 323 - 324 - if pushOptions.verboseCi { 325 - if compiler.Diagnostics.IsEmpty() { 326 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 327 - } 328 - 329 - for _, w := range compiler.Diagnostics.Warnings { 330 - *clientMsgs = append(*clientMsgs, w.String()) 331 - } 332 - } 333 - 334 - // do not run empty pipelines 335 - if cp.Workflows == nil { 336 - return nil 337 - } 338 - 339 - event := db.Event{ 340 - Rkey: TID(), 341 - Nsid: tangled.PipelineNSID, 342 - EventJson: string(eventJson), 343 - } 344 - 345 - return h.db.InsertEvent(event, h.n) 346 238 } 347 239 348 240 func (h *InternalHandle) emitCompareLink(
+25
knotserver/router.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/http" 8 + "strings" 8 9 9 10 "github.com/go-chi/chi/v5" 10 11 "tangled.org/core/idresolver" ··· 79 80 }) 80 81 81 82 r.Route("/{did}", func(r chi.Router) { 83 + r.Use(h.resolveDidRedirect) 82 84 r.Route("/{name}", func(r chi.Router) { 83 85 // routes for git operations 84 86 r.Get("/info/refs", h.InfoRefs) ··· 114 116 } 115 117 116 118 return xrpc.Router() 119 + } 120 + 121 + func (h *Knot) resolveDidRedirect(next http.Handler) http.Handler { 122 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 123 + didOrHandle := chi.URLParam(r, "did") 124 + if strings.HasPrefix(didOrHandle, "did:") { 125 + next.ServeHTTP(w, r) 126 + return 127 + } 128 + 129 + trimmed := strings.TrimPrefix(didOrHandle, "@") 130 + id, err := h.resolver.ResolveIdent(r.Context(), trimmed) 131 + if err != nil { 132 + // invalid did or handle 133 + h.l.Error("failed to resolve did/handle", "handle", trimmed, "err", err) 134 + http.Error(w, fmt.Sprintf("failed to resolve did/handle: %s", trimmed), http.StatusInternalServerError) 135 + return 136 + } 137 + 138 + suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle) 139 + newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery) 140 + http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) 141 + }) 117 142 } 118 143 119 144 func (h *Knot) configureOwner() error {
-1
knotserver/server.go
··· 79 79 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 80 80 tangled.PublicKeyNSID, 81 81 tangled.KnotMemberNSID, 82 - tangled.RepoPullNSID, 83 82 tangled.RepoCollaboratorNSID, 84 83 }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 85 84 if err != nil {
+8 -1
knotserver/xrpc/merge_check.go
··· 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/knotserver/git" 12 + "tangled.org/core/patchutil" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) 14 15 ··· 51 52 return 52 53 } 53 54 54 - err = gr.MergeCheck(data.Patch, data.Branch) 55 + mo := git.MergeOptions{} 56 + mo.CommitMessage = "merge check" 57 + mo.CommitterName = x.Config.Git.UserName 58 + mo.CommitterEmail = x.Config.Git.UserEmail 59 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 60 + 61 + err = gr.MergeCheckWithOptions(data.Patch, data.Branch, mo) 55 62 56 63 response := tangled.RepoMergeCheck_Output{ 57 64 Is_conflicted: false,
+23
knotserver/xrpc/repo_blob.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 + "context" 4 5 "crypto/sha256" 5 6 "encoding/base64" 6 7 "fmt" ··· 8 9 "path/filepath" 9 10 "slices" 10 11 "strings" 12 + "time" 11 13 12 14 "tangled.org/core/api/tangled" 13 15 "tangled.org/core/knotserver/git" ··· 140 142 141 143 if mimeType != "" { 142 144 response.MimeType = &mimeType 145 + } 146 + 147 + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 148 + defer cancel() 149 + 150 + lastCommit, err := gr.LastCommitFile(ctx, treePath) 151 + if err == nil && lastCommit != nil { 152 + response.LastCommit = &tangled.RepoBlob_LastCommit{ 153 + Hash: lastCommit.Hash.String(), 154 + Message: lastCommit.Message, 155 + When: lastCommit.When.Format(time.RFC3339), 156 + } 157 + 158 + // try to get author information 159 + commit, err := gr.Commit(lastCommit.Hash) 160 + if err == nil { 161 + response.LastCommit.Author = &tangled.RepoBlob_Signature{ 162 + Name: commit.Author.Name, 163 + Email: commit.Author.Email, 164 + } 165 + } 143 166 } 144 167 145 168 writeJson(w, response)
+14 -21
knotserver/xrpc/repo_branches.go
··· 17 17 return 18 18 } 19 19 20 - cursor := r.URL.Query().Get("cursor") 20 + // default 21 + limit := 50 22 + offset := 0 21 23 22 - // limit := 50 // default 23 - // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 24 - // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 25 - // limit = l 26 - // } 27 - // } 24 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { 25 + limit = l 26 + } 28 27 29 - limit := 500 28 + if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 29 + offset = o 30 + } 30 31 31 32 gr, err := git.PlainOpen(repoPath) 32 33 if err != nil { ··· 34 35 return 35 36 } 36 37 37 - branches, _ := gr.Branches() 38 - 39 - offset := 0 40 - if cursor != "" { 41 - if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 42 - offset = o 43 - } 44 - } 45 - 46 - end := min(offset+limit, len(branches)) 47 - 48 - paginatedBranches := branches[offset:end] 38 + branches, _ := gr.Branches(&git.BranchesOptions{ 39 + Limit: limit, 40 + Offset: offset, 41 + }) 49 42 50 43 // Create response using existing types.RepoBranchesResponse 51 44 response := types.RepoBranchesResponse{ 52 - Branches: paginatedBranches, 45 + Branches: branches, 53 46 } 54 47 55 48 writeJson(w, response)
+85
knotserver/xrpc/repo_tag.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTag(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + tagName := r.URL.Query().Get("tag") 24 + if tagName == "" { 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("missing name parameter"), 28 + ), http.StatusBadRequest) 29 + return 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + // if this is not already formatted as refs/tags/v0.1.0, then format it 40 + if !plumbing.ReferenceName(tagName).IsTag() { 41 + tagName = plumbing.NewTagReferenceName(tagName).String() 42 + } 43 + 44 + tags, err := gr.Tags(&git.TagsOptions{ 45 + Pattern: tagName, 46 + }) 47 + 48 + if len(tags) != 1 { 49 + writeError(w, xrpcerr.NewXrpcError( 50 + xrpcerr.WithTag("TagNotFound"), 51 + xrpcerr.WithMessage(fmt.Sprintf("expected 1 tag to be returned, got %d tags", len(tags))), 52 + ), http.StatusBadRequest) 53 + return 54 + } 55 + 56 + tag := tags[0] 57 + 58 + if err != nil { 59 + x.Logger.Warn("getting tags", "error", err.Error()) 60 + tags = []object.Tag{} 61 + } 62 + 63 + var target *object.Tag 64 + if tag.Target != plumbing.ZeroHash { 65 + target = &tag 66 + } 67 + tr := types.TagReference{ 68 + Tag: target, 69 + } 70 + 71 + tr.Reference = types.Reference{ 72 + Name: tag.Name, 73 + Hash: tag.Hash.String(), 74 + } 75 + 76 + if tag.Message != "" { 77 + tr.Message = tag.Message 78 + } 79 + 80 + response := types.RepoTagResponse{ 81 + Tag: &tr, 82 + } 83 + 84 + writeJson(w, response) 85 + }
+15 -22
knotserver/xrpc/repo_tags.go
··· 20 20 return 21 21 } 22 22 23 - cursor := r.URL.Query().Get("cursor") 23 + // default 24 + limit := 50 25 + offset := 0 24 26 25 - limit := 50 // default 26 - if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 - limit = l 29 - } 27 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + 31 + if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 32 + offset = o 30 33 } 31 34 32 35 gr, err := git.PlainOpen(repoPath) ··· 36 39 return 37 40 } 38 41 39 - tags, err := gr.Tags() 42 + tags, err := gr.Tags(&git.TagsOptions{ 43 + Limit: limit, 44 + Offset: offset, 45 + }) 46 + 40 47 if err != nil { 41 48 x.Logger.Warn("getting tags", "error", err.Error()) 42 49 tags = []object.Tag{} ··· 64 71 rtags = append(rtags, &tr) 65 72 } 66 73 67 - // apply pagination manually 68 - offset := 0 69 - if cursor != "" { 70 - if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 - offset = o 72 - } 73 - } 74 - 75 - // calculate end index 76 - end := min(offset+limit, len(rtags)) 77 - 78 - paginatedTags := rtags[offset:end] 79 - 80 - // Create response using existing types.RepoTagsResponse 81 74 response := types.RepoTagsResponse{ 82 - Tags: paginatedTags, 75 + Tags: rtags, 83 76 } 84 77 85 78 writeJson(w, response)
+35
knotserver/xrpc/repo_tree.go
··· 9 9 "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/pages/markup" 11 11 "tangled.org/core/knotserver/git" 12 + "tangled.org/core/types" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) 14 15 ··· 105 106 Filename: readmeFileName, 106 107 Contents: readmeContents, 107 108 }, 109 + } 110 + 111 + // calculate lastCommit for the directory as a whole 112 + var lastCommitTree *types.LastCommitInfo 113 + for _, e := range files { 114 + if e.LastCommit == nil { 115 + continue 116 + } 117 + 118 + if lastCommitTree == nil { 119 + lastCommitTree = e.LastCommit 120 + continue 121 + } 122 + 123 + if lastCommitTree.When.After(e.LastCommit.When) { 124 + lastCommitTree = e.LastCommit 125 + } 126 + } 127 + 128 + if lastCommitTree != nil { 129 + response.LastCommit = &tangled.RepoTree_LastCommit{ 130 + Hash: lastCommitTree.Hash.String(), 131 + Message: lastCommitTree.Message, 132 + When: lastCommitTree.When.Format(time.RFC3339), 133 + } 134 + 135 + // try to get author information 136 + commit, err := gr.Commit(lastCommitTree.Hash) 137 + if err == nil { 138 + response.LastCommit.Author = &tangled.RepoTree_Signature{ 139 + Name: commit.Author.Name, 140 + Email: commit.Author.Email, 141 + } 142 + } 108 143 } 109 144 110 145 writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
··· 59 59 r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 60 r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 61 r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoTagNSID, x.RepoTag) 62 63 r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 63 64 r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 64 65 r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
+2 -1
lexicons/actor/profile.json
··· 45 45 "open-pull-request-count", 46 46 "open-issue-count", 47 47 "closed-issue-count", 48 - "repository-count" 48 + "repository-count", 49 + "star-count" 49 50 ] 50 51 } 51 52 },
-4
lexicons/repo/blob.json
··· 115 115 "type": "string", 116 116 "description": "Commit hash" 117 117 }, 118 - "shortHash": { 119 - "type": "string", 120 - "description": "Short commit hash" 121 - }, 122 118 "message": { 123 119 "type": "string", 124 120 "description": "Commit message"
+43
lexicons/repo/tag.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tag", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo", 11 + "tag" 12 + ], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "description": "Repository identifier in format 'did:plc:.../repoName'" 17 + }, 18 + "tag": { 19 + "type": "string", 20 + "description": "Name of tag, such as v1.3.0" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "*/*" 26 + }, 27 + "errors": [ 28 + { 29 + "name": "RepoNotFound", 30 + "description": "Repository not found or access denied" 31 + }, 32 + { 33 + "name": "TagNotFound", 34 + "description": "Tag not found" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+53 -5
lexicons/repo/tree.json
··· 6 6 "type": "query", 7 7 "parameters": { 8 8 "type": "params", 9 - "required": ["repo", "ref"], 9 + "required": [ 10 + "repo", 11 + "ref" 12 + ], 10 13 "properties": { 11 14 "repo": { 12 15 "type": "string", ··· 27 30 "encoding": "application/json", 28 31 "schema": { 29 32 "type": "object", 30 - "required": ["ref", "files"], 33 + "required": [ 34 + "ref", 35 + "files" 36 + ], 31 37 "properties": { 32 38 "ref": { 33 39 "type": "string", ··· 45 51 "type": "ref", 46 52 "ref": "#readme", 47 53 "description": "Readme for this file tree" 54 + }, 55 + "lastCommit": { 56 + "type": "ref", 57 + "ref": "#lastCommit" 48 58 }, 49 59 "files": { 50 60 "type": "array", ··· 77 87 }, 78 88 "readme": { 79 89 "type": "object", 80 - "required": ["filename", "contents"], 90 + "required": [ 91 + "filename", 92 + "contents" 93 + ], 81 94 "properties": { 82 95 "filename": { 83 96 "type": "string", ··· 91 104 }, 92 105 "treeEntry": { 93 106 "type": "object", 94 - "required": ["name", "mode", "size"], 107 + "required": [ 108 + "name", 109 + "mode", 110 + "size" 111 + ], 95 112 "properties": { 96 113 "name": { 97 114 "type": "string", ··· 113 130 }, 114 131 "lastCommit": { 115 132 "type": "object", 116 - "required": ["hash", "message", "when"], 133 + "required": [ 134 + "hash", 135 + "message", 136 + "when" 137 + ], 117 138 "properties": { 118 139 "hash": { 119 140 "type": "string", ··· 123 144 "type": "string", 124 145 "description": "Commit message" 125 146 }, 147 + "author": { 148 + "type": "ref", 149 + "ref": "#signature" 150 + }, 126 151 "when": { 127 152 "type": "string", 128 153 "format": "datetime", 129 154 "description": "Commit timestamp" 155 + } 156 + } 157 + }, 158 + "signature": { 159 + "type": "object", 160 + "required": [ 161 + "name", 162 + "email", 163 + "when" 164 + ], 165 + "properties": { 166 + "name": { 167 + "type": "string", 168 + "description": "Author name" 169 + }, 170 + "email": { 171 + "type": "string", 172 + "description": "Author email" 173 + }, 174 + "when": { 175 + "type": "string", 176 + "format": "datetime", 177 + "description": "Author timestamp" 130 178 } 131 179 } 132 180 }
+91 -6
nix/gomod2nix.toml
··· 16 16 [mod."github.com/RoaringBitmap/roaring/v2"] 17 17 version = "v2.4.5" 18 18 hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA=" 19 + [mod."github.com/RussellLuo/slidingwindow"] 20 + version = "v0.0.0-20200528002341-535bb99d338b" 21 + hash = "sha256-nhrU0P0wJeim6imjmnqgOIf+kJh74iNfHUaXY/pQ6YU=" 19 22 [mod."github.com/alecthomas/assert/v2"] 20 23 version = "v2.11.0" 21 24 hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" ··· 103 106 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 104 107 replaced = "tangled.sh/oppi.li/go-gitdiff" 105 108 [mod."github.com/bluesky-social/indigo"] 106 - version = "v0.0.0-20251003000214-3259b215110e" 107 - hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 109 + version = "v0.0.0-20260302045703-861eeb80e873" 110 + hash = "sha256-WSsFGuNzYFqkU376uInDwQnTqR1lBWFHmzKig6bKZj8=" 111 + replaced = "github.com/boltlessengineer/indigo" 108 112 [mod."github.com/bluesky-social/jetstream"] 109 - version = "v0.0.0-20241210005130-ea96859b93d1" 110 - hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 113 + version = "v0.0.0-20260226214936-e0274250f654" 114 + hash = "sha256-VE93NvI3PreteLHnlv7WT6GgH2vSjtoFjMygCmrznfg=" 111 115 [mod."github.com/bmatcuk/doublestar/v4"] 112 116 version = "v4.9.1" 113 117 hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" ··· 189 193 [mod."github.com/dustin/go-humanize"] 190 194 version = "v1.0.1" 191 195 hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 196 + [mod."github.com/earthboundkid/versioninfo/v2"] 197 + version = "v2.24.1" 198 + hash = "sha256-nbRdiX9WN2y1aiw1CR/DQ6AYqztow8FazndwY3kByHM=" 192 199 [mod."github.com/emirpasic/gods"] 193 200 version = "v1.18.1" 194 201 hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" ··· 247 254 [mod."github.com/goki/freetype"] 248 255 version = "v1.0.5" 249 256 hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 257 + [mod."github.com/golang-jwt/jwt"] 258 + version = "v3.2.2+incompatible" 259 + hash = "sha256-LOkpuXhWrFayvVf1GOaOmZI5YKEsgqVSb22aF8LnCEM=" 250 260 [mod."github.com/golang-jwt/jwt/v5"] 251 261 version = "v5.2.3" 252 262 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 304 314 [mod."github.com/hashicorp/go-sockaddr"] 305 315 version = "v1.0.7" 306 316 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 317 + [mod."github.com/hashicorp/go-version"] 318 + version = "v1.8.0" 319 + hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 307 320 [mod."github.com/hashicorp/golang-lru"] 308 321 version = "v1.0.2" 309 322 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" ··· 331 344 [mod."github.com/ipfs/go-block-format"] 332 345 version = "v0.2.2" 333 346 hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 347 + [mod."github.com/ipfs/go-blockservice"] 348 + version = "v0.5.2" 349 + hash = "sha256-DIik12zEKdemFWatKrtlq+89jEnPtjQ1uDpSHoGs5t0=" 334 350 [mod."github.com/ipfs/go-cid"] 335 351 version = "v0.5.0" 336 352 hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" ··· 343 359 [mod."github.com/ipfs/go-ipfs-ds-help"] 344 360 version = "v1.1.1" 345 361 hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 362 + [mod."github.com/ipfs/go-ipfs-exchange-interface"] 363 + version = "v0.2.1" 364 + hash = "sha256-LWw+UXf2O+if+hKDfcx+Di39yobeMDyjTt8TLOGPskA=" 365 + [mod."github.com/ipfs/go-ipfs-util"] 366 + version = "v0.0.3" 367 + hash = "sha256-pO+XQc9EnV5mKE7WagePl88uvk5jgd7HIZt9UJRhAHU=" 346 368 [mod."github.com/ipfs/go-ipld-cbor"] 347 369 version = "v0.2.1" 348 370 hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 349 371 [mod."github.com/ipfs/go-ipld-format"] 350 372 version = "v0.6.2" 351 373 hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 374 + [mod."github.com/ipfs/go-ipld-legacy"] 375 + version = "v0.2.2" 376 + hash = "sha256-721PGmtaHpCUgKrU3nSfUwRHVwkZc3qlVdmwZZlpcBs=" 352 377 [mod."github.com/ipfs/go-log"] 353 378 version = "v1.0.5" 354 379 hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" 355 380 [mod."github.com/ipfs/go-log/v2"] 356 381 version = "v2.6.0" 357 382 hash = "sha256-cZ+rsx7LIROoNITyu/s0B6hq8lNQsUC1ynvx2f2o4Gk=" 383 + [mod."github.com/ipfs/go-merkledag"] 384 + version = "v0.11.0" 385 + hash = "sha256-+ZXWFgpT8gXSOM38pxAfgc0WBWNble1xmfsdHsXMMDw=" 358 386 [mod."github.com/ipfs/go-metrics-interface"] 359 387 version = "v0.3.0" 360 388 hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 389 + [mod."github.com/ipfs/go-verifcid"] 390 + version = "v0.0.3" 391 + hash = "sha256-VepuUyOwGE5DZ4oAKs9MQPFl5FG77nhDphVYasXlJhY=" 392 + [mod."github.com/ipld/go-car"] 393 + version = "v0.6.2" 394 + hash = "sha256-PyEEDiLGcI6XyX/Nq/W6COs0HQ+xuB3etY08PTxuUeY=" 395 + [mod."github.com/ipld/go-codec-dagpb"] 396 + version = "v1.7.0" 397 + hash = "sha256-jJmMUBdHHSavEvpvZCQnqfiMrqrahD5YNaaSIUdGSgo=" 398 + [mod."github.com/ipld/go-ipld-prime"] 399 + version = "v0.21.0" 400 + hash = "sha256-aOLwqmDtt3jA4Fb3U+FOBX6T0yOMujuVInJN2a+7iSA=" 401 + [mod."github.com/jackc/pgpassfile"] 402 + version = "v1.0.0" 403 + hash = "sha256-H0nFbC34/3pZUFnuiQk9W7yvAMh6qJDrqvHp+akBPLM=" 404 + [mod."github.com/jackc/pgservicefile"] 405 + version = "v0.0.0-20221227161230-091c0ba34f0a" 406 + hash = "sha256-rBtUw15WPPDp2eulHXH5e2zCIed1OPFYwlCpgDOnGRM=" 407 + [mod."github.com/jackc/pgx/v5"] 408 + version = "v5.5.0" 409 + hash = "sha256-ifh4kjdg1YFzFaqMf2QDWjUMJerc8B9HH1/h0n7WsIY=" 410 + [mod."github.com/jackc/puddle/v2"] 411 + version = "v2.2.1" 412 + hash = "sha256-Edf8SLT/8l+xfHm9IjUGxs1MHtic2VgRyfqb6OzGA9k=" 413 + [mod."github.com/jinzhu/inflection"] 414 + version = "v1.0.0" 415 + hash = "sha256-3h3pHib5MaCXKyKLIMyQnSptDJ16kPjCOQPoEBoQsZg=" 416 + [mod."github.com/jinzhu/now"] 417 + version = "v1.1.5" 418 + hash = "sha256-NNLqoFx9FczBBDpD0O0aSHnC/YDZ9E/xQ340o7u63IM=" 361 419 [mod."github.com/json-iterator/go"] 362 420 version = "v1.1.12" 363 421 hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" ··· 370 428 [mod."github.com/klauspost/cpuid/v2"] 371 429 version = "v2.3.0" 372 430 hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 431 + [mod."github.com/labstack/echo/v4"] 432 + version = "v4.11.3" 433 + hash = "sha256-rdqH4HQB/vZyEsoymrEsQ8izjf0m7jhrIxbF6r5ZmBo=" 434 + [mod."github.com/labstack/gommon"] 435 + version = "v0.4.1" 436 + hash = "sha256-qfjV9jmtR8I7gC7/Hm02XDbuVLX8UkRNi3wPer8Jkm4=" 373 437 [mod."github.com/lucasb-eyer/go-colorful"] 374 438 version = "v1.2.0" 375 439 hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 440 + [mod."github.com/mattn/go-colorable"] 441 + version = "v0.1.14" 442 + hash = "sha256-JC60PjKj7MvhZmUHTZ9p372FV72I9Mxvli3fivTbxuA=" 376 443 [mod."github.com/mattn/go-isatty"] 377 444 version = "v0.0.20" 378 445 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" ··· 478 545 [mod."github.com/prometheus/procfs"] 479 546 version = "v0.16.1" 480 547 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 548 + [mod."github.com/puzpuzpuz/xsync/v4"] 549 + version = "v4.2.0" 550 + hash = "sha256-VY1ugvPLGDJZsCgVxyUMUS4AAK1w+9eu86WXBMcB3dw=" 481 551 [mod."github.com/redis/go-redis/v9"] 482 552 version = "v9.7.3" 483 553 hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" ··· 510 580 version = "v1.10.0" 511 581 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 512 582 [mod."github.com/urfave/cli/v3"] 513 - version = "v3.3.3" 514 - hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" 583 + version = "v3.4.1" 584 + hash = "sha256-cDMaQrIVMthUhdyI1mKXzDC5/wIK151073lzRl92RnA=" 585 + [mod."github.com/valyala/bytebufferpool"] 586 + version = "v1.0.0" 587 + hash = "sha256-I9FPZ3kCNRB+o0dpMwBnwZ35Fj9+ThvITn8a3Jr8mAY=" 588 + [mod."github.com/valyala/fasttemplate"] 589 + version = "v1.2.2" 590 + hash = "sha256-gp+lNXE8zjO+qJDM/YbS6V43HFsYP6PKn4ux1qa5lZ0=" 515 591 [mod."github.com/vmihailenco/go-tinylfu"] 516 592 version = "v0.2.2" 517 593 hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM=" ··· 629 705 [mod."gopkg.in/yaml.v3"] 630 706 version = "v3.0.1" 631 707 hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" 708 + [mod."gorm.io/driver/postgres"] 709 + version = "v1.5.7" 710 + hash = "sha256-o3Akrtc6yEBr344MlMBP27ySluUoDeHvc+EQ5+jwdPQ=" 711 + [mod."gorm.io/driver/sqlite"] 712 + version = "v1.5.5" 713 + hash = "sha256-LvQ6bOu1d061XZAQRUUFzKumFaELm7GWPecLjjMz76A=" 714 + [mod."gorm.io/gorm"] 715 + version = "v1.25.9" 716 + hash = "sha256-8S5hSP9369EA9F2jwUFJYF657kjFOVFBwPjsCxyZ3RQ=" 632 717 [mod."gotest.tools/v3"] 633 718 version = "v3.5.2" 634 719 hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE="
+64
nix/modules/bluesky-jetstream.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-jetstream; 8 + in 9 + with lib; { 10 + options.services.bluesky-jetstream = { 11 + enable = mkEnableOption "jetstream server"; 12 + package = mkPackageOption pkgs "bluesky-jetstream" {}; 13 + 14 + # dataDir = mkOption { 15 + # type = types.str; 16 + # default = "/var/lib/jetstream"; 17 + # description = "directory to store data (pebbleDB)"; 18 + # }; 19 + livenessTtl = mkOption { 20 + type = types.int; 21 + default = 15; 22 + description = "time to restart when no event detected (seconds)"; 23 + }; 24 + websocketUrl = mkOption { 25 + type = types.str; 26 + default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; 27 + description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; 28 + }; 29 + }; 30 + config = mkIf cfg.enable { 31 + systemd.services.bluesky-jetstream = { 32 + description = "bluesky jetstream"; 33 + after = ["network.target" "pds.service"]; 34 + wantedBy = ["multi-user.target"]; 35 + 36 + serviceConfig = { 37 + User = "jetstream"; 38 + Group = "jetstream"; 39 + StateDirectory = "jetstream"; 40 + StateDirectoryMode = "0755"; 41 + # preStart = '' 42 + # mkdir -p "${cfg.dataDir}" 43 + # chown -R jetstream:jetstream "${cfg.dataDir}" 44 + # ''; 45 + # WorkingDirectory = cfg.dataDir; 46 + Environment = [ 47 + "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" 48 + "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" 49 + "JETSTREAM_WS_URL=${cfg.websocketUrl}" 50 + ]; 51 + ExecStart = getExe cfg.package; 52 + Restart = "always"; 53 + RestartSec = 5; 54 + }; 55 + }; 56 + users = { 57 + users.jetstream = { 58 + group = "jetstream"; 59 + isSystemUser = true; 60 + }; 61 + groups.jetstream = {}; 62 + }; 63 + }; 64 + }
+48
nix/modules/bluesky-relay.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-relay; 8 + in 9 + with lib; { 10 + options.services.bluesky-relay = { 11 + enable = mkEnableOption "relay server"; 12 + package = mkPackageOption pkgs "bluesky-relay" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + systemd.services.bluesky-relay = { 16 + description = "bluesky relay"; 17 + after = ["network.target" "pds.service"]; 18 + wantedBy = ["multi-user.target"]; 19 + 20 + serviceConfig = { 21 + User = "relay"; 22 + Group = "relay"; 23 + StateDirectory = "relay"; 24 + StateDirectoryMode = "0755"; 25 + Environment = [ 26 + "RELAY_ADMIN_PASSWORD=password" 27 + "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" 28 + "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" 29 + "RELAY_IP_BIND=:2470" 30 + "RELAY_PERSIST_DIR=/var/lib/relay" 31 + "RELAY_DISABLE_REQUEST_CRAWL=0" 32 + "RELAY_INITIAL_SEQ_NUMBER=1" 33 + "RELAY_ALLOW_INSECURE_HOSTS=1" 34 + ]; 35 + ExecStart = "${getExe cfg.package} serve"; 36 + Restart = "always"; 37 + RestartSec = 5; 38 + }; 39 + }; 40 + users = { 41 + users.relay = { 42 + group = "relay"; 43 + isSystemUser = true; 44 + }; 45 + groups.relay = {}; 46 + }; 47 + }; 48 + }
+76
nix/modules/did-method-plc.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.did-method-plc; 8 + in 9 + with lib; { 10 + options.services.did-method-plc = { 11 + enable = mkEnableOption "did-method-plc server"; 12 + package = mkPackageOption pkgs "did-method-plc" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + services.postgresql = { 16 + enable = true; 17 + package = pkgs.postgresql_14; 18 + ensureDatabases = ["plc"]; 19 + ensureUsers = [ 20 + { 21 + name = "pg"; 22 + # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; 23 + } 24 + ]; 25 + authentication = '' 26 + local all all trust 27 + host all all 127.0.0.1/32 trust 28 + ''; 29 + }; 30 + systemd.services.did-method-plc = { 31 + description = "did-method-plc"; 32 + 33 + after = ["postgresql.service"]; 34 + wants = ["postgresql.service"]; 35 + wantedBy = ["multi-user.target"]; 36 + 37 + environment = let 38 + db_creds_json = builtins.toJSON { 39 + username = "pg"; 40 + password = ""; 41 + host = "127.0.0.1"; 42 + port = 5432; 43 + }; 44 + in { 45 + # TODO: inherit from config 46 + DEBUG_MODE = "1"; 47 + LOG_ENABLED = "true"; 48 + LOG_LEVEL = "debug"; 49 + LOG_DESTINATION = "1"; 50 + ENABLE_MIGRATIONS = "true"; 51 + DB_CREDS_JSON = db_creds_json; 52 + DB_MIGRATE_CREDS_JSON = db_creds_json; 53 + PLC_VERSION = "0.0.1"; 54 + PORT = "8080"; 55 + }; 56 + 57 + serviceConfig = { 58 + ExecStart = getExe cfg.package; 59 + User = "plc"; 60 + Group = "plc"; 61 + StateDirectory = "plc"; 62 + StateDirectoryMode = "0755"; 63 + Restart = "always"; 64 + 65 + # Hardening 66 + }; 67 + }; 68 + users = { 69 + users.plc = { 70 + group = "plc"; 71 + isSystemUser = true; 72 + }; 73 + groups.plc = {}; 74 + }; 75 + }; 76 + }
+1
nix/modules/knot.nix
··· 239 239 advertisePushOptions = true 240 240 [uploadpack] 241 241 allowFilter = true 242 + allowReachableSHA1InWant = true 242 243 EOF 243 244 ${setMotd} 244 245 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
+28 -8
nix/modules/spindle.nix
··· 1 1 { 2 2 config, 3 + pkgs, 3 4 lib, 4 5 ... 5 6 }: let ··· 25 26 description = "Address to listen on"; 26 27 }; 27 28 28 - dbPath = mkOption { 29 + stateDir = mkOption { 29 30 type = types.path; 30 - default = "/var/lib/spindle/spindle.db"; 31 - description = "Path to the database file"; 31 + default = "/var/lib/spindle"; 32 + description = "Tangled spindle data directory"; 32 33 }; 33 34 34 35 hostname = mkOption { ··· 43 44 description = "atproto PLC directory"; 44 45 }; 45 46 46 - jetstreamEndpoint = mkOption { 47 + relayUrl = mkOption { 47 48 type = types.str; 48 - default = "wss://jetstream1.us-west.bsky.network/subscribe"; 49 - description = "Jetstream endpoint to subscribe to"; 49 + default = "https://relay1.us-east.bsky.network"; 50 + description = "atproto relay"; 51 + }; 52 + 53 + tapPort = mkOption { 54 + type = types.port; 55 + default = 6480; 56 + description = "Internal port to run the spindle tap"; 57 + }; 58 + 59 + tapDbUrl = mkOption { 60 + type = types.str; 61 + default = "sqlite:///var/lib/spindle/tap.db"; 62 + description = "tap db url"; 50 63 }; 51 64 52 65 dev = mkOption { ··· 86 99 proxyAddr = mkOption { 87 100 type = types.str; 88 101 default = "http://127.0.0.1:8200"; 102 + description = "Address of the OpenBAO proxy server"; 89 103 }; 90 104 mount = mkOption { 91 105 type = types.str; 92 106 default = "spindle"; 107 + description = "Mount path in OpenBAO to read secrets from"; 93 108 }; 94 109 }; 95 110 }; ··· 118 133 description = "spindle service"; 119 134 after = ["network.target" "docker.service"]; 120 135 wantedBy = ["multi-user.target"]; 136 + path = [ 137 + pkgs.git 138 + ]; 121 139 serviceConfig = { 122 140 LogsDirectory = "spindle"; 123 141 StateDirectory = "spindle"; 124 142 Environment = [ 125 143 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 126 - "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 144 + "SPINDLE_SERVER_DATA_DIR=${cfg.server.stateDir}" 127 145 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 128 146 "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 - "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 147 + "SPINDLE_SERVER_RELAY_URL=${cfg.server.relayUrl}" 130 148 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 131 149 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 132 150 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" ··· 134 152 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 135 153 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 136 154 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 155 + "SPINDLE_SERVER_TAP_PORT=${toString cfg.server.tapPort}" 156 + "SPINDLE_SERVER_TAP_DB_URL=${cfg.server.tapDbUrl}" 137 157 "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 138 158 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 139 159 ];
+20
nix/pkgs/bluesky-jetstream.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-jetstream"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "jetstream"; 11 + rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; 12 + sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; 13 + }; 14 + subPackages = ["cmd/jetstream"]; 15 + vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "jetstream"; 19 + }; 20 + }
+20
nix/pkgs/bluesky-relay.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-relay"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "boltlessengineer"; 10 + repo = "indigo"; 11 + rev = "7fe70a304d795b998f354d2b7b2050b909709c99"; 12 + sha256 = "sha256-+h34x67cqH5t30+8rua53/ucvbn3BanrmH0Og3moHok="; 13 + }; 14 + subPackages = ["cmd/relay"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "relay"; 19 + }; 20 + }
+65
nix/pkgs/did-method-plc.nix
··· 1 + # inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix 2 + { 3 + lib, 4 + stdenv, 5 + fetchFromGitHub, 6 + fetchYarnDeps, 7 + yarnConfigHook, 8 + yarnBuildHook, 9 + nodejs, 10 + makeBinaryWrapper, 11 + }: 12 + stdenv.mkDerivation (finalAttrs: { 13 + pname = "did-method-plc"; 14 + version = "0.0.1"; 15 + 16 + src = fetchFromGitHub { 17 + owner = "did-method-plc"; 18 + repo = "did-method-plc"; 19 + rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; 20 + sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; 21 + }; 22 + postPatch = '' 23 + # remove dd-trace dependency 24 + sed -i '3d' packages/server/service/index.js 25 + ''; 26 + 27 + yarnOfflineCache = fetchYarnDeps { 28 + yarnLock = finalAttrs.src + "/yarn.lock"; 29 + hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; 30 + }; 31 + 32 + nativeBuildInputs = [ 33 + yarnConfigHook 34 + yarnBuildHook 35 + nodejs 36 + makeBinaryWrapper 37 + ]; 38 + yarnBuildScript = "lerna"; 39 + yarnBuildFlags = [ 40 + "run" 41 + "build" 42 + "--scope" 43 + "@did-plc/server" 44 + "--include-dependencies" 45 + ]; 46 + 47 + installPhase = '' 48 + runHook preInstall 49 + 50 + mkdir -p $out/lib/node_modules/ 51 + mv packages/ $out/lib/packages/ 52 + mv node_modules/* $out/lib/node_modules/ 53 + 54 + makeWrapper ${lib.getExe nodejs} $out/bin/plc \ 55 + --add-flags $out/lib/packages/server/service/index.js \ 56 + --add-flags --enable-source-maps \ 57 + --set NODE_PATH $out/lib/node_modules 58 + 59 + runHook postInstall 60 + ''; 61 + 62 + meta = { 63 + mainProgram = "plc"; 64 + }; 65 + })
+20
nix/pkgs/tap.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "tap"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "indigo"; 11 + rev = "498ecb9693e8ae050f73234c86f340f51ad896a9"; 12 + sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k="; 13 + }; 14 + subPackages = ["cmd/tap"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "tap"; 19 + }; 20 + }
+130 -2
nix/vm.nix
··· 19 19 20 20 plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 21 jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 + relayUrl = envVarOr "TANGLED_VM_RELAY_URL" "https://relay1.us-east.bsky.network"; 22 23 in 23 24 nixpkgs.lib.nixosSystem { 24 25 inherit system; 25 26 modules = [ 27 + self.nixosModules.did-method-plc 28 + self.nixosModules.bluesky-jetstream 29 + self.nixosModules.bluesky-relay 26 30 self.nixosModules.knot 27 31 self.nixosModules.spindle 28 32 ({ ··· 39 43 diskSize = 10 * 1024; 40 44 cores = 2; 41 45 forwardPorts = [ 46 + # caddy 47 + { 48 + from = "host"; 49 + host.port = 80; 50 + guest.port = 80; 51 + } 52 + { 53 + from = "host"; 54 + host.port = 443; 55 + guest.port = 443; 56 + } 57 + { 58 + from = "host"; 59 + proto = "udp"; 60 + host.port = 443; 61 + guest.port = 443; 62 + } 42 63 # ssh 43 64 { 44 65 from = "host"; ··· 57 78 host.port = 6555; 58 79 guest.port = 6555; 59 80 } 81 + { 82 + from = "host"; 83 + host.port = 6556; 84 + guest.port = 2480; 85 + } 60 86 ]; 61 87 sharedDirectories = { 62 88 # We can't use the 9p mounts directly for most of these 63 89 # as SQLite is incompatible with them. So instead we 64 90 # mount the shared directories to a different location 65 91 # and copy the contents around on service start/stop. 92 + caddyData = { 93 + source = "$TANGLED_VM_DATA_DIR/caddy"; 94 + target = config.services.caddy.dataDir; 95 + }; 66 96 knotData = { 67 97 source = "$TANGLED_VM_DATA_DIR/knot"; 68 98 target = "/mnt/knot-data"; ··· 79 109 }; 80 110 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 81 111 networking.firewall.enable = false; 112 + # resolve `*.tngl.boltless.dev` to host 113 + services.dnsmasq.enable = true; 114 + services.dnsmasq.settings.address = "/tngl.boltless.dev/10.0.2.2"; 115 + security.pki.certificates = [ 116 + (builtins.readFile ../contrib/certs/root.crt) 117 + ]; 82 118 time.timeZone = "Europe/London"; 119 + services.timesyncd.enable = lib.mkVMOverride true; 83 120 services.getty.autologinUser = "root"; 84 121 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 122 + virtualisation.docker.extraOptions = '' 123 + --dns 172.17.0.1 124 + ''; 85 125 services.tangled.knot = { 86 126 enable = true; 87 127 motd = "Welcome to the development knot!\n"; ··· 99 139 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 100 140 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 141 plcUrl = plcUrl; 102 - jetstreamEndpoint = jetstream; 142 + relayUrl = relayUrl; 103 143 listenAddr = "0.0.0.0:6555"; 104 144 dev = true; 105 145 queueSize = 100; ··· 109 149 }; 110 150 }; 111 151 }; 152 + services.did-method-plc.enable = true; 153 + services.bluesky-pds = { 154 + enable = true; 155 + # overriding package version to support emails 156 + package = pkgs.bluesky-pds.overrideAttrs (old: rec { 157 + version = "0.4.188"; 158 + src = pkgs.fetchFromGitHub { 159 + owner = "bluesky-social"; 160 + repo = "pds"; 161 + tag = "v${version}"; 162 + hash = "sha256-t8KdyEygXdbj/5Rhj8W40e1o8mXprELpjsKddHExmo0="; 163 + }; 164 + pnpmDeps = pkgs.fetchPnpmDeps { 165 + inherit version src; 166 + pname = old.pname; 167 + sourceRoot = old.sourceRoot; 168 + fetcherVersion = 2; 169 + hash = "sha256-lQie7f8JbWKSpoavnMjHegBzH3GB9teXsn+S2SLJHHU="; 170 + }; 171 + }); 172 + settings = { 173 + LOG_ENABLED = "true"; 174 + 175 + PDS_JWT_SECRET = "8cae8bffcc73d9932819650791e4e89a"; 176 + PDS_ADMIN_PASSWORD = "d6a902588cd93bee1af83f924f60cfd3"; 177 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = "2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7"; 178 + 179 + PDS_EMAIL_SMTP_URL = envVarOr "TANGLED_VM_PDS_EMAIL_SMTP_URL" null; 180 + PDS_EMAIL_FROM_ADDRESS = envVarOr "TANGLED_VM_PDS_EMAIL_FROM_ADDRESS" null; 181 + 182 + PDS_DID_PLC_URL = "http://localhost:8080"; 183 + PDS_CRAWLERS = "https://relay.tngl.boltless.dev"; 184 + PDS_HOSTNAME = "pds.tngl.boltless.dev"; 185 + PDS_PORT = 3000; 186 + }; 187 + }; 188 + services.bluesky-relay = { 189 + enable = true; 190 + }; 191 + services.bluesky-jetstream = { 192 + enable = true; 193 + livenessTtl = 300; 194 + websocketUrl = "ws://localhost:3000/xrpc/com.atproto.sync.subscribeRepos"; 195 + }; 196 + services.caddy = { 197 + enable = true; 198 + configFile = pkgs.writeText "Caddyfile" '' 199 + { 200 + debug 201 + cert_lifetime 3601d 202 + pki { 203 + ca local { 204 + intermediate_lifetime 3599d 205 + } 206 + } 207 + } 208 + 209 + plc.tngl.boltless.dev { 210 + tls internal 211 + reverse_proxy http://localhost:8080 212 + } 213 + 214 + *.pds.tngl.boltless.dev, pds.tngl.boltless.dev { 215 + tls internal 216 + reverse_proxy http://localhost:3000 217 + } 218 + 219 + jetstream.tngl.boltless.dev { 220 + tls internal 221 + reverse_proxy http://localhost:6008 222 + } 223 + 224 + relay.tngl.boltless.dev { 225 + tls internal 226 + reverse_proxy http://localhost:2470 227 + } 228 + 229 + knot.tngl.boltless.dev { 230 + tls internal 231 + reverse_proxy http://localhost:6444 232 + } 233 + 234 + spindle.tngl.boltless.dev { 235 + tls internal 236 + reverse_proxy http://localhost:6555 237 + } 238 + ''; 239 + }; 112 240 users = { 113 241 # So we don't have to deal with permission clashing between 114 242 # blank disk VMs and existing state ··· 134 262 }; 135 263 in { 136 264 knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 137 - spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 265 + spindle = mkDataSyncScripts "/mnt/spindle-data" config.services.tangled.spindle.server.stateDir; 138 266 }; 139 267 }) 140 268 ];
+10
orm/orm.go
··· 20 20 } 21 21 defer tx.Rollback() 22 22 23 + _, err = tx.Exec(` 24 + create table if not exists migrations ( 25 + id integer primary key autoincrement, 26 + name text unique 27 + ); 28 + `) 29 + if err != nil { 30 + return fmt.Errorf("creating migrations table: %w", err) 31 + } 32 + 23 33 var exists bool 24 34 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 25 35 if err != nil {
+52
rbac2/bytesadapter/adapter.go
··· 1 + package bytesadapter 2 + 3 + import ( 4 + "bufio" 5 + "bytes" 6 + "errors" 7 + "strings" 8 + 9 + "github.com/casbin/casbin/v2/model" 10 + "github.com/casbin/casbin/v2/persist" 11 + ) 12 + 13 + var ( 14 + errNotImplemented = errors.New("not implemented") 15 + ) 16 + 17 + type Adapter struct { 18 + b []byte 19 + } 20 + 21 + var _ persist.Adapter = &Adapter{} 22 + 23 + func NewAdapter(b []byte) *Adapter { 24 + return &Adapter{b} 25 + } 26 + 27 + func (a *Adapter) LoadPolicy(model model.Model) error { 28 + scanner := bufio.NewScanner(bytes.NewReader(a.b)) 29 + for scanner.Scan() { 30 + line := strings.TrimSpace(scanner.Text()) 31 + if err := persist.LoadPolicyLine(line, model); err != nil { 32 + return err 33 + } 34 + } 35 + return scanner.Err() 36 + } 37 + 38 + func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { 39 + return errNotImplemented 40 + } 41 + 42 + func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { 43 + return errNotImplemented 44 + } 45 + 46 + func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { 47 + return errNotImplemented 48 + } 49 + 50 + func (a *Adapter) SavePolicy(model model.Model) error { 51 + return errNotImplemented 52 + }
+139
rbac2/rbac2.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "database/sql" 5 + _ "embed" 6 + "fmt" 7 + 8 + adapter "github.com/Blank-Xu/sql-adapter" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/casbin/casbin/v2" 11 + "github.com/casbin/casbin/v2/model" 12 + "github.com/casbin/casbin/v2/util" 13 + "tangled.org/core/rbac2/bytesadapter" 14 + ) 15 + 16 + const ( 17 + Model = ` 18 + [request_definition] 19 + r = sub, dom, obj, act 20 + 21 + [policy_definition] 22 + p = sub, dom, obj, act 23 + 24 + [role_definition] 25 + g = _, _, _ 26 + 27 + [policy_effect] 28 + e = some(where (p.eft == allow)) 29 + 30 + [matchers] 31 + m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 32 + ` 33 + ) 34 + 35 + type Enforcer struct { 36 + e *casbin.Enforcer 37 + } 38 + 39 + //go:embed tangled_policy.csv 40 + var tangledPolicy []byte 41 + 42 + func NewEnforcer(path string) (*Enforcer, error) { 43 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 44 + if err != nil { 45 + return nil, err 46 + } 47 + return NewEnforcerWithDB(db) 48 + } 49 + 50 + func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) { 51 + m, err := model.NewModelFromString(Model) 52 + if err != nil { 53 + return nil, err 54 + } 55 + 56 + a, err := adapter.NewAdapter(db, "sqlite3", "acl") 57 + if err != nil { 58 + return nil, err 59 + } 60 + 61 + // // PATCH: create unique index to make `AddPoliciesEx` work 62 + // _, err = db.Exec(fmt.Sprintf( 63 + // `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`, 64 + // tableName, 65 + // )) 66 + // if err != nil { 67 + // return nil, err 68 + // } 69 + 70 + e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error 71 + // e.EnableLog(true) 72 + 73 + // NOTE: casbin clears the model on init, so we should intialize with temporary adapter first 74 + // and then override the adapter to sql-adapter. 75 + // `e.SetModel(m)` after init doesn't work for some reason 76 + if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil { 77 + return nil, err 78 + } 79 + 80 + // load dynamic policy from db 81 + e.EnableAutoSave(false) 82 + if err := a.LoadPolicy(e.GetModel()); err != nil { 83 + return nil, err 84 + } 85 + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 86 + e.BuildRoleLinks() 87 + e.SetAdapter(a) 88 + e.EnableAutoSave(true) 89 + 90 + return &Enforcer{e}, nil 91 + } 92 + 93 + // CaptureModel returns copy of current model. Used for testing 94 + func (e *Enforcer) CaptureModel() model.Model { 95 + return e.e.GetModel().Copy() 96 + } 97 + 98 + func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 99 + roles, err := e.e.GetImplicitRolesForUser(name, domain...) 100 + if err != nil { 101 + return false, err 102 + } 103 + for _, r := range roles { 104 + if r == role { 105 + return true, nil 106 + } 107 + } 108 + return false, nil 109 + } 110 + 111 + // setRoleForUser sets single user role for specified domain. 112 + // All existing users with that role will be removed. 113 + func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 114 + currentUsers, err := e.e.GetUsersForRole(role, domain...) 115 + if err != nil { 116 + return err 117 + } 118 + 119 + for _, oldUser := range currentUsers { 120 + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 121 + if err != nil { 122 + return err 123 + } 124 + } 125 + 126 + _, err = e.e.AddRoleForUser(name, role, domain...) 127 + return err 128 + } 129 + 130 + // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 131 + func validateAtUri(uri syntax.ATURI, expected string) error { 132 + if !uri.Authority().IsDID() { 133 + return fmt.Errorf("expected at-uri with did") 134 + } 135 + if expected != "" && uri.Collection().String() != expected { 136 + return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 137 + } 138 + return nil 139 + }
+150
rbac2/rbac2_test.go
··· 1 + package rbac2_test 2 + 3 + import ( 4 + "database/sql" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + _ "github.com/mattn/go-sqlite3" 9 + "github.com/stretchr/testify/assert" 10 + "tangled.org/core/rbac2" 11 + ) 12 + 13 + func setup(t *testing.T) *rbac2.Enforcer { 14 + enforcer, err := rbac2.NewEnforcer(":memory:") 15 + assert.NoError(t, err) 16 + 17 + return enforcer 18 + } 19 + 20 + func TestNewEnforcer(t *testing.T) { 21 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 22 + assert.NoError(t, err) 23 + 24 + enforcer1, err := rbac2.NewEnforcerWithDB(db) 25 + assert.NoError(t, err) 26 + enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")) 27 + model1 := enforcer1.CaptureModel() 28 + 29 + enforcer2, err := rbac2.NewEnforcerWithDB(db) 30 + assert.NoError(t, err) 31 + model2 := enforcer2.CaptureModel() 32 + 33 + // model1.GetLogger().EnableLog(true) 34 + // model1.PrintModel() 35 + // model1.PrintPolicy() 36 + // model1.GetLogger().EnableLog(false) 37 + 38 + model2.GetLogger().EnableLog(true) 39 + model2.PrintModel() 40 + model2.PrintPolicy() 41 + model2.GetLogger().EnableLog(false) 42 + 43 + assert.Equal(t, model1, model2) 44 + } 45 + 46 + func TestRepoOwnerPermissions(t *testing.T) { 47 + var ( 48 + e = setup(t) 49 + ok bool 50 + err error 51 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 52 + fooUser = syntax.DID("did:plc:foo") 53 + ) 54 + 55 + assert.NoError(t, e.AddRepo(fooRepo)) 56 + 57 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 58 + assert.NoError(t, err) 59 + assert.True(t, ok, "repo author should be repo owner") 60 + 61 + ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 62 + assert.NoError(t, err) 63 + assert.True(t, ok, "repo owner should be able to modify the repo itself") 64 + 65 + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 66 + assert.NoError(t, err) 67 + assert.True(t, ok, "repo owner should inherit role role:collaborator") 68 + 69 + ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 70 + assert.NoError(t, err) 71 + assert.True(t, ok, "repo owner should inherit collaborator permissions") 72 + } 73 + 74 + func TestRepoCollaboratorPermissions(t *testing.T) { 75 + var ( 76 + e = setup(t) 77 + ok bool 78 + err error 79 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 80 + barUser = syntax.DID("did:plc:bar") 81 + ) 82 + 83 + assert.NoError(t, e.AddRepo(fooRepo)) 84 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 85 + 86 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 87 + assert.NoError(t, err) 88 + assert.True(t, ok, "should set repo collaborator") 89 + 90 + ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 91 + assert.NoError(t, err) 92 + assert.True(t, ok, "repo collaborator should be able to edit repo settings") 93 + 94 + ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 95 + assert.NoError(t, err) 96 + assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 97 + } 98 + 99 + func TestGetByRole(t *testing.T) { 100 + var ( 101 + e = setup(t) 102 + err error 103 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 104 + owner = syntax.DID("did:plc:foo") 105 + collaborator1 = syntax.DID("did:plc:bar") 106 + collaborator2 = syntax.DID("did:plc:baz") 107 + ) 108 + 109 + assert.NoError(t, e.AddRepo(fooRepo)) 110 + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 111 + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 112 + 113 + collaborators, err := e.GetRepoCollaborators(fooRepo) 114 + assert.NoError(t, err) 115 + assert.ElementsMatch(t, []syntax.DID{ 116 + owner, 117 + collaborator1, 118 + collaborator2, 119 + }, collaborators) 120 + } 121 + 122 + func TestSpindleOwnerPermissions(t *testing.T) { 123 + var ( 124 + e = setup(t) 125 + ok bool 126 + err error 127 + spindle = syntax.DID("did:web:spindle.example.com") 128 + owner = syntax.DID("did:plc:foo") 129 + member = syntax.DID("did:plc:bar") 130 + ) 131 + 132 + assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 133 + assert.NoError(t, e.AddSpindleMember(member, spindle)) 134 + 135 + ok, err = e.IsSpindleMember(owner, spindle) 136 + assert.NoError(t, err) 137 + assert.True(t, ok, "spindle owner is spindle member") 138 + 139 + ok, err = e.IsSpindleMember(member, spindle) 140 + assert.NoError(t, err) 141 + assert.True(t, ok, "spindle member is spindle member") 142 + 143 + ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 144 + assert.NoError(t, err) 145 + assert.True(t, ok, "spindle owner can invite members") 146 + 147 + ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 148 + assert.NoError(t, err) 149 + assert.False(t, ok, "spindle member cannot invite members") 150 + }
+91
rbac2/repo.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "slices" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // AddRepo adds new repo with its owner to rbac enforcer 12 + func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 + return err 15 + } 16 + user := repo.Authority() 17 + 18 + return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 + } 20 + 21 + // DeleteRepo deletes all policies related to the repo 22 + func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 + return err 25 + } 26 + 27 + _, err := e.e.DeleteDomains(repo.String()) 28 + return err 29 + } 30 + 31 + // AddRepoCollaborator adds new collaborator to the repo 32 + func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 + return err 35 + } 36 + 37 + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 + return err 39 + } 40 + 41 + // RemoveRepoCollaborator removes the collaborator from the repo. 42 + // This won't remove inherited roles like repository owner. 43 + func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 + return err 46 + } 47 + 48 + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 + return err 50 + } 51 + 52 + func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 + var collaborators []syntax.DID 54 + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + for _, m := range members { 59 + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 + continue 61 + } 62 + collaborators = append(collaborators, syntax.DID(m)) 63 + } 64 + 65 + slices.Sort(collaborators) 66 + return slices.Compact(collaborators), nil 67 + } 68 + 69 + func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 + } 72 + 73 + func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 + } 76 + 77 + func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 + return e.e.Enforce(user.String(), repo.String(), "/", "write") 79 + } 80 + 81 + func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 + return e.e.Enforce(user.String(), repo.String(), "/settings", "write") 83 + } 84 + 85 + func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 + return e.e.Enforce(user.String(), repo.String(), "/collaborator", "write") 87 + } 88 + 89 + func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 + return e.e.Enforce(user.String(), repo.String(), "/git", "write") 91 + }
+29
rbac2/spindle.go
··· 1 + package rbac2 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 + return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 + } 8 + 9 + func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 + return e.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 11 + } 12 + 13 + func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 + _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 + return err 16 + } 17 + 18 + func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 + _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 + return err 21 + } 22 + 23 + func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 + return e.e.Enforce(user.String(), intoSpindle(spindle), "/member", "write") 25 + } 26 + 27 + func intoSpindle(did syntax.DID) string { 28 + return "/spindle/" + did.String() 29 + }
+19
rbac2/tangled_policy.csv
··· 1 + #, policies 2 + #, sub, dom, obj, act 3 + p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write 4 + p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write 5 + p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write 6 + p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write 7 + 8 + p, server:owner, /knot/{did}, /member, write 9 + p, server:member, /knot/{did}, /git, write 10 + 11 + p, server:owner, /spindle/{did}, /member, write 12 + 13 + 14 + #, group policies 15 + #, sub, role, dom 16 + g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey} 17 + 18 + g, server:owner, server:member, /knot/{did} 19 + g, server:owner, server:member, /spindle/{did}
+22 -11
spindle/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "path/filepath" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/sethvargo/go-envconfig" 9 10 ) 10 11 11 12 type Server struct { 12 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 - DBPath string `env:"DB_PATH, default=spindle.db"` 14 - Hostname string `env:"HOSTNAME, required"` 15 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 - Dev bool `env:"DEV, default=false"` 18 - Owner string `env:"OWNER, required"` 19 - Secrets Secrets `env:",prefix=SECRETS_"` 20 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 - QueueSize int `env:"QUEUE_SIZE, default=100"` 22 - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 13 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + TapDBUrl string `env:"TAP_DB_URL, default=sqlite://./spindle-tap.db"` 16 + TapPort string `env:"TAP_PORT, default=6480"` 17 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 18 + RelayUrl string `env:"RELAY_URL, default=https://relay1.us-east.bsky.network"` 19 + Dev bool `env:"DEV, default=false"` 20 + Owner syntax.DID `env:"OWNER, required"` 21 + Secrets Secrets `env:",prefix=SECRETS_"` 22 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 23 + DataDir string `env:"DATA_DIR, default=/var/lib/spindle"` 24 + QueueSize int `env:"QUEUE_SIZE, default=100"` 25 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 23 26 } 24 27 25 28 func (s Server) Did() syntax.DID { 26 29 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 30 + } 31 + 32 + func (s Server) RepoDir() string { 33 + return filepath.Join(s.DataDir, "repos") 34 + } 35 + 36 + func (s Server) DBPath() string { 37 + return filepath.Join(s.DataDir, "spindle.db") 27 38 } 28 39 29 40 type Secrets struct {
+73 -18
spindle/db/db.go
··· 1 1 package db 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + "tangled.org/core/orm" 8 12 ) 9 13 10 14 type DB struct { 11 15 *sql.DB 12 16 } 13 17 14 - func Make(dbPath string) (*DB, error) { 18 + func Make(ctx context.Context, dbPath string) (*DB, error) { 15 19 // https://github.com/mattn/go-sqlite3#connection-string 16 20 opts := []string{ 17 21 "_foreign_keys=1", ··· 20 24 "_auto_vacuum=incremental", 21 25 } 22 26 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 23 30 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 31 if err != nil { 25 32 return nil, err 26 33 } 27 34 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 31 40 32 41 _, err = db.Exec(` 33 42 create table if not exists _jetstream ( ··· 49 58 unique(owner, name) 50 59 ); 51 60 61 + create table if not exists repo_collaborators ( 62 + -- identifiers 63 + id integer primary key autoincrement, 64 + did text not null, 65 + rkey text not null, 66 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.collaborator' || '/' || rkey) stored, 67 + 68 + repo text not null, 69 + subject text not null, 70 + 71 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 72 + unique(did, rkey) 73 + ); 74 + 52 75 create table if not exists spindle_members ( 53 76 -- identifiers for the record 54 77 id integer primary key autoincrement, ··· 76 99 return nil, err 77 100 } 78 101 102 + // run migrations 103 + 104 + // NOTE: this won't migrate existing records 105 + // they will be fetched again with tap instead 106 + orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error { 107 + // archive legacy repos (just in case) 108 + _, err = tx.Exec(`alter table repos rename to repos_old`) 109 + if err != nil { 110 + return err 111 + } 112 + 113 + _, err := tx.Exec(` 114 + create table repos ( 115 + -- identifiers 116 + id integer primary key autoincrement, 117 + did text not null, 118 + rkey text not null, 119 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored, 120 + 121 + name text not null, 122 + knot text not null, 123 + 124 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 125 + unique(did, rkey) 126 + ); 127 + `) 128 + if err != nil { 129 + return err 130 + } 131 + 132 + return nil 133 + }) 134 + 79 135 return &DB{db}, nil 80 136 } 81 137 82 - func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 83 - _, err := d.Exec(` 84 - insert into _jetstream (id, last_time_us) 85 - values (1, ?) 86 - on conflict(id) do update set last_time_us = excluded.last_time_us 87 - `, lastTimeUs) 88 - return err 89 - } 90 - 91 - func (d *DB) GetLastTimeUs() (int64, error) { 92 - var lastTimeUs int64 93 - row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) 94 - err := row.Scan(&lastTimeUs) 95 - return lastTimeUs, err 138 + func (d *DB) IsKnownDid(did syntax.DID) (bool, error) { 139 + // is spindle member / repo collaborator 140 + var exists bool 141 + err := d.QueryRow( 142 + `select exists ( 143 + select 1 from repo_collaborators where subject = ? 144 + union all 145 + select 1 from spindle_members where did = ? 146 + )`, 147 + did, 148 + did, 149 + ).Scan(&exists) 150 + return exists, err 96 151 }
+14
spindle/db/events.go
··· 70 70 return evts, nil 71 71 } 72 72 73 + func (d *DB) CreatePipelineEvent(rkey string, pipeline tangled.Pipeline, n *notifier.Notifier) error { 74 + eventJson, err := json.Marshal(pipeline) 75 + if err != nil { 76 + return err 77 + } 78 + event := Event{ 79 + Rkey: rkey, 80 + Nsid: tangled.PipelineNSID, 81 + Created: time.Now().UnixNano(), 82 + EventJson: string(eventJson), 83 + } 84 + return d.insertEvent(event, n) 85 + } 86 + 73 87 func (d *DB) createStatusEvent( 74 88 workflowId models.WorkflowId, 75 89 statusKind models.StatusKind,
-44
spindle/db/known_dids.go
··· 1 - package db 2 - 3 - func (d *DB) AddDid(did string) error { 4 - _, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did) 5 - return err 6 - } 7 - 8 - func (d *DB) RemoveDid(did string) error { 9 - _, err := d.Exec(`delete from known_dids where did = ?`, did) 10 - return err 11 - } 12 - 13 - func (d *DB) GetAllDids() ([]string, error) { 14 - var dids []string 15 - 16 - rows, err := d.Query(`select did from known_dids`) 17 - if err != nil { 18 - return nil, err 19 - } 20 - defer rows.Close() 21 - 22 - for rows.Next() { 23 - var did string 24 - if err := rows.Scan(&did); err != nil { 25 - return nil, err 26 - } 27 - dids = append(dids, did) 28 - } 29 - 30 - if err := rows.Err(); err != nil { 31 - return nil, err 32 - } 33 - 34 - return dids, nil 35 - } 36 - 37 - func (d *DB) HasKnownDids() bool { 38 - var count int 39 - err := d.QueryRow(`select count(*) from known_dids`).Scan(&count) 40 - if err != nil { 41 - return false 42 - } 43 - return count > 0 44 - }
+119 -11
spindle/db/repos.go
··· 1 1 package db 2 2 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 3 5 type Repo struct { 4 - Knot string 5 - Owner string 6 - Name string 6 + Did syntax.DID 7 + Rkey syntax.RecordKey 8 + Name string 9 + Knot string 10 + } 11 + 12 + type RepoCollaborator struct { 13 + Did syntax.DID 14 + Rkey syntax.RecordKey 15 + Repo syntax.ATURI 16 + Subject syntax.DID 17 + } 18 + 19 + func (d *DB) PutRepo(repo *Repo) error { 20 + _, err := d.Exec( 21 + `insert or ignore into repos (did, rkey, name, knot) 22 + values (?, ?, ?, ?) 23 + on conflict(did, rkey) do update set 24 + name = excluded.name, 25 + knot = excluded.knot`, 26 + repo.Did, 27 + repo.Rkey, 28 + repo.Name, 29 + repo.Knot, 30 + ) 31 + return err 7 32 } 8 33 9 - func (d *DB) AddRepo(knot, owner, name string) error { 10 - _, err := d.Exec(`insert or ignore into repos (knot, owner, name) values (?, ?, ?)`, knot, owner, name) 34 + func (d *DB) DeleteRepo(did syntax.DID, rkey syntax.RecordKey) error { 35 + _, err := d.Exec( 36 + `delete from repos where did = ? and rkey = ?`, 37 + did, 38 + rkey, 39 + ) 11 40 return err 12 41 } 13 42 ··· 34 63 return knots, nil 35 64 } 36 65 37 - func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) { 66 + func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) { 38 67 var repo Repo 39 - 40 - query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?" 41 - err := d.DB.QueryRow(query, knot, owner, name). 42 - Scan(&repo.Knot, &repo.Owner, &repo.Name) 43 - 68 + err := d.DB.QueryRow( 69 + `select 70 + did, 71 + rkey, 72 + name, 73 + knot 74 + from repos where at_uri = ?`, 75 + repoAt, 76 + ).Scan( 77 + &repo.Did, 78 + &repo.Rkey, 79 + &repo.Name, 80 + &repo.Knot, 81 + ) 44 82 if err != nil { 45 83 return nil, err 46 84 } 85 + return &repo, nil 86 + } 47 87 88 + func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) { 89 + var repo Repo 90 + err := d.DB.QueryRow( 91 + `select 92 + did, 93 + rkey, 94 + name, 95 + knot 96 + from repos where did = ? and name = ?`, 97 + did, 98 + name, 99 + ).Scan( 100 + &repo.Did, 101 + &repo.Rkey, 102 + &repo.Name, 103 + &repo.Knot, 104 + ) 105 + if err != nil { 106 + return nil, err 107 + } 48 108 return &repo, nil 49 109 } 110 + 111 + func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error { 112 + _, err := d.Exec( 113 + `insert into repo_collaborators (did, rkey, repo, subject) 114 + values (?, ?, ?, ?) 115 + on conflict(did, rkey) do update set 116 + repo = excluded.repo, 117 + subject = excluded.subject`, 118 + collaborator.Did, 119 + collaborator.Rkey, 120 + collaborator.Repo, 121 + collaborator.Subject, 122 + ) 123 + return err 124 + } 125 + 126 + func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error { 127 + _, err := d.Exec( 128 + `delete from repo_collaborators where did = ? and rkey = ?`, 129 + did, 130 + rkey, 131 + ) 132 + return err 133 + } 134 + 135 + func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) { 136 + var collaborator RepoCollaborator 137 + err := d.DB.QueryRow( 138 + `select 139 + did, 140 + rkey, 141 + repo, 142 + subject 143 + from repo_collaborators 144 + where did = ? and rkey = ?`, 145 + did, 146 + rkey, 147 + ).Scan( 148 + &collaborator.Did, 149 + &collaborator.Rkey, 150 + &collaborator.Repo, 151 + &collaborator.Subject, 152 + ) 153 + if err != nil { 154 + return nil, err 155 + } 156 + return &collaborator, nil 157 + }
+16 -14
spindle/engine/engine.go
··· 30 30 } 31 31 } 32 32 33 + secretValues := make([]string, len(allSecrets)) 34 + for i, s := range allSecrets { 35 + secretValues[i] = s.Value 36 + } 37 + 33 38 var wg sync.WaitGroup 34 39 for eng, wfs := range pipeline.Workflows { 35 40 workflowTimeout := eng.WorkflowTimeout() ··· 45 50 Name: w.Name, 46 51 } 47 52 48 - err := db.StatusRunning(wid, n) 53 + wfLogger, err := models.NewFileWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 54 + if err != nil { 55 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 56 + wfLogger = models.NullLogger{} 57 + } else { 58 + l.Info("setup step logger; logs will be persisted", "logDir", cfg.Server.LogDir, "wid", wid) 59 + defer wfLogger.Close() 60 + } 61 + 62 + err = db.StatusRunning(wid, n) 49 63 if err != nil { 50 64 l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 65 return 52 66 } 53 67 54 - err = eng.SetupWorkflow(ctx, wid, &w) 68 + err = eng.SetupWorkflow(ctx, wid, &w, wfLogger) 55 69 if err != nil { 56 70 // TODO(winter): Should this always set StatusFailed? 57 71 // In the original, we only do in a subset of cases. ··· 69 83 return 70 84 } 71 85 defer eng.DestroyWorkflow(ctx, wid) 72 - 73 - secretValues := make([]string, len(allSecrets)) 74 - for i, s := range allSecrets { 75 - secretValues[i] = s.Value 76 - } 77 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 78 - if err != nil { 79 - l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 80 - wfLogger = nil 81 - } else { 82 - defer wfLogger.Close() 83 - } 84 86 85 87 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 86 88 defer cancel()
+52 -9
spindle/engines/nixery/engine.go
··· 1 1 package nixery 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "errors" 6 7 "fmt" 7 8 "io" 8 9 "log/slog" 9 - "os" 10 10 "path" 11 11 "runtime" 12 12 "sync" ··· 169 169 return e, nil 170 170 } 171 171 172 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 173 - e.l.Info("setting up workflow", "workflow", wid) 172 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error { 173 + /// -------------------------INITIAL SETUP------------------------------------------ 174 + l := e.l.With("workflow", wid) 175 + l.Info("setting up workflow") 176 + 177 + setupStep := Step{ 178 + name: "nixery image pull", 179 + kind: models.StepKindSystem, 180 + } 181 + setupStepIdx := -1 182 + 183 + wfLogger.ControlWriter(setupStepIdx, setupStep, models.StepStatusStart).Write([]byte{0}) 184 + defer wfLogger.ControlWriter(setupStepIdx, setupStep, models.StepStatusEnd).Write([]byte{0}) 174 185 186 + /// -------------------------NETWORK CREATION--------------------------------------- 175 187 _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 176 188 Driver: "bridge", 177 189 }) 178 190 if err != nil { 179 191 return err 180 192 } 193 + 181 194 e.registerCleanup(wid, func(ctx context.Context) error { 182 195 if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil { 183 196 return fmt.Errorf("removing network: %w", err) ··· 185 198 return nil 186 199 }) 187 200 201 + /// -------------------------IMAGE PULL--------------------------------------------- 188 202 addl := wf.Data.(addlFields) 203 + l.Info("pulling image", "image", addl.image) 204 + fmt.Fprintf( 205 + wfLogger.DataWriter(setupStepIdx, "stdout"), 206 + "pulling image: %s", 207 + addl.image, 208 + ) 189 209 190 210 reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 191 211 if err != nil { 192 - e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 193 - 212 + l.Error("pipeline image pull failed!", "error", err.Error()) 213 + fmt.Fprintf(wfLogger.DataWriter(setupStepIdx, "stderr"), "image pull failed: %s", err) 194 214 return fmt.Errorf("pulling image: %w", err) 195 215 } 196 216 defer reader.Close() 197 - io.Copy(os.Stdout, reader) 217 + 218 + scanner := bufio.NewScanner(reader) 219 + for scanner.Scan() { 220 + line := scanner.Text() 221 + wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte(line)) 222 + l.Info("image pull progress", "stdout", line) 223 + } 224 + 225 + /// -------------------------CONTAINER CREATION------------------------------------- 226 + l.Info("creating container") 227 + wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte("creating container...")) 198 228 199 229 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 200 230 Image: addl.image, ··· 229 259 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 230 260 }, nil, nil, "") 231 261 if err != nil { 262 + fmt.Fprintf( 263 + wfLogger.DataWriter(setupStepIdx, "stderr"), 264 + "container creation failed: %s", 265 + err, 266 + ) 232 267 return fmt.Errorf("creating container: %w", err) 233 268 } 269 + 234 270 e.registerCleanup(wid, func(ctx context.Context) error { 235 271 if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil { 236 272 return fmt.Errorf("stopping container: %w", err) ··· 244 280 if err != nil { 245 281 return fmt.Errorf("removing container: %w", err) 246 282 } 283 + 247 284 return nil 248 285 }) 249 286 287 + /// -------------------------CONTAINER START---------------------------------------- 288 + wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte("starting container...")) 250 289 if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { 251 290 return fmt.Errorf("starting container: %w", err) 252 291 } ··· 273 312 return err 274 313 } 275 314 315 + /// -----------------------------------FINISH--------------------------------------- 276 316 execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 277 317 if err != nil { 278 318 return err ··· 290 330 return nil 291 331 } 292 332 293 - func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 333 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error { 294 334 addl := w.Data.(addlFields) 295 335 workflowEnvs := ConstructEnvs(w.Environment) 296 336 // TODO(winter): should SetupWorkflow also have secret access? ··· 313 353 envs.AddEnv(k, v) 314 354 } 315 355 } 356 + 316 357 envs.AddEnv("HOME", homeDir) 358 + existingPath := "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 359 + envs.AddEnv("PATH", fmt.Sprintf("%s/.nix-profile/bin:/nix/var/nix/profiles/default/bin:%s", homeDir, existingPath)) 317 360 318 361 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 319 362 Cmd: []string{"bash", "-c", step.Command()}, ··· 328 371 // start tailing logs in background 329 372 tailDone := make(chan error, 1) 330 373 go func() { 331 - tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 374 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, idx) 332 375 }() 333 376 334 377 select { ··· 374 417 return nil 375 418 } 376 419 377 - func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 420 + func (e *Engine) tailStep(ctx context.Context, wfLogger models.WorkflowLogger, execID string, stepIdx int) error { 378 421 if wfLogger == nil { 379 422 return nil 380 423 }
+2 -2
spindle/engines/nixery/setup_steps.go
··· 17 17 18 18 // dependencyStep processes dependencies defined in the workflow. 19 19 // For dependencies using a custom registry (i.e. not nixpkgs), it collects 20 - // all packages and adds a single 'nix profile install' step to the 20 + // all packages and adds a single 'nix profile add' step to the 21 21 // beginning of the workflow's step list. 22 22 func dependencyStep(deps map[string][]string) *Step { 23 23 var customPackages []string ··· 37 37 } 38 38 39 39 if len(customPackages) > 0 { 40 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 40 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile add" 41 41 cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 42 42 installStep := Step{ 43 43 command: cmd,
+73
spindle/git/git.go
··· 1 + package git 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "os" 8 + "os/exec" 9 + "strings" 10 + 11 + "github.com/hashicorp/go-version" 12 + ) 13 + 14 + func Version() (*version.Version, error) { 15 + var buf bytes.Buffer 16 + cmd := exec.Command("git", "version") 17 + cmd.Stdout = &buf 18 + cmd.Stderr = os.Stderr 19 + err := cmd.Run() 20 + if err != nil { 21 + return nil, err 22 + } 23 + fields := strings.Fields(buf.String()) 24 + if len(fields) < 3 { 25 + return nil, fmt.Errorf("invalid git version: %s", buf.String()) 26 + } 27 + 28 + // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" 29 + versionString := fields[2] 30 + if pos := strings.Index(versionString, "windows"); pos >= 1 { 31 + versionString = versionString[:pos-1] 32 + } 33 + return version.NewVersion(versionString) 34 + } 35 + 36 + const WorkflowDir = `/.tangled/workflows` 37 + 38 + func SparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error { 39 + exist, err := isDir(path) 40 + if err != nil { 41 + return err 42 + } 43 + if rev == "" { 44 + rev = "HEAD" 45 + } 46 + if !exist { 47 + if err := exec.Command("git", "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path).Run(); err != nil { 48 + return fmt.Errorf("git clone: %w", err) 49 + } 50 + if err := exec.Command("git", "-C", path, "sparse-checkout", "set", "--no-cone", WorkflowDir).Run(); err != nil { 51 + return fmt.Errorf("git sparse-checkout set: %w", err) 52 + } 53 + } else { 54 + if err := exec.Command("git", "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev).Run(); err != nil { 55 + return fmt.Errorf("git pull: %w", err) 56 + } 57 + } 58 + if err := exec.Command("git", "-C", path, "checkout", rev).Run(); err != nil { 59 + return fmt.Errorf("git checkout: %w", err) 60 + } 61 + return nil 62 + } 63 + 64 + func isDir(path string) (bool, error) { 65 + info, err := os.Stat(path) 66 + if err == nil && info.IsDir() { 67 + return true, nil 68 + } 69 + if os.IsNotExist(err) { 70 + return false, nil 71 + } 72 + return false, err 73 + }
-300
spindle/ingester.go
··· 1 - package spindle 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "time" 9 - 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/eventconsumer" 12 - "tangled.org/core/rbac" 13 - "tangled.org/core/spindle/db" 14 - 15 - comatproto "github.com/bluesky-social/indigo/api/atproto" 16 - "github.com/bluesky-social/indigo/atproto/identity" 17 - "github.com/bluesky-social/indigo/atproto/syntax" 18 - "github.com/bluesky-social/indigo/xrpc" 19 - "github.com/bluesky-social/jetstream/pkg/models" 20 - securejoin "github.com/cyphar/filepath-securejoin" 21 - ) 22 - 23 - type Ingester func(ctx context.Context, e *models.Event) error 24 - 25 - func (s *Spindle) ingest() Ingester { 26 - return func(ctx context.Context, e *models.Event) error { 27 - var err error 28 - defer func() { 29 - eventTime := e.TimeUS 30 - lastTimeUs := eventTime + 1 31 - if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil { 32 - err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 33 - } 34 - }() 35 - 36 - if e.Kind != models.EventKindCommit { 37 - return nil 38 - } 39 - 40 - switch e.Commit.Collection { 41 - case tangled.SpindleMemberNSID: 42 - err = s.ingestMember(ctx, e) 43 - case tangled.RepoNSID: 44 - err = s.ingestRepo(ctx, e) 45 - case tangled.RepoCollaboratorNSID: 46 - err = s.ingestCollaborator(ctx, e) 47 - } 48 - 49 - if err != nil { 50 - s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 51 - } 52 - 53 - return nil 54 - } 55 - } 56 - 57 - func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 58 - var err error 59 - did := e.Did 60 - rkey := e.Commit.RKey 61 - 62 - l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 63 - 64 - switch e.Commit.Operation { 65 - case models.CommitOperationCreate, models.CommitOperationUpdate: 66 - raw := e.Commit.Record 67 - record := tangled.SpindleMember{} 68 - err = json.Unmarshal(raw, &record) 69 - if err != nil { 70 - l.Error("invalid record", "error", err) 71 - return err 72 - } 73 - 74 - domain := s.cfg.Server.Hostname 75 - recordInstance := record.Instance 76 - 77 - if recordInstance != domain { 78 - l.Error("domain mismatch", "domain", recordInstance, "expected", domain) 79 - return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) 80 - } 81 - 82 - ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) 83 - if err != nil || !ok { 84 - l.Error("failed to add member", "did", did, "error", err) 85 - return fmt.Errorf("failed to enforce permissions: %w", err) 86 - } 87 - 88 - if err := db.AddSpindleMember(s.db, db.SpindleMember{ 89 - Did: syntax.DID(did), 90 - Rkey: rkey, 91 - Instance: recordInstance, 92 - Subject: syntax.DID(record.Subject), 93 - Created: time.Now(), 94 - }); err != nil { 95 - l.Error("failed to add member", "error", err) 96 - return fmt.Errorf("failed to add member: %w", err) 97 - } 98 - 99 - if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 100 - l.Error("failed to add member", "error", err) 101 - return fmt.Errorf("failed to add member: %w", err) 102 - } 103 - l.Info("added member from firehose", "member", record.Subject) 104 - 105 - if err := s.db.AddDid(record.Subject); err != nil { 106 - l.Error("failed to add did", "error", err) 107 - return fmt.Errorf("failed to add did: %w", err) 108 - } 109 - s.jc.AddDid(record.Subject) 110 - 111 - return nil 112 - 113 - case models.CommitOperationDelete: 114 - record, err := db.GetSpindleMember(s.db, did, rkey) 115 - if err != nil { 116 - l.Error("failed to find member", "error", err) 117 - return fmt.Errorf("failed to find member: %w", err) 118 - } 119 - 120 - if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 121 - l.Error("failed to remove member", "error", err) 122 - return fmt.Errorf("failed to remove member: %w", err) 123 - } 124 - 125 - if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 126 - l.Error("failed to add member", "error", err) 127 - return fmt.Errorf("failed to add member: %w", err) 128 - } 129 - l.Info("added member from firehose", "member", record.Subject) 130 - 131 - if err := s.db.RemoveDid(record.Subject.String()); err != nil { 132 - l.Error("failed to add did", "error", err) 133 - return fmt.Errorf("failed to add did: %w", err) 134 - } 135 - s.jc.RemoveDid(record.Subject.String()) 136 - 137 - } 138 - return nil 139 - } 140 - 141 - func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 - var err error 143 - did := e.Did 144 - 145 - l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 - 147 - l.Info("ingesting repo record", "did", did) 148 - 149 - switch e.Commit.Operation { 150 - case models.CommitOperationCreate, models.CommitOperationUpdate: 151 - raw := e.Commit.Record 152 - record := tangled.Repo{} 153 - err = json.Unmarshal(raw, &record) 154 - if err != nil { 155 - l.Error("invalid record", "error", err) 156 - return err 157 - } 158 - 159 - domain := s.cfg.Server.Hostname 160 - 161 - // no spindle configured for this repo 162 - if record.Spindle == nil { 163 - l.Info("no spindle configured", "name", record.Name) 164 - return nil 165 - } 166 - 167 - // this repo did not want this spindle 168 - if *record.Spindle != domain { 169 - l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 170 - return nil 171 - } 172 - 173 - // add this repo to the watch list 174 - if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 175 - l.Error("failed to add repo", "error", err) 176 - return fmt.Errorf("failed to add repo: %w", err) 177 - } 178 - 179 - didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 180 - if err != nil { 181 - return err 182 - } 183 - 184 - // add repo to rbac 185 - if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 186 - l.Error("failed to add repo to enforcer", "error", err) 187 - return fmt.Errorf("failed to add repo: %w", err) 188 - } 189 - 190 - // add collaborators to rbac 191 - owner, err := s.res.ResolveIdent(ctx, did) 192 - if err != nil || owner.Handle.IsInvalidHandle() { 193 - return err 194 - } 195 - if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 196 - return err 197 - } 198 - 199 - // add this knot to the event consumer 200 - src := eventconsumer.NewKnotSource(record.Knot) 201 - s.ks.AddSource(context.Background(), src) 202 - 203 - return nil 204 - 205 - } 206 - return nil 207 - } 208 - 209 - func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 210 - var err error 211 - 212 - l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 213 - 214 - l.Info("ingesting collaborator record") 215 - 216 - switch e.Commit.Operation { 217 - case models.CommitOperationCreate, models.CommitOperationUpdate: 218 - raw := e.Commit.Record 219 - record := tangled.RepoCollaborator{} 220 - err = json.Unmarshal(raw, &record) 221 - if err != nil { 222 - l.Error("invalid record", "error", err) 223 - return err 224 - } 225 - 226 - subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 - if err != nil || subjectId.Handle.IsInvalidHandle() { 228 - return err 229 - } 230 - 231 - repoAt, err := syntax.ParseATURI(record.Repo) 232 - if err != nil { 233 - l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 - return nil 235 - } 236 - 237 - // TODO: get rid of this entirely 238 - // resolve this aturi to extract the repo record 239 - owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 - if err != nil || owner.Handle.IsInvalidHandle() { 241 - return fmt.Errorf("failed to resolve handle: %w", err) 242 - } 243 - 244 - xrpcc := xrpc.Client{ 245 - Host: owner.PDSEndpoint(), 246 - } 247 - 248 - resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 - if err != nil { 250 - return err 251 - } 252 - 253 - repo := resp.Value.Val.(*tangled.Repo) 254 - didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 - 256 - // check perms for this user 257 - if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 - return fmt.Errorf("insufficient permissions: %w", err) 259 - } 260 - 261 - // add collaborator to rbac 262 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 - l.Error("failed to add repo to enforcer", "error", err) 264 - return fmt.Errorf("failed to add repo: %w", err) 265 - } 266 - 267 - return nil 268 - } 269 - return nil 270 - } 271 - 272 - func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 - l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 - 275 - l.Info("fetching and adding existing collaborators") 276 - 277 - xrpcc := xrpc.Client{ 278 - Host: owner.PDSEndpoint(), 279 - } 280 - 281 - resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 - if err != nil { 283 - return err 284 - } 285 - 286 - var errs error 287 - for _, r := range resp.Records { 288 - if r == nil { 289 - continue 290 - } 291 - record := r.Value.Val.(*tangled.RepoCollaborator) 292 - 293 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 - l.Error("failed to add repo to enforcer", "error", err) 295 - errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 - } 297 - } 298 - 299 - return errs 300 - }
+2 -2
spindle/models/engine.go
··· 10 10 11 11 type Engine interface { 12 12 InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 - SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow, wfLogger WorkflowLogger) error 14 14 WorkflowTimeout() time.Duration 15 15 DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 - RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger WorkflowLogger) error 17 17 }
+22 -10
spindle/models/logger.go
··· 9 9 "strings" 10 10 ) 11 11 12 - type WorkflowLogger struct { 12 + type WorkflowLogger interface { 13 + Close() error 14 + DataWriter(idx int, stream string) io.Writer 15 + ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer 16 + } 17 + 18 + type NullLogger struct{} 19 + 20 + func (l NullLogger) Close() error { return nil } 21 + func (l NullLogger) DataWriter(idx int, stream string) io.Writer { return io.Discard } 22 + func (l NullLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 23 + return io.Discard 24 + } 25 + 26 + type FileWorkflowLogger struct { 13 27 file *os.File 14 28 encoder *json.Encoder 15 29 mask *SecretMask 16 30 } 17 31 18 - func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 32 + func NewFileWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (WorkflowLogger, error) { 19 33 path := LogFilePath(baseDir, wid) 20 - 21 34 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 22 35 if err != nil { 23 36 return nil, fmt.Errorf("creating log file: %w", err) 24 37 } 25 - 26 - return &WorkflowLogger{ 38 + return &FileWorkflowLogger{ 27 39 file: file, 28 40 encoder: json.NewEncoder(file), 29 41 mask: NewSecretMask(secretValues), ··· 35 47 return logFilePath 36 48 } 37 49 38 - func (l *WorkflowLogger) Close() error { 50 + func (l *FileWorkflowLogger) Close() error { 39 51 return l.file.Close() 40 52 } 41 53 42 - func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 54 + func (l *FileWorkflowLogger) DataWriter(idx int, stream string) io.Writer { 43 55 return &dataWriter{ 44 56 logger: l, 45 57 idx: idx, ··· 47 59 } 48 60 } 49 61 50 - func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 62 + func (l *FileWorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 51 63 return &controlWriter{ 52 64 logger: l, 53 65 idx: idx, ··· 57 69 } 58 70 59 71 type dataWriter struct { 60 - logger *WorkflowLogger 72 + logger *FileWorkflowLogger 61 73 idx int 62 74 stream string 63 75 } ··· 75 87 } 76 88 77 89 type controlWriter struct { 78 - logger *WorkflowLogger 90 + logger *FileWorkflowLogger 79 91 idx int 80 92 step Step 81 93 stepStatus StepStatus
+258 -150
spindle/server.go
··· 4 4 "context" 5 5 _ "embed" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "log/slog" 9 10 "maps" 10 11 "net/http" 12 + "path/filepath" 11 13 "sync" 14 + "time" 12 15 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/bluesky-social/indigo/service/tap" 13 18 "github.com/go-chi/chi/v5" 19 + "github.com/go-git/go-git/v5/plumbing/object" 20 + "github.com/hashicorp/go-version" 14 21 "tangled.org/core/api/tangled" 15 22 "tangled.org/core/eventconsumer" 16 23 "tangled.org/core/eventconsumer/cursor" 17 24 "tangled.org/core/idresolver" 18 - "tangled.org/core/jetstream" 25 + kgit "tangled.org/core/knotserver/git" 19 26 "tangled.org/core/log" 20 27 "tangled.org/core/notifier" 21 - "tangled.org/core/rbac" 28 + "tangled.org/core/rbac2" 22 29 "tangled.org/core/spindle/config" 23 30 "tangled.org/core/spindle/db" 24 31 "tangled.org/core/spindle/engine" 25 32 "tangled.org/core/spindle/engines/nixery" 33 + "tangled.org/core/spindle/git" 26 34 "tangled.org/core/spindle/models" 27 35 "tangled.org/core/spindle/queue" 28 36 "tangled.org/core/spindle/secrets" 29 37 "tangled.org/core/spindle/xrpc" 38 + "tangled.org/core/tapc" 39 + "tangled.org/core/tid" 40 + "tangled.org/core/workflow" 30 41 "tangled.org/core/xrpc/serviceauth" 31 42 ) 32 43 33 44 //go:embed motd 34 45 var defaultMotd []byte 35 46 36 - const ( 37 - rbacDomain = "thisserver" 38 - ) 39 - 40 47 type Spindle struct { 41 - jc *jetstream.JetstreamClient 48 + tap *tapc.Client 42 49 db *db.DB 43 - e *rbac.Enforcer 50 + e *rbac2.Enforcer 44 51 l *slog.Logger 45 52 n *notifier.Notifier 46 53 engs map[string]models.Engine ··· 57 64 func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 58 65 logger := log.FromContext(ctx) 59 66 60 - d, err := db.Make(cfg.Server.DBPath) 67 + if err := ensureGitVersion(); err != nil { 68 + return nil, fmt.Errorf("ensuring git version: %w", err) 69 + } 70 + 71 + d, err := db.Make(ctx, cfg.Server.DBPath()) 61 72 if err != nil { 62 73 return nil, fmt.Errorf("failed to setup db: %w", err) 63 74 } 64 75 65 - e, err := rbac.NewEnforcer(cfg.Server.DBPath) 76 + e, err := rbac2.NewEnforcer(cfg.Server.DBPath()) 66 77 if err != nil { 67 78 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 68 79 } 69 - e.E.EnableAutoSave(true) 70 80 71 81 n := notifier.New() 72 82 ··· 86 96 } 87 97 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 98 case "sqlite", "": 89 - vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 99 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath(), secrets.WithTableName("secrets")) 90 100 if err != nil { 91 101 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 102 } 93 - logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 103 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath()) 94 104 default: 95 105 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 106 } ··· 98 108 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 99 109 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 100 110 101 - collections := []string{ 102 - tangled.SpindleMemberNSID, 103 - tangled.RepoNSID, 104 - tangled.RepoCollaboratorNSID, 105 - } 106 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 107 - if err != nil { 108 - return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 109 - } 110 - jc.AddDid(cfg.Server.Owner) 111 - 112 - // Check if the spindle knows about any Dids; 113 - dids, err := d.GetAllDids() 114 - if err != nil { 115 - return nil, fmt.Errorf("failed to get all dids: %w", err) 116 - } 117 - for _, d := range dids { 118 - jc.AddDid(d) 119 - } 111 + tap := tapc.NewClient("http://localhost:"+cfg.Server.TapPort, "") 120 112 121 113 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 122 114 123 115 spindle := &Spindle{ 124 - jc: jc, 116 + tap: &tap, 125 117 e: e, 126 118 db: d, 127 119 l: logger, ··· 134 126 motd: defaultMotd, 135 127 } 136 128 137 - err = e.AddSpindle(rbacDomain) 138 - if err != nil { 139 - return nil, fmt.Errorf("failed to set rbac domain: %w", err) 140 - } 141 - err = spindle.configureOwner() 129 + err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did()) 142 130 if err != nil { 143 131 return nil, err 144 132 } 145 133 logger.Info("owner set", "did", cfg.Server.Owner) 146 134 147 - cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 135 + cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath()) 148 136 if err != nil { 149 137 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 150 138 } 151 139 152 - err = jc.StartJetstream(ctx, spindle.ingest()) 153 - if err != nil { 154 - return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 155 - } 156 - 157 - // for each incoming sh.tangled.pipeline, we execute 158 - // spindle.processPipeline, which in turn enqueues the pipeline 159 - // job in the above registered queue. 140 + // spindle listen to knot stream for sh.tangled.git.refUpdate 141 + // which will sync the local workflow files in spindle and enqueues the 142 + // pipeline job for on-push workflows 160 143 ccfg := eventconsumer.NewConsumerConfig() 161 144 ccfg.Logger = log.SubLogger(logger, "eventconsumer") 162 145 ccfg.Dev = cfg.Server.Dev 163 - ccfg.ProcessFunc = spindle.processPipeline 146 + ccfg.ProcessFunc = spindle.processKnotStream 164 147 ccfg.CursorStore = cursorStore 165 148 knownKnots, err := d.Knots() 166 149 if err != nil { ··· 201 184 } 202 185 203 186 // Enforcer returns the RBAC enforcer instance. 204 - func (s *Spindle) Enforcer() *rbac.Enforcer { 187 + func (s *Spindle) Enforcer() *rbac2.Enforcer { 205 188 return s.e 206 189 } 207 190 ··· 221 204 222 205 // Start starts the Spindle server (blocking). 223 206 func (s *Spindle) Start(ctx context.Context) error { 207 + svcErr := make(chan error, 1) 208 + 224 209 // starts a job queue runner in the background 225 210 s.jq.Start() 226 211 defer s.jq.Stop() ··· 235 220 s.ks.Start(ctx) 236 221 }() 237 222 223 + tap, err := tap.New(tap.Config{ 224 + DatabaseURL: s.cfg.Server.TapDBUrl, 225 + DBMaxConns: 32, 226 + PLCURL: s.cfg.Server.PlcUrl, 227 + RelayUrl: s.cfg.Server.RelayUrl, 228 + FirehoseParallelism: 10, 229 + ResyncParallelism: 5, 230 + OutboxParallelism: 1, 231 + FirehoseCursorSaveInterval: 1 * time.Second, 232 + RepoFetchTimeout: 300 * time.Second, 233 + IdentityCacheSize: 2_000_000, 234 + EventCacheSize: 100_000, 235 + SignalCollection: tangled.RepoPullNSID, // HACK: listen for repo.pull from non-tangled users 236 + CollectionFilters: []string{tangled.RepoNSID, tangled.RepoCollaboratorNSID, tangled.RepoPullNSID, tangled.SpindleMemberNSID}, 237 + RetryTimeout: 3 * time.Second, 238 + }) 239 + if err != nil { 240 + return err 241 + } 242 + go func() { 243 + if err := tap.RunTap(ctx, ":"+s.cfg.Server.TapPort); err != nil { 244 + svcErr <- err 245 + } 246 + }() 247 + go func() { 248 + s.l.Info("starting embedded tap server") 249 + 250 + s.l.Info("starting tap stream consumer") 251 + s.tap.Connect(ctx, &tapc.SimpleIndexer{ 252 + EventHandler: s.processEvent, 253 + }) 254 + }() 255 + 256 + s.l.Debug("waiting until tap connection") 257 + if err := s.tap.Wait(ctx); err != nil { 258 + return err 259 + } 260 + 261 + // ensure server owner is tracked 262 + s.l.Debug("adding server owner to tap") 263 + if err := s.tap.AddRepos(ctx, []syntax.DID{s.cfg.Server.Owner}); err != nil { 264 + return err 265 + } 266 + 238 267 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 239 268 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 240 269 } ··· 293 322 return x.Router() 294 323 } 295 324 296 - func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 297 - if msg.Nsid == tangled.PipelineNSID { 298 - tpl := tangled.Pipeline{} 299 - err := json.Unmarshal(msg.EventJson, &tpl) 300 - if err != nil { 301 - fmt.Println("error unmarshalling", err) 325 + func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 326 + l := log.FromContext(ctx).With("handler", "processKnotStream") 327 + l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 328 + if msg.Nsid == tangled.GitRefUpdateNSID { 329 + event := tangled.GitRefUpdate{} 330 + if err := json.Unmarshal(msg.EventJson, &event); err != nil { 331 + l.Error("error unmarshalling", "err", err) 302 332 return err 303 333 } 334 + l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName) 304 335 305 - if tpl.TriggerMetadata == nil { 306 - return fmt.Errorf("no trigger metadata found") 336 + // resolve repo name to rkey 337 + // TODO: git.refUpdate should respond with rkey instead of repo name 338 + repo, err := s.db.GetRepoWithName(syntax.DID(event.RepoDid), event.RepoName) 339 + if err != nil { 340 + return fmt.Errorf("get repo with did and name (%s/%s): %w", event.RepoDid, event.RepoName, err) 307 341 } 308 342 309 - if tpl.TriggerMetadata.Repo == nil { 310 - return fmt.Errorf("no repo data found") 343 + // NOTE: we are blindly trusting the knot that it will return only repos it own 344 + repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName) 345 + repoPath := s.newRepoPath(repo.Did, repo.Rkey) 346 + if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 347 + return fmt.Errorf("sync git repo: %w", err) 311 348 } 349 + l.Info("synced git repo") 312 350 313 - if src.Key() != tpl.TriggerMetadata.Repo.Knot { 314 - return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 351 + compiler := workflow.Compiler{ 352 + Trigger: tangled.Pipeline_TriggerMetadata{ 353 + Kind: string(workflow.TriggerKindPush), 354 + Push: &tangled.Pipeline_PushTriggerData{ 355 + Ref: event.Ref, 356 + OldSha: event.OldSha, 357 + NewSha: event.NewSha, 358 + }, 359 + Repo: &tangled.Pipeline_TriggerRepo{ 360 + Did: repo.Did.String(), 361 + Knot: repo.Knot, 362 + Repo: repo.Name, 363 + }, 364 + }, 315 365 } 316 366 317 - // filter by repos 318 - _, err = s.db.GetRepo( 319 - tpl.TriggerMetadata.Repo.Knot, 320 - tpl.TriggerMetadata.Repo.Did, 321 - tpl.TriggerMetadata.Repo.Repo, 322 - ) 367 + // load workflow definitions from rev (without spindle context) 368 + rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha) 323 369 if err != nil { 324 - return fmt.Errorf("failed to get repo: %w", err) 370 + return fmt.Errorf("loading pipeline: %w", err) 371 + } 372 + if len(rawPipeline) == 0 { 373 + l.Info("no workflow definition find for the repo. skipping the event") 374 + return nil 375 + } 376 + tpl := compiler.Compile(compiler.Parse(rawPipeline)) 377 + // TODO: pass compile error to workflow log 378 + for _, w := range compiler.Diagnostics.Errors { 379 + l.Error(w.String()) 380 + } 381 + for _, w := range compiler.Diagnostics.Warnings { 382 + l.Warn(w.String()) 325 383 } 326 384 327 385 pipelineId := models.PipelineId{ 328 - Knot: src.Key(), 329 - Rkey: msg.Rkey, 386 + Knot: tpl.TriggerMetadata.Repo.Knot, 387 + Rkey: tid.TID(), 330 388 } 331 - 332 - workflows := make(map[models.Engine][]models.Workflow) 389 + if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 390 + l.Error("failed to create pipeline event", "err", err) 391 + return nil 392 + } 393 + err = s.processPipeline(ctx, tpl, pipelineId) 394 + if err != nil { 395 + return err 396 + } 397 + } 333 398 334 - // Build pipeline environment variables once for all workflows 335 - pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 399 + return nil 400 + } 336 401 337 - for _, w := range tpl.Workflows { 338 - if w != nil { 339 - if _, ok := s.engs[w.Engine]; !ok { 340 - err = s.db.StatusFailed(models.WorkflowId{ 341 - PipelineId: pipelineId, 342 - Name: w.Name, 343 - }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 344 - if err != nil { 345 - return fmt.Errorf("db.StatusFailed: %w", err) 346 - } 402 + func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { 403 + if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { 404 + return nil, fmt.Errorf("syncing git repo: %w", err) 405 + } 406 + gr, err := kgit.Open(repoPath, rev) 407 + if err != nil { 408 + return nil, fmt.Errorf("opening git repo: %w", err) 409 + } 347 410 348 - continue 349 - } 411 + workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 412 + if errors.Is(err, object.ErrDirectoryNotFound) { 413 + // return empty RawPipeline when directory doesn't exist 414 + return nil, nil 415 + } else if err != nil { 416 + return nil, fmt.Errorf("loading file tree: %w", err) 417 + } 350 418 351 - eng := s.engs[w.Engine] 419 + var rawPipeline workflow.RawPipeline 420 + for _, e := range workflowDir { 421 + if !e.IsFile() { 422 + continue 423 + } 352 424 353 - if _, ok := workflows[eng]; !ok { 354 - workflows[eng] = []models.Workflow{} 355 - } 425 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 426 + contents, err := gr.RawContent(fpath) 427 + if err != nil { 428 + return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err) 429 + } 356 430 357 - ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 358 - if err != nil { 359 - return fmt.Errorf("init workflow: %w", err) 360 - } 431 + rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 432 + Name: e.Name, 433 + Contents: contents, 434 + }) 435 + } 361 436 362 - // inject TANGLED_* env vars after InitWorkflow 363 - // This prevents user-defined env vars from overriding them 364 - if ewf.Environment == nil { 365 - ewf.Environment = make(map[string]string) 366 - } 367 - maps.Copy(ewf.Environment, pipelineEnv) 437 + return rawPipeline, nil 438 + } 368 439 369 - workflows[eng] = append(workflows[eng], *ewf) 440 + func (s *Spindle) processPipeline(ctx context.Context, tpl tangled.Pipeline, pipelineId models.PipelineId) error { 441 + // Build pipeline environment variables once for all workflows 442 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 370 443 371 - err = s.db.StatusPending(models.WorkflowId{ 372 - PipelineId: pipelineId, 373 - Name: w.Name, 374 - }, s.n) 375 - if err != nil { 376 - return fmt.Errorf("db.StatusPending: %w", err) 377 - } 444 + // filter & init workflows 445 + workflows := make(map[models.Engine][]models.Workflow) 446 + for _, w := range tpl.Workflows { 447 + if w == nil { 448 + continue 449 + } 450 + if _, ok := s.engs[w.Engine]; !ok { 451 + err := s.db.StatusFailed(models.WorkflowId{ 452 + PipelineId: pipelineId, 453 + Name: w.Name, 454 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 455 + if err != nil { 456 + return fmt.Errorf("db.StatusFailed: %w", err) 378 457 } 458 + 459 + continue 379 460 } 380 461 381 - ok := s.jq.Enqueue(queue.Job{ 382 - Run: func() error { 383 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 384 - RepoOwner: tpl.TriggerMetadata.Repo.Did, 385 - RepoName: tpl.TriggerMetadata.Repo.Repo, 386 - Workflows: workflows, 387 - }, pipelineId) 388 - return nil 389 - }, 390 - OnFail: func(jobError error) { 391 - s.l.Error("pipeline run failed", "error", jobError) 392 - }, 393 - }) 394 - if ok { 395 - s.l.Info("pipeline enqueued successfully", "id", msg.Rkey) 396 - } else { 397 - s.l.Error("failed to enqueue pipeline: queue is full") 462 + eng := s.engs[w.Engine] 463 + 464 + if _, ok := workflows[eng]; !ok { 465 + workflows[eng] = []models.Workflow{} 398 466 } 399 - } 400 467 401 - return nil 402 - } 468 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 469 + if err != nil { 470 + return fmt.Errorf("init workflow: %w", err) 471 + } 403 472 404 - func (s *Spindle) configureOwner() error { 405 - cfgOwner := s.cfg.Server.Owner 473 + // inject TANGLED_* env vars after InitWorkflow 474 + // This prevents user-defined env vars from overriding them 475 + if ewf.Environment == nil { 476 + ewf.Environment = make(map[string]string) 477 + } 478 + maps.Copy(ewf.Environment, pipelineEnv) 406 479 407 - existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 408 - if err != nil { 409 - return err 480 + workflows[eng] = append(workflows[eng], *ewf) 410 481 } 411 482 412 - switch len(existing) { 413 - case 0: 414 - // no owner configured, continue 415 - case 1: 416 - // find existing owner 417 - existingOwner := existing[0] 483 + // enqueue pipeline 484 + ok := s.jq.Enqueue(queue.Job{ 485 + Run: func() error { 486 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 487 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 488 + RepoName: tpl.TriggerMetadata.Repo.Repo, 489 + Workflows: workflows, 490 + }, pipelineId) 491 + return nil 492 + }, 493 + OnFail: func(jobError error) { 494 + s.l.Error("pipeline run failed", "error", jobError) 495 + }, 496 + }) 497 + if !ok { 498 + return fmt.Errorf("failed to enqueue pipeline: queue is full") 499 + } 500 + s.l.Info("pipeline enqueued successfully", "id", pipelineId) 418 501 419 - // no ownership change, this is okay 420 - if existingOwner == s.cfg.Server.Owner { 421 - break 502 + // emit StatusPending for all workflows here (after successful enqueue) 503 + for _, ewfs := range workflows { 504 + for _, ewf := range ewfs { 505 + err := s.db.StatusPending(models.WorkflowId{ 506 + PipelineId: pipelineId, 507 + Name: ewf.Name, 508 + }, s.n) 509 + if err != nil { 510 + return fmt.Errorf("db.StatusPending: %w", err) 511 + } 422 512 } 513 + } 514 + return nil 515 + } 516 + 517 + // newRepoPath creates a path to store repository by its did and rkey. 518 + // The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey 519 + func (s *Spindle) newRepoPath(did syntax.DID, rkey syntax.RecordKey) string { 520 + return filepath.Join(s.cfg.Server.RepoDir(), did.String(), tangled.RepoNSID, rkey.String()) 521 + } 423 522 424 - // remove existing owner 425 - err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 426 - if err != nil { 427 - return nil 428 - } 429 - default: 430 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 523 + func (s *Spindle) newRepoCloneUrl(knot, did, name string) string { 524 + scheme := "https://" 525 + if s.cfg.Server.Dev { 526 + scheme = "http://" 431 527 } 528 + return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name) 529 + } 432 530 433 - return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 531 + const RequiredVersion = "2.49.0" 532 + 533 + func ensureGitVersion() error { 534 + v, err := git.Version() 535 + if err != nil { 536 + return fmt.Errorf("fetching git version: %w", err) 537 + } 538 + if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 539 + return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 540 + } 541 + return nil 434 542 }
+391
spindle/tap.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/spindle/db" 13 + "tangled.org/core/spindle/git" 14 + "tangled.org/core/spindle/models" 15 + "tangled.org/core/tapc" 16 + "tangled.org/core/tid" 17 + "tangled.org/core/workflow" 18 + ) 19 + 20 + func (s *Spindle) processEvent(ctx context.Context, evt tapc.Event) error { 21 + l := s.l.With("component", "tapIndexer") 22 + 23 + var err error 24 + switch evt.Type { 25 + case tapc.EvtRecord: 26 + switch evt.Record.Collection.String() { 27 + case tangled.SpindleMemberNSID: 28 + err = s.processMember(ctx, evt) 29 + case tangled.RepoNSID: 30 + err = s.processRepo(ctx, evt) 31 + case tangled.RepoCollaboratorNSID: 32 + err = s.processCollaborator(ctx, evt) 33 + case tangled.RepoPullNSID: 34 + err = s.processPull(ctx, evt) 35 + } 36 + case tapc.EvtIdentity: 37 + // no-op 38 + } 39 + 40 + if err != nil { 41 + l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err) 42 + return err 43 + } 44 + return nil 45 + } 46 + 47 + // NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated) 48 + 49 + func (s *Spindle) processMember(ctx context.Context, evt tapc.Event) error { 50 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 51 + 52 + l.Info("processing spindle.member record") 53 + 54 + // only listen to members 55 + if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 56 + l.Warn("forbidden request: member invite not allowed", "did", evt.Record.Did, "error", err) 57 + return nil 58 + } 59 + 60 + switch evt.Record.Action { 61 + case tapc.RecordCreateAction, tapc.RecordUpdateAction: 62 + record := tangled.SpindleMember{} 63 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 64 + return fmt.Errorf("parsing record: %w", err) 65 + } 66 + 67 + domain := s.cfg.Server.Hostname 68 + if record.Instance != domain { 69 + l.Info("domain mismatch", "domain", record.Instance, "expected", domain) 70 + return nil 71 + } 72 + 73 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 74 + if err != nil { 75 + created = time.Now() 76 + } 77 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 78 + Did: evt.Record.Did, 79 + Rkey: evt.Record.Rkey.String(), 80 + Instance: record.Instance, 81 + Subject: syntax.DID(record.Subject), 82 + Created: created, 83 + }); err != nil { 84 + l.Error("failed to add member", "error", err) 85 + return fmt.Errorf("adding member to db: %w", err) 86 + } 87 + if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil { 88 + return fmt.Errorf("adding member to rbac: %w", err) 89 + } 90 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 91 + return fmt.Errorf("adding did to tapc: %w", err) 92 + } 93 + 94 + l.Info("added member", "member", record.Subject) 95 + return nil 96 + 97 + case tapc.RecordDeleteAction: 98 + var ( 99 + did = evt.Record.Did.String() 100 + rkey = evt.Record.Rkey.String() 101 + ) 102 + member, err := db.GetSpindleMember(s.db, did, rkey) 103 + if err != nil { 104 + return fmt.Errorf("finding member: %w", err) 105 + } 106 + 107 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 108 + return fmt.Errorf("removing member from db: %w", err) 109 + } 110 + if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil { 111 + return fmt.Errorf("removing member from rbac: %w", err) 112 + } 113 + if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil { 114 + return fmt.Errorf("removing did from tapc: %w", err) 115 + } 116 + 117 + l.Info("removed member", "member", member.Subject) 118 + return nil 119 + } 120 + return nil 121 + } 122 + 123 + func (s *Spindle) processCollaborator(ctx context.Context, evt tapc.Event) error { 124 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 125 + 126 + l.Info("processing repo.collaborator record") 127 + 128 + // only listen to members 129 + if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 130 + l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 131 + return nil 132 + } 133 + 134 + switch evt.Record.Action { 135 + case tapc.RecordCreateAction, tapc.RecordUpdateAction: 136 + record := tangled.RepoCollaborator{} 137 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 138 + l.Error("invalid record", "err", err) 139 + return fmt.Errorf("parsing record: %w", err) 140 + } 141 + 142 + // retry later if target repo is not ingested yet 143 + if _, err := s.db.GetRepo(syntax.ATURI(record.Repo)); err != nil { 144 + l.Warn("target repo is not ingested yet", "repo", record.Repo, "err", err) 145 + return fmt.Errorf("target repo is unknown") 146 + } 147 + 148 + // check perms for this user 149 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil { 150 + l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 151 + return nil 152 + } 153 + 154 + if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{ 155 + Did: evt.Record.Did, 156 + Rkey: evt.Record.Rkey, 157 + Repo: syntax.ATURI(record.Repo), 158 + Subject: syntax.DID(record.Subject), 159 + }); err != nil { 160 + return fmt.Errorf("adding collaborator to db: %w", err) 161 + } 162 + if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil { 163 + return fmt.Errorf("adding collaborator to rbac: %w", err) 164 + } 165 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 166 + return fmt.Errorf("adding did to tapc: %w", err) 167 + } 168 + 169 + l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo) 170 + return nil 171 + 172 + case tapc.RecordDeleteAction: 173 + // get existing collaborator 174 + collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey) 175 + if err != nil { 176 + return fmt.Errorf("failed to get existing collaborator info: %w", err) 177 + } 178 + 179 + // check perms for this user 180 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil { 181 + l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 182 + return nil 183 + } 184 + 185 + if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil { 186 + return fmt.Errorf("removing collaborator from db: %w", err) 187 + } 188 + if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil { 189 + return fmt.Errorf("removing collaborator from rbac: %w", err) 190 + } 191 + if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil { 192 + return fmt.Errorf("removing did from tapc: %w", err) 193 + } 194 + 195 + l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo) 196 + return nil 197 + } 198 + return nil 199 + } 200 + 201 + func (s *Spindle) processRepo(ctx context.Context, evt tapc.Event) error { 202 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 203 + 204 + l.Info("processing repo record") 205 + 206 + // only listen to members 207 + if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 208 + l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 209 + return nil 210 + } 211 + 212 + switch evt.Record.Action { 213 + case tapc.RecordCreateAction, tapc.RecordUpdateAction: 214 + record := tangled.Repo{} 215 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 216 + return fmt.Errorf("parsing record: %w", err) 217 + } 218 + 219 + domain := s.cfg.Server.Hostname 220 + if record.Spindle == nil || *record.Spindle != domain { 221 + if record.Spindle == nil { 222 + l.Info("spindle isn't configured", "name", record.Name) 223 + } else { 224 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 225 + } 226 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 227 + return fmt.Errorf("deleting repo from db: %w", err) 228 + } 229 + return nil 230 + } 231 + 232 + repo := &db.Repo{ 233 + Did: evt.Record.Did, 234 + Rkey: evt.Record.Rkey, 235 + Name: record.Name, 236 + Knot: record.Knot, 237 + } 238 + 239 + if err := s.db.PutRepo(repo); err != nil { 240 + return fmt.Errorf("adding repo to db: %w", err) 241 + } 242 + 243 + if err := s.e.AddRepo(evt.Record.AtUri()); err != nil { 244 + return fmt.Errorf("adding repo to rbac") 245 + } 246 + 247 + // add this knot to the event consumer 248 + src := eventconsumer.NewKnotSource(record.Knot) 249 + s.ks.AddSource(context.Background(), src) 250 + 251 + // setup sparse sync 252 + repoCloneUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 253 + repoPath := s.newRepoPath(repo.Did, repo.Rkey) 254 + if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, ""); err != nil { 255 + return fmt.Errorf("setting up sparse-clone git repo: %w", err) 256 + } 257 + 258 + l.Info("added repo", "repo", evt.Record.AtUri()) 259 + return nil 260 + 261 + case tapc.RecordDeleteAction: 262 + // check perms for this user 263 + if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil { 264 + l.Warn("forbidden request: not repo owner", "did", evt.Record.Did, "err", err) 265 + return nil 266 + } 267 + 268 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 269 + return fmt.Errorf("deleting repo from db: %w", err) 270 + } 271 + 272 + if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil { 273 + return fmt.Errorf("deleting repo from rbac: %w", err) 274 + } 275 + 276 + l.Info("deleted repo", "repo", evt.Record.AtUri()) 277 + return nil 278 + } 279 + return nil 280 + } 281 + 282 + func (s *Spindle) processPull(ctx context.Context, evt tapc.Event) error { 283 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 284 + 285 + l.Info("processing pull record") 286 + 287 + // only listen to live events 288 + if !evt.Record.Live { 289 + l.Info("skipping backfill event", "event", evt.Record.AtUri()) 290 + return nil 291 + } 292 + 293 + switch evt.Record.Action { 294 + case tapc.RecordCreateAction, tapc.RecordUpdateAction: 295 + record := tangled.RepoPull{} 296 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 297 + l.Error("invalid record", "err", err) 298 + return fmt.Errorf("parsing record: %w", err) 299 + } 300 + 301 + // ignore legacy records 302 + if record.Target == nil { 303 + l.Info("ignoring pull record: target repo is nil") 304 + return nil 305 + } 306 + 307 + // ignore patch-based and fork-based PRs 308 + if record.Source == nil || record.Source.Repo != nil { 309 + l.Info("ignoring pull record: not a branch-based pull request") 310 + return nil 311 + } 312 + 313 + // skip if target repo is unknown 314 + repo, err := s.db.GetRepo(syntax.ATURI(record.Target.Repo)) 315 + if err != nil { 316 + l.Warn("target repo is not ingested yet", "repo", record.Target.Repo, "err", err) 317 + return fmt.Errorf("target repo is unknown") 318 + } 319 + 320 + compiler := workflow.Compiler{ 321 + Trigger: tangled.Pipeline_TriggerMetadata{ 322 + Kind: string(workflow.TriggerKindPullRequest), 323 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 324 + Action: "create", 325 + SourceBranch: record.Source.Branch, 326 + SourceSha: record.Source.Sha, 327 + TargetBranch: record.Target.Branch, 328 + }, 329 + Repo: &tangled.Pipeline_TriggerRepo{ 330 + Did: repo.Did.String(), 331 + Knot: repo.Knot, 332 + Repo: repo.Name, 333 + }, 334 + }, 335 + } 336 + 337 + repoUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 338 + repoPath := s.newRepoPath(repo.Did, repo.Rkey) 339 + 340 + // load workflow definitions from rev (without spindle context) 341 + rawPipeline, err := s.loadPipeline(ctx, repoUri, repoPath, record.Source.Sha) 342 + if err != nil { 343 + // don't retry 344 + l.Error("failed loading pipeline", "err", err) 345 + return nil 346 + } 347 + if len(rawPipeline) == 0 { 348 + l.Info("no workflow definition find for the repo. skipping the event") 349 + return nil 350 + } 351 + tpl := compiler.Compile(compiler.Parse(rawPipeline)) 352 + // TODO: pass compile error to workflow log 353 + for _, w := range compiler.Diagnostics.Errors { 354 + l.Error(w.String()) 355 + } 356 + for _, w := range compiler.Diagnostics.Warnings { 357 + l.Warn(w.String()) 358 + } 359 + 360 + pipelineId := models.PipelineId{ 361 + Knot: tpl.TriggerMetadata.Repo.Knot, 362 + Rkey: tid.TID(), 363 + } 364 + if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 365 + l.Error("failed to create pipeline event", "err", err) 366 + return nil 367 + } 368 + err = s.processPipeline(ctx, tpl, pipelineId) 369 + if err != nil { 370 + // don't retry 371 + l.Error("failed processing pipeline", "err", err) 372 + return nil 373 + } 374 + case tapc.RecordDeleteAction: 375 + // no-op 376 + } 377 + return nil 378 + } 379 + 380 + func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error { 381 + known, err := s.db.IsKnownDid(syntax.DID(did)) 382 + if err != nil { 383 + return fmt.Errorf("ensuring did known state: %w", err) 384 + } 385 + if !known { 386 + if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { 387 + return fmt.Errorf("removing did from tapc: %w", err) 388 + } 389 + } 390 + return nil 391 + }
+1 -2
spindle/xrpc/add_secret.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 68 67 return 69 68 } 70 69 71 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 70 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 72 71 l.Error("insufficent permissions", "did", actorDid.String()) 73 72 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 73 return
+1 -2
spindle/xrpc/list_secrets.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 63 62 return 64 63 } 65 64 66 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 67 66 l.Error("insufficent permissions", "did", actorDid.String()) 68 67 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 68 return
+1 -1
spindle/xrpc/owner.go
··· 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 - owner := x.Config.Server.Owner 12 + owner := x.Config.Server.Owner.String() 13 13 if owner == "" { 14 14 writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 15 return
+1 -26
spindle/xrpc/pipeline_cancelPipeline.go
··· 6 6 "net/http" 7 7 "strings" 8 8 9 - "github.com/bluesky-social/indigo/api/atproto" 10 9 "github.com/bluesky-social/indigo/atproto/syntax" 11 - "github.com/bluesky-social/indigo/xrpc" 12 - securejoin "github.com/cyphar/filepath-securejoin" 13 10 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 11 "tangled.org/core/spindle/models" 16 12 xrpcerr "tangled.org/core/xrpc/errors" 17 13 ) ··· 53 49 return 54 50 } 55 51 56 - ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 57 - if err != nil || ident.Handle.IsInvalidHandle() { 58 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 59 - return 60 - } 61 - 62 - xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 63 - resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 64 - if err != nil { 65 - fail(xrpcerr.GenericError(err)) 66 - return 67 - } 68 - 69 - repo := resp.Value.Val.(*tangled.Repo) 70 - didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 71 - if err != nil { 72 - fail(xrpcerr.GenericError(err)) 73 - return 74 - } 75 - 76 - // TODO: fine-grained role based control 77 - isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, didSlashRepo) 52 + isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt) 78 53 if err != nil || !isRepoOwner { 79 54 fail(xrpcerr.AccessControlError(actorDid.String())) 80 55 return
+1 -2
spindle/xrpc/remove_secret.go
··· 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "tangled.org/core/api/tangled" 13 - "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/secrets" 15 14 xrpcerr "tangled.org/core/xrpc/errors" 16 15 ) ··· 62 61 return 63 62 } 64 63 65 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 64 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 66 65 l.Error("insufficent permissions", "did", actorDid.String()) 67 66 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 67 return
+2 -2
spindle/xrpc/xrpc.go
··· 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/idresolver" 13 13 "tangled.org/core/notifier" 14 - "tangled.org/core/rbac" 14 + "tangled.org/core/rbac2" 15 15 "tangled.org/core/spindle/config" 16 16 "tangled.org/core/spindle/db" 17 17 "tangled.org/core/spindle/models" ··· 25 25 type Xrpc struct { 26 26 Logger *slog.Logger 27 27 Db *db.DB 28 - Enforcer *rbac.Enforcer 28 + Enforcer *rbac2.Enforcer 29 29 Engines map[string]models.Engine 30 30 Config *config.Config 31 31 Resolver *idresolver.Resolver
+3
tapc/readme.md
··· 1 + basic tap client package 2 + 3 + Replace this to official indigo package when <https://github.com/bluesky-social/indigo/pull/1241> gets merged.
+24
tapc/simpleIndexer.go
··· 1 + package tapc 2 + 3 + import "context" 4 + 5 + type SimpleIndexer struct { 6 + EventHandler func(ctx context.Context, evt Event) error 7 + ErrorHandler func(ctx context.Context, err error) 8 + } 9 + 10 + var _ Handler = (*SimpleIndexer)(nil) 11 + 12 + func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error { 13 + if i.EventHandler == nil { 14 + return nil 15 + } 16 + return i.EventHandler(ctx, evt) 17 + } 18 + 19 + func (i *SimpleIndexer) OnError(ctx context.Context, err error) { 20 + if i.ErrorHandler == nil { 21 + return 22 + } 23 + i.ErrorHandler(ctx, err) 24 + }
+192
tapc/tap.go
··· 1 + /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 + 3 + package tapc 4 + 5 + import ( 6 + "bytes" 7 + "context" 8 + "encoding/json" 9 + "fmt" 10 + "net/http" 11 + "net/url" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/websocket" 16 + "tangled.org/core/log" 17 + ) 18 + 19 + // type WebsocketOptions struct { 20 + // maxReconnectSeconds int 21 + // heartbeatIntervalMs int 22 + // // onReconnectError 23 + // } 24 + 25 + type Handler interface { 26 + OnEvent(ctx context.Context, evt Event) error 27 + OnError(ctx context.Context, err error) 28 + } 29 + 30 + type Client struct { 31 + Url string 32 + AdminPassword string 33 + HTTPClient *http.Client 34 + 35 + connectedSig chan struct{} 36 + } 37 + 38 + func NewClient(url, adminPassword string) Client { 39 + return Client{ 40 + Url: url, 41 + AdminPassword: adminPassword, 42 + HTTPClient: &http.Client{}, 43 + 44 + connectedSig: make(chan struct{}), 45 + } 46 + } 47 + 48 + func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 49 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 50 + if err != nil { 51 + return err 52 + } 53 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 54 + if err != nil { 55 + return err 56 + } 57 + req.SetBasicAuth("admin", c.AdminPassword) 58 + req.Header.Set("Content-Type", "application/json") 59 + 60 + resp, err := c.HTTPClient.Do(req) 61 + if err != nil { 62 + return err 63 + } 64 + defer resp.Body.Close() 65 + if resp.StatusCode != http.StatusOK { 66 + return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 67 + } 68 + return nil 69 + } 70 + 71 + func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 72 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 73 + if err != nil { 74 + return err 75 + } 76 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 77 + if err != nil { 78 + return err 79 + } 80 + req.SetBasicAuth("admin", c.AdminPassword) 81 + req.Header.Set("Content-Type", "application/json") 82 + 83 + resp, err := c.HTTPClient.Do(req) 84 + if err != nil { 85 + return err 86 + } 87 + defer resp.Body.Close() 88 + if resp.StatusCode != http.StatusOK { 89 + return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 90 + } 91 + return nil 92 + } 93 + 94 + func (c *Client) Wait(ctx context.Context) error { 95 + select { 96 + case <-ctx.Done(): 97 + return ctx.Err() 98 + case <-c.connectedSig: 99 + return nil 100 + } 101 + } 102 + 103 + func (c *Client) Connect(ctx context.Context, handler Handler) error { 104 + l := log.FromContext(ctx) 105 + 106 + u, err := url.Parse(c.Url) 107 + if err != nil { 108 + return err 109 + } 110 + if u.Scheme == "https" { 111 + u.Scheme = "wss" 112 + } else { 113 + u.Scheme = "ws" 114 + } 115 + u.Path = "/channel" 116 + 117 + // TODO: set auth on dial 118 + 119 + url := u.String() 120 + 121 + var backoff int 122 + for { 123 + select { 124 + case <-ctx.Done(): 125 + return ctx.Err() 126 + default: 127 + } 128 + 129 + header := http.Header{ 130 + "Authorization": []string{""}, 131 + } 132 + conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 133 + if err != nil { 134 + l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 135 + time.Sleep(time.Duration(5+backoff) * time.Second) 136 + backoff++ 137 + 138 + continue 139 + } else { 140 + backoff = 0 141 + } 142 + 143 + c.connectedSig <- struct{}{} 144 + 145 + l.Info("tap event subscription response", "code", res.StatusCode) 146 + 147 + if err = c.handleConnection(ctx, conn, handler); err != nil { 148 + l.Warn("tap connection failed", "err", err, "backoff", backoff) 149 + } 150 + } 151 + } 152 + 153 + func (c *Client) handleConnection(ctx context.Context, conn *websocket.Conn, handler Handler) error { 154 + l := log.FromContext(ctx) 155 + 156 + defer func() { 157 + conn.Close() 158 + l.Warn("closed tap conection") 159 + }() 160 + l.Info("established tap conection") 161 + 162 + for { 163 + select { 164 + case <-ctx.Done(): 165 + return ctx.Err() 166 + default: 167 + } 168 + _, message, err := conn.ReadMessage() 169 + if err != nil { 170 + return err 171 + } 172 + 173 + var ev Event 174 + if err := json.Unmarshal(message, &ev); err != nil { 175 + handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 176 + continue 177 + } 178 + if err := handler.OnEvent(ctx, ev); err != nil { 179 + handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 180 + continue 181 + } 182 + 183 + ack := map[string]any{ 184 + "type": "ack", 185 + "id": ev.ID, 186 + } 187 + if err := conn.WriteJSON(ack); err != nil { 188 + l.Warn("failed to send ack", "err", err) 189 + continue 190 + } 191 + } 192 + }
+62
tapc/types.go
··· 1 + package tapc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type EventType string 11 + 12 + const ( 13 + EvtRecord EventType = "record" 14 + EvtIdentity EventType = "identity" 15 + ) 16 + 17 + type Event struct { 18 + ID int64 `json:"id"` 19 + Type EventType `json:"type"` 20 + Record *RecordEventData `json:"record,omitempty"` 21 + Identity *IdentityEventData `json:"identity,omitempty"` 22 + } 23 + 24 + type RecordEventData struct { 25 + Live bool `json:"live"` 26 + Did syntax.DID `json:"did"` 27 + Rev string `json:"rev"` 28 + Collection syntax.NSID `json:"collection"` 29 + Rkey syntax.RecordKey `json:"rkey"` 30 + Action RecordAction `json:"action"` 31 + Record json.RawMessage `json:"record,omitempty"` 32 + CID *syntax.CID `json:"cid,omitempty"` 33 + } 34 + 35 + func (r *RecordEventData) AtUri() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey)) 37 + } 38 + 39 + type RecordAction string 40 + 41 + const ( 42 + RecordCreateAction RecordAction = "create" 43 + RecordUpdateAction RecordAction = "update" 44 + RecordDeleteAction RecordAction = "delete" 45 + ) 46 + 47 + type IdentityEventData struct { 48 + DID syntax.DID `json:"did"` 49 + Handle string `json:"handle"` 50 + IsActive bool `json:"is_active"` 51 + Status RepoStatus `json:"status"` 52 + } 53 + 54 + type RepoStatus string 55 + 56 + const ( 57 + RepoStatusActive RepoStatus = "active" 58 + RepoStatusTakendown RepoStatus = "takendown" 59 + RepoStatusSuspended RepoStatus = "suspended" 60 + RepoStatusDeactivated RepoStatus = "deactivated" 61 + RepoStatusDeleted RepoStatus = "deleted" 62 + )
+4 -10
types/repo.go
··· 94 94 Tags []*TagReference `json:"tags,omitempty"` 95 95 } 96 96 97 + type RepoTagResponse struct { 98 + Tag *TagReference `json:"tag,omitempty"` 99 + } 100 + 97 101 type RepoBranchesResponse struct { 98 102 Branches []Branch `json:"branches,omitempty"` 99 103 } ··· 104 108 105 109 type RepoDefaultBranchResponse struct { 106 110 Branch string `json:"branch,omitempty"` 107 - } 108 - 109 - type RepoBlobResponse struct { 110 - Contents string `json:"contents,omitempty"` 111 - Ref string `json:"ref,omitempty"` 112 - Path string `json:"path,omitempty"` 113 - IsBinary bool `json:"is_binary,omitempty"` 114 - 115 - Lines int `json:"lines,omitempty"` 116 - SizeHint uint64 `json:"size_hint,omitempty"` 117 111 } 118 112 119 113 type ForkStatus int
+5
types/tree.go
··· 105 105 Hash plumbing.Hash 106 106 Message string 107 107 When time.Time 108 + Author struct { 109 + Email string 110 + Name string 111 + When time.Time 112 + } 108 113 }
+19
xrpc/blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "io" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + // RepoUploadBlob calls the XRPC method "com.atproto.repo.uploadBlob". 12 + func RepoUploadBlob(ctx context.Context, c util.LexClient, input io.Reader, contentType string) (*comatproto.RepoUploadBlob_Output, error) { 13 + var out comatproto.RepoUploadBlob_Output 14 + if err := c.LexDo(ctx, util.Procedure, contentType, "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 15 + return nil, err 16 + } 17 + 18 + return &out, nil 19 + }