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

Compare changes

Choose any two refs to compare.

+1215 -624
+140
appview/db/db.go
··· 954 954 return err 955 955 }) 956 956 957 + // add generated at_uri column to pulls table 958 + // 959 + // this requires a full table recreation because stored columns 960 + // cannot be added via alter 961 + // 962 + // disable foreign-keys for the next migration 963 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 + runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 + _, err := tx.Exec(` 966 + create table if not exists pulls_new ( 967 + -- identifiers 968 + id integer primary key autoincrement, 969 + pull_id integer not null, 970 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 + 972 + -- at identifiers 973 + repo_at text not null, 974 + owner_did text not null, 975 + rkey text not null, 976 + 977 + -- content 978 + title text not null, 979 + body text not null, 980 + target_branch text not null, 981 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 + 983 + -- source info 984 + source_branch text, 985 + source_repo_at text, 986 + 987 + -- stacking 988 + stack_id text, 989 + change_id text, 990 + parent_change_id text, 991 + 992 + -- meta 993 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 + 995 + -- constraints 996 + unique(repo_at, pull_id), 997 + unique(at_uri), 998 + foreign key (repo_at) references repos(at_uri) on delete cascade 999 + ); 1000 + `) 1001 + if err != nil { 1002 + return err 1003 + } 1004 + 1005 + // transfer data 1006 + _, err = tx.Exec(` 1007 + insert into pulls_new ( 1008 + id, pull_id, repo_at, owner_did, rkey, 1009 + title, body, target_branch, state, 1010 + source_branch, source_repo_at, 1011 + stack_id, change_id, parent_change_id, 1012 + created 1013 + ) 1014 + select 1015 + id, pull_id, repo_at, owner_did, rkey, 1016 + title, body, target_branch, state, 1017 + source_branch, source_repo_at, 1018 + stack_id, change_id, parent_change_id, 1019 + created 1020 + from pulls; 1021 + `) 1022 + if err != nil { 1023 + return err 1024 + } 1025 + 1026 + // drop old table 1027 + _, err = tx.Exec(`drop table pulls`) 1028 + if err != nil { 1029 + return err 1030 + } 1031 + 1032 + // rename new table 1033 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 + return err 1035 + }) 1036 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 + 1038 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 + // 1040 + // this requires a full table recreation because stored columns 1041 + // cannot be added via alter 1042 + // 1043 + // disable foreign-keys for the next migration 1044 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 + runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 + _, err := tx.Exec(` 1047 + create table if not exists pull_submissions_new ( 1048 + -- identifiers 1049 + id integer primary key autoincrement, 1050 + pull_at text not null, 1051 + 1052 + -- content, these are immutable, and require a resubmission to update 1053 + round_number integer not null default 0, 1054 + patch text, 1055 + source_rev text, 1056 + 1057 + -- meta 1058 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 + 1060 + -- constraints 1061 + unique(pull_at, round_number), 1062 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 + ); 1064 + `) 1065 + if err != nil { 1066 + return err 1067 + } 1068 + 1069 + // transfer data, constructing pull_at from pulls table 1070 + _, err = tx.Exec(` 1071 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 + select 1073 + ps.id, 1074 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 + ps.round_number, 1076 + ps.patch, 1077 + ps.created 1078 + from pull_submissions ps 1079 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 + `) 1081 + if err != nil { 1082 + return err 1083 + } 1084 + 1085 + // drop old table 1086 + _, err = tx.Exec(`drop table pull_submissions`) 1087 + if err != nil { 1088 + return err 1089 + } 1090 + 1091 + // rename new table 1092 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 + return err 1094 + }) 1095 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 + 957 1097 return &DB{db}, nil 958 1098 } 959 1099
+13 -9
appview/db/email.go
··· 71 71 return did, nil 72 72 } 73 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 76 return make(map[string]string), nil 77 77 } 78 78 ··· 80 80 if isVerifiedFilter { 81 81 verifiedFilter = 1 82 82 } 83 + 84 + assoc := make(map[string]string) 83 85 84 86 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 87 89 88 90 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 92 98 } 93 99 94 100 query := ` ··· 104 110 return nil, err 105 111 } 106 112 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 113 110 114 for rows.Next() { 111 115 var email, did string
+34
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 "tangled.org/core/appview/models" 8 10 ) 9 11 ··· 82 84 83 85 return nil 84 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+145 -229
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 9 + "slices" 7 10 "sort" 8 11 "strings" 9 12 "time" ··· 87 90 pull.ID = int(id) 88 91 89 92 _, err = tx.Exec(` 90 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 91 - values (?, ?, ?, ?, ?) 92 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 93 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 + values (?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 93 96 return err 94 97 } 95 98 ··· 108 111 } 109 112 110 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 111 - pulls := make(map[int]*models.Pull) 114 + pulls := make(map[syntax.ATURI]*models.Pull) 112 115 113 116 var conditions []string 114 117 var args []any ··· 211 214 pull.ParentChangeId = parentChangeId.String 212 215 } 213 216 214 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 215 218 } 216 219 217 - // get latest round no. for each pull 218 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 219 - submissionsQuery := fmt.Sprintf(` 220 - select 221 - id, pull_id, round_number, patch, created, source_rev 222 - from 223 - pull_submissions 224 - where 225 - repo_at in (%s) and pull_id in (%s) 226 - `, inClause, inClause) 227 - 228 - args = make([]any, len(pulls)*2) 229 - idx := 0 220 + var pullAts []syntax.ATURI 230 221 for _, p := range pulls { 231 - args[idx] = p.RepoAt 232 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 233 223 } 234 - for _, p := range pulls { 235 - args[idx] = p.PullId 236 - idx += 1 237 - } 238 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 239 225 if err != nil { 240 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 241 227 } 242 - defer submissionsRows.Close() 243 228 244 - for submissionsRows.Next() { 245 - var s models.PullSubmission 246 - var sourceRev sql.NullString 247 - var createdAt string 248 - err := submissionsRows.Scan( 249 - &s.ID, 250 - &s.PullId, 251 - &s.RoundNumber, 252 - &s.Patch, 253 - &createdAt, 254 - &sourceRev, 255 - ) 256 - if err != nil { 257 - return nil, err 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 258 232 } 233 + } 259 234 260 - createdTime, err := time.Parse(time.RFC3339, createdAt) 261 - if err != nil { 262 - return nil, err 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 239 + } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 263 243 } 264 - s.Created = createdTime 244 + } 265 245 266 - if sourceRev.Valid { 267 - s.SourceRev = sourceRev.String 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 + for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 268 251 } 269 - 270 - if p, ok := pulls[s.PullId]; ok { 271 - p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 272 - p.Submissions[s.RoundNumber] = &s 273 - } 252 + } 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 274 256 } 275 - if err := rows.Err(); err != nil { 276 - return nil, err 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 277 260 } 278 - 279 - // get comment count on latest submission on each pull 280 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 281 - commentsQuery := fmt.Sprintf(` 282 - select 283 - count(id), pull_id 284 - from 285 - pull_comments 286 - where 287 - submission_id in (%s) 288 - group by 289 - submission_id 290 - `, inClause) 291 - 292 - args = []any{} 293 261 for _, p := range pulls { 294 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 295 - } 296 - commentsRows, err := e.Query(commentsQuery, args...) 297 - if err != nil { 298 - return nil, err 299 - } 300 - defer commentsRows.Close() 301 - 302 - for commentsRows.Next() { 303 - var commentCount, pullId int 304 - err := commentsRows.Scan( 305 - &commentCount, 306 - &pullId, 307 - ) 308 - if err != nil { 309 - return nil, err 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 310 266 } 311 - if p, ok := pulls[pullId]; ok { 312 - p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 313 - } 314 - } 315 - if err := rows.Err(); err != nil { 316 - return nil, err 317 267 } 318 268 319 269 orderedByPullId := []*models.Pull{} ··· 332 282 } 333 283 334 284 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 335 - query := ` 336 - select 337 - id, 338 - owner_did, 339 - pull_id, 340 - created, 341 - title, 342 - state, 343 - target_branch, 344 - repo_at, 345 - body, 346 - rkey, 347 - source_branch, 348 - source_repo_at, 349 - stack_id, 350 - change_id, 351 - parent_change_id 352 - from 353 - pulls 354 - where 355 - repo_at = ? and pull_id = ? 356 - ` 357 - row := e.QueryRow(query, repoAt, pullId) 358 - 359 - var pull models.Pull 360 - var createdAt string 361 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 362 - err := row.Scan( 363 - &pull.ID, 364 - &pull.OwnerDid, 365 - &pull.PullId, 366 - &createdAt, 367 - &pull.Title, 368 - &pull.State, 369 - &pull.TargetBranch, 370 - &pull.RepoAt, 371 - &pull.Body, 372 - &pull.Rkey, 373 - &sourceBranch, 374 - &sourceRepoAt, 375 - &stackId, 376 - &changeId, 377 - &parentChangeId, 378 - ) 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 379 286 if err != nil { 380 287 return nil, err 381 288 } 382 - 383 - createdTime, err := time.Parse(time.RFC3339, createdAt) 384 - if err != nil { 385 - return nil, err 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 386 291 } 387 - pull.Created = createdTime 388 292 389 - // populate source 390 - if sourceBranch.Valid { 391 - pull.PullSource = &models.PullSource{ 392 - Branch: sourceBranch.String, 393 - } 394 - if sourceRepoAt.Valid { 395 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 396 - if err != nil { 397 - return nil, err 398 - } 399 - pull.PullSource.RepoAt = &sourceRepoAtParsed 400 - } 293 + return pulls[0], nil 294 + } 295 + 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 401 303 } 402 304 403 - if stackId.Valid { 404 - pull.StackId = stackId.String 405 - } 406 - if changeId.Valid { 407 - pull.ChangeId = changeId.String 408 - } 409 - if parentChangeId.Valid { 410 - pull.ParentChangeId = parentChangeId.String 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 411 308 } 412 309 413 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 414 311 select 415 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 416 318 from 417 319 pull_submissions 418 - where 419 - repo_at = ? and pull_id = ? 420 - ` 421 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 422 326 if err != nil { 423 327 return nil, err 424 328 } 425 - defer submissionsRows.Close() 329 + defer rows.Close() 426 330 427 - submissionsMap := make(map[int]*models.PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 428 332 429 - for submissionsRows.Next() { 333 + for rows.Next() { 430 334 var submission models.PullSubmission 431 - var submissionCreatedStr string 432 - var submissionSourceRev sql.NullString 433 - err := submissionsRows.Scan( 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 434 338 &submission.ID, 435 - &submission.PullId, 436 - &submission.RepoAt, 339 + &submission.PullAt, 437 340 &submission.RoundNumber, 438 341 &submission.Patch, 439 - &submissionCreatedStr, 440 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 441 344 ) 442 345 if err != nil { 443 346 return nil, err 444 347 } 445 348 446 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 447 350 if err != nil { 448 351 return nil, err 449 352 } 450 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 451 354 452 - if submissionSourceRev.Valid { 453 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 454 357 } 455 358 456 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 360 + } 361 + 362 + if err := rows.Err(); err != nil { 363 + return nil, err 457 364 } 458 - if err = submissionsRows.Close(); err != nil { 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 459 370 return nil, err 460 371 } 461 - if len(submissionsMap) == 0 { 462 - return &pull, nil 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 376 + } 377 + 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 463 382 } 464 383 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 389 + } 390 + 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 465 396 var args []any 466 - for k := range submissionsMap { 467 - args = append(args, k) 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 468 400 } 469 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 470 - commentsQuery := fmt.Sprintf(` 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 405 + } 406 + 407 + query := fmt.Sprintf(` 471 408 select 472 409 id, 473 410 pull_id, ··· 479 416 created 480 417 from 481 418 pull_comments 482 - where 483 - submission_id IN (%s) 419 + %s 484 420 order by 485 421 created asc 486 - `, inClause) 487 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 488 425 if err != nil { 489 426 return nil, err 490 427 } 491 - defer commentsRows.Close() 428 + defer rows.Close() 492 429 493 - for commentsRows.Next() { 430 + var comments []models.PullComment 431 + for rows.Next() { 494 432 var comment models.PullComment 495 - var commentCreatedStr string 496 - err := commentsRows.Scan( 433 + var createdAt string 434 + err := rows.Scan( 497 435 &comment.ID, 498 436 &comment.PullId, 499 437 &comment.SubmissionId, ··· 501 439 &comment.OwnerDid, 502 440 &comment.CommentAt, 503 441 &comment.Body, 504 - &commentCreatedStr, 442 + &createdAt, 505 443 ) 506 444 if err != nil { 507 445 return nil, err 508 446 } 509 447 510 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 511 - if err != nil { 512 - return nil, err 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 513 450 } 514 - comment.Created = commentCreatedTime 515 451 516 - // Add the comment to its submission 517 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 518 - submission.Comments = append(submission.Comments, comment) 519 - } 452 + comments = append(comments, comment) 453 + } 520 454 521 - } 522 - if err = commentsRows.Err(); err != nil { 455 + if err := rows.Err(); err != nil { 523 456 return nil, err 524 457 } 525 458 526 - var pullSourceRepo *models.Repo 527 - if pull.PullSource != nil { 528 - if pull.PullSource.RepoAt != nil { 529 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 530 - if err != nil { 531 - log.Printf("failed to get repo by at uri: %v", err) 532 - } else { 533 - pull.PullSource.Repo = pullSourceRepo 534 - } 535 - } 536 - } 537 - 538 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 539 - for _, submission := range submissionsMap { 540 - pull.Submissions[submission.RoundNumber] = submission 541 - } 542 - 543 - return &pull, nil 459 + return comments, nil 544 460 } 545 461 546 462 // timeframe here is directly passed into the sql query filter, and any ··· 677 593 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 678 594 newRoundNumber := len(pull.Submissions) 679 595 _, err := e.Exec(` 680 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 681 - values (?, ?, ?, ?, ?) 682 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 683 599 684 600 return err 685 601 }
+5 -1
appview/issues/issues.go
··· 798 798 return 799 799 } 800 800 801 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 802 806 if err != nil { 803 807 log.Println("failed to fetch labels", err) 804 808 rp.pages.Error503(w)
+14 -13
appview/models/label.go
··· 461 461 return result 462 462 } 463 463 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 464 472 func DefaultLabelDefs() []string { 465 - rkeys := []string{ 466 - "wontfix", 467 - "duplicate", 468 - "assignee", 469 - "good-first-issue", 470 - "documentation", 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 471 479 } 472 - 473 - defs := make([]string, len(rkeys)) 474 - for i, r := range rkeys { 475 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 476 - } 477 - 478 - return defs 479 480 } 480 481 481 482 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+46 -4
appview/models/pull.go
··· 77 77 PullSource *PullSource 78 78 79 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 80 + Labels LabelState 81 + Repo *Repo 81 82 } 82 83 83 84 func (p Pull) AsRecord() tangled.RepoPull { ··· 125 126 126 127 type PullSubmission struct { 127 128 // ids 128 - ID int 129 - PullId int 129 + ID int 130 130 131 131 // at ids 132 - RepoAt syntax.ATURI 132 + PullAt syntax.ATURI 133 133 134 134 // content 135 135 RoundNumber int ··· 207 207 return p.StackId != "" 208 208 } 209 209 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 210 232 func (s PullSubmission) IsFormatPatch() bool { 211 233 return patchutil.IsFormatPatch(s.Patch) 212 234 } ··· 219 241 } 220 242 221 243 return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 222 264 } 223 265 224 266 type Stack []*Pull
+5
appview/models/repo.go
··· 86 86 RepoAt syntax.ATURI 87 87 LabelAt syntax.ATURI 88 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+17
appview/pages/pages.go
··· 306 306 LoggedInUser *oauth.User 307 307 Timeline []models.TimelineEvent 308 308 Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 309 310 } 310 311 311 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 312 313 return p.execute("timeline/timeline", w, params) 314 + } 315 + 316 + type GoodFirstIssuesParams struct { 317 + LoggedInUser *oauth.User 318 + Issues []models.Issue 319 + RepoGroups []*models.RepoGroup 320 + LabelDefs map[string]*models.LabelDefinition 321 + GfiLabel *models.LabelDefinition 322 + Page pagination.Page 323 + } 324 + 325 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 326 + return p.execute("goodfirstissues/index", w, params) 313 327 } 314 328 315 329 type UserProfileSettingsParams struct { ··· 1086 1100 FilteringBy models.PullState 1087 1101 Stacks map[string]models.Stack 1088 1102 Pipelines map[string]models.Pipeline 1103 + LabelDefs map[string]*models.LabelDefinition 1089 1104 } 1090 1105 1091 1106 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1125 1140 OrderedReactionKinds []models.ReactionKind 1126 1141 Reactions map[models.ReactionKind]int 1127 1142 UserReacted map[models.ReactionKind]bool 1143 + 1144 + LabelDefs map[string]*models.LabelDefinition 1128 1145 } 1129 1146 1130 1147 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+167
appview/pages/templates/goodfirstissues/index.html
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 27 + <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 28 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 29 + <header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 30 29 31 {{ if .LoggedInUser }} 30 32 <div id="upgrade-banner" ··· 38 40 {{ end }} 39 41 40 42 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <main class="col-span-1 md:col-span-8"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 44 47 {{ block "content" . }}{{ end }} 45 48 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 50 53 {{ block "contentAfter" . }}{{ end }} 51 54 </main> 52 - {{ end }} 55 + {{ end }} 56 + </div> 53 57 </div> 54 58 {{ end }} 55 59 56 60 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 58 62 {{ template "layouts/fragments/footer" . }} 59 63 </footer> 60 64 {{ end }}
+87 -34
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 2 + <div class="w-full p-8"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 + 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 10 33 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 19 46 </div> 20 47 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 27 51 </div> 52 + </div> 28 53 29 - <div class="flex flex-col gap-1"> 30 - <div class="{{ $headerStyle }}">social</div> 31 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 32 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 33 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 34 64 </div> 35 65 36 - <div class="flex flex-col gap-1"> 37 - <div class="{{ $headerStyle }}">contact</div> 38 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 39 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 40 93 </div> 41 - </div> 42 94 43 - <div class="text-center lg:text-right flex-shrink-0"> 44 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 45 98 </div> 46 99 </div> 47 100 </div>
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
+27 -208
appview/pages/templates/notifications/fragments/item.html
··· 1 1 {{define "notifications/fragments/item"}} 2 - <div 3 - class=" 4 - w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 5 - {{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}} 6 - flex gap-2 items-center 7 - " 8 - > 2 + <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 3 + <div 4 + class=" 5 + w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 6 + {{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}} 7 + flex gap-2 items-center 8 + "> 9 + {{ template "notificationIcon" . }} 10 + <div class="flex-1 w-full flex flex-col gap-1"> 11 + <span>{{ template "notificationHeader" . }}</span> 12 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 + </div> 9 14 10 - {{ template "notificationIcon" . }} 11 - <div class="flex-1 w-full flex flex-col gap-1"> 12 - <span>{{ template "notificationHeader" . }}</span> 13 - <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 14 15 </div> 15 - 16 - </div> 16 + </a> 17 17 {{end}} 18 18 19 19 {{ define "notificationIcon" }} ··· 64 64 {{ end }} 65 65 {{ end }} 66 66 67 - {{define "issueNotification"}} 68 - {{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 69 - <a 70 - href="{{$url}}" 71 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 72 - > 73 - <div class="flex items-center justify-between"> 74 - <div class="min-w-0 flex-1"> 75 - <!-- First line: icon + actor action --> 76 - <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 77 - {{if eq .Type "issue_created"}} 78 - <span class="text-green-600 dark:text-green-500"> 79 - {{ i "circle-dot" "w-4 h-4" }} 80 - </span> 81 - {{else if eq .Type "issue_commented"}} 82 - <span class="text-gray-500 dark:text-gray-400"> 83 - {{ i "message-circle" "w-4 h-4" }} 84 - </span> 85 - {{else if eq .Type "issue_closed"}} 86 - <span class="text-gray-500 dark:text-gray-400"> 87 - {{ i "ban" "w-4 h-4" }} 88 - </span> 89 - {{end}} 90 - {{template "user/fragments/picHandle" .ActorDid}} 91 - {{if eq .Type "issue_created"}} 92 - <span class="text-gray-500 dark:text-gray-400">opened issue</span> 93 - {{else if eq .Type "issue_commented"}} 94 - <span class="text-gray-500 dark:text-gray-400">commented on issue</span> 95 - {{else if eq .Type "issue_closed"}} 96 - <span class="text-gray-500 dark:text-gray-400">closed issue</span> 97 - {{end}} 98 - {{if not .Read}} 99 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 100 - {{end}} 101 - </div> 102 - 103 - <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 104 - <span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span> 105 - <span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span> 106 - <span>on</span> 107 - <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 108 - </div> 109 - </div> 67 + {{ define "notificationUrl" }} 68 + {{ $url := "" }} 69 + {{ if eq .Type "repo_starred" }} 70 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 71 + {{ else if .Issue }} 72 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 73 + {{ else if .Pull }} 74 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 75 + {{ else if eq .Type "followed" }} 76 + {{$url = printf "/%s" (resolve .ActorDid)}} 77 + {{ else }} 78 + {{ end }} 110 79 111 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 112 - {{ template "repo/fragments/time" .Created }} 113 - </div> 114 - </div> 115 - </a> 116 - {{end}} 117 - 118 - {{define "pullNotification"}} 119 - {{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 120 - <a 121 - href="{{$url}}" 122 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 123 - > 124 - <div class="flex items-center justify-between"> 125 - <div class="min-w-0 flex-1"> 126 - <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 127 - {{if eq .Type "pull_created"}} 128 - <span class="text-green-600 dark:text-green-500"> 129 - {{ i "git-pull-request-create" "w-4 h-4" }} 130 - </span> 131 - {{else if eq .Type "pull_commented"}} 132 - <span class="text-gray-500 dark:text-gray-400"> 133 - {{ i "message-circle" "w-4 h-4" }} 134 - </span> 135 - {{else if eq .Type "pull_merged"}} 136 - <span class="text-purple-600 dark:text-purple-500"> 137 - {{ i "git-merge" "w-4 h-4" }} 138 - </span> 139 - {{else if eq .Type "pull_closed"}} 140 - <span class="text-red-600 dark:text-red-500"> 141 - {{ i "git-pull-request-closed" "w-4 h-4" }} 142 - </span> 143 - {{end}} 144 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 145 - {{if eq .Type "pull_created"}} 146 - <span class="text-gray-500 dark:text-gray-400">opened pull request</span> 147 - {{else if eq .Type "pull_commented"}} 148 - <span class="text-gray-500 dark:text-gray-400">commented on pull request</span> 149 - {{else if eq .Type "pull_merged"}} 150 - <span class="text-gray-500 dark:text-gray-400">merged pull request</span> 151 - {{else if eq .Type "pull_closed"}} 152 - <span class="text-gray-500 dark:text-gray-400">closed pull request</span> 153 - {{end}} 154 - {{if not .Read}} 155 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 156 - {{end}} 157 - </div> 158 - 159 - <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 160 - <span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span> 161 - <span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span> 162 - <span>on</span> 163 - <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 164 - </div> 165 - </div> 166 - 167 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 168 - {{ template "repo/fragments/time" .Created }} 169 - </div> 170 - </div> 171 - </a> 172 - {{end}} 173 - 174 - {{define "repoNotification"}} 175 - {{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 176 - <a 177 - href="{{$url}}" 178 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 179 - > 180 - <div class="flex items-center justify-between"> 181 - <div class="flex items-center gap-2 min-w-0 flex-1"> 182 - <span class="text-yellow-500 dark:text-yellow-400"> 183 - {{ i "star" "w-4 h-4" }} 184 - </span> 185 - 186 - <div class="min-w-0 flex-1"> 187 - <!-- Single line for stars: actor action subject --> 188 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 189 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 190 - <span class="text-gray-500 dark:text-gray-400">starred</span> 191 - <span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 192 - {{if not .Read}} 193 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 194 - {{end}} 195 - </div> 196 - </div> 197 - </div> 198 - 199 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 200 - {{ template "repo/fragments/time" .Created }} 201 - </div> 202 - </div> 203 - </a> 204 - {{end}} 205 - 206 - {{define "followNotification"}} 207 - {{$url := printf "/%s" (resolve .ActorDid)}} 208 - <a 209 - href="{{$url}}" 210 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 211 - > 212 - <div class="flex items-center justify-between"> 213 - <div class="flex items-center gap-2 min-w-0 flex-1"> 214 - <span class="text-blue-600 dark:text-blue-400"> 215 - {{ i "user-plus" "w-4 h-4" }} 216 - </span> 217 - 218 - <div class="min-w-0 flex-1"> 219 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 220 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 221 - <span class="text-gray-500 dark:text-gray-400">followed you</span> 222 - {{if not .Read}} 223 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 224 - {{end}} 225 - </div> 226 - </div> 227 - </div> 228 - 229 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 230 - {{ template "repo/fragments/time" .Created }} 231 - </div> 232 - </div> 233 - </a> 234 - {{end}} 235 - 236 - {{define "genericNotification"}} 237 - <a 238 - href="#" 239 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 240 - > 241 - <div class="flex items-center justify-between"> 242 - <div class="flex items-center gap-2 min-w-0 flex-1"> 243 - <span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}"> 244 - {{ i "bell" "w-4 h-4" }} 245 - </span> 246 - 247 - <div class="min-w-0 flex-1"> 248 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 249 - <span>New notification</span> 250 - {{if not .Read}} 251 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 252 - {{end}} 253 - </div> 254 - </div> 255 - </div> 256 - 257 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 258 - {{ template "repo/fragments/time" .Created }} 259 - </div> 260 - </div> 261 - </a> 262 - {{end}} 80 + {{ $url }} 81 + {{ end }}
+7
appview/pages/templates/repo/fork.html
··· 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+26
appview/pages/templates/repo/fragments/participants.html
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-6 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+1 -27
appview/pages/templates/repo/issues/issue.html
··· 22 22 "Defs" $.LabelDefs 23 23 "Subject" $.Issue.AtUri 24 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 26 </div> 27 27 </div> 28 28 {{ end }} ··· 122 122 </div> 123 123 {{ end }} 124 124 125 - {{ define "issueParticipants" }} 126 - {{ $all := .Issue.Participants }} 127 - {{ $ps := take $all 5 }} 128 - <div> 129 - <div class="py-1 flex items-center text-sm"> 130 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 - </div> 133 - <div class="flex items-center -space-x-3 mt-2"> 134 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 - {{ range $i, $p := $ps }} 136 - <img 137 - src="{{ tinyAvatar . }}" 138 - alt="" 139 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 - /> 141 - {{ end }} 142 - 143 - {{ if gt (len $all) 5 }} 144 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 - +{{ sub (len $all) 5 }} 146 - </span> 147 - {{ end }} 148 - </div> 149 - </div> 150 - {{ end }} 151 125 152 126 {{ define "repoAfter" }} 153 127 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 42 </div> 93 43 {{ block "pagination" . }} {{ end }} 94 44 {{ end }}
+30 -12
appview/pages/templates/repo/pulls/pull.html
··· 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 12 + {{ define "repoContentLayout" }} 13 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 14 + <div class="col-span-1 md:col-span-8"> 15 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 16 + {{ block "repoContent" . }}{{ end }} 17 + </section> 18 + {{ block "repoAfter" . }}{{ end }} 19 + </div> 20 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 21 + {{ template "repo/fragments/labelPanel" 22 + (dict "RepoInfo" $.RepoInfo 23 + "Defs" $.LabelDefs 24 + "Subject" $.Pull.PullAt 25 + "State" $.Pull.Labels) }} 26 + {{ template "repo/fragments/participants" $.Pull.Participants }} 27 + </div> 28 + </div> 29 + {{ end }} 12 30 13 31 {{ define "repoContent" }} 14 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 57 {{ with $item }} 40 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 43 61 <!-- round number --> 44 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 64 </div> 47 65 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 67 <span class="gap-1 flex items-center"> 50 68 {{ $owner := resolve $.Pull.OwnerDid }} 51 69 {{ $re := "re" }} ··· 72 90 <span class="hidden md:inline">diff</span> 73 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 92 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 83 - <span id="interdiff-error-{{.RoundNumber}}"></span> 93 + {{ if ne $idx 0 }} 94 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 95 + hx-boost="true" 96 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 97 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 98 + <span class="hidden md:inline">interdiff</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </a> 84 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 85 103 </div> 86 104 </summary> 87 105 ··· 146 164 147 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 166 {{ range $cidx, $c := .Comments }} 149 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 168 {{ if gt $cidx 0 }} 151 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 170 {{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 108 <span class="before:content-['·']"></span> 109 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 111 118 </div> 112 119 </div> 113 120 {{ if .StackId }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+1
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head>
+1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 13 14
+42 -3
appview/pulls/pulls.go
··· 200 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 201 } 202 202 203 + labelDefs, err := db.GetLabelDefinitions( 204 + s.db, 205 + db.FilterIn("at_uri", f.Repo.Labels), 206 + db.FilterContains("scope", tangled.RepoPullNSID), 207 + ) 208 + if err != nil { 209 + log.Println("failed to fetch labels", err) 210 + s.pages.Error503(w) 211 + return 212 + } 213 + 214 + defs := make(map[string]*models.LabelDefinition) 215 + for _, l := range labelDefs { 216 + defs[l.AtUri().String()] = &l 217 + } 218 + 203 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 204 220 LoggedInUser: user, 205 221 RepoInfo: repoInfo, ··· 213 229 OrderedReactionKinds: models.OrderedReactionKinds, 214 230 Reactions: reactionCountMap, 215 231 UserReacted: userReactions, 232 + 233 + LabelDefs: defs, 216 234 }) 217 235 } 218 236 ··· 557 575 m[p.Sha] = p 558 576 } 559 577 578 + labelDefs, err := db.GetLabelDefinitions( 579 + s.db, 580 + db.FilterIn("at_uri", f.Repo.Labels), 581 + db.FilterContains("scope", tangled.RepoPullNSID), 582 + ) 583 + if err != nil { 584 + log.Println("failed to fetch labels", err) 585 + s.pages.Error503(w) 586 + return 587 + } 588 + 589 + defs := make(map[string]*models.LabelDefinition) 590 + for _, l := range labelDefs { 591 + defs[l.AtUri().String()] = &l 592 + } 593 + 560 594 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 595 LoggedInUser: s.oauth.GetUser(r), 562 596 RepoInfo: f.RepoInfo(user), 563 597 Pulls: pulls, 598 + LabelDefs: defs, 564 599 FilteringBy: state, 565 600 Stacks: stacks, 566 601 Pipelines: m, ··· 1058 1093 1059 1094 // We've already checked earlier if it's diff-based and title is empty, 1060 1095 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1096 + if title == "" || body == "" { 1062 1097 formatPatches, err := patchutil.ExtractPatches(patch) 1063 1098 if err != nil { 1064 1099 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 1104 return 1070 1105 } 1071 1106 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1107 + if title == "" { 1108 + title = formatPatches[0].Title 1109 + } 1110 + if body == "" { 1111 + body = formatPatches[0].Body 1112 + } 1074 1113 } 1075 1114 1076 1115 rkey := tid.TID()
+12 -1
appview/repo/index.go
··· 200 200 }) 201 201 } 202 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 203 209 // update appview's cache 204 - err = db.InsertRepoLanguages(rp.db, langs) 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 205 211 if err != nil { 206 212 // non-fatal 207 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 208 219 } 209 220 } 210 221
+11 -7
appview/repo/repo.go
··· 2129 2129 } 2130 2130 2131 2131 // choose a name for a fork 2132 - forkName := f.Name 2132 + forkName := r.FormValue("repo_name") 2133 + if forkName == "" { 2134 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2135 + return 2136 + } 2137 + 2133 2138 // this check is *only* to see if the forked repo name already exists 2134 2139 // in the user's account. 2135 2140 existingRepo, err := db.GetRepo( 2136 2141 rp.db, 2137 2142 db.FilterEq("did", user.Did), 2138 - db.FilterEq("name", f.Name), 2143 + db.FilterEq("name", forkName), 2139 2144 ) 2140 2145 if err != nil { 2141 - if errors.Is(err, sql.ErrNoRows) { 2142 - // no existing repo with this name found, we can use the name as is 2143 - } else { 2146 + if !errors.Is(err, sql.ErrNoRows) { 2144 2147 log.Println("error fetching existing repo from db", "err", err) 2145 2148 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2146 2149 return 2147 2150 } 2148 2151 } else if existingRepo != nil { 2149 - // repo with this name already exists, append random string 2150 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2152 + // repo with this name already exists 2153 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2154 + return 2151 2155 } 2152 2156 l = l.With("forkName", forkName) 2153 2157
+1 -1
appview/signup/signup.go
··· 133 133 noticeId := "signup-msg" 134 134 135 135 if err := s.validateCaptcha(cfToken, r); err != nil { 136 - s.l.Warn("turnstile validation failed", "error", err) 136 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 137 137 s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 138 return 139 139 }
+151
appview/state/gfi.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+14 -1
appview/state/knotstream.go
··· 172 172 }) 173 173 } 174 174 175 - return db.InsertRepoLanguages(d, langs) 175 + tx, err := d.Begin() 176 + if err != nil { 177 + return err 178 + } 179 + defer tx.Rollback() 180 + 181 + // update appview's cache 182 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 183 + if err != nil { 184 + fmt.Printf("failed; %s\n", err) 185 + // non-fatal 186 + } 187 + 188 + return tx.Commit() 176 189 } 177 190 178 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+3
appview/state/router.go
··· 37 37 router.Use(middleware.TryRefreshSession()) 38 38 router.Get("/favicon.svg", s.Favicon) 39 39 router.Get("/favicon.ico", s.Favicon) 40 + router.Get("/pwa-manifest.json", s.PWAManifest) 40 41 41 42 userRouter := s.UserRouter(&middleware) 42 43 standardRouter := s.StandardRouter(&middleware) ··· 130 131 }) 131 132 // r.Post("/import", s.ImportRepo) 132 133 }) 134 + 135 + r.Get("/goodfirstissues", s.GoodFirstIssues) 133 136 134 137 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 135 138 r.Post("/", s.Follow)
+31 -2
appview/state/state.go
··· 198 198 s.pages.Favicon(w) 199 199 } 200 200 201 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 202 + const manifestJson = `{ 203 + "name": "tangled", 204 + "description": "tightly-knit social coding.", 205 + "icons": [ 206 + { 207 + "src": "/favicon.svg", 208 + "sizes": "144x144" 209 + } 210 + ], 211 + "start_url": "/", 212 + "id": "org.tangled", 213 + 214 + "display": "standalone", 215 + "background_color": "#111827", 216 + "theme_color": "#111827" 217 + }` 218 + 219 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 220 + w.Header().Set("Content-Type", "application/json") 221 + w.Write([]byte(manifestJson)) 222 + } 223 + 201 224 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 202 225 user := s.oauth.GetUser(r) 203 226 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 247 270 return 248 271 } 249 272 250 - s.pages.Timeline(w, pages.TimelineParams{ 273 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 274 + if err != nil { 275 + // non-fatal 276 + } 277 + 278 + fmt.Println(s.pages.Timeline(w, pages.TimelineParams{ 251 279 LoggedInUser: user, 252 280 Timeline: timeline, 253 281 Repos: repos, 254 - }) 282 + GfiLabel: gfiLabel, 283 + })) 255 284 } 256 285 257 286 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
+1 -1
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 25 26 26 ```yaml 27 27 when:
+1 -1
go.mod
··· 43 43 github.com/yuin/goldmark v1.7.12 44 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 46 47 golang.org/x/net v0.42.0 47 48 golang.org/x/sync v0.16.0 48 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ··· 168 169 go.uber.org/atomic v1.11.0 // indirect 169 170 go.uber.org/multierr v1.11.0 // indirect 170 171 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 172 golang.org/x/sys v0.34.0 // indirect 173 173 golang.org/x/text v0.27.0 // indirect 174 174 golang.org/x/time v0.12.0 // indirect
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";