Compare changes

Choose any two refs to compare.

+4777 -3043
-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.
+12 -1
appview/config/config.go
··· 13 13 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 14 DbPath string `env:"DB_PATH, default=appview.db"` 15 15 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=tangled.org"` 17 17 AppviewName string `env:"APPVIEW_Name, default=Tangled"` 18 18 Dev bool `env:"DEV, default=false"` 19 19 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` ··· 23 23 24 24 // uhhhh this is because knot1 is under icy's did 25 25 TmpAltAppPassword string `env:"ALT_APP_PASSWORD"` 26 + } 27 + 28 + func (c *CoreConfig) UseTLS() bool { 29 + return !c.Dev 30 + } 31 + 32 + func (c *CoreConfig) BaseUrl() string { 33 + if c.UseTLS() { 34 + return "https://" + c.AppviewHost 35 + } 36 + return "http://" + c.AppviewHost 26 37 } 27 38 28 39 type OAuthConfig struct {
+24
appview/db/db.go
··· 1181 1181 return err 1182 1182 }) 1183 1183 1184 + orm.RunMigration(conn, logger, "remove-profile-stats-column-constraint", func(tx *sql.Tx) error { 1185 + _, err := tx.Exec(` 1186 + -- create new table without the check constraint 1187 + create table profile_stats_new ( 1188 + id integer primary key autoincrement, 1189 + did text not null, 1190 + kind text not null, -- no constraint this time 1191 + foreign key (did) references profile(did) on delete cascade 1192 + ); 1193 + 1194 + -- copy data from old table 1195 + insert into profile_stats_new (id, did, kind) 1196 + select id, did, kind 1197 + from profile_stats; 1198 + 1199 + -- drop old table 1200 + drop table profile_stats; 1201 + 1202 + -- rename new table 1203 + alter table profile_stats_new rename to profile_stats; 1204 + `) 1205 + return err 1206 + }) 1207 + 1184 1208 return &DB{ 1185 1209 db, 1186 1210 logger,
+7
appview/db/profile.go
··· 450 450 case models.VanityStatRepositoryCount: 451 451 query = `select count(id) from repos where did = ?` 452 452 args = append(args, did) 453 + case models.VanityStatStarCount: 454 + query = `select count(id) from stars where subject_at like 'at://' || ? || '%'` 455 + args = append(args, did) 456 + case models.VanityStatNone: 457 + return 0, nil 458 + default: 459 + return 0, fmt.Errorf("invalid vanity stat kind: %s", stat) 453 460 } 454 461 455 462 var result uint64
+1 -1
appview/ingester.go
··· 317 317 var stats [2]models.VanityStat 318 318 for i, s := range record.Stats { 319 319 if i < 2 { 320 - stats[i].Kind = models.VanityStatKind(s) 320 + stats[i].Kind = models.ParseVanityStatKind(s) 321 321 } 322 322 } 323 323
+18 -1
appview/issues/issues.go
··· 822 822 823 823 keyword := params.Get("q") 824 824 825 + repoInfo := rp.repoResolver.GetRepoInfo(r, user) 826 + 825 827 var issues []models.Issue 826 828 searchOpts := models.IssueSearchOptions{ 827 829 Keyword: keyword, ··· 837 839 } 838 840 l.Debug("searched issues with indexer", "count", len(res.Hits)) 839 841 totalIssues = int(res.Total) 842 + 843 + // count matching issues in the opposite state to display correct counts 844 + countRes, err := rp.indexer.Search(r.Context(), models.IssueSearchOptions{ 845 + Keyword: keyword, RepoAt: f.RepoAt().String(), IsOpen: !isOpen, 846 + Page: pagination.Page{Limit: 1}, 847 + }) 848 + if err == nil { 849 + if isOpen { 850 + repoInfo.Stats.IssueCount.Open = int(res.Total) 851 + repoInfo.Stats.IssueCount.Closed = int(countRes.Total) 852 + } else { 853 + repoInfo.Stats.IssueCount.Closed = int(res.Total) 854 + repoInfo.Stats.IssueCount.Open = int(countRes.Total) 855 + } 856 + } 840 857 841 858 issues, err = db.GetIssues( 842 859 rp.db, ··· 884 901 885 902 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 886 903 LoggedInUser: rp.oauth.GetMultiAccountUser(r), 887 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 904 + RepoInfo: repoInfo, 888 905 Issues: issues, 889 906 IssueCount: totalIssues, 890 907 LabelDefs: defs,
+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 }
+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 {
+42 -14
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, ··· 204 215 var repoId, issueId, pullId *int64 205 216 206 217 n.notifyEvent( 218 + ctx, 207 219 actorDid, 208 220 recipients, 209 221 eventType, ··· 220 232 } 221 233 222 234 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 235 + l := log.FromContext(ctx) 236 + 223 237 repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 224 238 if err != nil { 225 - log.Printf("NewPull: failed to get repos: %v", err) 239 + l.Error("failed to get repos", "err", err) 226 240 return 227 241 } 228 242 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 229 243 if err != nil { 230 - log.Printf("failed to fetch collaborators: %v", err) 244 + l.Error("failed to fetch collaborators", "err", err) 231 245 return 232 246 } 233 247 ··· 249 263 pullId := &p 250 264 251 265 n.notifyEvent( 266 + ctx, 252 267 actorDid, 253 268 recipients, 254 269 eventType, ··· 261 276 } 262 277 263 278 func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 279 + l := log.FromContext(ctx) 280 + 264 281 pull, err := db.GetPull(n.db, 265 282 syntax.ATURI(comment.RepoAt), 266 283 comment.PullId, 267 284 ) 268 285 if err != nil { 269 - log.Printf("NewPullComment: failed to get pulls: %v", err) 286 + l.Error("failed to get pulls", "err", err) 270 287 return 271 288 } 272 289 273 290 repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 274 291 if err != nil { 275 - log.Printf("NewPullComment: failed to get repos: %v", err) 292 + l.Error("failed to get repos", "err", err) 276 293 return 277 294 } 278 295 ··· 298 315 pullId := &p 299 316 300 317 n.notifyEvent( 318 + ctx, 301 319 actorDid, 302 320 recipients, 303 321 eventType, ··· 308 326 pullId, 309 327 ) 310 328 n.notifyEvent( 329 + ctx, 311 330 actorDid, 312 331 sets.Collect(slices.Values(mentions)), 313 332 models.NotificationTypeUserMentioned, ··· 336 355 } 337 356 338 357 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 358 + l := log.FromContext(ctx) 359 + 339 360 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 340 361 if err != nil { 341 - log.Printf("failed to fetch collaborators: %v", err) 362 + l.Error("failed to fetch collaborators", "err", err) 342 363 return 343 364 } 344 365 ··· 368 389 } 369 390 370 391 n.notifyEvent( 392 + ctx, 371 393 actor, 372 394 recipients, 373 395 eventType, ··· 380 402 } 381 403 382 404 func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 405 + l := log.FromContext(ctx) 406 + 383 407 // Get repo details 384 408 repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 385 409 if err != nil { 386 - log.Printf("NewPullState: failed to get repos: %v", err) 410 + l.Error("failed to get repos", "err", err) 387 411 return 388 412 } 389 413 390 414 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 391 415 if err != nil { 392 - log.Printf("failed to fetch collaborators: %v", err) 416 + l.Error("failed to fetch collaborators", "err", err) 393 417 return 394 418 } 395 419 ··· 417 441 case models.PullMerged: 418 442 eventType = models.NotificationTypePullMerged 419 443 default: 420 - log.Println("NewPullState: unexpected new PR state:", pull.State) 444 + l.Error("unexpected new PR state", "state", pull.State) 421 445 return 422 446 } 423 447 p := int64(pull.ID) 424 448 pullId := &p 425 449 426 450 n.notifyEvent( 451 + ctx, 427 452 actor, 428 453 recipients, 429 454 eventType, ··· 436 461 } 437 462 438 463 func (n *databaseNotifier) notifyEvent( 464 + ctx context.Context, 439 465 actorDid syntax.DID, 440 466 recipients sets.Set[syntax.DID], 441 467 eventType models.NotificationType, ··· 445 471 issueId *int64, 446 472 pullId *int64, 447 473 ) { 474 + l := log.FromContext(ctx) 475 + 448 476 // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody 449 477 if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { 450 478 return ··· 494 522 } 495 523 496 524 if err := db.CreateNotification(tx, notif); err != nil { 497 - log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err) 525 + l.Error("failed to create notification", "recipientDid", recipientDid, "err", err) 498 526 } 499 527 } 500 528
+105
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) NewFollow(ctx context.Context, follow *models.Follow) { 63 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewFollow")) 64 + l.inner.NewFollow(ctx, follow) 65 + } 66 + 67 + func (l *loggingNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 68 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteFollow")) 69 + l.inner.DeleteFollow(ctx, follow) 70 + } 71 + 72 + func (l *loggingNotifier) NewPull(ctx context.Context, pull *models.Pull) { 73 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPull")) 74 + l.inner.NewPull(ctx, pull) 75 + } 76 + 77 + func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 78 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullComment")) 79 + l.inner.NewPullComment(ctx, comment, mentions) 80 + } 81 + 82 + func (l *loggingNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 83 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullState")) 84 + l.inner.NewPullState(ctx, actor, pull) 85 + } 86 + 87 + func (l *loggingNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "UpdateProfile")) 89 + l.inner.UpdateProfile(ctx, profile) 90 + } 91 + 92 + func (l *loggingNotifier) NewString(ctx context.Context, s *models.String) { 93 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewString")) 94 + l.inner.NewString(ctx, s) 95 + } 96 + 97 + func (l *loggingNotifier) EditString(ctx context.Context, s *models.String) { 98 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "EditString")) 99 + l.inner.EditString(ctx, s) 100 + } 101 + 102 + func (l *loggingNotifier) DeleteString(ctx context.Context, did, rkey string) { 103 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteString")) 104 + l.inner.DeleteString(ctx, did, rkey) 105 + }
+20 -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) }) 70 59 } 71 60 72 61 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 73 - m.fanout("NewFollow", ctx, follow) 62 + m.fanout(func(n Notifier) { n.NewFollow(ctx, follow) }) 74 63 } 75 64 76 65 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 77 - m.fanout("DeleteFollow", ctx, follow) 66 + m.fanout(func(n Notifier) { n.DeleteFollow(ctx, follow) }) 78 67 } 79 68 80 69 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 81 - m.fanout("NewPull", ctx, pull) 70 + m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) 82 71 } 83 72 84 73 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 85 - m.fanout("NewPullComment", ctx, comment, mentions) 74 + m.fanout(func(n Notifier) { n.NewPullComment(ctx, comment, mentions) }) 86 75 } 87 76 88 77 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 89 - m.fanout("NewPullState", ctx, actor, pull) 78 + m.fanout(func(n Notifier) { n.NewPullState(ctx, actor, pull) }) 90 79 } 91 80 92 81 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 93 - m.fanout("UpdateProfile", ctx, profile) 82 + m.fanout(func(n Notifier) { n.UpdateProfile(ctx, profile) }) 94 83 } 95 84 96 85 func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 97 - m.fanout("NewString", ctx, s) 86 + m.fanout(func(n Notifier) { n.NewString(ctx, s) }) 98 87 } 99 88 100 89 func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 101 - m.fanout("EditString", ctx, s) 90 + m.fanout(func(n Notifier) { n.EditString(ctx, s) }) 102 91 } 103 92 104 93 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 105 - m.fanout("DeleteString", ctx, did, rkey) 94 + m.fanout(func(n Notifier) { n.DeleteString(ctx, did, rkey) }) 106 95 }
+1 -1
appview/oauth/handler.go
··· 167 167 return 168 168 } 169 169 170 - l.Debug("addings to default knot") 170 + l.Debug("adding to default knot") 171 171 session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 172 172 if err != nil { 173 173 l.Error("failed to create session", "err", err)
+6
appview/oauth/oauth.go
··· 169 169 170 170 // delete the session 171 171 err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 172 + if err1 != nil { 173 + err1 = fmt.Errorf("failed to logout: %w", err1) 174 + } 172 175 173 176 // remove the cookie 174 177 userSession.Options.MaxAge = -1 175 178 err2 := o.SessStore.Save(r, w, userSession) 179 + if err2 != nil { 180 + err2 = fmt.Errorf("failed to save into session store: %w", err2) 181 + } 176 182 177 183 return errors.Join(err1, err2) 178 184 }
+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 + }
+149
appview/pages/markup/extension/tangledlink.go
··· 1 + package extension 2 + 3 + import ( 4 + "net/url" 5 + "strings" 6 + 7 + "github.com/yuin/goldmark" 8 + "github.com/yuin/goldmark/ast" 9 + "github.com/yuin/goldmark/parser" 10 + "github.com/yuin/goldmark/renderer" 11 + "github.com/yuin/goldmark/text" 12 + "github.com/yuin/goldmark/util" 13 + ) 14 + 15 + // KindTangledLink is a NodeKind of the TangledLink node. 16 + var KindTangledLink = ast.NewNodeKind("TangledLink") 17 + 18 + type TangledLinkNode struct { 19 + ast.BaseInline 20 + Destination string 21 + Commit *TangledCommitLink 22 + // TODO: add more Tangled-link types 23 + } 24 + 25 + type TangledCommitLink struct { 26 + Sha string 27 + } 28 + 29 + var _ ast.Node = new(TangledLinkNode) 30 + 31 + // Dump implements [ast.Node]. 32 + func (n *TangledLinkNode) Dump(source []byte, level int) { 33 + ast.DumpHelper(n, source, level, nil, nil) 34 + } 35 + 36 + // Kind implements [ast.Node]. 37 + func (n *TangledLinkNode) Kind() ast.NodeKind { 38 + return KindTangledLink 39 + } 40 + 41 + type tangledLinkTransformer struct { 42 + host string 43 + } 44 + 45 + var _ parser.ASTTransformer = new(tangledLinkTransformer) 46 + 47 + // Transform implements [parser.ASTTransformer]. 48 + func (t *tangledLinkTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 49 + ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 50 + if !entering { 51 + return ast.WalkContinue, nil 52 + } 53 + 54 + var dest string 55 + 56 + switch n := n.(type) { 57 + case *ast.AutoLink: 58 + dest = string(n.URL(reader.Source())) 59 + case *ast.Link: 60 + // maybe..? not sure 61 + default: 62 + return ast.WalkContinue, nil 63 + } 64 + 65 + if sha := t.parseLinkCommitSha(dest); sha != "" { 66 + newLink := &TangledLinkNode{ 67 + Destination: dest, 68 + Commit: &TangledCommitLink{ 69 + Sha: sha, 70 + }, 71 + } 72 + n.Parent().ReplaceChild(n.Parent(), n, newLink) 73 + } 74 + 75 + return ast.WalkContinue, nil 76 + }) 77 + } 78 + 79 + func (t *tangledLinkTransformer) parseLinkCommitSha(raw string) string { 80 + u, err := url.Parse(raw) 81 + if err != nil || u.Host != t.host { 82 + return "" 83 + } 84 + 85 + // /{owner}/{repo}/commit/<sha> 86 + parts := strings.Split(strings.Trim(u.Path, "/"), "/") 87 + if len(parts) != 4 || parts[2] != "commit" { 88 + return "" 89 + } 90 + 91 + sha := parts[3] 92 + 93 + // basic sha validation 94 + if len(sha) < 7 { 95 + return "" 96 + } 97 + for _, c := range sha { 98 + if !strings.ContainsRune("0123456789abcdef", c) { 99 + return "" 100 + } 101 + } 102 + 103 + return sha[:8] 104 + } 105 + 106 + type tangledLinkRenderer struct{} 107 + 108 + var _ renderer.NodeRenderer = new(tangledLinkRenderer) 109 + 110 + // RegisterFuncs implements [renderer.NodeRenderer]. 111 + func (r *tangledLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 112 + reg.Register(KindTangledLink, r.renderTangledLink) 113 + } 114 + 115 + func (r *tangledLinkRenderer) renderTangledLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 116 + link := node.(*TangledLinkNode) 117 + 118 + if link.Commit != nil { 119 + if entering { 120 + w.WriteString(`<a href="`) 121 + w.WriteString(link.Destination) 122 + w.WriteString(`"><code>`) 123 + w.WriteString(link.Commit.Sha) 124 + } else { 125 + w.WriteString(`</code></a>`) 126 + } 127 + } 128 + 129 + return ast.WalkContinue, nil 130 + } 131 + 132 + type tangledLinkExt struct { 133 + host string 134 + } 135 + 136 + var _ goldmark.Extender = new(tangledLinkExt) 137 + 138 + func (e *tangledLinkExt) Extend(m goldmark.Markdown) { 139 + m.Parser().AddOptions(parser.WithASTTransformers( 140 + util.Prioritized(&tangledLinkTransformer{host: e.host}, 500), 141 + )) 142 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 143 + util.Prioritized(&tangledLinkRenderer{}, 500), 144 + )) 145 + } 146 + 147 + func NewTangledLinkExt(host string) goldmark.Extender { 148 + return &tangledLinkExt{host} 149 + }
+4 -2
appview/pages/markup/markdown.go
··· 46 46 CamoSecret string 47 47 repoinfo.RepoInfo 48 48 IsDev bool 49 + Hostname string 49 50 RendererType RendererType 50 51 Sanitizer Sanitizer 51 52 Files fs.FS 52 53 } 53 54 54 - func NewMarkdown() goldmark.Markdown { 55 + func NewMarkdown(hostname string) goldmark.Markdown { 55 56 md := goldmark.New( 56 57 goldmark.WithExtensions( 57 58 extension.GFM, ··· 67 68 ), 68 69 callout.CalloutExtention, 69 70 textension.AtExt, 71 + textension.NewTangledLinkExt(hostname), 70 72 emoji.Emoji, 71 73 ), 72 74 goldmark.WithParserOptions( ··· 78 80 } 79 81 80 82 func (rctx *RenderContext) RenderMarkdown(source string) string { 81 - return rctx.RenderMarkdownWith(source, NewMarkdown()) 83 + return rctx.RenderMarkdownWith(source, NewMarkdown(rctx.Hostname)) 82 84 } 83 85 84 86 func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
+2 -2
appview/pages/markup/markdown_test.go
··· 50 50 51 51 for _, tt := range tests { 52 52 t.Run(tt.name, func(t *testing.T) { 53 - md := NewMarkdown() 53 + md := NewMarkdown("tangled.org") 54 54 55 55 var buf bytes.Buffer 56 56 if err := md.Convert([]byte(tt.markdown), &buf); err != nil { ··· 105 105 106 106 for _, tt := range tests { 107 107 t.Run(tt.name, func(t *testing.T) { 108 - md := NewMarkdown() 108 + md := NewMarkdown("tangled.org") 109 109 110 110 var buf bytes.Buffer 111 111 if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
+4 -7
appview/pages/markup/reference_link.go
··· 18 18 // like issues, PRs, comments or even @-mentions 19 19 // This funciton doesn't actually check for the existence of records in the DB 20 20 // or the PDS; it merely returns a list of what are presumed to be references. 21 - func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) { 21 + func FindReferences(host string, source string) ([]string, []models.ReferenceLink) { 22 22 var ( 23 23 refLinkSet = make(map[models.ReferenceLink]struct{}) 24 24 mentionsSet = make(map[string]struct{}) 25 - md = NewMarkdown() 25 + md = NewMarkdown(host) 26 26 sourceBytes = []byte(source) 27 27 root = md.Parser().Parse(text.NewReader(sourceBytes)) 28 28 ) 29 - // trim url scheme. the SSL shouldn't matter 30 - baseUrl = strings.TrimPrefix(baseUrl, "https://") 31 - baseUrl = strings.TrimPrefix(baseUrl, "http://") 32 29 33 30 ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 34 31 if !entering { ··· 41 38 return ast.WalkSkipChildren, nil 42 39 case ast.KindLink: 43 40 dest := string(n.(*ast.Link).Destination) 44 - ref := parseTangledLink(baseUrl, dest) 41 + ref := parseTangledLink(host, dest) 45 42 if ref != nil { 46 43 refLinkSet[*ref] = struct{}{} 47 44 } ··· 50 47 an := n.(*ast.AutoLink) 51 48 if an.AutoLinkType == ast.AutoLinkURL { 52 49 dest := string(an.URL(sourceBytes)) 53 - ref := parseTangledLink(baseUrl, dest) 50 + ref := parseTangledLink(host, dest) 54 51 if ref != nil { 55 52 refLinkSet[*ref] = struct{}{} 56 53 }
+52 -15
appview/pages/pages.go
··· 55 55 // initialized with safe defaults, can be overriden per use 56 56 rctx := &markup.RenderContext{ 57 57 IsDev: config.Core.Dev, 58 + Hostname: config.Core.AppviewHost, 58 59 CamoUrl: config.Camo.Host, 59 60 CamoSecret: config.Camo.SharedSecret, 60 61 Sanitizer: markup.NewSanitizer(), ··· 177 178 return p.parse(stack...) 178 179 } 179 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 + 180 190 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 181 191 tpl, err := p.parse(name) 182 192 if err != nil { ··· 184 194 } 185 195 186 196 return tpl.Execute(w, params) 197 + } 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) 187 206 } 188 207 189 208 func (p *Pages) execute(name string, w io.Writer, params any) error { ··· 236 255 } 237 256 238 257 func (p *Pages) Login(w io.Writer, params LoginParams) error { 239 - return p.executePlain("user/login", w, params) 258 + return p.executeLogin("user/login", w, params) 240 259 } 241 260 242 261 type SignupParams struct { ··· 244 263 } 245 264 246 265 func (p *Pages) Signup(w io.Writer, params SignupParams) error { 247 - return p.executePlain("user/signup", w, params) 266 + return p.executeLogin("user/signup", w, params) 248 267 } 249 268 250 269 func (p *Pages) CompleteSignup(w io.Writer) error { 251 - return p.executePlain("user/completeSignup", w, nil) 270 + return p.executeLogin("user/completeSignup", w, nil) 252 271 } 253 272 254 273 type TermsOfServiceParams struct { ··· 745 764 } 746 765 747 766 type RepoTreeParams struct { 748 - LoggedInUser *oauth.MultiAccountUser 749 - RepoInfo repoinfo.RepoInfo 750 - Active string 751 - BreadCrumbs [][]string 752 - TreePath string 753 - Raw bool 754 - HTMLReadme template.HTML 767 + LoggedInUser *oauth.MultiAccountUser 768 + RepoInfo repoinfo.RepoInfo 769 + Active string 770 + BreadCrumbs [][]string 771 + TreePath string 772 + Raw bool 773 + HTMLReadme template.HTML 774 + EmailToDid map[string]string 775 + LastCommitInfo *types.LastCommitInfo 755 776 types.RepoTreeResponse 756 777 } 757 778 ··· 825 846 return p.executeRepo("repo/tags", w, params) 826 847 } 827 848 849 + type RepoTagParams struct { 850 + LoggedInUser *oauth.MultiAccountUser 851 + RepoInfo repoinfo.RepoInfo 852 + Active string 853 + types.RepoTagResponse 854 + ArtifactMap map[plumbing.Hash][]models.Artifact 855 + DanglingArtifacts []models.Artifact 856 + } 857 + 858 + func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error { 859 + params.Active = "overview" 860 + return p.executeRepo("repo/tag", w, params) 861 + } 862 + 828 863 type RepoArtifactParams struct { 829 864 LoggedInUser *oauth.MultiAccountUser 830 865 RepoInfo repoinfo.RepoInfo ··· 836 871 } 837 872 838 873 type RepoBlobParams struct { 839 - LoggedInUser *oauth.MultiAccountUser 840 - RepoInfo repoinfo.RepoInfo 841 - Active string 842 - BreadCrumbs [][]string 843 - BlobView models.BlobView 874 + LoggedInUser *oauth.MultiAccountUser 875 + RepoInfo repoinfo.RepoInfo 876 + Active string 877 + BreadCrumbs [][]string 878 + BlobView models.BlobView 879 + EmailToDid map[string]string 880 + LastCommitInfo *types.LastCommitInfo 844 881 *tangled.RepoBlob_Output 845 882 } 846 883
+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 }}
+3 -7
appview/pages/templates/fragments/tinyAvatarList.html
··· 5 5 <div class="inline-flex items-center -space-x-3"> 6 6 {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 7 {{ range $i, $p := $ps }} 8 - <a href="/{{ resolve . }}" title="{{ resolve . }}"> 9 - <img 10 - src="{{ tinyAvatar . }}" 11 - alt="" 12 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 13 - /> 14 - </a> 8 + {{ $zIdx := printf "z-%d0" (sub 5 $i) }} 9 + {{ $classes = printf "%s %s" $zIdx $classes }} 10 + {{ template "user/fragments/picLink" (list . $classes ) }} 15 11 {{ end }} 16 12 17 13 {{ if gt (len $all) 5 }}
+2 -6
appview/pages/templates/layouts/fragments/topbar.html
··· 46 46 <details class="relative inline-block text-left nav-dropdown"> 47 47 <summary class="cursor-pointer list-none flex items-center gap-1"> 48 48 {{ $user := .Active.Did }} 49 - <img 50 - src="{{ tinyAvatar $user }}" 51 - alt="" 52 - class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 53 - /> 49 + {{ template "user/fragments/pic" (list $user "size-6") }} 54 50 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 55 51 </summary> 56 52 <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm" style="width: 14rem;"> ··· 68 64 hx-swap="none" 69 65 class="{{$linkStyle}} w-full text-left pl-3" 70 66 > 71 - <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full size-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 67 + {{ template "user/fragments/pic" (list .Did "size-6") }} 72 68 <span class="truncate flex-1">{{ .Did | resolve }}</span> 73 69 </button> 74 70 {{ end }}
+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 +
+6
appview/pages/templates/repo/blob.html
··· 12 12 13 13 {{ define "repoContent" }} 14 14 {{ $linkstyle := "no-underline hover:underline" }} 15 + 15 16 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 16 17 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 17 18 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> ··· 57 58 </div> 58 59 </div> 59 60 </div> 61 + 62 + {{ if .LastCommitInfo }} 63 + {{ template "repo/fragments/lastCommitPanel" $ }} 64 + {{ end }} 65 + 60 66 {{ if .BlobView.IsUnsupported }} 61 67 <p class="text-center text-gray-400 dark:text-gray-500"> 62 68 Previews are not supported for this file type.
+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 +
+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
+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 items-center justify-between text-sm"> 4 + <div class="flex 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="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="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"> 26 + {{ slice .LastCommitInfo.Hash.String 0 8 }} 27 + </a> 28 + </div> 29 + {{ 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 -10
appview/pages/templates/repo/issues/fragments/commentList.html
··· 41 41 {{ define "topLevelComment" }} 42 42 <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 43 <div class="flex-shrink-0"> 44 - <img 45 - src="{{ tinyAvatar .Comment.Did }}" 46 - alt="" 47 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 48 - /> 44 + {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} 49 45 </div> 50 46 <div class="flex-1 min-w-0"> 51 47 {{ template "repo/issues/fragments/issueCommentHeader" . }} ··· 57 53 {{ define "replyComment" }} 58 54 <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 59 55 <div class="flex-shrink-0"> 60 - <img 61 - src="{{ tinyAvatar .Comment.Did }}" 62 - alt="" 63 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 64 - /> 56 + {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} 65 57 </div> 66 58 <div class="flex-1 min-w-0"> 67 59 {{ template "repo/issues/fragments/issueCommentHeader" . }}
+1 -5
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 2 <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 3 {{ if .LoggedInUser }} 4 - <img 5 - src="{{ tinyAvatar .LoggedInUser.Did }}" 6 - alt="" 7 - class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 - /> 4 + {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} 9 5 {{ end }} 10 6 <input 11 7 class="w-full p-0 border-none focus:outline-none bg-transparent"
+3 -3
appview/pages/templates/repo/issues/issues.html
··· 33 33 <div class="flex-1 flex relative"> 34 34 <input 35 35 id="search-q" 36 - class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer" 36 + class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none peer" 37 37 type="text" 38 38 name="q" 39 39 value="{{ .FilterQuery }}" 40 - placeholder=" " 40 + placeholder="search issues..." 41 41 > 42 42 <a 43 43 href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" ··· 48 48 </div> 49 49 <button 50 50 type="submit" 51 - class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 51 + class="p-2 text-gray-400 border rounded-r border-gray-300 dark:border-gray-600" 52 52 > 53 53 {{ i "search" "w-4 h-4" }} 54 54 </button>
+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 -15
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> 131 146 147 + {{ template "resize-grip" (list "resize-subs" "subs" "after") }} 148 + 132 149 <!-- right panel --> 133 150 {{ template "subsPanel" $ }} 134 151 </div> ··· 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; 210 224 } 225 + #subsToggle:not(:checked) ~ div div#resize-subs { 226 + display: none; 227 + } 211 228 } 212 229 </style> 213 230 <label title="Toggle review panel" for="subsToggle" class="hidden md:flex items-center justify-end rounded cursor-pointer"> ··· 265 282 flex gap-2 sticky top-0 z-20"> 266 283 <!-- left column: just profile picture --> 267 284 <div class="flex-shrink-0 pt-2"> 268 - <img 269 - src="{{ tinyAvatar $root.Pull.OwnerDid }}" 270 - alt="" 271 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 272 - /> 285 + {{ template "user/fragments/picLink" (list $root.Pull.OwnerDid "size-8") }} 273 286 </div> 274 287 <!-- right column --> 275 288 <div class="flex-1 min-w-0 flex flex-col gap-1"> ··· 568 581 <div id="comment-{{.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 569 582 <!-- left column: profile picture --> 570 583 <div class="flex-shrink-0 h-fit relative"> 571 - <img 572 - src="{{ tinyAvatar .OwnerDid }}" 573 - alt="" 574 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-5" 575 - /> 584 + {{ template "user/fragments/picLink" (list .OwnerDid "size-8") }} 576 585 </div> 577 586 <!-- right column: name and body in two rows --> 578 587 <div class="flex-1 min-w-0">
+3 -3
appview/pages/templates/repo/pulls/pulls.html
··· 39 39 <div class="flex-1 flex relative"> 40 40 <input 41 41 id="search-q" 42 - class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer" 42 + class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none peer" 43 43 type="text" 44 44 name="q" 45 45 value="{{ .FilterQuery }}" 46 - placeholder=" " 46 + placeholder="search pulls..." 47 47 > 48 48 <a 49 49 href="?state={{ .FilteringBy.String }}" ··· 54 54 </div> 55 55 <button 56 56 type="submit" 57 - class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 57 + class="p-2 text-gray-400 border rounded-r border-gray-300 dark:border-gray-600" 58 58 > 59 59 {{ i "search" "w-4 h-4" }} 60 60 </button>
+1 -4
appview/pages/templates/repo/settings/access.html
··· 32 32 {{ $handle := resolve .Did }} 33 33 <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 34 34 <div class="flex items-center gap-3"> 35 - <img 36 - src="{{ fullAvatar $handle }}" 37 - alt="{{ $handle }}" 38 - class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 35 + {{ template "user/fragments/picLink" (list .Did "size-10") }} 39 36 40 37 <div class="flex-1 min-w-0"> 41 38 <a href="/{{ $handle }}" class="block truncate">
+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">
+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 }}
+18 -17
appview/pages/templates/user/fragments/editAvatar.html
··· 2 2 <form 3 3 hx-post="/profile/avatar" 4 4 hx-encoding="multipart/form-data" 5 - hx-indicator="#spinner" 6 5 hx-swap="none" 7 - class="flex flex-col gap-2"> 6 + class="flex flex-col gap-2 group/form"> 8 7 <label for="avatar-file" class="uppercase p-0"> 9 8 Upload or Remove Avatar 10 9 </label> ··· 22 21 file:bg-gray-100 file:text-gray-700 23 22 dark:file:bg-gray-700 dark:file:text-gray-300 24 23 hover:file:bg-gray-200 dark:hover:file:bg-gray-600" /> 25 - <div id="avatar-error" class="text-red-500 dark:text-red-400 text-sm min-h-5"></div> 26 24 <div class="flex flex-col gap-2 pt-2"> 27 25 <button type="submit" class="btn w-full flex items-center justify-center gap-2"> 28 - <span class="inline-flex gap-2 items-center">{{ i "upload" "size-4" }} upload</span> 29 - <span id="spinner" class="group"> 30 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </span> 26 + {{ i "upload" "size-4 inline group-[.htmx-request]/form:hidden" }} 27 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]/form:inline" }} 28 + upload 32 29 </button> 33 - <button 34 - type="button" 35 - hx-delete="/profile/avatar" 36 - hx-confirm="Are you sure you want to remove your profile picture?" 37 - hx-swap="none" 38 - class="btn w-full flex items-center justify-center gap-2"> 39 - {{ i "trash-2" "size-4" }} 40 - remove avatar 41 - </button> 30 + {{ if .Profile.Avatar }} 31 + <button 32 + type="button" 33 + hx-delete="/profile/avatar" 34 + hx-confirm="Are you sure you want to remove your profile picture?" 35 + hx-swap="none" 36 + class="btn w-full flex items-center justify-center gap-2 group"> 37 + {{ i "trash-2" "size-4 inline group-[.htmx-request]:hidden" }} 38 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 + remove avatar 40 + </button> 41 + {{ end }} 42 42 <button 43 43 id="cancel-avatar-btn" 44 44 type="button" 45 45 popovertarget="avatar-upload-modal" 46 46 popovertargetaction="hide" 47 - class="btn w-full flex items-center justify-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"> 47 + class="btn text-red-500 dark:text-red-400 w-full flex items-center justify-center gap-2"> 48 48 {{ i "x" "size-4" }} 49 49 cancel 50 50 </button> 51 51 </div> 52 + <div id="avatar-error" class="text-red-500 dark:text-red-400 text-sm"></div> 52 53 </form> 53 54 {{ end }}
+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 }}
+11
appview/pages/templates/user/fragments/pic.html
··· 1 + {{ define "user/fragments/pic" }} 2 + {{ $did := index . 0 }} 3 + {{ $classes := index . 1 }} 4 + <img 5 + src="{{ tinyAvatar $did }}" 6 + alt="" 7 + class="rounded-full border border-gray-300 dark:border-gray-700 {{ $classes }}" 8 + /> 9 + {{ end }} 10 + 11 +
+15
appview/pages/templates/user/fragments/picLink.html
··· 1 + {{ define "user/fragments/picLink" }} 2 + {{ $did := index . 0 }} 3 + {{ $classes := index . 1 }} 4 + {{ $handle := resolve $did }} 5 + <a href="/{{ $handle }}" title="{{ $handle }}"> 6 + <img 7 + src="{{ tinyAvatar $did }}" 8 + alt="" 9 + class="rounded-full border border-gray-300 dark:border-gray-700 {{ $classes }}" 10 + /> 11 + </a> 12 + {{ end }} 13 + 14 + 15 +
+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 - <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 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 +
+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 }}
+27 -19
appview/pulls/opengraph.go
··· 128 128 } 129 129 130 130 // Split stats area: left side for status/stats (80%), right side for dolly (20%) 131 - statusStatsArea, dollyArea := statsArea.Split(true, 80) 131 + statusArea, dollyArea := statsArea.Split(true, 80) 132 132 133 133 // Draw status and stats 134 - statsBounds := statusStatsArea.Img.Bounds() 134 + statsBounds := statusArea.Img.Bounds() 135 135 statsX := statsBounds.Min.X + 60 // left padding 136 136 statsY := statsBounds.Min.Y 137 137 ··· 157 157 } else { 158 158 statusIcon = "git-pull-request-closed" 159 159 statusText = "closed" 160 - statusColor = color.RGBA{128, 128, 128, 255} // gray 160 + statusColor = color.RGBA{52, 58, 64, 255} // dark gray 161 161 } 162 162 163 - statusIconSize := 36 163 + statusTextWidth := statusArea.TextWidth(statusText, textSize) 164 + badgePadding := 12 165 + badgeHeight := int(textSize) + (badgePadding * 2) 166 + badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) 167 + cornerRadius := 8 168 + badgeX := 60 169 + badgeY := 0 170 + 171 + statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) 164 172 165 - // Draw icon with status color 166 - err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 173 + whiteColor := color.RGBA{255, 255, 255, 255} 174 + iconX := statsX + badgePadding 175 + iconY := statsY + (badgeHeight-iconSize)/2 176 + err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) 167 177 if err != nil { 168 178 log.Printf("failed to draw status icon: %v", err) 169 179 } 170 180 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) 181 + textX := statsX + badgePadding + iconSize + badgePadding 182 + textY := statsY + (badgeHeight-int(textSize))/2 - 5 183 + err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) 175 184 if err != nil { 176 185 log.Printf("failed to draw status text: %v", err) 177 186 } 178 187 179 - statusTextWidth := len(statusText) * 20 180 - currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 188 + currentX := statsX + badgeWidth + 50 181 189 182 190 // Draw comment count 183 - err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 191 + err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) 184 192 if err != nil { 185 193 log.Printf("failed to draw comment icon: %v", err) 186 194 } ··· 190 198 if commentCount == 1 { 191 199 commentText = "1 comment" 192 200 } 193 - err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 201 + err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 194 202 if err != nil { 195 203 log.Printf("failed to draw comment text: %v", err) 196 204 } ··· 199 207 currentX += commentTextWidth + 40 200 208 201 209 // Draw files changed 202 - err = statusStatsArea.DrawLucideIcon("file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 210 + err = statusArea.DrawLucideIcon("file-diff", currentX, iconY, iconSize, iconColor) 203 211 if err != nil { 204 212 log.Printf("failed to draw file diff icon: %v", err) 205 213 } ··· 209 217 if filesChanged == 1 { 210 218 filesText = "1 file" 211 219 } 212 - err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 220 + err = statusArea.DrawTextAt(filesText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 213 221 if err != nil { 214 222 log.Printf("failed to draw files text: %v", err) 215 223 } ··· 220 228 // Draw additions (green +) 221 229 greenColor := color.RGBA{34, 139, 34, 255} 222 230 additionsText := fmt.Sprintf("+%d", diffStats.Insertions) 223 - err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left) 231 + err = statusArea.DrawTextAt(additionsText, currentX, textY, greenColor, textSize, ogcard.Top, ogcard.Left) 224 232 if err != nil { 225 233 log.Printf("failed to draw additions text: %v", err) 226 234 } ··· 231 239 // Draw deletions (red -) right next to additions 232 240 redColor := color.RGBA{220, 20, 60, 255} 233 241 deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) 234 - err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left) 242 + err = statusArea.DrawTextAt(deletionsText, currentX, textY, redColor, textSize, ogcard.Top, ogcard.Left) 235 243 if err != nil { 236 244 log.Printf("failed to draw deletions text: %v", err) 237 245 } ··· 254 262 openedDate := pull.Created.Format("Jan 2, 2006") 255 263 metaText := fmt.Sprintf("opened by %s ยท %s", authorHandle, openedDate) 256 264 257 - err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 265 + err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 258 266 if err != nil { 259 267 log.Printf("failed to draw metadata: %v", err) 260 268 }
+8 -5
appview/pulls/pulls.go
··· 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" ··· 47 48 "github.com/go-chi/chi/v5" 48 49 "github.com/google/uuid" 49 50 ) 51 + 52 + const ApplicationGzip = "application/gzip" 50 53 51 54 type Pulls struct { 52 55 oauth *oauth.OAuth ··· 1227 1230 return 1228 1231 } 1229 1232 1230 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1233 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1231 1234 if err != nil { 1232 1235 log.Println("failed to upload patch", err) 1233 1236 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1321 1324 // apply all record creations at once 1322 1325 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1323 1326 for _, p := range stack { 1324 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch())) 1327 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 1325 1328 if err != nil { 1326 1329 log.Println("failed to upload patch blob", err) 1327 1330 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1871 1874 return 1872 1875 } 1873 1876 1874 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1877 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1875 1878 if err != nil { 1876 1879 log.Println("failed to upload patch blob", err) 1877 1880 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") ··· 2014 2017 return 2015 2018 } 2016 2019 2017 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2020 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2018 2021 if err != nil { 2019 2022 log.Println("failed to upload patch blob", err) 2020 2023 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") ··· 2056 2059 return 2057 2060 } 2058 2061 2059 - blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2062 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2060 2063 if err != nil { 2061 2064 log.Println("failed to upload patch blob", err) 2062 2065 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
+1 -1
appview/repo/archive.go
··· 66 66 if link := resp.Header.Get("Link"); link != "" { 67 67 if resolvedRef, err := extractImmutableLink(link); err == nil { 68 68 newLink := fmt.Sprintf("<%s/%s/archive/%s.tar.gz>; rel=\"immutable\"", 69 - rp.config.Core.AppviewHost, f.DidSlashRepo(), resolvedRef) 69 + rp.config.Core.BaseUrl(), f.DidSlashRepo(), resolvedRef) 70 70 w.Header().Set("Link", newLink) 71 71 } 72 72 }
+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 }
+4 -4
appview/repo/feed.go
··· 37 37 38 38 feed := &feeds.Feed{ 39 39 Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo), 40 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"}, 40 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.BaseUrl(), ownerSlashRepo), Type: "text/html", Rel: "alternate"}, 41 41 Items: make([]*feeds.Item, 0), 42 42 Updated: time.UnixMilli(0), 43 43 } ··· 86 86 mainItem := &feeds.Item{ 87 87 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 88 88 Description: description, 89 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)}, 89 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId)}, 90 90 Created: pull.Created, 91 91 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 92 92 } ··· 100 100 roundItem := &feeds.Item{ 101 101 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 102 102 Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 103 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)}, 103 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.RoundNumber)}, 104 104 Created: round.Created, 105 105 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 106 106 } ··· 124 124 return &feeds.Item{ 125 125 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 126 126 Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo), 127 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)}, 127 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, issue.IssueId)}, 128 128 Created: issue.Created, 129 129 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 130 130 }, nil
+1
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
+58
appview/repo/tags.go
··· 14 14 "tangled.org/core/types" 15 15 16 16 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 17 18 "github.com/go-git/go-git/v5/plumbing" 18 19 ) 19 20 ··· 70 71 } 71 72 } 72 73 user := rp.oauth.GetMultiAccountUser(r) 74 + 73 75 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 76 LoggedInUser: user, 75 77 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 78 80 DanglingArtifacts: danglingArtifacts, 79 81 }) 80 82 } 83 + 84 + func (rp *Repo) Tag(w http.ResponseWriter, r *http.Request) { 85 + l := rp.logger.With("handler", "RepoTag") 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + l.Error("failed to get repo and knot", "err", err) 89 + return 90 + } 91 + scheme := "http" 92 + if !rp.config.Core.Dev { 93 + scheme = "https" 94 + } 95 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 96 + xrpcc := &indigoxrpc.Client{ 97 + Host: host, 98 + } 99 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 100 + tag := chi.URLParam(r, "tag") 101 + 102 + xrpcBytes, err := tangled.RepoTag(r.Context(), xrpcc, repo, tag) 103 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 104 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 105 + rp.pages.Error503(w) 106 + return 107 + } 108 + var result types.RepoTagResponse 109 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 110 + l.Error("failed to decode XRPC response", "err", err) 111 + rp.pages.Error503(w) 112 + return 113 + } 114 + 115 + filters := []orm.Filter{orm.FilterEq("repo_at", f.RepoAt())} 116 + if result.Tag.Tag != nil { 117 + filters = append(filters, orm.FilterEq("tag", result.Tag.Tag.Hash[:])) 118 + } 119 + 120 + artifacts, err := db.GetArtifact(rp.db, filters...) 121 + if err != nil { 122 + l.Error("failed grab artifacts", "err", err) 123 + return 124 + } 125 + // convert artifacts to map for easy UI building 126 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 127 + for _, a := range artifacts { 128 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 129 + } 130 + 131 + user := rp.oauth.GetMultiAccountUser(r) 132 + rp.pages.RepoTag(w, pages.RepoTagParams{ 133 + LoggedInUser: user, 134 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 135 + RepoTagResponse: result, 136 + ArtifactMap: artifactMap, 137 + }) 138 + }
+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 }
+7 -8
appview/settings/settings.go
··· 277 277 } 278 278 279 279 func (s *Settings) verifyUrl(did string, email string, code string) string { 280 - var appUrl string 281 - if s.Config.Core.Dev { 282 - appUrl = "http://" + s.Config.Core.ListenAddr 283 - } else { 284 - appUrl = s.Config.Core.AppviewHost 285 - } 286 - 287 - return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 280 + return fmt.Sprintf( 281 + "%s/settings/emails/verify?did=%s&email=%s&code=%s", 282 + s.Config.Core.BaseUrl(), 283 + url.QueryEscape(did), 284 + url.QueryEscape(email), 285 + url.QueryEscape(code), 286 + ) 288 287 } 289 288 290 289 func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
+86
appview/state/knotstream.go
··· 18 18 "tangled.org/core/log" 19 19 "tangled.org/core/orm" 20 20 "tangled.org/core/rbac" 21 + "tangled.org/core/workflow" 21 22 23 + "github.com/bluesky-social/indigo/atproto/syntax" 22 24 "github.com/go-git/go-git/v5/plumbing" 23 25 "github.com/posthog/posthog-go" 24 26 ) ··· 65 67 switch msg.Nsid { 66 68 case tangled.GitRefUpdateNSID: 67 69 return ingestRefUpdate(d, enforcer, posthog, dev, source, msg) 70 + case tangled.PipelineNSID: 71 + return ingestPipeline(d, source, msg) 68 72 } 69 73 70 74 return nil ··· 186 190 187 191 return tx.Commit() 188 192 } 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 + }
+12 -16
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) { ··· 415 416 416 417 feed := feeds.Feed{ 417 418 Title: fmt.Sprintf("%s's timeline", author.Name), 418 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 419 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.BaseUrl(), id.Handle), Type: "text/html", Rel: "alternate"}, 419 420 Items: make([]*feeds.Item, 0), 420 421 Updated: time.UnixMilli(0), 421 422 Author: author, ··· 483 484 func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 484 485 return &feeds.Item{ 485 486 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 486 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 487 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.BaseUrl(), owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 487 488 Created: pull.Created, 488 489 Author: author, 489 490 } ··· 492 493 func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 493 494 return &feeds.Item{ 494 495 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 495 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 496 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.BaseUrl(), owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 496 497 Created: issue.Created, 497 498 Author: author, 498 499 } ··· 512 513 513 514 return &feeds.Item{ 514 515 Title: title, 515 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 516 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.BaseUrl(), author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 516 517 Created: repo.Repo.Created, 517 518 Author: author, 518 519 }, nil ··· 549 550 stat0 := r.FormValue("stat0") 550 551 stat1 := r.FormValue("stat1") 551 552 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 - } 553 + profile.Stats[0].Kind = models.ParseVanityStatKind(stat0) 554 + profile.Stats[1].Kind = models.ParseVanityStatKind(stat1) 559 555 560 556 if err := db.ValidateProfile(s.db, profile); err != nil { 561 557 log.Println("invalid profile", err) ··· 741 737 return 742 738 } 743 739 744 - file, handler, err := r.FormFile("avatar") 740 + file, header, err := r.FormFile("avatar") 745 741 if err != nil { 746 742 l.Error("failed to read avatar file", "err", err) 747 743 s.pages.Notice(w, "avatar-error", "Failed to read avatar file") ··· 749 745 } 750 746 defer file.Close() 751 747 752 - if handler.Size > 1000000 { 753 - l.Warn("avatar file too large", "size", handler.Size) 748 + if header.Size > 1000000 { 749 + l.Warn("avatar file too large", "size", header.Size) 754 750 s.pages.Notice(w, "avatar-error", "Avatar file too large (max 1MB)") 755 751 return 756 752 } 757 753 758 - contentType := handler.Header.Get("Content-Type") 754 + contentType := header.Header.Get("Content-Type") 759 755 if contentType != "image/png" && contentType != "image/jpeg" { 760 756 l.Warn("invalid image type", "contentType", contentType) 761 757 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)") ··· 769 765 return 770 766 } 771 767 772 - uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 768 + uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type")) 773 769 if err != nil { 774 770 l.Error("failed to upload avatar blob", "err", err) 775 771 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS")
-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" 24 23 ) 25 24 26 25 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 63 62 func spindleIngester(ctx context.Context, logger *slog.Logger, d *db.DB) ec.ProcessFunc { 64 63 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 65 64 switch msg.Nsid { 66 - case tangled.PipelineNSID: 67 - return ingestPipeline(logger, d, source, msg) 68 65 case tangled.PipelineStatusNSID: 69 66 return ingestPipelineStatus(ctx, logger, d, source, msg) 70 67 } 71 68 72 69 return nil 73 70 } 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 160 71 } 161 72 162 73 func ingestPipelineStatus(ctx context.Context, logger *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
+2 -1
appview/state/state.go
··· 173 173 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 174 174 } 175 175 notifiers = append(notifiers, indexer) 176 - notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 176 + notifier := notify.NewMergedNotifier(notifiers) 177 + notifier = notify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) 177 178 178 179 state := &State{ 179 180 d,
+1 -1
appview/validator/label.go
··· 4 4 "context" 5 5 "fmt" 6 6 "regexp" 7 + "slices" 7 8 "strings" 8 9 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "golang.org/x/exp/slices" 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/appview/models" 13 13 )
+83
docs/DOCS.md
··· 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 ··· 1561 1590 Refer to the [jujutsu 1562 1591 documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1563 1592 for more information. 1593 + 1594 + # Troubleshooting guide 1595 + 1596 + ## Login issues 1597 + 1598 + Owing to the distributed nature of OAuth on AT Protocol, you 1599 + may run into issues with logging in. If you run a 1600 + self-hosted PDS: 1601 + 1602 + - You may need to ensure that your PDS is timesynced using 1603 + NTP: 1604 + * Enable the `ntpd` service 1605 + * Run `ntpd -qg` to synchronize your clock 1606 + - You may need to increase the default request timeout: 1607 + `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"` 1608 + 1609 + ## Empty punchcard 1610 + 1611 + For Tangled to register commits that you make across the 1612 + network, you need to setup one of following: 1613 + 1614 + - The committer email should be a verified email associated 1615 + to your account. You can add and verify emails on the 1616 + settings page. 1617 + - Or, the committer email should be set to your account's 1618 + DID: `git config user.email "did:plc:foobar". You can find 1619 + your account's DID on the settings page 1620 + 1621 + ## Commit is not marked as verified 1622 + 1623 + Presently, Tangled only supports SSH commit signatures. 1624 + 1625 + To sign commits using an SSH key with git: 1626 + 1627 + ``` 1628 + git config --global gpg.format ssh 1629 + git config --global user.signingkey ~/.ssh/tangled-key 1630 + ``` 1631 + 1632 + To sign commits using an SSH key with jj, add this to your 1633 + config: 1634 + 1635 + ``` 1636 + [signing] 1637 + behavior = "own" 1638 + backend = "ssh" 1639 + key = "~/.ssh/tangled-key" 1640 + ``` 1641 + 1642 + ## Self-hosted knot issues 1643 + 1644 + If you need help troubleshooting a self-hosted knot, check 1645 + out the [knot troubleshooting 1646 + guide](/knot-self-hosting-guide.html#troubleshooting).
+1 -34
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 {}; 102 98 }); 103 99 in { 104 100 overlays.default = final: prev: { 105 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly did-method-plc bluesky-jetstream bluesky-relay tap; 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 106 102 }; 107 103 108 104 packages = forAllSystems (system: let ··· 123 119 sqlite-lib 124 120 docs 125 121 dolly 126 - did-method-plc 127 - bluesky-jetstream 128 - bluesky-relay 129 - tap 130 122 ; 131 123 132 124 pkgsStatic-appview = staticPackages.appview; ··· 331 323 imports = [./nix/modules/spindle.nix]; 332 324 333 325 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 334 - services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap; 335 - }; 336 - nixosModules.did-method-plc = { 337 - lib, 338 - pkgs, 339 - ... 340 - }: { 341 - imports = [./nix/modules/did-method-plc.nix]; 342 - services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 343 - }; 344 - nixosModules.bluesky-relay = { 345 - lib, 346 - pkgs, 347 - ... 348 - }: { 349 - imports = [./nix/modules/bluesky-relay.nix]; 350 - services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 351 - }; 352 - nixosModules.bluesky-jetstream = { 353 - lib, 354 - pkgs, 355 - ... 356 - }: { 357 - imports = [./nix/modules/bluesky-jetstream.nix]; 358 - services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 359 326 }; 360 327 }; 361 328 }
+1 -2
go.mod
··· 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 33 32 github.com/hiddeco/sshsig v0.2.0 34 33 github.com/hpcloud/tail v1.0.0 35 34 github.com/ipfs/go-cid v0.5.0 ··· 50 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 51 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 52 51 golang.org/x/crypto v0.40.0 53 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 54 52 golang.org/x/image v0.31.0 55 53 golang.org/x/net v0.42.0 56 54 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ··· 204 202 go.uber.org/atomic v1.11.0 // indirect 205 203 go.uber.org/multierr v1.11.0 // indirect 206 204 go.uber.org/zap v1.27.0 // indirect 205 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 207 206 golang.org/x/sync v0.17.0 // indirect 208 207 golang.org/x/sys v0.34.0 // indirect 209 208 golang.org/x/text v0.29.0 // indirect
-2
go.sum
··· 265 265 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 266 266 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 267 267 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 268 - github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 269 - github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 270 268 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 271 269 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 272 270 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+15 -5
input.css
··· 92 92 label { 93 93 @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 94 } 95 - input { 96 - @apply p-3 border border-gray-100 block rounded bg-gray-50 focus:outline-none focus:ring-1 focus:ring-gray-200 p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;; 97 - } 98 - textarea { 99 - @apply border border-gray-100 block rounded bg-gray-50 focus:outline-none focus:ring-1 focus:ring-gray-200 p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 95 + input, textarea { 96 + @apply 97 + block rounded p-3 98 + bg-gray-50 dark:bg-gray-800 dark:text-white 99 + border border-gray-300 dark:border-gray-600 100 + focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; 100 101 } 101 102 details summary::-webkit-details-marker { 102 103 display: none; ··· 172 173 173 174 .prose .heading .anchor:hover { 174 175 @apply opacity-70; 176 + } 177 + 178 + .prose h1:target, 179 + .prose h2:target, 180 + .prose h3:target, 181 + .prose h4:target, 182 + .prose h5:target, 183 + .prose h6:target { 184 + @apply bg-yellow-200/30 dark:bg-yellow-600/30; 175 185 } 176 186 177 187 .prose a.footnote-backref {
+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))
+38 -3
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", ··· 29 43 if i != 0 { 30 44 outFormat.WriteString(fieldSeparator) 31 45 } 32 - outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 46 + fmt.Fprintf(&outFormat, "%%(%s)", f) 33 47 } 34 48 outFormat.WriteString("") 35 49 outFormat.WriteString(recordSeparator) 36 50 37 - output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 51 + args := []string{outFormat.String(), "--sort=-creatordate"} 52 + 53 + // only add the count if the limit is a non-zero value, 54 + // if it is zero, get as many tags as we can 55 + if opts.Limit > 0 { 56 + args = append(args, fmt.Sprintf("--count=%d", opts.Offset+opts.Limit)) 57 + } 58 + 59 + args = append(args, opts.Pattern) 60 + 61 + output, err := g.forEachRef(args...) 38 62 if err != nil { 39 63 return nil, fmt.Errorf("failed to get tags: %w", err) 40 64 } ··· 44 68 return nil, nil 45 69 } 46 70 71 + startIdx := opts.Offset 72 + if startIdx >= len(records) { 73 + return nil, nil 74 + } 75 + 76 + endIdx := len(records) 77 + if opts.Limit > 0 { 78 + endIdx = min(startIdx+opts.Limit, len(records)) 79 + } 80 + 81 + records = records[startIdx:endIdx] 47 82 tags := make([]object.Tag, 0, len(records)) 48 83 49 84 for _, line := range records {
+365
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.", 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_Pattern() { 357 + s.setupRepoWithTags() 358 + 359 + v1tag, err := s.repo.Tags(&TagsOptions{ 360 + Pattern: "refs/tags/v1.0.0", 361 + }) 362 + 363 + require.NoError(s.T(), err) 364 + assert.Len(s.T(), v1tag, 1, "expected 1 tag") 365 + }
+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" 10 11 "strings" 11 12 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 16 17 securejoin "github.com/cyphar/filepath-securejoin" 17 18 "tangled.org/core/api/tangled" 18 19 "tangled.org/core/knotserver/db" 20 + "tangled.org/core/knotserver/git" 19 21 "tangled.org/core/log" 20 22 "tangled.org/core/rbac" 23 + "tangled.org/core/workflow" 21 24 ) 22 25 23 26 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 82 85 return nil 83 86 } 84 87 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 + 85 219 // duplicated from add collaborator 86 220 func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 87 221 raw := json.RawMessage(event.Commit.Record) ··· 204 338 err = h.processPublicKey(ctx, event) 205 339 case tangled.KnotMemberNSID: 206 340 err = h.processKnotMember(ctx, event) 341 + case tangled.RepoPullNSID: 342 + err = h.processPull(ctx, event) 207 343 case tangled.RepoCollaboratorNSID: 208 344 err = h.processCollaborator(ctx, event) 209 345 }
+109 -1
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" 26 27 ) 27 28 28 29 type InternalHandle struct { ··· 175 176 } 176 177 177 178 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) 188 194 // non-fatal 189 195 } 190 196 } ··· 235 241 } 236 242 237 243 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) 238 346 } 239 347 240 348 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, 82 83 tangled.RepoCollaboratorNSID, 83 84 }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil {
+7 -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.CommitterName = x.Config.Git.UserName 57 + mo.CommitterEmail = x.Config.Git.UserEmail 58 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 59 + 60 + err = gr.MergeCheckWithOptions(data.Patch, data.Branch, mo) 55 61 56 62 response := tangled.RepoMergeCheck_Output{ 57 63 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 }
-3
nix/gomod2nix.toml
··· 304 304 [mod."github.com/hashicorp/go-sockaddr"] 305 305 version = "v1.0.7" 306 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 - [mod."github.com/hashicorp/go-version"] 308 - version = "v1.8.0" 309 - hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 310 307 [mod."github.com/hashicorp/golang-lru"] 311 308 version = "v1.0.2" 312 309 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
+2 -2
nix/modules/appview.nix
··· 41 41 42 42 appviewHost = mkOption { 43 43 type = types.str; 44 - default = "https://tangled.org"; 45 - example = "https://example.com"; 44 + default = "tangled.org"; 45 + example = "example.com"; 46 46 description = "Public host URL for the appview instance"; 47 47 }; 48 48
-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 - }
+12 -46
nix/modules/spindle.nix
··· 1 1 { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 5 }: let ··· 18 17 type = types.package; 19 18 description = "Package to use for the spindle"; 20 19 }; 21 - tap-package = mkOption { 22 - type = types.package; 23 - description = "Package to use for the spindle"; 24 - }; 25 - 26 - atpRelayUrl = mkOption { 27 - type = types.str; 28 - default = "https://relay1.us-east.bsky.network"; 29 - description = "atproto relay"; 30 - }; 31 20 32 21 server = { 33 22 listenAddr = mkOption { ··· 36 25 description = "Address to listen on"; 37 26 }; 38 27 39 - stateDir = mkOption { 28 + dbPath = mkOption { 40 29 type = types.path; 41 - default = "/var/lib/spindle"; 42 - description = "Tangled spindle data directory"; 30 + default = "/var/lib/spindle/spindle.db"; 31 + description = "Path to the database file"; 43 32 }; 44 33 45 34 hostname = mkOption { ··· 52 41 type = types.str; 53 42 default = "https://plc.directory"; 54 43 description = "atproto PLC directory"; 44 + }; 45 + 46 + jetstreamEndpoint = mkOption { 47 + type = types.str; 48 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 49 + description = "Jetstream endpoint to subscribe to"; 55 50 }; 56 51 57 52 dev = mkOption { ··· 119 114 config = mkIf cfg.enable { 120 115 virtualisation.docker.enable = true; 121 116 122 - systemd.services.spindle-tap = { 123 - description = "spindle tap service"; 124 - after = ["network.target" "docker.service"]; 125 - wantedBy = ["multi-user.target"]; 126 - serviceConfig = { 127 - LogsDirectory = "spindle-tap"; 128 - StateDirectory = "spindle-tap"; 129 - Environment = [ 130 - "TAP_BIND=:2480" 131 - "TAP_PLC_URL=${cfg.server.plcUrl}" 132 - "TAP_RELAY_URL=${cfg.atpRelayUrl}" 133 - "TAP_DATABASE_URL=sqlite:///var/lib/spindle-tap/tap.db" 134 - "TAP_RETRY_TIMEOUT=3s" 135 - "TAP_COLLECTION_FILTERS=${concatStringsSep "," [ 136 - "sh.tangled.repo" 137 - "sh.tangled.repo.collaborator" 138 - "sh.tangled.spindle.member" 139 - "sh.tangled.repo.pull" 140 - ]}" 141 - # temporary hack to listen for repo.pull from non-tangled users 142 - "TAP_SIGNAL_COLLECTION=sh.tangled.repo.pull" 143 - ]; 144 - ExecStart = "${getExe cfg.tap-package} run"; 145 - }; 146 - }; 147 - 148 117 systemd.services.spindle = { 149 118 description = "spindle service"; 150 - after = ["network.target" "docker.service" "spindle-tap.service"]; 119 + after = ["network.target" "docker.service"]; 151 120 wantedBy = ["multi-user.target"]; 152 - path = [ 153 - pkgs.git 154 - ]; 155 121 serviceConfig = { 156 122 LogsDirectory = "spindle"; 157 123 StateDirectory = "spindle"; 158 124 Environment = [ 159 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 160 - "SPINDLE_SERVER_DATA_DIR=${cfg.server.stateDir}" 126 + "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 161 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 162 128 "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 163 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 164 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 165 132 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" ··· 167 134 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 168 135 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 169 136 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 170 - "SPINDLE_SERVER_TAP_URL=http://localhost:2480" 171 137 "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 172 138 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 173 139 ];
-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 - }
+2 -8
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"; 23 22 in 24 23 nixpkgs.lib.nixosSystem { 25 24 inherit system; ··· 58 57 host.port = 6555; 59 58 guest.port = 6555; 60 59 } 61 - { 62 - from = "host"; 63 - host.port = 6556; 64 - guest.port = 2480; 65 - } 66 60 ]; 67 61 sharedDirectories = { 68 62 # We can't use the 9p mounts directly for most of these ··· 101 95 }; 102 96 services.tangled.spindle = { 103 97 enable = true; 104 - atpRelayUrl = relayUrl; 105 98 server = { 106 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 107 100 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 108 101 plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 109 103 listenAddr = "0.0.0.0:6555"; 110 104 dev = true; 111 105 queueSize = 100; ··· 140 134 }; 141 135 in { 142 136 knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 143 - spindle = mkDataSyncScripts "/mnt/spindle-data" config.services.tangled.spindle.server.stateDir; 137 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 144 138 }; 145 139 }) 146 140 ];
-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 - 33 23 var exists bool 34 24 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 35 25 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}
+11 -20
spindle/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "path/filepath" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/sethvargo/go-envconfig" 10 9 ) 11 10 12 11 type Server struct { 13 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 - Hostname string `env:"HOSTNAME, required"` 15 - TapUrl string `env:"TAP_URL, required"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 - Dev bool `env:"DEV, default=false"` 18 - Owner syntax.DID `env:"OWNER, required"` 19 - Secrets Secrets `env:",prefix=SECRETS_"` 20 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 - DataDir string `env:"DATA_DIR, default=/var/lib/spindle"` 22 - QueueSize int `env:"QUEUE_SIZE, default=100"` 23 - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 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 24 23 } 25 24 26 25 func (s Server) Did() syntax.DID { 27 26 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 28 - } 29 - 30 - func (s Server) RepoDir() string { 31 - return filepath.Join(s.DataDir, "repos") 32 - } 33 - 34 - func (s Server) DBPath() string { 35 - return filepath.Join(s.DataDir, "spindle.db") 36 27 } 37 28 38 29 type Secrets struct {
+18 -73
spindle/db/db.go
··· 1 1 package db 2 2 3 3 import ( 4 - "context" 5 4 "database/sql" 6 5 "strings" 7 6 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 7 _ "github.com/mattn/go-sqlite3" 10 - "tangled.org/core/log" 11 - "tangled.org/core/orm" 12 8 ) 13 9 14 10 type DB struct { 15 11 *sql.DB 16 12 } 17 13 18 - func Make(ctx context.Context, dbPath string) (*DB, error) { 14 + func Make(dbPath string) (*DB, error) { 19 15 // https://github.com/mattn/go-sqlite3#connection-string 20 16 opts := []string{ 21 17 "_foreign_keys=1", ··· 24 20 "_auto_vacuum=incremental", 25 21 } 26 22 27 - logger := log.FromContext(ctx) 28 - logger = log.SubLogger(logger, "db") 29 - 30 23 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 24 if err != nil { 32 25 return nil, err 33 26 } 34 27 35 - conn, err := db.Conn(ctx) 36 - if err != nil { 37 - return nil, err 38 - } 39 - defer conn.Close() 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. 40 31 41 32 _, err = db.Exec(` 42 33 create table if not exists _jetstream ( ··· 58 49 unique(owner, name) 59 50 ); 60 51 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 - 75 52 create table if not exists spindle_members ( 76 53 -- identifiers for the record 77 54 id integer primary key autoincrement, ··· 99 76 return nil, err 100 77 } 101 78 102 - // run migrations 79 + return &DB{db}, nil 80 + } 103 81 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 - 135 - return &DB{db}, nil 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 136 89 } 137 90 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 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 151 96 }
-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 - 87 73 func (d *DB) createStatusEvent( 88 74 workflowId models.WorkflowId, 89 75 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 + }
+11 -119
spindle/db/repos.go
··· 1 1 package db 2 2 3 - import "github.com/bluesky-social/indigo/atproto/syntax" 4 - 5 3 type Repo struct { 6 - Did syntax.DID 7 - Rkey syntax.RecordKey 8 - Name string 9 - Knot string 4 + Knot string 5 + Owner string 6 + Name string 10 7 } 11 8 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 32 - } 33 - 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 - ) 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) 40 11 return err 41 12 } 42 13 ··· 63 34 return knots, nil 64 35 } 65 36 66 - func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) { 37 + func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) { 67 38 var repo Repo 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 - ) 82 - if err != nil { 83 - return nil, err 84 - } 85 - return &repo, nil 86 - } 87 39 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 - ) 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 + 105 44 if err != nil { 106 45 return nil, err 107 46 } 47 + 108 48 return &repo, nil 109 49 } 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 }
+1 -1
spindle/engines/nixery/setup_steps.go
··· 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
+150 -222
spindle/server.go
··· 4 4 "context" 5 5 _ "embed" 6 6 "encoding/json" 7 - "errors" 8 7 "fmt" 9 8 "log/slog" 10 9 "maps" 11 10 "net/http" 12 - "path/filepath" 13 11 "sync" 14 12 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 13 "github.com/go-chi/chi/v5" 17 - "github.com/go-git/go-git/v5/plumbing/object" 18 - "github.com/hashicorp/go-version" 19 14 "tangled.org/core/api/tangled" 20 15 "tangled.org/core/eventconsumer" 21 16 "tangled.org/core/eventconsumer/cursor" 22 17 "tangled.org/core/idresolver" 23 - kgit "tangled.org/core/knotserver/git" 18 + "tangled.org/core/jetstream" 24 19 "tangled.org/core/log" 25 20 "tangled.org/core/notifier" 26 - "tangled.org/core/rbac2" 21 + "tangled.org/core/rbac" 27 22 "tangled.org/core/spindle/config" 28 23 "tangled.org/core/spindle/db" 29 24 "tangled.org/core/spindle/engine" 30 25 "tangled.org/core/spindle/engines/nixery" 31 - "tangled.org/core/spindle/git" 32 26 "tangled.org/core/spindle/models" 33 27 "tangled.org/core/spindle/queue" 34 28 "tangled.org/core/spindle/secrets" 35 29 "tangled.org/core/spindle/xrpc" 36 - "tangled.org/core/tap" 37 - "tangled.org/core/tid" 38 - "tangled.org/core/workflow" 39 30 "tangled.org/core/xrpc/serviceauth" 40 31 ) 41 32 42 33 //go:embed motd 43 34 var defaultMotd []byte 35 + 36 + const ( 37 + rbacDomain = "thisserver" 38 + ) 44 39 45 40 type Spindle struct { 46 - tap *tap.Client 41 + jc *jetstream.JetstreamClient 47 42 db *db.DB 48 - e *rbac2.Enforcer 43 + e *rbac.Enforcer 49 44 l *slog.Logger 50 45 n *notifier.Notifier 51 46 engs map[string]models.Engine ··· 62 57 func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 63 58 logger := log.FromContext(ctx) 64 59 65 - if err := ensureGitVersion(); err != nil { 66 - return nil, fmt.Errorf("ensuring git version: %w", err) 67 - } 68 - 69 - d, err := db.Make(ctx, cfg.Server.DBPath()) 60 + d, err := db.Make(cfg.Server.DBPath) 70 61 if err != nil { 71 62 return nil, fmt.Errorf("failed to setup db: %w", err) 72 63 } 73 64 74 - e, err := rbac2.NewEnforcer(cfg.Server.DBPath()) 65 + e, err := rbac.NewEnforcer(cfg.Server.DBPath) 75 66 if err != nil { 76 67 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 77 68 } 69 + e.E.EnableAutoSave(true) 78 70 79 71 n := notifier.New() 80 72 ··· 94 86 } 95 87 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 96 88 case "sqlite", "": 97 - vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath(), secrets.WithTableName("secrets")) 89 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 98 90 if err != nil { 99 91 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 100 92 } 101 - logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath()) 93 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 102 94 default: 103 95 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 104 96 } ··· 106 98 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 107 99 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 108 100 109 - tap := tap.NewClient(cfg.Server.TapUrl, "") 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 + } 110 120 111 121 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 112 122 113 123 spindle := &Spindle{ 114 - tap: &tap, 124 + jc: jc, 115 125 e: e, 116 126 db: d, 117 127 l: logger, ··· 124 134 motd: defaultMotd, 125 135 } 126 136 127 - err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did()) 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() 128 142 if err != nil { 129 143 return nil, err 130 144 } 131 145 logger.Info("owner set", "did", cfg.Server.Owner) 132 146 133 - cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath()) 147 + cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 134 148 if err != nil { 135 149 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 136 150 } 137 151 138 - // spindle listen to knot stream for sh.tangled.git.refUpdate 139 - // which will sync the local workflow files in spindle and enqueues the 140 - // pipeline job for on-push workflows 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. 141 160 ccfg := eventconsumer.NewConsumerConfig() 142 161 ccfg.Logger = log.SubLogger(logger, "eventconsumer") 143 162 ccfg.Dev = cfg.Server.Dev 144 - ccfg.ProcessFunc = spindle.processKnotStream 163 + ccfg.ProcessFunc = spindle.processPipeline 145 164 ccfg.CursorStore = cursorStore 146 165 knownKnots, err := d.Knots() 147 166 if err != nil { ··· 182 201 } 183 202 184 203 // Enforcer returns the RBAC enforcer instance. 185 - func (s *Spindle) Enforcer() *rbac2.Enforcer { 204 + func (s *Spindle) Enforcer() *rbac.Enforcer { 186 205 return s.e 187 206 } 188 207 ··· 216 235 s.ks.Start(ctx) 217 236 }() 218 237 219 - // ensure server owner is tracked 220 - if err := s.tap.AddRepos(ctx, []syntax.DID{s.cfg.Server.Owner}); err != nil { 221 - return err 222 - } 223 - 224 - go func() { 225 - s.l.Info("starting tap stream consumer") 226 - s.tap.Connect(ctx, &tap.SimpleIndexer{ 227 - EventHandler: s.processEvent, 228 - }) 229 - }() 230 - 231 238 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 232 239 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 233 240 } ··· 286 293 return x.Router() 287 294 } 288 295 289 - func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 290 - l := log.FromContext(ctx).With("handler", "processKnotStream") 291 - l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 292 - if msg.Nsid == tangled.GitRefUpdateNSID { 293 - event := tangled.GitRefUpdate{} 294 - if err := json.Unmarshal(msg.EventJson, &event); err != nil { 295 - l.Error("error unmarshalling", "err", err) 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) 296 302 return err 297 303 } 298 - l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName) 299 304 300 - // resolve repo name to rkey 301 - // TODO: git.refUpdate should respond with rkey instead of repo name 302 - repo, err := s.db.GetRepoWithName(syntax.DID(event.RepoDid), event.RepoName) 303 - if err != nil { 304 - return fmt.Errorf("get repo with did and name (%s/%s): %w", event.RepoDid, event.RepoName, err) 305 + if tpl.TriggerMetadata == nil { 306 + return fmt.Errorf("no trigger metadata found") 305 307 } 306 308 307 - // NOTE: we are blindly trusting the knot that it will return only repos it own 308 - repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName) 309 - repoPath := s.newRepoPath(repo.Did, repo.Rkey) 310 - if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 311 - return fmt.Errorf("sync git repo: %w", err) 309 + if tpl.TriggerMetadata.Repo == nil { 310 + return fmt.Errorf("no repo data found") 312 311 } 313 - l.Info("synced git repo") 314 312 315 - compiler := workflow.Compiler{ 316 - Trigger: tangled.Pipeline_TriggerMetadata{ 317 - Kind: string(workflow.TriggerKindPush), 318 - Push: &tangled.Pipeline_PushTriggerData{ 319 - Ref: event.Ref, 320 - OldSha: event.OldSha, 321 - NewSha: event.NewSha, 322 - }, 323 - Repo: &tangled.Pipeline_TriggerRepo{ 324 - Did: repo.Did.String(), 325 - Knot: repo.Knot, 326 - Repo: repo.Name, 327 - }, 328 - }, 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) 329 315 } 330 316 331 - // load workflow definitions from rev (without spindle context) 332 - rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha) 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 + ) 333 323 if err != nil { 334 - return fmt.Errorf("loading pipeline: %w", err) 335 - } 336 - if len(rawPipeline) == 0 { 337 - l.Info("no workflow definition find for the repo. skipping the event") 338 - return nil 339 - } 340 - tpl := compiler.Compile(compiler.Parse(rawPipeline)) 341 - // TODO: pass compile error to workflow log 342 - for _, w := range compiler.Diagnostics.Errors { 343 - l.Error(w.String()) 344 - } 345 - for _, w := range compiler.Diagnostics.Warnings { 346 - l.Warn(w.String()) 324 + return fmt.Errorf("failed to get repo: %w", err) 347 325 } 348 326 349 327 pipelineId := models.PipelineId{ 350 - Knot: tpl.TriggerMetadata.Repo.Knot, 351 - Rkey: tid.TID(), 352 - } 353 - if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 354 - l.Error("failed to create pipeline event", "err", err) 355 - return nil 356 - } 357 - err = s.processPipeline(ctx, tpl, pipelineId) 358 - if err != nil { 359 - return err 328 + Knot: src.Key(), 329 + Rkey: msg.Rkey, 360 330 } 361 - } 362 331 363 - return nil 364 - } 365 - 366 - func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { 367 - if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { 368 - return nil, fmt.Errorf("syncing git repo: %w", err) 369 - } 370 - gr, err := kgit.Open(repoPath, rev) 371 - if err != nil { 372 - return nil, fmt.Errorf("opening git repo: %w", err) 373 - } 374 - 375 - workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 376 - if errors.Is(err, object.ErrDirectoryNotFound) { 377 - // return empty RawPipeline when directory doesn't exist 378 - return nil, nil 379 - } else if err != nil { 380 - return nil, fmt.Errorf("loading file tree: %w", err) 381 - } 332 + workflows := make(map[models.Engine][]models.Workflow) 382 333 383 - var rawPipeline workflow.RawPipeline 384 - for _, e := range workflowDir { 385 - if !e.IsFile() { 386 - continue 387 - } 334 + // Build pipeline environment variables once for all workflows 335 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 388 336 389 - fpath := filepath.Join(workflow.WorkflowDir, e.Name) 390 - contents, err := gr.RawContent(fpath) 391 - if err != nil { 392 - return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err) 393 - } 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 + } 394 347 395 - rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 396 - Name: e.Name, 397 - Contents: contents, 398 - }) 399 - } 348 + continue 349 + } 400 350 401 - return rawPipeline, nil 402 - } 351 + eng := s.engs[w.Engine] 403 352 404 - func (s *Spindle) processPipeline(ctx context.Context, tpl tangled.Pipeline, pipelineId models.PipelineId) error { 405 - // Build pipeline environment variables once for all workflows 406 - pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 353 + if _, ok := workflows[eng]; !ok { 354 + workflows[eng] = []models.Workflow{} 355 + } 407 356 408 - // filter & init workflows 409 - workflows := make(map[models.Engine][]models.Workflow) 410 - for _, w := range tpl.Workflows { 411 - if w == nil { 412 - continue 413 - } 414 - if _, ok := s.engs[w.Engine]; !ok { 415 - err := s.db.StatusFailed(models.WorkflowId{ 416 - PipelineId: pipelineId, 417 - Name: w.Name, 418 - }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 419 - if err != nil { 420 - return fmt.Errorf("db.StatusFailed: %w", err) 421 - } 357 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 358 + if err != nil { 359 + return fmt.Errorf("init workflow: %w", err) 360 + } 422 361 423 - continue 424 - } 425 - 426 - eng := s.engs[w.Engine] 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) 427 368 428 - if _, ok := workflows[eng]; !ok { 429 - workflows[eng] = []models.Workflow{} 430 - } 369 + workflows[eng] = append(workflows[eng], *ewf) 431 370 432 - ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 433 - if err != nil { 434 - return fmt.Errorf("init workflow: %w", err) 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 + } 378 + } 435 379 } 436 380 437 - // inject TANGLED_* env vars after InitWorkflow 438 - // This prevents user-defined env vars from overriding them 439 - if ewf.Environment == nil { 440 - ewf.Environment = make(map[string]string) 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") 441 398 } 442 - maps.Copy(ewf.Environment, pipelineEnv) 443 - 444 - workflows[eng] = append(workflows[eng], *ewf) 445 399 } 446 400 447 - // enqueue pipeline 448 - ok := s.jq.Enqueue(queue.Job{ 449 - Run: func() error { 450 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 451 - RepoOwner: tpl.TriggerMetadata.Repo.Did, 452 - RepoName: tpl.TriggerMetadata.Repo.Repo, 453 - Workflows: workflows, 454 - }, pipelineId) 455 - return nil 456 - }, 457 - OnFail: func(jobError error) { 458 - s.l.Error("pipeline run failed", "error", jobError) 459 - }, 460 - }) 461 - if !ok { 462 - return fmt.Errorf("failed to enqueue pipeline: queue is full") 463 - } 464 - s.l.Info("pipeline enqueued successfully", "id", pipelineId) 465 - 466 - // emit StatusPending for all workflows here (after successful enqueue) 467 - for _, ewfs := range workflows { 468 - for _, ewf := range ewfs { 469 - err := s.db.StatusPending(models.WorkflowId{ 470 - PipelineId: pipelineId, 471 - Name: ewf.Name, 472 - }, s.n) 473 - if err != nil { 474 - return fmt.Errorf("db.StatusPending: %w", err) 475 - } 476 - } 477 - } 478 401 return nil 479 402 } 480 403 481 - // newRepoPath creates a path to store repository by its did and rkey. 482 - // The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey 483 - func (s *Spindle) newRepoPath(did syntax.DID, rkey syntax.RecordKey) string { 484 - return filepath.Join(s.cfg.Server.RepoDir(), did.String(), tangled.RepoNSID, rkey.String()) 485 - } 404 + func (s *Spindle) configureOwner() error { 405 + cfgOwner := s.cfg.Server.Owner 486 406 487 - func (s *Spindle) newRepoCloneUrl(knot, did, name string) string { 488 - scheme := "https://" 489 - if s.cfg.Server.Dev { 490 - scheme = "http://" 407 + existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 408 + if err != nil { 409 + return err 491 410 } 492 - return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name) 493 - } 494 411 495 - const RequiredVersion = "2.49.0" 412 + switch len(existing) { 413 + case 0: 414 + // no owner configured, continue 415 + case 1: 416 + // find existing owner 417 + existingOwner := existing[0] 496 418 497 - func ensureGitVersion() error { 498 - v, err := git.Version() 499 - if err != nil { 500 - return fmt.Errorf("fetching git version: %w", err) 419 + // no ownership change, this is okay 420 + if existingOwner == s.cfg.Server.Owner { 421 + break 422 + } 423 + 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) 501 431 } 502 - if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 503 - return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 504 - } 505 - return nil 432 + 433 + return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 506 434 }
-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/tap" 16 - "tangled.org/core/tid" 17 - "tangled.org/core/workflow" 18 - ) 19 - 20 - func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error { 21 - l := s.l.With("component", "tapIndexer") 22 - 23 - var err error 24 - switch evt.Type { 25 - case tap.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 tap.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 tap.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 tap.RecordCreateAction, tap.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 tap: %w", err) 92 - } 93 - 94 - l.Info("added member", "member", record.Subject) 95 - return nil 96 - 97 - case tap.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 tap: %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 tap.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 tap.RecordCreateAction, tap.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 tap: %w", err) 167 - } 168 - 169 - l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo) 170 - return nil 171 - 172 - case tap.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 tap: %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 tap.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 tap.RecordCreateAction, tap.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 tap.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 tap.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 tap.RecordCreateAction, tap.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 tap.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 tap: %w", err) 388 - } 389 - } 390 - return nil 391 - }
+2 -1
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" 14 15 "tangled.org/core/spindle/secrets" 15 16 xrpcerr "tangled.org/core/xrpc/errors" 16 17 ) ··· 67 68 return 68 69 } 69 70 70 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 71 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 72 l.Error("insufficent permissions", "did", actorDid.String()) 72 73 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 74 return
+2 -1
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" 14 15 "tangled.org/core/spindle/secrets" 15 16 xrpcerr "tangled.org/core/xrpc/errors" 16 17 ) ··· 62 63 return 63 64 } 64 65 65 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 66 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 67 l.Error("insufficent permissions", "did", actorDid.String()) 67 68 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 69 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.String() 12 + owner := x.Config.Server.Owner 13 13 if owner == "" { 14 14 writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 15 return
+26 -1
spindle/xrpc/pipeline_cancelPipeline.go
··· 6 6 "net/http" 7 7 "strings" 8 8 9 + "github.com/bluesky-social/indigo/api/atproto" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 10 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 11 15 "tangled.org/core/spindle/models" 12 16 xrpcerr "tangled.org/core/xrpc/errors" 13 17 ) ··· 49 53 return 50 54 } 51 55 52 - isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt) 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) 53 78 if err != nil || !isRepoOwner { 54 79 fail(xrpcerr.AccessControlError(actorDid.String())) 55 80 return
+2 -1
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" 13 14 "tangled.org/core/spindle/secrets" 14 15 xrpcerr "tangled.org/core/xrpc/errors" 15 16 ) ··· 61 62 return 62 63 } 63 64 64 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 65 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 66 l.Error("insufficent permissions", "did", actorDid.String()) 66 67 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 68 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/rbac2" 14 + "tangled.org/core/rbac" 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 *rbac2.Enforcer 28 + Enforcer *rbac.Enforcer 29 29 Engines map[string]models.Engine 30 30 Config *config.Config 31 31 Resolver *idresolver.Resolver
-24
tap/simpleIndexer.go
··· 1 - package tap 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 - }
-169
tap/tap.go
··· 1 - /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 - 3 - package tap 4 - 5 - import ( 6 - "bytes" 7 - "context" 8 - "encoding/json" 9 - "fmt" 10 - "net/http" 11 - "net/url" 12 - 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/gorilla/websocket" 15 - "tangled.org/core/log" 16 - ) 17 - 18 - // type WebsocketOptions struct { 19 - // maxReconnectSeconds int 20 - // heartbeatIntervalMs int 21 - // // onReconnectError 22 - // } 23 - 24 - type Handler interface { 25 - OnEvent(ctx context.Context, evt Event) error 26 - OnError(ctx context.Context, err error) 27 - } 28 - 29 - type Client struct { 30 - Url string 31 - AdminPassword string 32 - HTTPClient *http.Client 33 - } 34 - 35 - func NewClient(url, adminPassword string) Client { 36 - return Client{ 37 - Url: url, 38 - AdminPassword: adminPassword, 39 - HTTPClient: &http.Client{}, 40 - } 41 - } 42 - 43 - func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 44 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 45 - if err != nil { 46 - return err 47 - } 48 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 49 - if err != nil { 50 - return err 51 - } 52 - req.SetBasicAuth("admin", c.AdminPassword) 53 - req.Header.Set("Content-Type", "application/json") 54 - 55 - resp, err := c.HTTPClient.Do(req) 56 - if err != nil { 57 - return err 58 - } 59 - defer resp.Body.Close() 60 - if resp.StatusCode != http.StatusOK { 61 - return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 62 - } 63 - return nil 64 - } 65 - 66 - func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 67 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 68 - if err != nil { 69 - return err 70 - } 71 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 72 - if err != nil { 73 - return err 74 - } 75 - req.SetBasicAuth("admin", c.AdminPassword) 76 - req.Header.Set("Content-Type", "application/json") 77 - 78 - resp, err := c.HTTPClient.Do(req) 79 - if err != nil { 80 - return err 81 - } 82 - defer resp.Body.Close() 83 - if resp.StatusCode != http.StatusOK { 84 - return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 85 - } 86 - return nil 87 - } 88 - 89 - func (c *Client) Connect(ctx context.Context, handler Handler) error { 90 - l := log.FromContext(ctx) 91 - 92 - u, err := url.Parse(c.Url) 93 - if err != nil { 94 - return err 95 - } 96 - if u.Scheme == "https" { 97 - u.Scheme = "wss" 98 - } else { 99 - u.Scheme = "ws" 100 - } 101 - u.Path = "/channel" 102 - 103 - // TODO: set auth on dial 104 - 105 - url := u.String() 106 - 107 - // var backoff int 108 - // for { 109 - // select { 110 - // case <-ctx.Done(): 111 - // return ctx.Err() 112 - // default: 113 - // } 114 - // 115 - // header := http.Header{ 116 - // "Authorization": []string{""}, 117 - // } 118 - // conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 119 - // if err != nil { 120 - // l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 121 - // time.Sleep(time.Duration(5+backoff) * time.Second) 122 - // backoff++ 123 - // 124 - // continue 125 - // } else { 126 - // backoff = 0 127 - // } 128 - // 129 - // l.Info("event subscription response", "code", res.StatusCode) 130 - // } 131 - 132 - // TODO: keep websocket connection alive 133 - conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 134 - if err != nil { 135 - return err 136 - } 137 - defer conn.Close() 138 - 139 - for { 140 - select { 141 - case <-ctx.Done(): 142 - return ctx.Err() 143 - default: 144 - } 145 - _, message, err := conn.ReadMessage() 146 - if err != nil { 147 - return err 148 - } 149 - 150 - var ev Event 151 - if err := json.Unmarshal(message, &ev); err != nil { 152 - handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 153 - continue 154 - } 155 - if err := handler.OnEvent(ctx, ev); err != nil { 156 - handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 157 - continue 158 - } 159 - 160 - ack := map[string]any{ 161 - "type": "ack", 162 - "id": ev.ID, 163 - } 164 - if err := conn.WriteJSON(ack); err != nil { 165 - l.Warn("failed to send ack", "err", err) 166 - continue 167 - } 168 - } 169 - }
-62
tap/types.go
··· 1 - package tap 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 + }