This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

appview: ref based PR

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

author
Seongmin Lee
date (Jul 26, 2026, 10:39 PM +0900) commit 9a925efe parent f3c356fc change-id uwlrnuzz
+4038 -5971
+42
appview/db/db.go
··· 2501 2501 return err 2502 2502 }) 2503 2503 2504 + orm.RunMigration(conn, logger, "ref-based-pr", func(tx *sql.Tx) error { 2505 + _, err := tx.Exec(` 2506 + ALTER TABLE pulls ADD COLUMN cid TEXT NOT NULL DEFAULT ''; 2507 + 2508 + -- TODO(boltless): set source_repo_did to '' 2509 + 2510 + CREATE TABLE pull_versions ( 2511 + pull_at TEXT NOT NULL, 2512 + id INTEGER NOT NULL, -- PR local version id 2513 + head TEXT NOT NULL, -- head commit ID 2514 + base TEXT NOT NULL, -- base commit ID (used on interdiff) 2515 + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 2516 + legacy INTEGER NOT NULL DEFAULT 0, -- 2517 + 2518 + UNIQUE(pull_at, id), 2519 + FOREIGN KEY (pull_at) REFERENCES pulls(at_uri) ON DELETE CASCADE 2520 + ); 2521 + 2522 + -- set source_repo_did to repo_did for all branch based PRs 2523 + UPDATE pulls 2524 + SET source_repo_did = repo_did 2525 + WHERE coalesce(source_branch, '') <> ''; 2526 + 2527 + INSERT INTO pull_versions ( 2528 + pull_at, 2529 + id, 2530 + head, 2531 + base, 2532 + created 2533 + ) 2534 + SELECT 2535 + pull_at, 2536 + round_number, 2537 + coalesce(source_rev, ''), 2538 + '', 2539 + created 2540 + FROM pull_submissions; 2541 + -- we keep 'pull_submissions' table just in case. 2542 + `) 2543 + return err 2544 + }) 2545 + 2504 2546 return &DB{ 2505 2547 db, 2506 2548 logger,
+10 -7
appview/db/entity_state_test.go
··· 39 39 } 40 40 pull := &models.Pull{ 41 41 RepoDid: syntax.DID(repo.RepoDid), 42 - OwnerDid: did, 43 - Rkey: rkey, 42 + OwnerDid: syntax.DID(did), 43 + Rkey: syntax.RecordKey(rkey), 44 44 Title: "title", 45 45 Body: "body", 46 46 TargetBranch: "main", 47 47 State: models.PullOpen, 48 + Versions: []models.PullVersion{ 49 + {Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, 50 + }, 48 51 } 49 - if err := PutPull(tx, pull); err != nil { 52 + if err := PutPull(t.Context(), tx, pull, nil); err != nil { 50 53 t.Fatalf("PutPull: %v", err) 51 54 } 52 55 if err := tx.Commit(); err != nil { ··· 120 123 121 124 func pullStateOf(t *testing.T, d *DB, subject syntax.ATURI) models.PullState { 122 125 t.Helper() 123 - pulls, err := GetPulls(d, orm.FilterEq("at_uri", subject)) 124 - if err != nil || len(pulls) != 1 { 125 - t.Fatalf("GetPulls: %v len %d", err, len(pulls)) 126 + pull, err := GetPull(t.Context(), d, orm.FilterEq("at_uri", subject)) 127 + if err != nil { 128 + t.Fatalf("GetPulls: %v", err) 126 129 } 127 - return pulls[0].State 130 + return pull.State 128 131 } 129 132 130 133 func issueRec(did, rkey string, subject syntax.ATURI, v models.StateValue, micros int64) models.StateRecord {
+4 -3
appview/db/focus.go
··· 7 7 "strings" 8 8 "time" 9 9 10 + "github.com/bluesky-social/indigo/atproto/syntax" 10 11 "tangled.org/core/appview/models" 11 12 ) 12 13 ··· 170 171 } 171 172 172 173 if pId.Valid { 173 - pull.ID = int(pId.Int64) 174 + pull.ID = pId.Int64 174 175 if pOwnerDid.Valid { 175 - pull.OwnerDid = pOwnerDid.String 176 + pull.OwnerDid = syntax.DID(pOwnerDid.String) 176 177 } 177 178 if pPullId.Valid { 178 - pull.PullId = int(pPullId.Int64) 179 + pull.PullId = pPullId.Int64 179 180 } 180 181 if pTitle.Valid { 181 182 pull.Title = pTitle.String
+6 -6
appview/db/notifications.go
··· 251 251 252 252 // populate pull if present 253 253 if pId.Valid { 254 - pull.ID = int(pId.Int64) 254 + pull.ID = pId.Int64 255 255 if pOwnerDid.Valid { 256 - pull.OwnerDid = pOwnerDid.String 256 + pull.OwnerDid = syntax.DID(pOwnerDid.String) 257 257 } 258 258 if pPullId.Valid { 259 - pull.PullId = int(pPullId.Int64) 259 + pull.PullId = pPullId.Int64 260 260 } 261 261 if pTitle.Valid { 262 262 pull.Title = pTitle.String ··· 738 738 } 739 739 740 740 if pId.Valid { 741 - pull.ID = int(pId.Int64) 741 + pull.ID = pId.Int64 742 742 if pOwnerDid.Valid { 743 - pull.OwnerDid = pOwnerDid.String 743 + pull.OwnerDid = syntax.DID(pOwnerDid.String) 744 744 } 745 745 if pPullId.Valid { 746 - pull.PullId = int(pPullId.Int64) 746 + pull.PullId = pPullId.Int64 747 747 } 748 748 if pTitle.Valid { 749 749 pull.Title = pTitle.String
+1 -1
appview/db/profile.go
··· 23 23 now := time.Now() 24 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 25 26 - pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) 26 + pulls, err := GetPullsByOwnerDid(e, syntax.DID(forDid), timeframe) 27 27 if err != nil { 28 28 return nil, fmt.Errorf("error getting pulls by owner did: %w", err) 29 29 }
+204 -584
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 - "cmp" 4 + "context" 5 5 "database/sql" 6 - "errors" 7 6 "fmt" 8 7 "maps" 9 8 "slices" ··· 12 11 "time" 13 12 14 13 "github.com/bluesky-social/indigo/atproto/syntax" 15 - lexutil "github.com/bluesky-social/indigo/lex/util" 16 - "github.com/ipfs/go-cid" 17 14 "tangled.org/core/appview/models" 18 15 "tangled.org/core/appview/pagination" 19 16 "tangled.org/core/orm" 20 - "tangled.org/core/sets" 21 17 ) 22 18 23 - func comparePullSource(existing, new *models.PullSource) bool { 24 - if existing == nil && new == nil { 25 - return true 26 - } 27 - if existing == nil || new == nil { 28 - return false 29 - } 30 - if existing.Branch != new.Branch { 31 - return false 32 - } 33 - if existing.RepoDid == nil && new.RepoDid == nil { 34 - return true 35 - } 36 - if existing.RepoDid == nil || new.RepoDid == nil { 37 - return false 38 - } 39 - return *existing.RepoDid == *new.RepoDid 40 - } 41 - 42 - func compareSubmissions(existing, new []*models.PullSubmission) bool { 43 - if len(existing) != len(new) { 44 - return false 45 - } 46 - for i := range existing { 47 - if existing[i].Blob.Ref.String() != new[i].Blob.Ref.String() { 48 - return false 49 - } 50 - if existing[i].Blob.MimeType != new[i].Blob.MimeType { 51 - return false 52 - } 53 - if existing[i].Blob.Size != new[i].Blob.Size { 54 - return false 55 - } 56 - } 57 - return true 58 - } 59 - 60 - func PutPull(tx *sql.Tx, pull *models.Pull) error { 19 + func PutPull(ctx context.Context, tx *sql.Tx, pull *models.Pull, references []syntax.ATURI) error { 61 20 // ensure sequence exists 62 - _, err := tx.Exec(` 21 + _, err := tx.ExecContext(ctx, ` 63 22 insert or ignore into repo_pull_seqs (repo_did, next_pull_id) 64 23 values (?, 1) 65 24 `, pull.RepoDid) ··· 67 26 return err 68 27 } 69 28 70 - pulls, err := GetPulls( 71 - tx, 72 - orm.FilterEq("owner_did", pull.OwnerDid), 73 - orm.FilterEq("rkey", pull.Rkey), 74 - ) 75 - switch { 76 - case err != nil: 29 + var exists bool 30 + if err := tx.QueryRowContext(ctx, 31 + `select exists (select 1 from pulls where at_uri = ?)`, 32 + pull.AtUri(), 33 + ).Scan(&exists); err != nil { 77 34 return err 78 - case len(pulls) == 0: 79 - return createNewPull(tx, pull) 80 - case len(pulls) != 1: // should be unreachable 81 - return fmt.Errorf("invalid number of pulls returned: %d", len(pulls)) 82 - default: 83 - existingPull := pulls[0] 84 - if existingPull.State == models.PullMerged { 85 - return nil 86 - } 87 - 88 - dependentOnEqual := (existingPull.DependentOn == nil && pull.DependentOn == nil) || 89 - (existingPull.DependentOn != nil && pull.DependentOn != nil && *existingPull.DependentOn == *pull.DependentOn) 90 - 91 - pullSourceEqual := comparePullSource(existingPull.PullSource, pull.PullSource) 92 - submissionsEqual := compareSubmissions(existingPull.Submissions, pull.Submissions) 93 - 94 - if existingPull.Title == pull.Title && 95 - existingPull.Body == pull.Body && 96 - existingPull.TargetBranch == pull.TargetBranch && 97 - existingPull.RepoDid == pull.RepoDid && 98 - dependentOnEqual && 99 - pullSourceEqual && 100 - submissionsEqual { 101 - return nil 102 - } 103 - 104 - isLonger := len(existingPull.Submissions) < len(pull.Submissions) 105 - if isLonger { 106 - isAppendOnly := compareSubmissions(existingPull.Submissions, pull.Submissions[:len(existingPull.Submissions)]) 107 - if !isAppendOnly { 108 - return fmt.Errorf("the new pull does not treat submissions as append-only") 109 - } 110 - } else if !submissionsEqual { 111 - return fmt.Errorf("the new pull does not treat submissions as append-only") 112 - } 113 - 114 - pull.ID = existingPull.ID 115 - pull.PullId = existingPull.PullId 116 - return updatePull(tx, pull, existingPull) 117 35 } 118 - } 119 36 120 - func createNewPull(tx *sql.Tx, pull *models.Pull) error { 121 - _, err := tx.Exec(` 122 - insert or ignore into repo_pull_seqs (repo_did, next_pull_id) 123 - values (?, 1) 124 - `, pull.RepoDid) 125 - if err != nil { 126 - return err 127 - } 128 - 129 - var nextId int 130 - err = tx.QueryRow(` 131 - update repo_pull_seqs 132 - set next_pull_id = next_pull_id + 1 133 - where repo_did = ? 134 - returning next_pull_id - 1 135 - `, pull.RepoDid).Scan(&nextId) 136 - if err != nil { 137 - return err 138 - } 139 - 140 - pull.PullId = nextId 141 - pull.State = models.PullOpen 142 - 143 - var sourceBranch, sourceRepoDid *string 144 - if pull.PullSource != nil { 145 - sourceBranch = &pull.PullSource.Branch 146 - if pull.PullSource.RepoDid != nil { 147 - x := string(*pull.PullSource.RepoDid) 148 - sourceRepoDid = &x 37 + if !exists { 38 + // assign new ID for a PR 39 + if err := tx.QueryRowContext(ctx, 40 + `update repo_pull_seqs 41 + set next_pull_id = next_pull_id + 1 42 + where repo_did = ? 43 + returning next_pull_id - 1`, 44 + pull.RepoDid, 45 + ).Scan(&pull.PullId); err != nil { 46 + return err 149 47 } 150 48 } 151 49 152 - result, err := tx.Exec( 153 - ` 154 - insert into pulls ( 155 - repo_did, 50 + result, err := tx.ExecContext(ctx, 51 + `insert into pulls ( 156 52 owner_did, 53 + rkey, 54 + cid, 55 + repo_did, 157 56 pull_id, 158 57 title, 159 - target_branch, 160 58 body, 161 - rkey, 162 - state, 163 - dependent_on, 59 + target_branch, 60 + source_repo_did, 164 61 source_branch, 165 - source_repo_did 62 + created, 63 + state 166 64 ) 167 - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 168 - pull.RepoDid, 65 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 66 + on conflict(at_uri) do update set 67 + cid = excluded.cid, 68 + repo_did = excluded.repo_did, 69 + title = excluded.title, 70 + body = excluded.body, 71 + target_branch = excluded.target_branch, 72 + source_repo_did = excluded.source_repo_did, 73 + source_branch = excluded.source_branch, 74 + created = excluded.created, 75 + state = excluded.state 76 + where pulls.cid is not excluded.cid`, 169 77 pull.OwnerDid, 78 + pull.Rkey, 79 + pull.Cid, 80 + pull.RepoDid, 170 81 pull.PullId, 171 82 pull.Title, 172 - pull.TargetBranch, 173 83 pull.Body, 174 - pull.Rkey, 84 + pull.TargetBranch, 85 + pull.SourceRepo, 86 + pull.SourceBranch, 87 + pull.Created.Format(time.RFC3339), 175 88 pull.State, 176 - pull.DependentOn, 177 - sourceBranch, 178 - sourceRepoDid, 179 89 ) 180 90 if err != nil { 181 - return err 91 + return fmt.Errorf("inserting pr: %w", err) 182 92 } 183 93 184 - // Set the database primary key ID 185 94 id, err := result.LastInsertId() 186 95 if err != nil { 187 96 return err 188 97 } 189 - pull.ID = int(id) 98 + pull.ID = id 190 99 191 - for i, s := range pull.Submissions { 192 - _, err = tx.Exec(` 193 - insert into pull_submissions ( 194 - pull_at, 195 - round_number, 196 - patch, 197 - combined, 198 - source_rev, 199 - patch_blob_ref, 200 - patch_blob_mime, 201 - patch_blob_size 202 - ) 203 - values (?, ?, ?, ?, ?, ?, ?, ?) 204 - `, 205 - pull.AtUri(), 206 - i, 207 - s.Patch, 208 - s.Combined, 209 - s.SourceRev, 210 - s.Blob.Ref.String(), 211 - s.Blob.MimeType, 212 - s.Blob.Size, 213 - ) 214 - if err != nil { 215 - return err 100 + // delete all existing versions 101 + if _, err := tx.ExecContext(ctx, 102 + `delete from pull_versions where pull_at = ?`, 103 + pull.AtUri(), 104 + ); err != nil { 105 + return fmt.Errorf("deleting old pr versions: %w", err) 106 + } 107 + 108 + // re-create all versions 109 + if len(pull.Versions) > 0 { 110 + pullAt := pull.AtUri() 111 + var sb strings.Builder 112 + sb.WriteString(`insert into pull_versions (pull_at, id, head, base, created) values `) 113 + args := make([]any, 0, len(pull.Versions)*5) 114 + for i, v := range pull.Versions { 115 + if i > 0 { 116 + sb.WriteString(", ") 117 + } 118 + sb.WriteString("(?, ?, ?, ?, ?)") 119 + args = append(args, pullAt, v.ID, v.Head, v.Base, v.Created.Format(time.RFC3339)) 120 + } 121 + if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil { 122 + return fmt.Errorf("inserting pr versions: %w", err) 216 123 } 217 124 } 218 125 219 - if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 126 + // update references when comment is updated 127 + if err := putReferences(tx, pull.AtUri(), references); err != nil { 220 128 return fmt.Errorf("put reference_links: %w", err) 221 129 } 222 130 223 131 return nil 224 132 } 225 133 226 - func updatePull(tx *sql.Tx, pull *models.Pull, existingPull *models.Pull) error { 227 - var sourceBranch, sourceRepoDid *string 228 - if pull.PullSource != nil { 229 - sourceBranch = &pull.PullSource.Branch 230 - if pull.PullSource.RepoDid != nil { 231 - x := string(*pull.PullSource.RepoDid) 232 - sourceRepoDid = &x 233 - } 234 - } 134 + func SubmitPullVersion(ctx context.Context, q Execer, pullAt syntax.ATURI, version models.PullVersion) error { 135 + _, err := q.ExecContext(ctx, 136 + `insert into pull_versions (pull_at, id, head, base, created) 137 + values (?, ?, ?, ?, ?)`, 138 + pullAt, 139 + version.ID, 140 + version.Head, 141 + version.Base, 142 + version.Created.Format(time.RFC3339), 143 + ) 144 + return err 145 + } 235 146 236 - _, err := tx.Exec(` 237 - update pulls set 238 - title = ?, 239 - body = ?, 240 - target_branch = ?, 241 - dependent_on = ?, 242 - source_branch = ?, 243 - source_repo_did = ? 244 - where owner_did = ? and rkey = ? 245 - `, pull.Title, pull.Body, pull.TargetBranch, pull.DependentOn, sourceBranch, sourceRepoDid, pull.OwnerDid, pull.Rkey) 147 + func GetPull(ctx context.Context, q Execer, filters ...orm.Filter) (*models.Pull, error) { 148 + pulls, err := GetPullsPaginated(ctx, q, pagination.Page{Limit: 1}, filters...) 246 149 if err != nil { 247 - return err 150 + return nil, err 248 151 } 249 - 250 - // insert new submissions (append-only) 251 - for i := len(existingPull.Submissions); i < len(pull.Submissions); i++ { 252 - s := pull.Submissions[i] 253 - _, err = tx.Exec(` 254 - insert into pull_submissions ( 255 - pull_at, 256 - round_number, 257 - patch, 258 - combined, 259 - source_rev, 260 - patch_blob_ref, 261 - patch_blob_mime, 262 - patch_blob_size 263 - ) 264 - values (?, ?, ?, ?, ?, ?, ?, ?) 265 - `, 266 - pull.AtUri(), 267 - i, 268 - s.Patch, 269 - s.Combined, 270 - s.SourceRev, 271 - s.Blob.Ref.String(), 272 - s.Blob.MimeType, 273 - s.Blob.Size, 274 - ) 275 - if err != nil { 276 - return err 277 - } 152 + if len(pulls) == 0 { 153 + return nil, sql.ErrNoRows 278 154 } 279 - 280 - if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 281 - return fmt.Errorf("put reference_links: %w", err) 282 - } 283 - return nil 155 + return pulls[0], nil 284 156 } 285 157 286 - func NextPullId(e Execer, repoDid string) (int, error) { 287 - var pullId int 288 - err := e.QueryRow(`select next_pull_id from repo_pull_seqs where repo_did = ?`, repoDid).Scan(&pullId) 289 - return pullId - 1, err 290 - } 291 - 292 - func GetPullsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Pull, error) { 158 + func GetPullsPaginated(ctx context.Context, q Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Pull, error) { 293 159 pulls := make(map[syntax.ATURI]*models.Pull) 294 160 295 161 var conditions []string ··· 316 182 select 317 183 id, 318 184 owner_did, 185 + rkey, 186 + cid, 319 187 repo_did, 320 188 pull_id, 321 - created, 322 189 title, 323 - state, 324 - target_branch, 325 190 body, 326 - rkey, 327 - source_branch, 191 + target_branch, 328 192 source_repo_did, 329 - dependent_on 193 + source_branch, 194 + created, 195 + state 330 196 from 331 197 pulls 332 198 %s ··· 335 201 %s 336 202 `, whereClause, pageClause) 337 203 338 - rows, err := e.Query(query, args...) 204 + rows, err := q.QueryContext(ctx, query, args...) 339 205 if err != nil { 340 206 return nil, err 341 207 } ··· 344 210 for rows.Next() { 345 211 var pull models.Pull 346 212 var createdAt string 347 - var sourceBranch, sourceRepoDid, dependentOn sql.NullString 213 + var sourceRepo, sourceBranch sql.NullString 348 214 err := rows.Scan( 349 215 &pull.ID, 350 216 &pull.OwnerDid, 217 + &pull.Rkey, 218 + &pull.Cid, 351 219 &pull.RepoDid, 352 220 &pull.PullId, 353 - &createdAt, 354 221 &pull.Title, 355 - &pull.State, 356 - &pull.TargetBranch, 357 222 &pull.Body, 358 - &pull.Rkey, 223 + &pull.TargetBranch, 224 + &sourceRepo, 359 225 &sourceBranch, 360 - &sourceRepoDid, 361 - &dependentOn, 226 + &createdAt, 227 + &pull.State, 362 228 ) 363 229 if err != nil { 364 - return nil, err 230 + return nil, fmt.Errorf("scanning row: %w", err) 365 231 } 366 232 367 233 createdTime, err := time.Parse(time.RFC3339, createdAt) 368 234 if err != nil { 369 - return nil, err 235 + return nil, fmt.Errorf("parsing created: %w", err) 370 236 } 371 237 pull.Created = createdTime 372 238 373 - if sourceBranch.Valid { 374 - pull.PullSource = &models.PullSource{ 375 - Branch: sourceBranch.String, 376 - } 377 - if sourceRepoDid.Valid { 378 - sourceRepoDidParsed, err := syntax.ParseDID(sourceRepoDid.String) 379 - if err != nil { 380 - return nil, err 381 - } 382 - pull.PullSource.RepoDid = &sourceRepoDidParsed 383 - } 239 + if sourceRepo.Valid { 240 + pull.SourceRepo = syntax.DID(sourceRepo.String) 241 + } else { 242 + // fallback to pull.target.repo 243 + pull.SourceRepo = pull.RepoDid 384 244 } 385 245 386 - if dependentOn.Valid { 387 - x := syntax.ATURI(dependentOn.String) 388 - pull.DependentOn = &x 246 + if sourceBranch.Valid { 247 + pull.SourceBranch = &sourceBranch.String 389 248 } 390 249 391 250 pulls[pull.AtUri()] = &pull 392 251 } 393 - 394 - var pullAts []syntax.ATURI 395 - for _, p := range pulls { 396 - pullAts = append(pullAts, p.AtUri()) 397 - } 398 - submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts)) 399 - if err != nil { 400 - return nil, fmt.Errorf("failed to get submissions: %w", err) 252 + if err := rows.Err(); err != nil { 253 + return nil, fmt.Errorf("scanning rows: %w", err) 401 254 } 402 255 403 - for pullAt, submissions := range submissionsMap { 404 - if p, ok := pulls[pullAt]; ok { 405 - p.Submissions = submissions 406 - } 407 - } 256 + pullAts := slices.Collect(maps.Keys(pulls)) 408 257 409 - // collect allLabels for each issue 410 - allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts)) 258 + versionsMap, err := ListVersions(ctx, q, pullAts) 411 259 if err != nil { 412 - return nil, fmt.Errorf("failed to query labels: %w", err) 413 - } 414 - for pullAt, labels := range allLabels { 415 - if p, ok := pulls[pullAt]; ok { 416 - p.Labels = labels 417 - } 260 + return nil, fmt.Errorf("querying versions: %w", err) 418 261 } 419 262 420 - // build up reverse mappings: p.Repo and p.PullSource.Repo 421 - var repoDids []syntax.DID 422 - for _, p := range pulls { 423 - repoDids = append(repoDids, p.RepoDid) 424 - if p.PullSource != nil && p.PullSource.RepoDid != nil { 425 - repoDids = append(repoDids, *p.PullSource.RepoDid) 263 + for pullAt, p := range pulls { 264 + if versions, ok := versionsMap[pullAt]; ok { 265 + p.Versions = versions 266 + } else { 267 + return nil, fmt.Errorf("find 0 versions for PR %s", pullAt) 426 268 } 427 269 } 428 270 429 - repos, err := GetRepos(e, orm.FilterIn("repo_did", repoDids)) 430 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 431 - return nil, fmt.Errorf("failed to get repos: %w", err) 432 - } 433 - 434 - repoMap := make(map[syntax.DID]*models.Repo) 435 - for _, r := range repos { 436 - repoMap[syntax.DID(r.RepoDid)] = &r 437 - } 271 + // collect reverse repos 272 + { 273 + repoDids := make([]string, 0, len(pulls)) 274 + for _, issue := range pulls { 275 + repoDids = append(repoDids, string(issue.RepoDid)) 276 + } 438 277 439 - for _, p := range pulls { 440 - if repo, ok := repoMap[p.RepoDid]; ok { 441 - p.Repo = repo 278 + repos, err := GetRepos(q, orm.FilterIn("repo_did", repoDids)) 279 + if err != nil { 280 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 281 + } 282 + repoMap := make(map[syntax.DID]*models.Repo) 283 + for i := range repos { 284 + repoMap[syntax.DID(repos[i].RepoDid)] = &repos[i] 442 285 } 443 - if p.PullSource != nil && p.PullSource.RepoDid != nil { 444 - if sourceRepo, ok := repoMap[*p.PullSource.RepoDid]; ok { 445 - p.PullSource.Repo = sourceRepo 286 + 287 + for pullAt, p := range pulls { 288 + if r, ok := repoMap[p.RepoDid]; ok { 289 + p.Repo = r 290 + } else { 291 + delete(pulls, pullAt) 446 292 } 447 293 } 448 294 } 449 295 450 - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts)) 451 - if err != nil { 452 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 453 - } 454 - for pullAt, references := range allReferences { 455 - if pull, ok := pulls[pullAt]; ok { 456 - pull.References = references 296 + // collect allLabels for each PR 297 + { 298 + allLabels, err := GetLabels(q, orm.FilterIn("subject", pullAts)) 299 + if err != nil { 300 + return nil, fmt.Errorf("failed to query labels: %w", err) 301 + } 302 + for pullAt, labels := range allLabels { 303 + if pull, ok := pulls[pullAt]; ok { 304 + pull.Labels = labels 305 + } 457 306 } 458 307 } 459 308 460 - orderedByPullId := []*models.Pull{} 309 + orderedById := []*models.Pull{} 461 310 for _, p := range pulls { 462 - orderedByPullId = append(orderedByPullId, p) 311 + orderedById = append(orderedById, p) 463 312 } 464 - sort.Slice(orderedByPullId, func(i, j int) bool { 465 - return orderedByPullId[i].PullId > orderedByPullId[j].PullId 313 + sort.Slice(orderedById, func(i, j int) bool { 314 + return orderedById[i].PullId > orderedById[j].PullId 466 315 }) 467 316 468 - return orderedByPullId, nil 469 - } 470 - 471 - func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 472 - return GetPullsPaginated(e, pagination.Page{}, filters...) 473 - } 474 - 475 - func GetPull(e Execer, filters ...orm.Filter) (*models.Pull, error) { 476 - pulls, err := GetPullsPaginated(e, pagination.Page{Limit: 1}, filters...) 477 - if err != nil { 478 - return nil, err 479 - } 480 - if len(pulls) == 0 { 481 - return nil, sql.ErrNoRows 482 - } 483 - 484 - return pulls[0], nil 317 + return orderedById, nil 485 318 } 486 319 487 320 // mapping from pull -> pull submissions 488 - func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 489 - var conditions []string 490 - var args []any 491 - for _, filter := range filters { 492 - conditions = append(conditions, filter.Condition()) 493 - args = append(args, filter.Arg()...) 494 - } 495 - 496 - whereClause := "" 497 - if conditions != nil { 498 - whereClause = " where " + strings.Join(conditions, " and ") 499 - } 321 + func ListVersions(ctx context.Context, q Execer, pullAts []syntax.ATURI) (map[syntax.ATURI][]models.PullVersion, error) { 322 + filter := orm.FilterIn("pull_at", pullAts) 500 323 501 324 query := fmt.Sprintf(` 502 325 select 326 + pull_at, 503 327 id, 504 - pull_at, 505 - round_number, 506 - patch, 507 - combined, 508 - created, 509 - source_rev, 510 - patch_blob_ref, 511 - patch_blob_mime, 512 - patch_blob_size 513 - from 514 - pull_submissions 515 - %s 516 - order by 517 - round_number asc 518 - `, whereClause) 328 + head, 329 + base, 330 + created 331 + from pull_versions 332 + where %s 333 + order by id asc 334 + `, filter.Condition()) 519 335 520 - rows, err := e.Query(query, args...) 336 + rows, err := q.QueryContext(ctx, query, filter.Arg()...) 521 337 if err != nil { 522 - return nil, err 338 + return nil, fmt.Errorf("failed to query: %w", err) 523 339 } 524 340 defer rows.Close() 525 341 526 - pullMap := make(map[syntax.ATURI][]*models.PullSubmission) 342 + versionsMap := make(map[syntax.ATURI][]models.PullVersion) 527 343 528 344 for rows.Next() { 529 - var submission models.PullSubmission 530 - var submissionCreatedStr string 531 - var submissionSourceRev, submissionCombined sql.Null[string] 532 - var patchBlobRef, patchBlobMime sql.Null[string] 533 - var patchBlobSize sql.Null[int64] 345 + var version models.PullVersion 346 + var pullAt syntax.ATURI 347 + var createdAt string 534 348 err := rows.Scan( 535 - &submission.ID, 536 - &submission.PullAt, 537 - &submission.RoundNumber, 538 - &submission.Patch, 539 - &submissionCombined, 540 - &submissionCreatedStr, 541 - &submissionSourceRev, 542 - &patchBlobRef, 543 - &patchBlobMime, 544 - &patchBlobSize, 349 + &pullAt, 350 + &version.ID, 351 + &version.Head, 352 + &version.Base, 353 + &createdAt, 545 354 ) 546 355 if err != nil { 547 - return nil, err 356 + return nil, fmt.Errorf("scanning row: %w", err) 548 357 } 549 358 550 - if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 551 - submission.Created = t 552 - } 553 - 554 - if submissionSourceRev.Valid { 555 - submission.SourceRev = submissionSourceRev.V 359 + createdTime, err := time.Parse(time.RFC3339, createdAt) 360 + if err != nil { 361 + return nil, fmt.Errorf("parsing created: %w", err) 556 362 } 363 + version.Created = createdTime 557 364 558 - if submissionCombined.Valid { 559 - submission.Combined = submissionCombined.V 560 - } 561 - 562 - if patchBlobRef.Valid { 563 - submission.Blob.Ref = lexutil.LexLink(cid.MustParse(patchBlobRef.V)) 564 - } 565 - 566 - if patchBlobMime.Valid { 567 - submission.Blob.MimeType = patchBlobMime.V 568 - } 569 - 570 - if patchBlobSize.Valid { 571 - submission.Blob.Size = patchBlobSize.V 572 - } 573 - 574 - pullMap[submission.PullAt] = append(pullMap[submission.PullAt], &submission) 365 + versionsMap[pullAt] = append(versionsMap[pullAt], version) 575 366 } 576 - 577 367 if err := rows.Err(); err != nil { 578 - return nil, err 368 + return nil, fmt.Errorf("scanning rows: %w", err) 579 369 } 580 370 581 - // Get comments for all submissions using GetComments 582 - pullAts := slices.Collect(maps.Keys(pullMap)) 583 - comments, err := GetComments(e, orm.FilterIn("subject_uri", pullAts)) 371 + comments, err := GetComments(q, orm.FilterIn("subject_uri", pullAts)) 584 372 if err != nil { 585 373 return nil, fmt.Errorf("failed to get pull comments: %w", err) 586 374 } 587 375 for _, comment := range comments { 588 - if comment.PullRoundIdx != nil { 589 - roundIdx := *comment.PullRoundIdx 590 - if submissions, ok := pullMap[syntax.ATURI(comment.Subject.Uri)]; ok { 591 - if roundIdx < len(submissions) { 592 - submission := submissions[roundIdx] 593 - submission.Comments = append(submission.Comments, comment) 594 - } 376 + if comment.PullRoundIdx == nil { 377 + continue 378 + } 379 + versionIdx := *comment.PullRoundIdx 380 + if versions, ok := versionsMap[syntax.ATURI(comment.Subject.Uri)]; ok { 381 + if versionIdx >= len(versions) { 382 + continue 595 383 } 384 + versions[versionIdx].Comments = append(versions[versionIdx].Comments, comment) 596 385 } 597 386 } 598 387 599 - // sort each one by round number 600 - for _, s := range pullMap { 601 - slices.SortFunc(s, func(a, b *models.PullSubmission) int { 602 - return cmp.Compare(a.RoundNumber, b.RoundNumber) 603 - }) 604 - } 388 + // TODO: reverse-map version.Comments 605 389 606 - return pullMap, nil 390 + return versionsMap, nil 607 391 } 608 392 609 393 // timeframe here is directly passed into the sql query filter, and any 610 394 // timeframe in the past should be negative; e.g.: "-3 months" 611 - func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 395 + func GetPullsByOwnerDid(e Execer, did syntax.DID, timeframe string) ([]models.Pull, error) { 612 396 var pulls []models.Pull 613 397 614 398 rows, err := e.Query(` ··· 683 467 } 684 468 685 469 // use with transaction 686 - func SetPullsState(e Execer, pullState models.PullState, filters ...orm.Filter) error { 470 + func setPullsState(e Execer, pullState models.PullState, filters ...orm.Filter) error { 687 471 var conditions []string 688 472 var args []any 689 473 ··· 707 491 } 708 492 709 493 func ClosePulls(e Execer, filters ...orm.Filter) error { 710 - return SetPullsState(e, models.PullClosed, filters...) 494 + return setPullsState(e, models.PullClosed, filters...) 711 495 } 712 496 713 497 func ReopenPulls(e Execer, filters ...orm.Filter) error { 714 - return SetPullsState(e, models.PullOpen, filters...) 498 + return setPullsState(e, models.PullOpen, filters...) 715 499 } 716 500 717 501 func MergePulls(e Execer, filters ...orm.Filter) error { 718 - return SetPullsState(e, models.PullMerged, filters...) 502 + return setPullsState(e, models.PullMerged, filters...) 719 503 } 720 504 721 505 func AbandonPulls(e Execer, filters ...orm.Filter) error { 722 - return SetPullsState(e, models.PullAbandoned, filters...) 723 - } 724 - 725 - func ResubmitPull( 726 - e Execer, 727 - pullAt syntax.ATURI, 728 - newRoundNumber int, 729 - newPatch string, 730 - combinedPatch string, 731 - newSourceRev string, 732 - blob *lexutil.LexBlob, 733 - ) error { 734 - _, err := e.Exec(` 735 - insert into pull_submissions ( 736 - pull_at, 737 - round_number, 738 - patch, 739 - combined, 740 - source_rev, 741 - patch_blob_ref, 742 - patch_blob_mime, 743 - patch_blob_size 744 - ) 745 - values (?, ?, ?, ?, ?, ?, ?, ?) 746 - `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Ref.String(), blob.MimeType, blob.Size) 747 - 748 - return err 749 - } 750 - 751 - func SetDependentOn(e Execer, dependentOn syntax.ATURI, filters ...orm.Filter) error { 752 - var conditions []string 753 - var args []any 754 - 755 - args = append(args, dependentOn) 756 - 757 - for _, filter := range filters { 758 - conditions = append(conditions, filter.Condition()) 759 - args = append(args, filter.Arg()...) 760 - } 761 - 762 - whereClause := "" 763 - if conditions != nil { 764 - whereClause = " where " + strings.Join(conditions, " and ") 765 - } 766 - 767 - query := fmt.Sprintf("update pulls set dependent_on = ? %s", whereClause) 768 - _, err := e.Exec(query, args...) 769 - 770 - return err 506 + return setPullsState(e, models.PullAbandoned, filters...) 771 507 } 772 508 773 509 func GetPullCount(e Execer, repoDid string) (models.PullCount, error) { ··· 793 529 794 530 return count, nil 795 531 } 796 - 797 - // change-id dependent_on 798 - // 799 - // 4 w ,-------- at_uri(z) (TOP) 800 - // 3 z <----',------- at_uri(y) 801 - // 2 y <-----',------ at_uri(x) 802 - // 1 x <------' nil (BOT) 803 - // 804 - // `w` has no dependents, so it is the top of the stack 805 - // 806 - // this unfortunately does a db query for *each* pull of the stack, 807 - // ideally this would be a recursive query, but in the interest of implementation simplicity, 808 - // we took the less performant route 809 - // 810 - // TODO: make this less bad 811 - func GetStack(e Execer, atUri syntax.ATURI) (models.Stack, error) { 812 - // first get the pull for the given at-uri 813 - pull, err := GetPull(e, orm.FilterEq("at_uri", atUri)) 814 - if err != nil { 815 - return nil, err 816 - } 817 - 818 - // Collect all pulls in the stack by traversing up and down 819 - allPulls := []*models.Pull{pull} 820 - visited := sets.New[syntax.ATURI]() 821 - 822 - // Traverse up to find all dependents 823 - current := pull 824 - for { 825 - dependent, err := GetPull(e, 826 - orm.FilterEq("dependent_on", current.AtUri()), 827 - orm.FilterNotEq("state", models.PullAbandoned), 828 - ) 829 - if err != nil || dependent == nil { 830 - break 831 - } 832 - if visited.Contains(dependent.AtUri()) { 833 - return allPulls, fmt.Errorf("circular dependency detected in stack") 834 - } 835 - allPulls = append(allPulls, dependent) 836 - visited.Insert(dependent.AtUri()) 837 - current = dependent 838 - } 839 - 840 - // Traverse down to find all dependencies 841 - current = pull 842 - for current.DependentOn != nil { 843 - dependency, err := GetPull( 844 - e, 845 - orm.FilterEq("at_uri", current.DependentOn), 846 - orm.FilterNotEq("state", models.PullAbandoned), 847 - ) 848 - 849 - if err != nil { 850 - return allPulls, fmt.Errorf("failed to find parent pull request, stack is malformed, missing PR: %s", current.DependentOn) 851 - } 852 - if visited.Contains(dependency.AtUri()) { 853 - return allPulls, fmt.Errorf("circular dependency detected in stack") 854 - } 855 - allPulls = append(allPulls, dependency) 856 - visited.Insert(dependency.AtUri()) 857 - current = dependency 858 - } 859 - 860 - // sort the list: find the top and build ordered list 861 - atUriMap := make(map[syntax.ATURI]*models.Pull, len(allPulls)) 862 - dependentMap := make(map[syntax.ATURI]*models.Pull, len(allPulls)) 863 - 864 - for _, p := range allPulls { 865 - atUriMap[p.AtUri()] = p 866 - if p.DependentOn != nil { 867 - dependentMap[*p.DependentOn] = p 868 - } 869 - } 870 - 871 - // the top of the stack is the pull that no other pull depends on 872 - var topPull *models.Pull 873 - for _, maybeTop := range allPulls { 874 - if _, ok := dependentMap[maybeTop.AtUri()]; !ok { 875 - topPull = maybeTop 876 - break 877 - } 878 - } 879 - 880 - pulls := []*models.Pull{} 881 - for { 882 - pulls = append(pulls, topPull) 883 - if topPull.DependentOn != nil { 884 - if next, ok := atUriMap[*topPull.DependentOn]; ok { 885 - topPull = next 886 - } else { 887 - return pulls, fmt.Errorf("failed to find parent pull request, stack is malformed") 888 - } 889 - } else { 890 - break 891 - } 892 - } 893 - 894 - return pulls, nil 895 - } 896 - 897 - func GetAbandonedPulls(e Execer, atUri syntax.ATURI) ([]*models.Pull, error) { 898 - stack, err := GetStack(e, atUri) 899 - if err != nil { 900 - return nil, err 901 - } 902 - 903 - var abandoned []*models.Pull 904 - for _, p := range stack { 905 - if p.State == models.PullAbandoned { 906 - abandoned = append(abandoned, p) 907 - } 908 - } 909 - 910 - return abandoned, nil 911 - }
+15 -14
appview/indexer/pulls/indexer.go
··· 20 20 "tangled.org/core/appview/indexer/base36" 21 21 bleveutil "tangled.org/core/appview/indexer/bleve" 22 22 "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/pagination" 23 24 tlog "tangled.org/core/log" 24 25 ) 25 26 ··· 164 165 165 166 func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { 166 167 l := tlog.FromContext(ctx) 167 - 168 - pulls, err := db.GetPulls(e) 169 - if err != nil { 170 - return err 171 - } 172 - count := len(pulls) 173 - err = ix.Index(ctx, pulls...) 174 - if err != nil { 175 - return err 176 - } 168 + count := 0 169 + err := pagination.IterateAll( 170 + func(page pagination.Page) ([]*models.Pull, error) { 171 + return db.GetPullsPaginated(ctx, e, page) 172 + }, 173 + func(pulls []*models.Pull) error { 174 + count += len(pulls) 175 + return ix.Index(ctx, pulls...) 176 + }, 177 + ) 177 178 l.Info("pulls indexed", "count", count) 178 179 return err 179 180 } ··· 194 195 195 196 func makePullData(pull *models.Pull) *pullData { 196 197 return &pullData{ 197 - ID: int64(pull.ID), 198 - RepoDid: string(pull.RepoDid), 199 - PullID: pull.PullId, 198 + ID: pull.ID, 199 + RepoDid: pull.RepoDid.String(), 200 + PullID: int(pull.PullId), 200 201 Title: pull.Title, 201 202 Body: pull.Body, 202 203 State: pull.State.String(), 203 - AuthorDid: pull.OwnerDid, 204 + AuthorDid: pull.OwnerDid.String(), 204 205 Labels: pull.Labels.LabelNames(), 205 206 LabelValues: pull.Labels.LabelNameValues(), 206 207 }
+86 -77
appview/ingester.go
··· 1 1 package appview 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "database/sql" 7 6 "encoding/json" 8 7 "errors" 9 8 "fmt" 10 - "io" 11 9 "log/slog" 12 10 "net/http" 13 11 "net/url" 14 12 "slices" 15 13 "strings" 14 + "sync" 16 15 17 16 "time" 18 17 ··· 1449 1448 } 1450 1449 1451 1450 func (i *Ingester) ingestPull(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { 1452 - did := e.Did 1453 - rkey := e.Commit.RKey 1451 + did := syntax.DID(e.Did) 1452 + rkey := syntax.RecordKey(e.Commit.RKey) 1453 + cid := syntax.CID(e.Commit.CID) 1454 1454 1455 1455 var err error 1456 1456 ··· 1466 1466 return err 1467 1467 } 1468 1468 1469 - ownerId, err := i.IdResolver.ResolveIdent(ctx, did) 1470 - if err != nil { 1471 - l.Error("failed to resolve did", "err", err) 1472 - return err 1473 - } 1469 + versions, err := func() ([]models.PullVersion, error) { 1470 + if len(record.Versions) > 0 { 1471 + versions := make([]models.PullVersion, len(record.Versions)) 1472 + var err error 1473 + for i, v := range record.Versions { 1474 + versions[i], err = models.PullVersionFromRecord(i, v) 1475 + if err != nil { 1476 + return nil, fmt.Errorf("versions[%d]: %w", i, err) 1477 + } 1478 + } 1479 + return versions, nil 1480 + } 1474 1481 1475 - // go through and fetch all blobs in parallel 1476 - blobs := make([]io.Reader, len(record.Rounds)) 1482 + if len(record.Rounds) == 0 { 1483 + return nil, nil 1484 + } 1477 1485 1478 - g, gctx := errgroup.WithContext(ctx) 1486 + ownerId, err := i.IdResolver.Directory().LookupDID(ctx, did) 1487 + if err != nil { 1488 + return nil, fmt.Errorf("failed to resolve did: %w", err) 1489 + } 1479 1490 1480 - for idx, b := range record.Rounds { 1481 - g.Go(func() error { 1482 - // for some reason, a blob is empty 1483 - if b.PatchBlob == nil { 1484 - return fmt.Errorf("missing patchBlob in round %d", idx) 1485 - } 1491 + // go through and fetch all blobs in parallel 1492 + versions := make([]models.PullVersion, len(record.Rounds)) 1493 + var mu sync.Mutex 1486 1494 1487 - ownerPds := ownerId.PDSEndpoint() 1488 - url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 1489 - q := url.Query() 1490 - q.Set("cid", b.PatchBlob.Ref.String()) 1491 - q.Set("did", did) 1492 - url.RawQuery = q.Encode() 1495 + g, gctx := errgroup.WithContext(ctx) 1493 1496 1494 - req, err := http.NewRequestWithContext(gctx, http.MethodGet, url.String(), nil) 1495 - if err != nil { 1496 - l.Error("failed to create request") 1497 - return err 1498 - } 1499 - req.Header.Set("Content-Type", "application/json") 1497 + for idx, b := range record.Rounds { 1498 + g.Go(func() error { 1499 + // for some reason, a blob is empty 1500 + if b.PatchBlob == nil { 1501 + return fmt.Errorf("missing patchBlob in round %d", idx) 1502 + } 1500 1503 1501 - resp, err := http.DefaultClient.Do(req) 1502 - if err != nil { 1503 - l.Error("failed to make request") 1504 - return err 1505 - } 1506 - defer resp.Body.Close() 1504 + ownerPds := ownerId.PDSEndpoint() 1505 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 1506 + q := url.Query() 1507 + q.Set("cid", b.PatchBlob.Ref.String()) 1508 + q.Set("did", did.String()) 1509 + url.RawQuery = q.Encode() 1507 1510 1508 - var buf bytes.Buffer 1509 - if _, err := io.Copy(&buf, io.LimitReader(resp.Body, 16<<20)); err != nil { 1510 - return fmt.Errorf("failed to read blob in round %d: %w", idx, err) 1511 - } 1512 - blobs[idx] = &buf 1511 + req, err := http.NewRequestWithContext(gctx, http.MethodGet, url.String(), nil) 1512 + if err != nil { 1513 + return fmt.Errorf("versions[%d]: failed to create request: %w", idx, err) 1514 + } 1515 + req.Header.Set("Content-Type", "application/json") 1516 + 1517 + resp, err := http.DefaultClient.Do(req) 1518 + if err != nil { 1519 + return fmt.Errorf("versions[%d]: failed to make request: %w", idx, err) 1520 + } 1521 + defer resp.Body.Close() 1522 + 1523 + version, err := models.PullVersionFromLegacy(idx, b, resp.Body) 1524 + if err != nil { 1525 + return fmt.Errorf("versions[%d]: %w", idx, err) 1526 + } 1527 + 1528 + mu.Lock() 1529 + versions[idx] = version 1530 + mu.Unlock() 1513 1531 1514 - return nil 1515 - }) 1516 - } 1532 + return nil 1533 + }) 1534 + } 1535 + 1536 + if err := g.Wait(); err != nil { 1537 + return nil, err 1538 + } 1517 1539 1518 - if err := g.Wait(); err != nil { 1519 - return err 1540 + return versions, nil 1541 + }() 1542 + if err != nil { 1543 + return fmt.Errorf("failed pares pull versions: %w", err) 1520 1544 } 1521 1545 1522 - pull, err := models.PullFromRecord(did, rkey, record, blobs) 1546 + pull, err := models.PullFromRecord(did, rkey, cid, record, versions) 1523 1547 if err != nil { 1524 1548 return fmt.Errorf("failed to parse pull from record: %w", err) 1525 1549 } 1526 1550 if err := pull.Validate(); err != nil { 1527 1551 return fmt.Errorf("failed to validate pull: %w", err) 1528 1552 } 1529 - if pull.DependentOn != nil { 1530 - if err := func() error { 1531 - dependentPull, err := db.GetPull( 1532 - i.Db, 1533 - orm.FilterEq("dependent_on", pull.DependentOn.String()), 1534 - ) 1535 - if errors.Is(err, sql.ErrNoRows) { 1536 - return nil 1537 - } 1538 - if err != nil { 1539 - return fmt.Errorf("failed to fetch pulls with same dependency: %w", err) 1540 - } 1541 - if dependentPull.AtUri() == pull.AtUri() { 1542 - return nil 1543 - } 1544 - return fmt.Errorf("another pull already depends on %s, which would form a DAG, this is presently disallowed", pull.DependentOn.String()) 1545 - }(); err != nil { 1546 - return fmt.Errorf("failed to validate pull stack: %w", err) 1547 - } 1553 + 1554 + var references []syntax.ATURI 1555 + if pull.Body != "" { 1556 + _, references = i.MentionsResolver.Resolve(ctx, pull.Body) 1548 1557 } 1549 1558 1550 1559 tx, err := i.Db.BeginTx(ctx, nil) ··· 1554 1563 } 1555 1564 defer tx.Rollback() 1556 1565 1557 - err = db.PutPull(tx, pull) 1566 + err = db.PutPull(ctx, tx, pull, references) 1558 1567 if err != nil { 1559 1568 l.Error("failed to create pull", "err", err) 1560 1569 return err ··· 1626 1635 type stateIngestSpec struct { 1627 1636 subjectNSID string 1628 1637 parse func(did, rkey string, raw json.RawMessage) (models.StateRecord, error) 1629 - findSubject func(e db.Execer, subject syntax.ATURI) (repo *models.Repo, authorDid string, found bool, err error) 1638 + findSubject func(ctx context.Context, e db.Execer, subject syntax.ATURI) (repo *models.Repo, authorDid string, found bool, err error) 1630 1639 put func(tx *sql.Tx, rec models.StateRecord) (syntax.ATURI, error) 1631 1640 resolve func(tx *sql.Tx, subject syntax.ATURI) error 1632 1641 recompute func(tx *sql.Tx, subject syntax.ATURI) error ··· 1642 1651 } 1643 1652 return models.IssueStateFromRecord(did, rkey, record) 1644 1653 }, 1645 - findSubject: func(e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1654 + findSubject: func(ctx context.Context, e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1646 1655 issues, err := db.GetIssues(e, orm.FilterEq("at_uri", subject)) 1647 1656 if err != nil { 1648 1657 return nil, "", false, err ··· 1667 1676 } 1668 1677 return models.PullStatusFromRecord(did, rkey, record) 1669 1678 }, 1670 - findSubject: func(e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1671 - pulls, err := db.GetPulls(e, orm.FilterEq("at_uri", subject)) 1679 + findSubject: func(ctx context.Context, e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1680 + pull, err := db.GetPull(ctx, e, orm.FilterEq("at_uri", subject)) 1672 1681 if err != nil { 1673 1682 return nil, "", false, err 1674 1683 } 1675 - if len(pulls) != 1 || pulls[0].Repo == nil { 1684 + if pull.Repo == nil { 1676 1685 return nil, "", false, nil 1677 1686 } 1678 - return pulls[0].Repo, pulls[0].OwnerDid, true, nil 1687 + return pull.Repo, pull.OwnerDid.String(), true, nil 1679 1688 }, 1680 1689 put: db.PutPullStatus, 1681 1690 resolve: db.ResolvePullStatus, ··· 1709 1718 return fmt.Errorf("state subject is not %s: %s", spec.subjectNSID, rec.Subject) 1710 1719 } 1711 1720 1712 - repo, authorDid, found, err := spec.findSubject(i.Db, rec.Subject) 1721 + repo, authorDid, found, err := spec.findSubject(ctx, i.Db, rec.Subject) 1713 1722 if err != nil { 1714 1723 return fmt.Errorf("failed to look up state subject: %w", err) 1715 1724 } ··· 2123 2132 return nil 2124 2133 } 2125 2134 2126 - func (i *Ingester) findLabelSubjectRepo(subject syntax.ATURI) (*models.Repo, bool, error) { 2135 + func (i *Ingester) findLabelSubjectRepo(ctx context.Context, subject syntax.ATURI) (*models.Repo, bool, error) { 2127 2136 var spec stateIngestSpec 2128 2137 switch subject.Collection() { 2129 2138 case tangled.RepoIssueNSID: ··· 2133 2142 default: 2134 2143 return nil, false, fmt.Errorf("unsupported label subject: %s", subject.Collection()) 2135 2144 } 2136 - repo, _, found, err := spec.findSubject(i.Db, subject) 2145 + repo, _, found, err := spec.findSubject(ctx, i.Db, subject) 2137 2146 return repo, found, err 2138 2147 } 2139 2148 ··· 2148 2157 return i.parkStateRecord(ctx, did, rkey, tangled.LabelOpNSID, subject, raw, l) 2149 2158 } 2150 2159 2151 - repo, found, err := i.findLabelSubjectRepo(subject) 2160 + repo, found, err := i.findLabelSubjectRepo(ctx, subject) 2152 2161 if err != nil { 2153 2162 return err 2154 2163 }
+3 -3
appview/labels/labels.go
··· 282 282 } 283 283 } 284 284 if subject.Collection() == tangled.RepoPullNSID { 285 - pulls, err := db.GetPulls(l.db, orm.FilterEq("at_uri", subjectUri)) 286 - if err == nil && len(pulls) == 1 { 287 - l.notifier.NewPullLabelOp(r.Context(), syntax.DID(did), pulls[0], validLabelOps) 285 + pull, err := db.GetPull(r.Context(), l.db, orm.FilterEq("at_uri", subjectUri)) 286 + if err == nil { 287 + l.notifier.NewPullLabelOp(r.Context(), syntax.DID(did), pull, validLabelOps) 288 288 } 289 289 } 290 290
-44
appview/middleware/middleware.go
··· 368 368 } 369 369 } 370 370 371 - // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 372 - func (mw Middleware) ResolvePull() middlewareFunc { 373 - return func(next http.Handler) http.Handler { 374 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 375 - l := mw.logger.With("middleware", "ResolvePull") 376 - f, err := mw.repoResolver.Resolve(r) 377 - if err != nil { 378 - l.Error("failed to fully resolve repo", "err", err) 379 - w.WriteHeader(http.StatusNotFound) 380 - mw.pages.ErrorKnot404(w) 381 - return 382 - } 383 - 384 - prId := chi.URLParam(r, "pull") 385 - prIdInt, err := strconv.Atoi(prId) 386 - if err != nil { 387 - l.Error("failed to parse pr id", "err", err) 388 - mw.pages.Error404(w) 389 - return 390 - } 391 - 392 - pr, err := db.GetPull(mw.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prIdInt)) 393 - if err != nil { 394 - l.Error("failed to get pull and comments", "err", err) 395 - mw.pages.Error404(w) 396 - return 397 - } 398 - 399 - ctx := context.WithValue(r.Context(), "pull", pr) 400 - 401 - stack, err := db.GetStack(mw.db, pr.AtUri()) 402 - if err != nil { 403 - l.Error("failed to get stack", "err", err) 404 - mw.pages.Error404(w) 405 - return 406 - } 407 - 408 - ctx = context.WithValue(ctx, "stack", stack) 409 - 410 - next.ServeHTTP(w, r.WithContext(ctx)) 411 - }) 412 - } 413 - } 414 - 415 371 // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 416 372 func (mw Middleware) ResolveIssue(next http.Handler) http.Handler { 417 373 l := mw.logger.With("middleware", "ResolveIssue")
+3 -3
appview/migration/backfill_entity_state_test.go
··· 214 214 } 215 215 pull := &models.Pull{ 216 216 RepoDid: syntax.DID(repoDid), 217 - OwnerDid: author, 218 - Rkey: rkey, 217 + OwnerDid: syntax.DID(author), 218 + Rkey: syntax.RecordKey(rkey), 219 219 Title: "title", 220 220 Body: "body", 221 221 TargetBranch: "main", 222 222 State: models.PullOpen, 223 223 } 224 - if err := db.PutPull(tx, pull); err != nil { 224 + if err := db.PutPull(t.Context(), tx, pull, nil); err != nil { 225 225 t.Fatalf("PutPull: %v", err) 226 226 } 227 227 if err := tx.Commit(); err != nil {
+19
appview/models/entity_state.go
··· 90 90 }, nil 91 91 } 92 92 93 + func AsPullStatusRecord(subject syntax.ATURI, value StateValue, createdAt time.Time) (tangled.RepoPullStatus, error) { 94 + var variant string 95 + switch value { 96 + case StateOpen: 97 + variant = tangled.RepoPullStatusOpen 98 + case StateClosed: 99 + variant = tangled.RepoPullStatusClosed 100 + case StateMerged: 101 + variant = tangled.RepoPullStatusMerged 102 + default: 103 + return tangled.RepoPullStatus{}, fmt.Errorf("invalid pull status: %q", value) 104 + } 105 + return tangled.RepoPullStatus{ 106 + Pull: subject.String(), 107 + Status: variant, 108 + CreatedAt: createdAt.UTC().Format(syntax.AtprotoDatetimeLayout), 109 + }, nil 110 + } 111 + 93 112 func AsPullStatusRecords(subjects []syntax.ATURI, value StateValue, createdAt time.Time) ([]tangled.RepoPullStatus, error) { 94 113 var variant string 95 114 switch value {
+167 -368
appview/models/pull.go
··· 3 3 import ( 4 4 "bytes" 5 5 "compress/gzip" 6 + "encoding/hex" 6 7 "fmt" 7 8 "io" 8 - "log" 9 + "maps" 9 10 "slices" 10 11 "strings" 11 12 "time" ··· 13 14 "tangled.org/core/api/tangled" 14 15 "tangled.org/core/appview/pages/markup/sanitizer" 15 16 "tangled.org/core/patchutil" 16 - "tangled.org/core/types" 17 17 18 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 - lexutil "github.com/bluesky-social/indigo/lex/util" 20 19 ) 21 20 22 21 type PullState int ··· 57 56 } 58 57 59 58 type Pull struct { 60 - // ids 61 - ID int 62 - PullId int 63 - 64 - // at ids 59 + ID int64 // appview-local PR id. Used for quick referencing 60 + OwnerDid syntax.DID 61 + Rkey syntax.RecordKey 62 + Cid syntax.CID 65 63 RepoDid syntax.DID 66 - OwnerDid string 67 - Rkey string 64 + PullId int64 68 65 69 - // content 70 66 Title string 71 67 Body string 72 68 TargetBranch string 73 - State PullState 74 - Submissions []*PullSubmission 75 - Mentions []syntax.DID 76 - References []syntax.ATURI 69 + SourceRepo syntax.DID 70 + SourceBranch *string 71 + Versions []PullVersion 72 + Created time.Time 77 73 78 - // stacking 79 - DependentOn *syntax.ATURI 80 - 81 - // meta 82 - Created time.Time 83 - PullSource *PullSource 74 + State PullState 84 75 85 76 // optionally, populate this when querying for reverse mappings 86 77 Labels LabelState 87 78 Repo *Repo 88 79 } 89 80 90 - func (p *Pull) SourceRepoDid() syntax.DID { 91 - if p.PullSource != nil && p.PullSource.RepoDid != nil { 92 - return *p.PullSource.RepoDid 93 - } 94 - return p.RepoDid 81 + func (p *Pull) AtUri() syntax.ATURI { 82 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 95 83 } 96 84 97 - // NOTE: This method does not include patch blob in returned atproto record 98 - func (p Pull) AsRecord() tangled.RepoPull { 99 - mentions := make([]string, len(p.Mentions)) 100 - for i, did := range p.Mentions { 101 - mentions[i] = string(did) 85 + func (p *Pull) AsRecord() tangled.RepoPull { 86 + sourceRepo := p.SourceRepo.String() 87 + var sourceBranch string 88 + if p.SourceBranch != nil { 89 + sourceBranch = *p.SourceBranch 102 90 } 103 - references := make([]string, len(p.References)) 104 - for i, uri := range p.References { 105 - references[i] = string(uri) 106 - } 107 - 108 - rounds := make([]*tangled.RepoPull_Round, len(p.Submissions)) 109 - for i, submission := range p.Submissions { 110 - rounds[i] = submission.AsRecord() 111 - } 112 - 113 - var dependentOn *string 114 - if p.DependentOn != nil { 115 - x := p.DependentOn.String() 116 - dependentOn = &x 91 + versions := make([]*tangled.RepoPull_Version, len(p.Versions)) 92 + for i, v := range p.Versions { 93 + var base *string 94 + if v.Base != "" { 95 + base = &v.Base 96 + } 97 + versions[i] = &tangled.RepoPull_Version{ 98 + Base: base, 99 + Head: v.Head, 100 + CreatedAt: v.Created.Format(time.RFC3339), 101 + } 117 102 } 118 - 119 103 return tangled.RepoPull{ 120 - Title: p.Title, 121 - Body: &p.Body, 122 - Mentions: mentions, 123 - References: references, 124 - CreatedAt: p.Created.Format(time.RFC3339), 104 + Title: p.Title, 105 + Body: &p.Body, 125 106 Target: &tangled.RepoPull_Target{ 126 - Repo: string(p.RepoDid), 107 + Repo: p.RepoDid.String(), 127 108 Branch: p.TargetBranch, 128 109 }, 129 - Rounds: rounds, 130 - Source: p.PullSource.AsRecord(), 131 - DependentOn: dependentOn, 110 + Source: &tangled.RepoPull_Source{ 111 + Repo: &sourceRepo, 112 + Branch: sourceBranch, 113 + }, 114 + Versions: versions, 115 + CreatedAt: p.Created.Format(time.RFC3339), 132 116 } 133 117 } 134 118 135 - func (pull *Pull) Validate() error { 136 - if len(pull.Submissions) == 0 { 137 - return fmt.Errorf("pull must have at least one submission") 119 + func (p *Pull) Validate() error { 120 + if len(p.Versions) == 0 { 121 + return fmt.Errorf("pull must have at least one version") 138 122 } 139 123 140 - latestSubmission := pull.LatestSubmission() 141 - if latestSubmission == nil { 142 - return fmt.Errorf("pull must have a valid latest submission") 124 + if p.Title == "" { 125 + return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") 126 + } 127 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(p.Title)); st == "" { 128 + return fmt.Errorf("title is empty after HTML sanitization") 143 129 } 144 130 145 - isFormatPatch := patchutil.IsFormatPatch(latestSubmission.Patch) 146 - 147 - // title and body can only be empty if the patch is a format-patch 148 - if !isFormatPatch { 149 - if pull.Title == "" { 150 - return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") 131 + for i, version := range p.Versions { 132 + if err := version.Validate(); err != nil { 133 + return fmt.Errorf("versions[%d]: %w", i, err) 151 134 } 152 - 153 - if pull.Body == "" { 154 - return fmt.Errorf("pull body is empty (required for non-format-patch pulls)") 155 - } 156 - 157 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(pull.Title)); st == "" { 158 - return fmt.Errorf("title is empty after HTML sanitization") 159 - } 135 + } 136 + return nil 137 + } 160 138 161 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(pull.Body)); sb == "" { 162 - return fmt.Errorf("body is empty after HTML sanitization") 163 - } 139 + func (v *PullVersion) Validate() error { 140 + if v.Base != "" && !IsHash(v.Base) { 141 + return fmt.Errorf("invalid base commit id: %q", v.Base) 142 + } 143 + if !IsHash(v.Head) { 144 + return fmt.Errorf("invalid head commit id: %q", v.Head) 164 145 } 165 146 return nil 166 147 } 167 148 168 - func PullFromRecord(did, rkey string, record tangled.RepoPull, blobs []io.Reader) (*Pull, error) { 149 + func PullFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.RepoPull, versions []PullVersion) (*Pull, error) { 169 150 created, err := time.Parse(time.RFC3339, record.CreatedAt) 170 151 if err != nil { 171 152 return nil, fmt.Errorf("invalid createdAt: %w", err) ··· 176 157 body = *record.Body 177 158 } 178 159 179 - var mentions []syntax.DID 180 - for _, m := range record.Mentions { 181 - if did, err := syntax.ParseDID(m); err == nil { 182 - mentions = append(mentions, did) 183 - } 184 - } 160 + // var mentions []syntax.DID 161 + // for _, m := range record.Mentions { 162 + // if did, err := syntax.ParseDID(m); err == nil { 163 + // mentions = append(mentions, did) 164 + // } 165 + // } 185 166 186 167 var targetRepoDid syntax.DID 187 168 var targetBranch string ··· 194 175 targetBranch = record.Target.Branch 195 176 } 196 177 197 - var pullSource *PullSource 178 + var sourceRepo syntax.DID 179 + var sourceBranch *string 198 180 if record.Source != nil { 199 - pullSource = &PullSource{ 200 - Branch: record.Source.Branch, 201 - } 202 - 203 181 if record.Source.Repo != nil { 204 182 did, err := syntax.ParseDID(*record.Source.Repo) 205 183 if err != nil { 206 184 return nil, fmt.Errorf("invalid source.repo did: %w", err) 207 185 } 208 - pullSource.RepoDid = &did 186 + sourceRepo = did 209 187 } 210 - } 211 - 212 - var dependentOn *syntax.ATURI 213 - if record.DependentOn != nil { 214 - uri, err := syntax.ParseATURI(*record.DependentOn) 215 - if err != nil { 216 - return nil, fmt.Errorf("invalid dependentOn aturi: %w", err) 188 + if record.Source.Branch != "" { 189 + sourceBranch = new(string) 190 + *sourceBranch = record.Source.Branch 217 191 } 218 - dependentOn = &uri 219 192 } 220 193 221 - var submissions []*PullSubmission 222 - for i, s := range record.Rounds { 223 - var blob io.Reader 224 - if i < len(blobs) { 225 - blob = blobs[i] 226 - } 227 - submission, err := PullSubmissionFromRecord(did, rkey, i, s, blob) 228 - if err != nil { 229 - return nil, fmt.Errorf("invalid pull round at index %d: %w", i, err) 230 - } 231 - submissions = append(submissions, submission) 232 - } 194 + return &Pull{ 195 + ID: -1, // uninitialized 196 + OwnerDid: did, 197 + Rkey: rkey, 198 + Cid: cid, 199 + RepoDid: targetRepoDid, 200 + PullId: 0, // uninitialized 233 201 234 - return &Pull{ 235 - RepoDid: targetRepoDid, 236 - OwnerDid: did, 237 - Rkey: rkey, 238 202 Title: record.Title, 239 203 Body: body, 240 204 TargetBranch: targetBranch, 241 - PullSource: pullSource, 242 - State: PullOpen, 243 - Submissions: submissions, 205 + SourceRepo: sourceRepo, 206 + SourceBranch: sourceBranch, 207 + Versions: versions, 244 208 Created: created, 245 - DependentOn: dependentOn, 209 + State: PullOpen, // default to open 246 210 }, nil 247 211 } 248 212 249 - func PullSubmissionFromRecord(did, rkey string, roundNumber int, round *tangled.RepoPull_Round, blob io.Reader) (*PullSubmission, error) { 250 - created, err := time.Parse(time.RFC3339, round.CreatedAt) 213 + func PullVersionFromRecord(idx int, record *tangled.RepoPull_Version) (PullVersion, error) { 214 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 251 215 if err != nil { 252 - return nil, fmt.Errorf("invalid createdAt: %w", err) 216 + return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) 253 217 } 254 218 255 - var patch, sourceRev string 256 - if blob != nil { 257 - p, err := extractGzip(blob) 258 - if err != nil { 259 - return nil, fmt.Errorf("failed to extract gzip: %w", err) 260 - } 261 - patch = p 262 - if patchutil.IsFormatPatch(p) { 263 - patches, err := patchutil.ExtractPatches(p) 264 - if err != nil { 265 - return nil, fmt.Errorf("failed to extract patches: %w", err) 266 - } 219 + var base string 220 + if record.Base != nil { 221 + base = *record.Base 222 + } 223 + return PullVersion{ 224 + ID: idx, 225 + Base: base, 226 + Head: record.Head, 227 + Created: created, 228 + }, nil 229 + } 230 + 231 + func PullVersionFromLegacy(idx int, record *tangled.RepoPull_Round, reader io.Reader) (PullVersion, error) { 232 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 233 + if err != nil { 234 + return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) 235 + } 267 236 268 - for _, part := range patches { 269 - sourceRev = part.SHA 270 - } 271 - } 237 + patch, err := extractGzip(reader) 238 + if err != nil { 239 + return PullVersion{}, fmt.Errorf("failed to extract gzip: %w", err) 240 + } 241 + if !patchutil.IsFormatPatch(patch) { 242 + return PullVersion{}, fmt.Errorf("only format-patch patch is supported") 272 243 } 273 244 274 - return &PullSubmission{ 275 - PullAt: syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoPullNSID, rkey)), 276 - RoundNumber: roundNumber, 277 - Blob: *round.PatchBlob, 278 - Created: created, 279 - Patch: patch, 280 - SourceRev: sourceRev, 245 + var sourceRev string 246 + patches, err := patchutil.ExtractPatches(patch) 247 + if err != nil { 248 + return PullVersion{}, fmt.Errorf("failed to extract patches: %w", err) 249 + } 250 + for _, part := range patches { 251 + sourceRev = part.SHA 252 + } 253 + if sourceRev == "" { 254 + return PullVersion{}, fmt.Errorf("source rev is missing") 255 + } 256 + return PullVersion{ 257 + ID: idx, 258 + Base: "", 259 + Head: sourceRev, 260 + Created: created, 281 261 }, nil 282 262 } 283 263 ··· 304 284 } 305 285 } 306 286 307 - type PullSubmission struct { 308 - // ids 309 - ID int 310 - 311 - // at ids 312 - PullAt syntax.ATURI 313 - 314 - // content 315 - RoundNumber int 316 - Blob lexutil.LexBlob 317 - Patch string 318 - Combined string 319 - Comments []Comment 320 - SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 321 - 322 - // meta 287 + type PullVersion struct { 288 + ID int 289 + Head string // head commit ID 290 + Base string // base commit ID (for combined interdiff) 323 291 Created time.Time 292 + 293 + // reverse mappings 294 + Comments []Comment 324 295 } 325 296 326 297 func (p *Pull) TotalComments() int { 327 298 total := 0 328 - for _, s := range p.Submissions { 299 + for _, s := range p.Versions { 329 300 total += len(s.Comments) 330 301 } 331 302 return total 332 303 } 333 304 334 - func (p *Pull) LastRoundNumber() int { 335 - return len(p.Submissions) - 1 305 + func (p *Pull) GetVersion(id int) (PullVersion, bool) { 306 + for _, version := range p.Versions { 307 + if version.ID == id { 308 + return version, true 309 + } 310 + } 311 + return PullVersion{}, false 336 312 } 337 313 338 - func (p *Pull) LatestSubmission() *PullSubmission { 339 - return p.Submissions[p.LastRoundNumber()] 314 + func (p *Pull) LatestVersionNumber() int { 315 + return len(p.Versions) - 1 340 316 } 341 317 342 - func (p *Pull) LatestPatch() string { 343 - return p.LatestSubmission().Patch 318 + func (p *Pull) LatestVersion() PullVersion { 319 + return p.Versions[p.LatestVersionNumber()] 344 320 } 345 321 346 322 func (p *Pull) LatestSha() string { 347 - return p.LatestSubmission().SourceRev 348 - } 349 - 350 - func (p *Pull) AtUri() syntax.ATURI { 351 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 352 - } 353 - 354 - func (p *Pull) IsPatchBased() bool { 355 - return p.PullSource == nil 356 - } 357 - 358 - func (p *Pull) IsBranchBased() bool { 359 - if p.PullSource != nil { 360 - if p.PullSource.RepoDid != nil { 361 - return *p.PullSource.RepoDid == p.RepoDid 362 - } 363 - // no repo specified 364 - return true 365 - } 366 - return false 323 + return p.LatestVersion().Head 367 324 } 368 325 369 326 func (p *Pull) IsForkBased() bool { 370 - if p.PullSource != nil { 371 - if p.PullSource.RepoDid != nil { 372 - // make sure repos are different 373 - return *p.PullSource.RepoDid != p.RepoDid 374 - } 375 - } 376 - return false 327 + return p.RepoDid != p.SourceRepo 377 328 } 378 329 379 330 func (p *Pull) Participants() []syntax.DID { 380 - participantSet := make(map[syntax.DID]struct{}) 381 - participants := []syntax.DID{} 331 + participants := make(map[syntax.DID]struct{}) 382 332 383 - addParticipant := func(did syntax.DID) { 384 - if _, exists := participantSet[did]; !exists { 385 - participantSet[did] = struct{}{} 386 - participants = append(participants, did) 387 - } 388 - } 389 - 390 - addParticipant(syntax.DID(p.OwnerDid)) 333 + participants[p.OwnerDid] = struct{}{} 391 334 392 - for _, s := range p.Submissions { 393 - for _, sp := range s.Participants() { 394 - addParticipant(syntax.DID(sp)) 335 + for _, v := range p.Versions { 336 + for _, sp := range v.Participants() { 337 + participants[sp] = struct{}{} 395 338 } 396 339 } 397 340 398 - return participants 341 + return slices.Collect(maps.Keys(participants)) 399 342 } 400 343 401 - func (s PullSubmission) IsFormatPatch() bool { 402 - return patchutil.IsFormatPatch(s.Patch) 403 - } 404 - 405 - func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 406 - patches, err := patchutil.ExtractPatches(s.Patch) 407 - if err != nil { 408 - log.Println("error extracting patches from submission:", err) 409 - return []types.FormatPatch{} 410 - } 411 - 412 - return patches 413 - } 414 - 415 - // empty if invalid, not otherwise 416 - func (s PullSubmission) ChangeId() string { 417 - patches := s.AsFormatPatch() 418 - if len(patches) != 1 { 419 - return "" 420 - } 421 - 422 - c, err := patches[0].ChangeId() 423 - if err != nil { 424 - return "" 425 - } 426 - 427 - return c 428 - } 429 - 430 - func (s *PullSubmission) Participants() []string { 431 - participantSet := make(map[string]struct{}) 432 - participants := []string{} 433 - 434 - addParticipant := func(did string) { 435 - if _, exists := participantSet[did]; !exists { 436 - participantSet[did] = struct{}{} 437 - participants = append(participants, did) 438 - } 439 - } 440 - 441 - addParticipant(s.PullAt.Authority().String()) 344 + func (s *PullVersion) Participants() []syntax.DID { 345 + participants := make(map[syntax.DID]struct{}) 442 346 443 347 for _, c := range s.Comments { 444 - addParticipant(c.Did.String()) 445 - } 446 - 447 - return participants 448 - } 449 - 450 - func (s PullSubmission) CombinedPatch() string { 451 - if s.Combined == "" { 452 - return s.Patch 453 - } 454 - 455 - return s.Combined 456 - } 457 - 458 - func (s *PullSubmission) GetBlob() *lexutil.LexBlob { 459 - if !s.Blob.Ref.Defined() { 460 - return nil 348 + participants[c.Did] = struct{}{} 461 349 } 462 350 463 - return &s.Blob 464 - } 465 - 466 - func (s *PullSubmission) AsRecord() *tangled.RepoPull_Round { 467 - return &tangled.RepoPull_Round{ 468 - CreatedAt: s.Created.Format(time.RFC3339), 469 - PatchBlob: s.GetBlob(), 470 - } 471 - } 472 - 473 - type Stack []*Pull 474 - 475 - // position of this pull in the stack 476 - func (stack Stack) Position(pull *Pull) int { 477 - return slices.IndexFunc(stack, func(p *Pull) bool { 478 - return p.AtUri() == pull.AtUri() 479 - }) 480 - } 481 - 482 - // all pulls below this pull (including self) in this stack 483 - // 484 - // nil if this pull does not belong to this stack 485 - func (stack Stack) Below(pull *Pull) Stack { 486 - position := stack.Position(pull) 487 - 488 - if position < 0 { 489 - return nil 490 - } 491 - 492 - return stack[position:] 493 - } 494 - 495 - // all pulls below this pull (excluding self) in this stack 496 - func (stack Stack) StrictlyBelow(pull *Pull) Stack { 497 - below := stack.Below(pull) 498 - 499 - if len(below) > 0 { 500 - return below[1:] 501 - } 502 - 503 - return nil 504 - } 505 - 506 - // all pulls above this pull (including self) in this stack 507 - func (stack Stack) Above(pull *Pull) Stack { 508 - position := stack.Position(pull) 509 - 510 - if position < 0 { 511 - return nil 512 - } 513 - 514 - return stack[:position+1] 515 - } 516 - 517 - // all pulls below this pull (excluding self) in this stack 518 - func (stack Stack) StrictlyAbove(pull *Pull) Stack { 519 - above := stack.Above(pull) 520 - 521 - if len(above) > 0 { 522 - return above[:len(above)-1] 523 - } 524 - 525 - return nil 526 - } 527 - 528 - // the combined format-patches of all the newest submissions in this stack 529 - func (stack Stack) CombinedPatch() string { 530 - // go in reverse order because the bottom of the stack is the last element in the slice 531 - var combined strings.Builder 532 - for idx := range stack { 533 - pull := stack[len(stack)-1-idx] 534 - combined.WriteString(pull.LatestPatch()) 535 - combined.WriteString("\n") 536 - } 537 - return combined.String() 538 - } 539 - 540 - // filter out PRs that are "active" 541 - // 542 - // PRs that are still open are active 543 - func (stack Stack) Mergeable() Stack { 544 - var mergeable Stack 545 - 546 - for _, p := range stack { 547 - // stop at the first merged PR 548 - if p.State == PullMerged || p.State == PullClosed { 549 - break 550 - } 551 - 552 - // skip over abandoned PRs 553 - if p.State != PullAbandoned { 554 - mergeable = append(mergeable, p) 555 - } 556 - } 557 - 558 - return mergeable 559 - } 560 - 561 - type BranchDeleteStatus struct { 562 - Repo *Repo 563 - Branch string 351 + return slices.Collect(maps.Keys(participants)) 564 352 } 565 353 566 354 func extractGzip(blob io.Reader) (string, error) { ··· 581 369 582 370 return b.String(), nil 583 371 } 372 + 373 + func IsHash(s string) bool { 374 + switch len(s) { 375 + case 40: // SHA1 376 + case 64: // SHA2 377 + default: 378 + return false 379 + } 380 + _, err := hex.DecodeString(s) 381 + return err == nil 382 + }
+2 -3
appview/notify/db/db.go
··· 158 158 ) 159 159 160 160 case tangled.RepoPullNSID: 161 - pull, err := db.GetPull( 162 - n.db, 161 + pull, err := db.GetPull(ctx, n.db, 163 162 orm.FilterEq("owner_did", subjectAt.Authority()), 164 163 orm.FilterEq("rkey", subjectAt.RecordKey()), 165 164 ) ··· 381 380 recipients.Insert(c.SubjectDid) 382 381 } 383 382 384 - actorDid := syntax.DID(pull.OwnerDid) 383 + actorDid := pull.OwnerDid 385 384 eventType := models.NotificationTypePullCreated 386 385 entityType := "pull" 387 386 entityId := pull.AtUri().String()
+2 -2
appview/notify/db/db_test.go
··· 129 129 t.Helper() 130 130 pull := &models.Pull{ 131 131 RepoDid: syntax.DID(repoDid), 132 - OwnerDid: authorDid, 132 + OwnerDid: syntax.DID(authorDid), 133 133 Rkey: "pullrkey", 134 134 Title: "test", 135 135 Body: "body", ··· 140 140 if err != nil { 141 141 t.Fatalf("Begin: %v", err) 142 142 } 143 - if err := appviewdb.PutPull(tx, pull); err != nil { 143 + if err := appviewdb.PutPull(t.Context(), tx, pull, nil); err != nil { 144 144 t.Fatalf("PutPull: %v", err) 145 145 } 146 146 if err := tx.Commit(); err != nil {
+2 -16
appview/notify/posthog/notifier.go
··· 96 96 97 97 func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 98 98 err := n.client.Enqueue(posthog.Capture{ 99 - DistinctId: pull.OwnerDid, 99 + DistinctId: pull.OwnerDid.String(), 100 100 Event: "new_pull", 101 - Properties: posthog.Properties{ 102 - "repo_did": string(pull.RepoDid), 103 - "pull_id": pull.PullId, 104 - }, 105 - }) 106 - if err != nil { 107 - log.Println("failed to enqueue posthog event:", err) 108 - } 109 - } 110 - 111 - func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 112 - err := n.client.Enqueue(posthog.Capture{ 113 - DistinctId: pull.OwnerDid, 114 - Event: "pull_closed", 115 101 Properties: posthog.Properties{ 116 102 "repo_did": string(pull.RepoDid), 117 103 "pull_id": pull.PullId, ··· 247 233 return 248 234 } 249 235 err := n.client.Enqueue(posthog.Capture{ 250 - DistinctId: pull.OwnerDid, 236 + DistinctId: pull.OwnerDid.String(), 251 237 Event: event, 252 238 Properties: posthog.Properties{ 253 239 "repo_did": string(pull.RepoDid),
+13 -13
appview/notify/webhook/notifier.go
··· 108 108 if !ok { 109 109 return 110 110 } 111 - w.pullRequestEvent(ctx, event, action, actor.String(), pull) 111 + w.pullRequestEvent(ctx, event, action, actor, pull) 112 112 } 113 113 114 114 // pullStateEvent maps a pull's state to the webhook event announcing the ··· 126 126 } 127 127 } 128 128 129 - func (w *Notifier) pullRequestEvent(ctx context.Context, event models.WebhookEvent, action, sender string, pull *models.Pull) { 129 + func (w *Notifier) pullRequestEvent(ctx context.Context, event models.WebhookEvent, action string, sender syntax.DID, pull *models.Pull) { 130 130 // pull request events originate from http handlers, whose context is 131 131 // canceled as soon as the handler returns; detach so in-flight 132 132 // deliveries are not cut short ··· 160 160 } 161 161 } 162 162 163 - func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull, sender, baseUrl string) *models.WebhookPullRequestPayload { 163 + func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull, sender syntax.DID, baseUrl string) *models.WebhookPullRequestPayload { 164 164 htmlUrl := fmt.Sprintf("%s/%s/%s/pulls/%d", baseUrl, repo.Did, repo.Slug(), pull.PullId) 165 165 166 166 pullRequest := models.WebhookPullRequest{ 167 - Number: pull.PullId, 167 + Number: int(pull.PullId), 168 168 Title: pull.Title, 169 169 Body: pull.Body, 170 170 State: pull.State.String(), 171 171 TargetBranch: pull.TargetBranch, 172 - Owner: models.WebhookUser{Did: pull.OwnerDid}, 172 + Owner: models.WebhookUser{Did: pull.OwnerDid.String()}, 173 173 HtmlUrl: htmlUrl, 174 174 CreatedAt: pull.Created.Format(time.RFC3339), 175 175 } 176 - if len(pull.Submissions) > 0 { 177 - pullRequest.RoundNumber = pull.LastRoundNumber() 178 - pullRequest.PatchUrl = fmt.Sprintf("%s/round/%d.patch", htmlUrl, pull.LastRoundNumber()) 176 + if len(pull.Versions) > 0 { 177 + pullRequest.RoundNumber = pull.LatestVersionNumber() 178 + pullRequest.PatchUrl = fmt.Sprintf("%s/%d.patch", htmlUrl, pull.LatestVersionNumber()) 179 179 } 180 - if pull.PullSource != nil { 180 + if pull.SourceBranch != nil { 181 181 source := &models.WebhookPullRequestSource{ 182 - Branch: pull.PullSource.Branch, 182 + Branch: *pull.SourceBranch, 183 183 } 184 - if len(pull.Submissions) > 0 { 184 + if len(pull.Versions) > 0 { 185 185 source.Sha = pull.LatestSha() 186 186 } 187 187 if pull.IsForkBased() { 188 - source.Repo = pull.PullSource.RepoDid.String() 188 + source.Repo = pull.SourceRepo.String() 189 189 } 190 190 pullRequest.Source = source 191 191 } ··· 194 194 Action: action, 195 195 PullRequest: pullRequest, 196 196 Repository: buildWebhookRepository(repo), 197 - Sender: models.WebhookUser{Did: sender}, 197 + Sender: models.WebhookUser{Did: sender.String()}, 198 198 } 199 199 } 200 200
+1
appview/oauth/scopes.go
··· 34 34 "rpc:sh.tangled.ci.triggerPipeline?aud=*", 35 35 "rpc:sh.tangled.ci.cancelPipeline?aud=*", 36 36 "rpc:sh.tangled.git.keepCommit?aud=*", 37 + "rpc:sh.tangled.git.mergeCommit?aud=*", 37 38 "rpc:sh.tangled.repo.addCollaborator?aud=*", 38 39 "rpc:sh.tangled.repo.addSecret?aud=*", 39 40 "rpc:sh.tangled.repo.create?aud=*",
-324
appview/pages/compose_parse_test.go
··· 1 - package pages 2 - 3 - import ( 4 - "bytes" 5 - "io" 6 - "log/slog" 7 - "strings" 8 - "testing" 9 - 10 - "tangled.org/core/appview/config" 11 - "tangled.org/core/appview/models" 12 - "tangled.org/core/appview/pages/repoinfo" 13 - "tangled.org/core/patchutil" 14 - "tangled.org/core/types" 15 - ) 16 - 17 - func TestPullComposeTemplatesParse(t *testing.T) { 18 - cfg := &config.Config{} 19 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 20 - 21 - cases := []struct { 22 - name string 23 - stack []string 24 - }{ 25 - {"new.html via repo base", []string{"layouts/base", "layouts/repobase", "repo/pulls/new"}}, 26 - {"pullComposeHost", []string{"repo/pulls/fragments/pullComposeHost"}}, 27 - {"pullStepSource", []string{"repo/pulls/fragments/pullStepSource"}}, 28 - {"pullStepReview", []string{"repo/pulls/fragments/pullStepReview"}}, 29 - {"pullStepDetails", []string{"repo/pulls/fragments/pullStepDetails"}}, 30 - {"pullCompareForks", []string{"repo/pulls/fragments/pullCompareForks"}}, 31 - {"pullCompareBranches", []string{"repo/pulls/fragments/pullCompareBranches"}}, 32 - {"pullCompareForksBranches", []string{"repo/pulls/fragments/pullCompareForksBranches"}}, 33 - {"pull.html via repo base", []string{"layouts/base", "layouts/repobase", "repo/pulls/pull"}}, 34 - {"pullComment", []string{"fragments/comment/pullComment"}}, 35 - } 36 - 37 - for _, c := range cases { 38 - t.Run(c.name, func(t *testing.T) { 39 - if _, err := p.rawParse(c.stack...); err != nil { 40 - t.Fatalf("parse %v: %v", c.stack, err) 41 - } 42 - }) 43 - } 44 - } 45 - 46 - func TestPullComposeHostRender(t *testing.T) { 47 - cfg := &config.Config{} 48 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 49 - 50 - base := RepoNewPullParams{ 51 - RepoInfo: repoinfo.RepoInfo{ 52 - OwnerDid: "did:plc:test", 53 - Name: "test-repo", 54 - }, 55 - } 56 - 57 - for _, source := range []Source{"", SourceBranch, SourceFork, SourcePatch} { 58 - for _, stacked := range []bool{false, true} { 59 - if source == SourcePatch && stacked { 60 - continue 61 - } 62 - params := base 63 - params.Source = source 64 - params.IsStacked = stacked 65 - name := string(source) 66 - if name == "" { 67 - name = "default" 68 - } 69 - if stacked { 70 - name += "-stacked" 71 - } 72 - t.Run(name, func(t *testing.T) { 73 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 74 - t.Fatalf("render source=%q stacked=%v: %v", source, stacked, err) 75 - } 76 - }) 77 - } 78 - } 79 - } 80 - 81 - func TestPullComposeHostRenderWithData(t *testing.T) { 82 - cfg := &config.Config{} 83 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 84 - 85 - sampleBranches := []types.Branch{ 86 - {Reference: types.Reference{Name: "feature"}}, 87 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 88 - } 89 - 90 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 91 - From: Test <test@best.fest> 92 - Date: Tue, 1 Jan 2020 00:00:00 +0000 93 - Subject: [PATCH] example commit 94 - 95 - --- 96 - a.txt | 1 + 97 - 1 file changed, 1 insertion(+) 98 - 99 - diff --git a/a.txt b/a.txt 100 - index 0000000..1111111 100644 101 - --- a/a.txt 102 - +++ b/a.txt 103 - @@ -0,0 +1 @@ 104 - +hello 105 - ` 106 - patches, err := patchutil.ExtractPatches(formatPatch) 107 - if err != nil { 108 - t.Fatalf("extract patches: %v", err) 109 - } 110 - comparison := &types.RepoFormatPatchResponse{ 111 - FormatPatchRaw: formatPatch, 112 - FormatPatch: patches, 113 - } 114 - diff := patchutil.AsNiceDiff(formatPatch, "main") 115 - 116 - params := RepoNewPullParams{ 117 - RepoInfo: repoinfo.RepoInfo{ 118 - OwnerDid: "did:plc:test", 119 - Name: "test-repo", 120 - }, 121 - Branches: sampleBranches, 122 - SourceBranches: []types.Branch{sampleBranches[0]}, 123 - ForkBranches: []types.Branch{sampleBranches[0]}, 124 - Source: SourceBranch, 125 - SourceBranch: "feature", 126 - TargetBranch: "main", 127 - Comparison: comparison, 128 - Diff: &diff, 129 - } 130 - 131 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 132 - t.Fatalf("render with data: %v", err) 133 - } 134 - 135 - params.IsStacked = true 136 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 137 - t.Fatalf("render stacked: %v", err) 138 - } 139 - 140 - params.PrefillError = "branch not found" 141 - params.Comparison = nil 142 - params.Diff = nil 143 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 144 - t.Fatalf("render with prefill error: %v", err) 145 - } 146 - 147 - bugDef := &models.LabelDefinition{ 148 - Did: "did:plc:test", 149 - Rkey: "bug", 150 - Name: "bug", 151 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 152 - Scope: []string{"sh.tangled.repo.pull"}, 153 - } 154 - priorityDef := &models.LabelDefinition{ 155 - Did: "did:plc:test", 156 - Rkey: "priority", 157 - Name: "priority", 158 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 159 - Scope: []string{"sh.tangled.repo.pull"}, 160 - } 161 - assigneeDef := &models.LabelDefinition{ 162 - Did: "did:plc:test", 163 - Rkey: "assignee", 164 - Name: "assignee", 165 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Format: models.ValueTypeFormatDid}, 166 - Scope: []string{"sh.tangled.repo.pull"}, 167 - Multiple: true, 168 - } 169 - labelDefs := map[string]*models.LabelDefinition{ 170 - bugDef.AtUri().String(): bugDef, 171 - priorityDef.AtUri().String(): priorityDef, 172 - assigneeDef.AtUri().String(): assigneeDef, 173 - } 174 - 175 - pushRepoInfo := repoinfo.RepoInfo{ 176 - OwnerDid: "did:plc:test", 177 - Name: "test-repo", 178 - Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 179 - } 180 - params = RepoNewPullParams{ 181 - RepoInfo: pushRepoInfo, 182 - Branches: sampleBranches, 183 - SourceBranches: []types.Branch{sampleBranches[0]}, 184 - Source: SourceBranch, 185 - SourceBranch: "feature", 186 - TargetBranch: "main", 187 - Comparison: comparison, 188 - Diff: &diff, 189 - LabelDefs: labelDefs, 190 - LabelState: models.NewLabelState(), 191 - } 192 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 193 - t.Fatalf("render with labels: %v", err) 194 - } 195 - 196 - params.IsStacked = true 197 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 198 - t.Fatalf("render stacked with labels: %v", err) 199 - } 200 - 201 - params.StackedDiffs = []StackedDiff{{ 202 - Diff: &diff, 203 - Opts: types.DiffOpts{Split: true, RefreshUrl: "/r", Target: "#stack-diff-x", Field: "stackSplit[x]"}, 204 - }} 205 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 206 - t.Fatalf("render stacked with per-commit diffs: %v", err) 207 - } 208 - } 209 - 210 - func TestPullComposeLabelStateRoundTrip(t *testing.T) { 211 - cfg := &config.Config{} 212 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 213 - 214 - sampleBranches := []types.Branch{ 215 - {Reference: types.Reference{Name: "feature"}}, 216 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 217 - } 218 - 219 - bugDef := &models.LabelDefinition{ 220 - Did: "did:plc:test", Rkey: "bug", Name: "bug", 221 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 222 - Scope: []string{"sh.tangled.repo.pull"}, 223 - } 224 - priorityDef := &models.LabelDefinition{ 225 - Did: "did:plc:test", Rkey: "priority", Name: "priority", 226 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 227 - Scope: []string{"sh.tangled.repo.pull"}, 228 - } 229 - bugKey := bugDef.AtUri().String() 230 - priorityKey := priorityDef.AtUri().String() 231 - labelDefs := map[string]*models.LabelDefinition{ 232 - bugKey: bugDef, 233 - priorityKey: priorityDef, 234 - } 235 - 236 - state := models.NewLabelState() 237 - actx := &models.LabelApplicationCtx{Defs: labelDefs} 238 - for _, op := range []models.LabelOp{ 239 - {OperandKey: bugKey, OperandValue: "null", Operation: models.LabelOperationAdd}, 240 - {OperandKey: priorityKey, OperandValue: "high", Operation: models.LabelOperationAdd}, 241 - } { 242 - if err := actx.ApplyLabelOp(state, op); err != nil { 243 - t.Fatalf("seed state: %v", err) 244 - } 245 - } 246 - 247 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 248 - From: Test <test@best.fest> 249 - Date: Tue, 1 Jan 2020 00:00:00 +0000 250 - Subject: [PATCH] example commit 251 - 252 - --- 253 - a.txt | 1 + 254 - 1 file changed, 1 insertion(+) 255 - 256 - diff --git a/a.txt b/a.txt 257 - index 0000000..1111111 100644 258 - --- a/a.txt 259 - +++ b/a.txt 260 - @@ -0,0 +1 @@ 261 - +hello 262 - ` 263 - patches, err := patchutil.ExtractPatches(formatPatch) 264 - if err != nil { 265 - t.Fatalf("extract patches: %v", err) 266 - } 267 - comparison := &types.RepoFormatPatchResponse{ 268 - FormatPatchRaw: formatPatch, 269 - FormatPatch: patches, 270 - } 271 - 272 - params := RepoNewPullParams{ 273 - RepoInfo: repoinfo.RepoInfo{ 274 - OwnerDid: "did:plc:test", 275 - Name: "test-repo", 276 - Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 277 - }, 278 - Branches: sampleBranches, 279 - SourceBranches: []types.Branch{sampleBranches[0]}, 280 - Source: SourceBranch, 281 - SourceBranch: "feature", 282 - TargetBranch: "main", 283 - Comparison: comparison, 284 - LabelDefs: labelDefs, 285 - LabelState: state, 286 - } 287 - 288 - var buf bytes.Buffer 289 - if err := p.PullComposeHostFragment(&buf, params); err != nil { 290 - t.Fatalf("render: %v", err) 291 - } 292 - out := buf.String() 293 - for _, want := range []string{ 294 - `value="null" checked`, 295 - `value="high" checked`, 296 - } { 297 - if !strings.Contains(out, want) { 298 - t.Errorf("missing pre-selection %q", want) 299 - } 300 - } 301 - } 302 - 303 - func TestParseSource(t *testing.T) { 304 - cases := []struct { 305 - in string 306 - want Source 307 - wantOk bool 308 - }{ 309 - {"branch", SourceBranch, true}, 310 - {"BRANCH", SourceBranch, true}, 311 - {"fork", SourceFork, true}, 312 - {"patch", SourcePatch, true}, 313 - {"", "", false}, 314 - {"method", "", false}, 315 - {"strategy", "", false}, 316 - {"unknown", "", false}, 317 - } 318 - for _, c := range cases { 319 - got, ok := ParseSource(c.in) 320 - if got != c.want || ok != c.wantOk { 321 - t.Errorf("ParseSource(%q) = %q, %v; want %q, %v", c.in, got, ok, c.want, c.wantOk) 322 - } 323 - } 324 - }
+9 -7
appview/pages/funcmap.go
··· 137 137 return "" 138 138 } 139 139 // GetPull's reverse-mapping already populates pull.Repo 140 - pull, err := db.GetPull(p.db, orm.FilterEq("at_uri", pullAtStr)) 140 + pull, err := db.GetPull(context.Background(), p.db, orm.FilterEq("at_uri", pullAtStr)) 141 141 if err != nil || pull == nil || pull.Repo == nil { 142 142 return "" 143 143 } ··· 150 150 return s[:30] + "…" 151 151 }, 152 152 // short prefix of a commit hash or jj change id, safe on short input 153 - "shortId": func(s string) string { 154 - if len(s) <= 8 { 155 - return s 156 - } 157 - return s[:8] 158 - }, 153 + "shortId": shortId, 159 154 "splitOn": func(s, sep string) []string { 160 155 return strings.Split(s, sep) 161 156 }, ··· 615 610 return syntax.DID(s) 616 611 }, 617 612 } 613 + } 614 + 615 + func shortId(s string) string { 616 + if len(s) <= 8 { 617 + return s 618 + } 619 + return s[:8] 618 620 } 619 621 620 622 func primaryHandle(r *idresolver.Resolver, s string) string {
+258 -76
appview/pages/pages.go
··· 22 22 "tangled.org/core/appview/commitverify" 23 23 "tangled.org/core/appview/config" 24 24 "tangled.org/core/appview/db" 25 + "tangled.org/core/appview/filetree" 25 26 "tangled.org/core/appview/models" 26 27 "tangled.org/core/appview/oauth" 27 28 "tangled.org/core/appview/pages/markup" 28 29 "tangled.org/core/appview/pages/markup/sanitizer" 29 30 "tangled.org/core/appview/pages/repoinfo" 30 31 "tangled.org/core/appview/pagination" 32 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 31 33 "tangled.org/core/idresolver" 32 34 "tangled.org/core/types" 33 35 ··· 1375 1377 1376 1378 type PullSubscribeParams struct { 1377 1379 RepoInfo repoinfo.RepoInfo 1378 - PullId int 1380 + PullId int64 1379 1381 IsSubscribed *bool 1380 1382 } 1381 1383 ··· 1429 1431 1430 1432 type RepoNewPullParams struct { 1431 1433 BaseParams 1432 - RepoInfo repoinfo.RepoInfo 1433 - Branches []types.Branch 1434 - SourceBranches []types.Branch 1435 - ForkBranches []types.Branch 1436 - Forks []models.Repo 1437 - Source Source 1438 - SourceBranch string 1439 - TargetBranch string 1440 - Fork string 1441 - Patch string 1442 - Title string 1443 - Body string 1444 - TitleDirty bool 1445 - BodyDirty bool 1446 - IsStacked bool 1447 - Comparison *types.RepoFormatPatchResponse 1448 - Diff *types.NiceDiff 1449 - DiffOpts types.DiffOpts 1450 - StackedDiffs []StackedDiff 1451 - MergeCheck *types.MergeCheckResponse 1452 - StackTitles map[string]string 1453 - StackBodies map[string]string 1454 - PrefillError string 1455 - Active string 1456 - LabelDefs map[string]*models.LabelDefinition 1457 - LabelState models.LabelState 1458 - StackLabelStates map[string]models.LabelState 1434 + RepoInfo repoinfo.RepoInfo 1435 + Active string 1436 + PrefillError string 1437 + 1438 + // step 1. choose source 1439 + // TODO: replace to RepoNewPull_StepSourceParams 1440 + Branches []types.Branch 1441 + SourceBranches []types.Branch 1442 + ForkBranches []types.Branch 1443 + Forks []models.Repo 1444 + // selected values 1445 + Source Source // source kind 1446 + TargetBranch string 1447 + Fork string // fork repo DID 1448 + SourceBranch string 1449 + Patch string 1450 + 1451 + // step 2. review changes 1452 + StepReviewParams *RepoNewPull_StepReviewParams // optional step 2 params 1453 + 1454 + // step 3. fill details 1455 + // TODO: replace to RepoNewPull_StepDetailsParams 1456 + Title string 1457 + Body string 1458 + TitleDirty bool // flag to avoid overwriting users input 1459 + BodyDirty bool 1460 + MergeCheck MergeCheckParams 1461 + LabelDefs map[string]*models.LabelDefinition 1462 + LabelState models.LabelState 1463 + } 1464 + 1465 + func (p RepoNewPullParams) SourceRepo() string { 1466 + if p.Fork != "" { 1467 + return p.Fork 1468 + } 1469 + return p.RepoInfo.RepoDid 1470 + } 1471 + 1472 + type RepoNewPull_StepSourceParams struct { 1473 + Branches []types.Branch 1474 + SourceBranches []types.Branch 1475 + ForkBranches []types.Branch 1476 + Forks []models.Repo 1477 + ErrorMsg string 1478 + // selected values 1479 + Source Source // source kind 1480 + TargetBranch string 1481 + Fork string // fork repo DID 1482 + SourceBranch string 1483 + Patch string 1484 + } 1485 + 1486 + type RepoNewPull_StepReviewParams struct { 1487 + Commits []types.Commit 1488 + } 1489 + 1490 + type RepoNewPull_StepDetailsParams struct { 1491 + Title string 1492 + Body string 1493 + TitleDirty bool 1494 + BodyDirty bool 1495 + } 1496 + 1497 + type MergeCheckParams struct { 1498 + IsConflicted bool 1499 + Conflicts []*gitmirrorv1.MergeConflict 1500 + Error string 1459 1501 } 1460 1502 1461 1503 func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { ··· 1489 1531 FilterState string 1490 1532 FilterQuery string 1491 1533 BaseFilterQuery string 1492 - Stacks []models.Stack 1493 1534 Pipelines map[string]types.Pipeline 1494 1535 LabelDefs map[string]*models.LabelDefinition 1495 1536 Page pagination.Page ··· 1520 1561 return r == Unknown 1521 1562 } 1522 1563 1523 - type RepoSinglePullParams struct { 1524 - BaseParams 1525 - RepoInfo repoinfo.RepoInfo 1526 - Active string 1527 - Pull *models.Pull 1528 - Stack models.Stack 1529 - Backlinks []models.RichReferenceLink 1530 - BranchDeleteStatus *models.BranchDeleteStatus 1531 - MergeCheck types.MergeCheckResponse 1532 - ResubmitCheck ResubmitResult 1533 - Pipelines map[string]types.Pipeline 1534 - Diff types.DiffRenderer 1535 - DiffOpts types.DiffOpts 1536 - ActiveRound int 1537 - IsInterdiff bool 1538 - 1539 - Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1540 - UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1541 - 1542 - LabelDefs map[string]*models.LabelDefinition 1543 - VouchRelationships map[syntax.DID]*models.VouchRelationship 1544 - VouchSkips map[syntax.DID]bool 1545 - 1546 - // IsSubscribed is nil when not logged in, true when subscribed, false when explicitly unsubscribed. 1547 - IsSubscribed *bool 1564 + type BranchDeleteStatus struct { 1565 + Repo *models.Repo 1566 + Branch string 1548 1567 } 1549 1568 1550 1569 type PullPageBaseParams struct { 1551 1570 BaseParams 1552 - Pull *models.Pull 1571 + RepoInfo repoinfo.RepoInfo 1572 + Pull *models.Pull 1553 1573 1554 1574 Backlinks []models.RichReferenceLink 1555 - Comments []models.Comment 1556 1575 Commits []types.Commit // all commits between <target>..<pr/head> 1576 + Pipelines map[string]types.Pipeline 1577 + 1578 + MergeCheck MergeCheckParams 1579 + ResubmitCheck ResubmitResult 1580 + BranchDeleteStatus *BranchDeleteStatus 1557 1581 1558 1582 LabelDefs map[string]*models.LabelDefinition 1559 1583 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData ··· 1561 1585 VouchRelationships map[syntax.DID]*models.VouchRelationship 1562 1586 VouchSkips map[syntax.DID]bool 1563 1587 1588 + // IsSubscribed is nil when not logged in, true when subscribed, false when explicitly unsubscribed. 1589 + IsSubscribed *bool 1590 + 1564 1591 // diff, branch-delete-status, merge-check, resubmit-check, pipelines will be lazy-loaded. 1565 1592 } 1566 1593 1567 1594 // /pulls/123/2/1a2b3c..d4e5f6 1568 1595 type PullDiffParams struct { 1569 1596 PullPageBaseParams 1570 - Version int 1571 - BaseCommitId string 1572 - HeadCommitId string 1597 + VersionId int 1598 + 1599 + DiffParams DiffParams_Diff 1600 + IsDiffBase bool 1601 + IsDiffHead bool 1573 1602 1574 1603 ErrorMsg string 1575 1604 } 1576 1605 1606 + func (p PullDiffParams) ActiveVersionId() int { 1607 + return p.VersionId 1608 + } 1609 + 1610 + func (p PullDiffParams) ActiveCommitId() string { 1611 + return p.DiffParams.Head 1612 + } 1613 + 1614 + func (p PullDiffParams) IsInterdiff() bool { 1615 + return false 1616 + } 1617 + 1618 + func (p PullDiffParams) DisplayDiffBase() string { 1619 + if p.IsDiffBase { 1620 + return "base" 1621 + } 1622 + return shortId(p.DiffParams.Base) 1623 + } 1624 + 1625 + func (p PullDiffParams) DisplayDiffHead() string { 1626 + if p.IsDiffHead { 1627 + return "head" 1628 + } 1629 + return shortId(p.DiffParams.Head) 1630 + } 1631 + 1577 1632 // /pulls/123/1..2/abcdef 1578 1633 type PullInterdiffParams struct { 1579 1634 PullPageBaseParams ··· 1581 1636 Version2 int 1582 1637 ChangeId string // optional change-id filter 1583 1638 1639 + DiffParams DiffParams 1640 + ActiveCommitId string 1641 + 1584 1642 ErrorMsg string 1585 1643 } 1586 1644 1587 - func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { 1588 - panic("unimplemented") 1645 + func (p PullInterdiffParams) ActiveVersionId() int { 1646 + return p.Version2 1647 + } 1648 + 1649 + func (p PullInterdiffParams) IsInterdiff() bool { 1650 + return true 1651 + } 1652 + 1653 + type DiffParams struct { 1654 + Diff *DiffParams_Diff 1655 + Interdiff *DiffParams_Interdiff 1589 1656 } 1590 1657 1591 - func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error { 1592 - panic("unimplemented") 1658 + type DiffParams_Diff struct { 1659 + Base string 1660 + Head string 1661 + } 1662 + 1663 + type DiffParams_Interdiff struct { 1664 + From DiffParams_Diff 1665 + To DiffParams_Diff 1666 + } 1667 + 1668 + // DiffLine is one row of a unified (inline) diff. Old/New are 1-based line numbers into the 1669 + // base/head blob, or 0 when that side has no line here. Content is pre-rendered, safe HTML. 1670 + type DiffLine struct { 1671 + Op string // " " context, "-" removed, "+" added 1672 + Old int 1673 + New int 1674 + Content template.HTML 1675 + } 1676 + 1677 + // DiffCell is one side of a side-by-side row. Kind is "ctx", "del", "add", or "empty" (a 1678 + // blank padding cell). Num is the 1-based line number, or 0 when empty. 1679 + type DiffCell struct { 1680 + Kind string 1681 + Num int 1682 + Content template.HTML 1593 1683 } 1594 1684 1595 - func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1596 - params.Active = "pulls" 1597 - return p.executeRepo("repo/pulls/pull", w, params) 1685 + // DiffRow is one side-by-side row: the left (base) and right (head) cells. 1686 + type DiffRow struct { 1687 + Left DiffCell 1688 + Right DiffCell 1598 1689 } 1599 1690 1600 - type PullResubmitParams struct { 1601 - BaseParams 1602 - RepoInfo repoinfo.RepoInfo 1603 - Pull *models.Pull 1604 - SubmissionId int 1691 + // DiffHunk holds a hunk's rows; exactly one of Lines (unified) / Rows (split) is populated, 1692 + // depending on PullDiffFragmentParams.Split. 1693 + type DiffHunk struct { 1694 + Lines []DiffLine 1695 + Rows []DiffRow 1605 1696 } 1606 1697 1607 - func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 1608 - return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 1698 + func (h DiffHunk) AtFileStart() bool { 1699 + if len(h.Rows) > 0 { 1700 + r := h.Rows[0] 1701 + return r.Left.Num == 1 || r.Right.Num == 1 1702 + } 1703 + if len(h.Lines) > 0 { 1704 + l := h.Lines[0] 1705 + return l.Old == 1 || l.New == 1 1706 + } 1707 + return false 1708 + } 1709 + 1710 + // DiffFile is one changed file. Note is set (and Hunks empty) for binary/submodule files. 1711 + type DiffFile struct { 1712 + Path string 1713 + Note string 1714 + Hunks []DiffHunk 1715 + } 1716 + 1717 + type PullDiffFragmentParams struct { 1718 + BaseRepo syntax.DID 1719 + HeadRepo syntax.DID 1720 + DiffBase string 1721 + DiffHead string 1722 + DiffUrl string 1723 + Unified bool 1724 + Files []DiffFile 1725 + 1726 + ErrorMsg string 1727 + } 1728 + 1729 + func (f *DiffFile) Id() string { 1730 + return f.Path 1731 + } 1732 + 1733 + func (f *DiffFile) Stats() types.DiffFileStat { 1734 + var ins, del int64 1735 + for _, hunk := range f.Hunks { 1736 + for _, line := range hunk.Lines { 1737 + switch line.Op { 1738 + case "+": 1739 + ins++ 1740 + case "-": 1741 + del++ 1742 + } 1743 + } 1744 + for _, row := range hunk.Rows { 1745 + if row.Left.Kind == "del" { 1746 + del++ 1747 + } 1748 + if row.Right.Kind == "add" { 1749 + ins++ 1750 + } 1751 + } 1752 + } 1753 + return types.DiffFileStat{ 1754 + Insertions: ins, 1755 + Deletions: del, 1756 + } 1757 + } 1758 + 1759 + func (p PullDiffFragmentParams) FileTree() *filetree.FileTreeNode { 1760 + fs := make([]string, len(p.Files)) 1761 + for i, s := range p.Files { 1762 + fs[i] = s.Id() 1763 + } 1764 + return filetree.FileTree(fs) 1765 + } 1766 + 1767 + func (p PullDiffFragmentParams) Stats() types.DiffStat { 1768 + var stat types.DiffStat 1769 + for _, df := range p.Files { 1770 + fileStats := df.Stats() 1771 + stat.Insertions += fileStats.Insertions 1772 + stat.Deletions += fileStats.Deletions 1773 + } 1774 + stat.FilesChanged = len(p.Files) 1775 + return stat 1776 + } 1777 + 1778 + func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { 1779 + return p.executeRepo("repo/pulls/single", w, params) 1780 + } 1781 + 1782 + func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error { 1783 + return p.executeRepo("repo/pulls/single", w, params) 1784 + } 1785 + 1786 + func (p *Pages) PullDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1787 + return p.executePlain("repo/pulls/fragments/diff", w, params) 1788 + } 1789 + 1790 + func (p *Pages) PullComposeDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1791 + return p.executePlain("repo/pulls/fragments/composediff", w, params) 1609 1792 } 1610 1793 1611 1794 type PullActionsParams struct { ··· 1613 1796 RepoInfo repoinfo.RepoInfo 1614 1797 Pull *models.Pull 1615 1798 RoundNumber int 1616 - MergeCheck types.MergeCheckResponse 1799 + MergeCheck MergeCheckParams 1617 1800 ResubmitCheck ResubmitResult 1618 - BranchDeleteStatus *models.BranchDeleteStatus 1619 - Stack models.Stack 1801 + BranchDeleteStatus *BranchDeleteStatus 1620 1802 1621 1803 // Workflow warning state for fork-based pulls without a pipeline on the 1622 1804 // latest commit. WorkflowsChanged and ChangedWorkflowFiles are computed
+27 -27
appview/pages/templates/fragments/line-quote-button.html
··· 21 21 Array.from(document.querySelectorAll('form[hx-post="/comment"] textarea')) 22 22 .find(ta => ta.offsetParent !== null) || null; 23 23 24 - const lineOf = (el) => 25 - el?.closest?.('span[id*="-O"]') 26 - || el?.closest?.('span[id*="-N"]'); 24 + const lineOf = (el) => el?.closest?.('[id*="-O"], [id*="-N"]'); 27 25 28 - const anchorOf = (el) => { 29 - const link = el.querySelector('a[href^="#"]'); 30 - return link ? link.getAttribute('href').slice(1) : el.id || null; 31 - }; 26 + const anchorOf = (el) => 27 + el.querySelector('a[href^="#"]')?.getAttribute('href').slice(1) ?? null; 32 28 33 29 const fileOf = (el) => { 34 30 const d = el.closest('details[id^="file-"]'); 35 31 return d ? d.id.replace(/^file-/, '') : null; 36 32 }; 37 33 38 - const lineNumOf = (el) => anchorOf(el)?.match(/(\d+)(?:-[ON]?\d+)?$/)?.[1]; 34 + const lineNumOf = (el) => anchorOf(el)?.match(/-[ON](\d+)$/)?.[1]; 39 35 40 - const columnOf = (el) => el.closest('.flex-col'); 36 + // Column key: split has two flex sides per row (left = first child / -O, 37 + // right = last child / -N); unified is a single column per file. 38 + const columnOf = (el) => { 39 + const file = fileOf(el); 40 + if (file == null) return null; 41 + const side = el.classList.contains('diff-side') 42 + ? (el === el.parentElement.firstElementChild ? 'L' : 'R') 43 + : 'U'; 44 + return file + '-' + side; 45 + }; 41 46 42 - const linesInColumn = (col) => 43 - Array.from(col.querySelectorAll('span[id*="-O"], span[id*="-N"]')) 44 - .filter(s => s.querySelector('a[href^="#"]')); 47 + const linesInColumn = (el) => { 48 + const diff = el.closest('.diff'); 49 + if (!diff) return []; 50 + if (el.classList.contains('diff-side')) { 51 + const pos = el === el.parentElement.firstElementChild 52 + ? ':first-child' : ':last-child'; 53 + return Array.from(diff.querySelectorAll(`.diff-line > .diff-side[id]${pos}`)); 54 + } 55 + return Array.from(diff.querySelectorAll('.diff-line[id]')); 56 + }; 45 57 46 58 let dragLines = null; 47 59 48 60 const rangeBetween = (a, b) => { 49 61 const col = columnOf(a); 50 62 if (!col || col !== columnOf(b)) return []; 51 - const all = dragLines || linesInColumn(col); 63 + const all = dragLines || linesInColumn(a); 52 64 const ai = all.indexOf(a); 53 65 const bi = all.indexOf(b); 54 66 if (ai === -1 || bi === -1) return []; ··· 73 85 if (hash.startsWith('comment-') || hash.startsWith('round-')) return; 74 86 const parts = hash.split('~'); 75 87 const startEl = document.getElementById(parts[0]); 76 - 77 - if (!startEl) { 78 - const params = new URLSearchParams(window.location.search); 79 - const hasCombined = parts.some(p => /-O\d+-N\d+$/.test(p)); 80 - if (hasCombined && params.get('diff') !== 'unified') { 81 - params.set('diff', 'unified'); 82 - window.location.replace( 83 - `${window.location.pathname}?${params}${window.location.hash}` 84 - ); 85 - } 86 - return; 87 - } 88 + if (!startEl) return; 88 89 89 90 const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl; 90 91 if (!endEl) return; ··· 180 181 e.preventDefault(); 181 182 dragging = true; 182 183 dragAnchor = dragCurrent = hoverTarget; 183 - const col = columnOf(hoverTarget); 184 - dragLines = col ? linesInColumn(col) : null; 184 + dragLines = linesInColumn(hoverTarget); 185 185 applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 186 186 btn.style.pointerEvents = 'none'; 187 187 document.body.style.userSelect = 'none';
+1 -1
appview/pages/templates/layouts/base.html
··· 110 110 </head> 111 111 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200 {{ block "bodyClasses" . }} {{ end }}"> 112 112 {{ block "topbarLayout" . }} 113 - <header class="w-full col-span-full md:col-span-1 md:col-start-2 shadow-sm dark:text-white bg-white dark:bg-gray-800 pt-[env(safe-area-inset-top)]" style="z-index: 20;"> 113 + <header class="w-full col-span-full md:col-span-1 md:col-start-2 shadow-sm dark:text-white bg-white dark:bg-gray-800 pt-[env(safe-area-inset-top)]" style="z-index: 40;"> 114 114 115 115 {{ if .LoggedInUser }} 116 116 <div id="upgrade-banner"
+5 -1
appview/pages/templates/repo/fragments/diff.html
··· 89 89 {{ $id := index . 0 }} 90 90 {{ $target := index . 1 }} 91 91 {{ $direction := index . 2 }} 92 + {{ $class := "hidden md:flex" }} 93 + {{ if gt (len .) 3 }} 94 + {{ $class = index . 3 }} 95 + {{ end }} 92 96 <div id="{{ $id }}" 93 97 data-resizer="vertical" 94 98 data-target="{{ $target }}" 95 99 data-direction="{{ $direction }}" 96 - class="resizer-vertical hidden md:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group"> 100 + class="resizer-vertical w-4 sticky top-12 max-h-screen flex-col items-center justify-center group {{ $class }}"> 97 101 <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> 98 102 </div> 99 103 {{ end }}
+84
appview/pages/templates/repo/pulls/fragments/composediff.html
··· 1 + {{ define "repo/pulls/fragments/composediff" }} 2 + <style> 3 + #composeFilesToggle:checked ~ * label[for="composeFilesToggle"] .show-text { display: none; } 4 + #composeFilesToggle:checked ~ * label[for="composeFilesToggle"] .hide-text { display: inline; } 5 + #composeFilesToggle:not(:checked) ~ * label[for="composeFilesToggle"] .hide-text { display: none; } 6 + #composeFilesToggle:checked ~ * div#composeFiles { width: fit-content; max-width: 15vw; } 7 + #composeFilesToggle:not(:checked) ~ * div#composeFiles { width: 0; display: none; margin-right: 0; } 8 + </style> 9 + <div id="diff-area"> 10 + <input type="checkbox" id="composeFilesToggle" class="peer/collapse hidden"/> 11 + <div class="bg-slate-100 dark:bg-gray-700/50 flex items-center gap-2 h-12 p-2 rounded-t-md border border-b-0 border-gray-200 dark:border-gray-700"> 12 + <label title="Toggle filetree panel" for="composeFilesToggle" class="hidden md:inline-flex btn-flat"> 13 + <span class="peer-checked:hidden">{{ i "panel-left-open" "size-4" }}</span> 14 + <span class="peer-checked:inline hidden">{{ i "panel-left-close" "size-4" }}</span> 15 + </label> 16 + 17 + {{ template "repo/fragments/diffStatPill" .Stats }} 18 + {{ $count := .Stats.FilesChanged }} 19 + <span class="text-xs text-gray-600 dark:text-gray-300 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 20 + 21 + <div class="flex-grow"></div> 22 + 23 + <label title="Expand/Collapse diffs" class="btn font-normal normal-case"> 24 + <input type="checkbox" id="diff-collapse-toggle" class="peer/collapse hidden"/> 25 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 26 + {{ i "unfold-vertical" "size-4" }} 27 + <span class="hidden md:inline">Expand all</span> 28 + </span> 29 + <span class="peer-checked/collapse:inline-flex hidden items-center gap-2"> 30 + {{ i "fold-vertical" "size-4" }} 31 + <span class="hidden md:inline">Collapse all</span> 32 + </span> 33 + </label> 34 + 35 + <!-- diff settings --> 36 + {{ template "repo/pulls/fragments/diffSettings" 37 + (dict "DiffUrl" (printf "%s?baseRepo=%s&base=%s&headRepo=%s&head=%s" .DiffUrl .BaseRepo .DiffBase .HeadRepo .DiffHead) 38 + "Unified" .Unified) }} 39 + 40 + </div> 41 + <div class="flex border border-gray-200 dark:border-gray-700 rounded-b-md"> 42 + <div id="composeFiles" class="hidden md:block overflow-hidden p-1 max-h-full overflow-y-auto border-r border-gray-200 dark:border-gray-700"> 43 + {{ template "repo/fragments/fileTree" .FileTree }} 44 + </div> 45 + <div id="diff-list" class="flex-1 min-w-0 p-2 space-y-3"> 46 + {{ range .Files }} 47 + {{ template "repo/pulls/fragments/diffFile" . }} 48 + {{ end }} 49 + </div> 50 + </div> 51 + </div> 52 + <script> 53 + (() => { 54 + const checkbox = document.getElementById('diff-collapse-toggle'); 55 + const diffList = document.getElementById('diff-list'); 56 + 57 + checkbox.addEventListener('change', () => { 58 + console.debug("checked", checkbox.checked); 59 + diffList.querySelectorAll('details[id^="file-"]').forEach(detail => { 60 + detail.open = checkbox.checked; 61 + }); 62 + }); 63 + 64 + if (window.__collapseToggleHandler) { 65 + diffList.removeEventListener('toggle', window.__collapseToggleHandler, true); 66 + } 67 + 68 + const handler = (e) => { 69 + if (!e.target.matches('details[id^="file-"]')) return; 70 + const details = document.querySelectorAll('details[id^="file-"]'); 71 + const allOpen = Array.from(details).every(d => d.open); 72 + const allClosed = Array.from(details).every(d => !d.open); 73 + 74 + if (allOpen) checkbox.checked = true; 75 + else if (allClosed) checkbox.checked = false; 76 + }; 77 + 78 + console.log("diffList", diffList); 79 + 80 + window.__collapseToggleHandler = handler; 81 + diffList.addEventListener('toggle', handler, true); 82 + })(); 83 + </script> 84 + {{ end }}
+334
appview/pages/templates/repo/pulls/fragments/diff.html
··· 1 + {{ define "repo/pulls/fragments/diff" }} 2 + <div id="diff-files-content" hx-swap-oob="outerHTML"> 3 + {{ template "repo/fragments/fileTree" .FileTree }} 4 + </div> 5 + <div id="diff-stats" hx-swap-oob="outerHTML"> 6 + {{ template "repo/fragments/diffStatPill" .Stats }} 7 + </div> 8 + <div id="diff-settings" hx-swap-oob="outerHTML"> 9 + {{ template "repo/pulls/fragments/diffSettings" 10 + (dict "DiffUrl" .DiffUrl 11 + "Unified" .Unified) }} 12 + </div> 13 + <script> 14 + (() => { 15 + if (window.__diffRestrictSelect) return; 16 + window.__diffRestrictSelect = true; 17 + document.addEventListener('mousedown', (e) => { 18 + document.querySelectorAll('.diff[data-restrict-select]') 19 + .forEach(d => d.removeAttribute('data-restrict-select')); 20 + const side = e.target.closest('.diff-side'); 21 + if (!side) return; 22 + const diff = side.closest('.diff'); 23 + if (!diff) return; 24 + const isLeft = side === side.parentElement.firstElementChild; 25 + diff.setAttribute('data-restrict-select', isLeft ? 'left' : 'right'); 26 + }); 27 + })(); 28 + </script> 29 + <div id="diff-list" class="space-y-4"> 30 + {{ if .ErrorMsg }} 31 + <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700"> 32 + <span>{{ .ErrorMsg }}</span> 33 + </div> 34 + {{ else if .Files }} 35 + {{ range .Files }} 36 + <div class="drop-shadow-sm rounded overflow-clip"> 37 + <details id="file-{{ .Id }}" class="group w-full" open> 38 + <summary class="list-none cursor-pointer sticky top-12 z-10 bg-slate-100 dark:bg-gray-900"> 39 + <div class="flex justify-between rounded border bg-white dark:bg-gray-800 group-open:rounded-b-none border-gray-200 dark:border-gray-700"> 40 + <div class="p-2 flex gap-2 items-center"> 41 + <span class="group-open:hidden inline">{{ i "chevron-right" "size-4" }}</span> 42 + <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 43 + {{ template "repo/fragments/diffStatPill" .Stats }} 44 + <span>{{ .Path }}</span> 45 + </div> 46 + <div class="px-2 flex gap-2 items-center"> 47 + <label 48 + data-review-btn="file-{{ .Id }}" 49 + onclick="event.stopPropagation()" 50 + class="review-btn hidden p-2 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer" 51 + title="Mark as reviewed" 52 + > 53 + <input 54 + type="checkbox" 55 + class="sr-only peer review-checkbox" 56 + data-file-id="file-{{ .Id }}" 57 + /> 58 + <span class="peer-checked:hidden">{{ i "circle" "size-4" }}</span> 59 + <span class="hidden peer-checked:inline text-green-600 dark:text-green-400">{{ i "circle-check" "size-4" }}</span> 60 + <span class="hidden md:inline">Reviewed</span> 61 + </label> 62 + </div> 63 + </div> 64 + </summary> 65 + 66 + <div class="rounded-b overflow-clip border-x border-b border-gray-200 dark:border-gray-700"> 67 + {{ if .Note }} 68 + <div class="p-4 text-center text-gray-400 dark:text-gray-500 text-sm">{{ .Note }}</div> 69 + {{ else if $.Unified }} 70 + {{ template "diffUnified" . }} 71 + {{ else }} 72 + {{ template "diffSplit" . }} 73 + {{ end }} 74 + </div> 75 + </details> 76 + </div> 77 + {{ end }} 78 + {{ else }} 79 + <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-blue-600 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700"> 80 + <span>No change between two revisions.</span> 81 + </div> 82 + {{ end }} 83 + </div> 84 + {{ template "activeFileHighlightScript" }} 85 + {{ template "fragments/line-quote-button" }} 86 + {{ template "reviewStateScript" }} 87 + {{ end }} 88 + 89 + {{ define "diffUnified" }} 90 + <div class="diff w-full"> 91 + {{ $name := .Id }} 92 + {{- range .Hunks -}} 93 + {{- if not .AtFileStart -}}<div class="diff-splitter">&middot;&middot;&middot;</div>{{- end -}} 94 + {{- range $i, $line := .Lines -}} 95 + {{- $lineId := "" -}} 96 + {{- if ge .New 0 -}} 97 + {{- $lineId = printf "%s-N%d" $name .New -}} 98 + {{- else -}} 99 + {{- $lineId = printf "%s-O%d" $name .Old -}} 100 + {{- end -}} 101 + {{- $cls := "" -}} 102 + {{- if eq .Op "+" -}} 103 + {{- $cls = "add" -}} 104 + {{- else if eq .Op "-" -}} 105 + {{- $cls = "del" -}} 106 + {{- end -}} 107 + <div id="{{ $lineId }}" class="diff-line {{ $cls }}"> 108 + <a href="#{{ $lineId }}" class="diff-num"> 109 + <span>{{ if gt .Old 0 }}{{ .Old }}{{ end }}</span> 110 + <span>{{ if gt .New 0 }}{{ .New }}{{ end }}</span> 111 + </a> 112 + <span class="diff-indicator">{{ .Op }}</span> 113 + <div class="diff-content">{{ .Content }}</div> 114 + </div> 115 + {{- end -}} 116 + {{- end -}} 117 + </div> 118 + {{ end }} 119 + 120 + {{ define "diffSplit" }} 121 + <div class="diff w-full"> 122 + {{ $name := .Id }} 123 + {{- range .Hunks -}} 124 + {{- if not .AtFileStart -}}<div class="diff-splitter">&middot;&middot;&middot;</div>{{- end -}} 125 + {{- range $i, $row := .Rows -}} 126 + <div class="diff-line"> 127 + {{- template "diffSplitSide" (list $name "O" $row.Left) -}} 128 + {{- template "diffSplitSide" (list $name "N" $row.Right) -}} 129 + </div> 130 + {{- end -}} 131 + {{- end -}} 132 + </div> 133 + {{ end }} 134 + 135 + {{ define "diffSplitSide" }} 136 + {{- $name := index . 0 -}} 137 + {{- $side := index . 1 -}} 138 + {{- $line := index . 2 -}} 139 + {{- $lineId := printf "%s-%s%d" $name $side $line.Num -}} 140 + {{- $cls := "" -}} 141 + {{- $mark := "" -}} 142 + {{- if eq $line.Kind "del" }}{{ $cls = "del" }}{{ $mark = "-" -}} 143 + {{- else if eq $line.Kind "add" }}{{ $cls = "add" }}{{ $mark = "+" -}} 144 + {{- else if eq $line.Kind "empty" }}{{ $cls = "empty" -}} 145 + {{- end -}} 146 + <div {{ if ge $line.Num 0 }}id="{{ $lineId }}"{{ end }} class="diff-side {{ $cls }}"> 147 + {{ if gt $line.Num 0 -}} 148 + <a href="#{{ $lineId }}" class="diff-num"><span>{{ $line.Num }}</span></a> 149 + {{- else -}} 150 + <span aria-hidden="true" class="diff-num"><span></span></span> 151 + {{- end }} 152 + <span class="diff-indicator">{{ $mark }}</span> 153 + <div class="diff-content">{{ $line.Content }}</div> 154 + </div> 155 + {{ end }} 156 + 157 + {{ define "activeFileHighlightScript" }} 158 + <script> 159 + (() => { 160 + if (window.__activeFileScrollHandler) { 161 + document.removeEventListener('scroll', window.__activeFileScrollHandler); 162 + } 163 + 164 + const filetreeLinks = document.querySelectorAll('.filetree-link'); 165 + if (filetreeLinks.length === 0) return; 166 + 167 + const linkMap = new Map(); 168 + filetreeLinks.forEach(link => { 169 + const path = link.getAttribute('data-path'); 170 + if (path) linkMap.set('file-' + path, link); 171 + }); 172 + 173 + let currentActive = null; 174 + const setActive = (link) => { 175 + if (link && link !== currentActive) { 176 + if (currentActive) currentActive.classList.remove('font-bold'); 177 + link.classList.add('font-bold'); 178 + currentActive = link; 179 + } 180 + }; 181 + 182 + filetreeLinks.forEach(link => { 183 + link.addEventListener('click', () => setActive(link)); 184 + }); 185 + 186 + const topbar = document.querySelector('.sticky.top-0.z-20'); 187 + const headerHeight = topbar ? topbar.offsetHeight : 0; 188 + 189 + const updateActiveFile = () => { 190 + const diffFiles = document.querySelectorAll('details[id^="file-"]'); 191 + Array.from(diffFiles).some(file => { 192 + const rect = file.getBoundingClientRect(); 193 + if (rect.top <= headerHeight && rect.bottom > headerHeight) { 194 + setActive(linkMap.get(file.id)); 195 + return true; 196 + } 197 + return false; 198 + }); 199 + }; 200 + 201 + window.__activeFileScrollHandler = updateActiveFile; 202 + document.addEventListener('scroll', updateActiveFile); 203 + updateActiveFile(); 204 + })(); 205 + </script> 206 + {{ end }} 207 + 208 + {{ define "reviewStateScript" }} 209 + <script> 210 + (() => { 211 + const targetRepoDid = document.getElementById('pull-target-repo-did')?.value; 212 + const pullId = document.getElementById('pull-id')?.value; 213 + const activeVersionId = document.getElementById('pull-active-version-id')?.value; 214 + if (!targetRepoDid || !pullId || !activeVersionId) return; 215 + 216 + const REVIEWED_PREFIX = 'reviewed:'; 217 + const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; 218 + 219 + const storageKey = REVIEWED_PREFIX + ':' + targetRepoDid + ':' + pullId + ':' + activeVersionId; 220 + 221 + const load = () => { 222 + try { 223 + const entry = JSON.parse(localStorage.getItem(storageKey) || '{}'); 224 + return new Set(Array.isArray(entry) ? entry : (entry.files || [])); 225 + } 226 + catch { return new Set(); } 227 + }; 228 + 229 + const save = (reviewed) => { 230 + const liveIds = new Set(Array.from(allFiles()).map(d => d.id)); 231 + localStorage.setItem(storageKey, JSON.stringify({ 232 + files: Array.from(reviewed).filter(id => liveIds.has(id)), 233 + ts: Date.now(), 234 + })); 235 + }; 236 + 237 + const pruneStale = () => { 238 + const now = Date.now(); 239 + Object.keys(localStorage) 240 + .filter(k => k.startsWith(REVIEWED_PREFIX) && k !== storageKey) 241 + .forEach(k => { 242 + try { 243 + const entry = JSON.parse(localStorage.getItem(k)); 244 + if (!entry.ts || now - entry.ts > MAX_AGE_MS) localStorage.removeItem(k); 245 + } catch { localStorage.removeItem(k); } 246 + }); 247 + }; 248 + if (Math.random() < 0.1) pruneStale(); 249 + 250 + const allFiles = () => 251 + document.querySelectorAll('details[id^="file-"]'); 252 + 253 + const applyOne = (fileId, isReviewed) => { 254 + const detail = document.getElementById(fileId); 255 + if (!detail) return; 256 + 257 + const btn = detail.querySelector('[data-review-btn]'); 258 + const checkbox = btn?.querySelector('input[type="checkbox"]'); 259 + const path = CSS.escape(fileId.replace('file-', '')); 260 + const treeLink = document.querySelector(`.filetree-link[data-path="${path}"]`); 261 + 262 + detail.classList.toggle('opacity-60', isReviewed); 263 + 264 + if (checkbox) checkbox.checked = isReviewed; 265 + 266 + if (treeLink) { 267 + const existing = treeLink.parentElement.querySelector('.review-indicator'); 268 + if (isReviewed && !existing) { 269 + const indicator = document.createElement('span'); 270 + indicator.className = 'review-indicator text-green-600 dark:text-green-400 flex-shrink-0'; 271 + indicator.innerHTML = '&#10003;'; 272 + treeLink.parentElement.appendChild(indicator); 273 + } else if (!isReviewed && existing) { 274 + existing.remove(); 275 + } 276 + } 277 + }; 278 + 279 + const updateProgress = (reviewed) => { 280 + const el = document.getElementById('changed-files-label'); 281 + if (!el) return; 282 + const total = parseInt(el.dataset.total, 10); 283 + const files = allFiles(); 284 + const count = Array.from(files).filter(d => reviewed.has(d.id)).length; 285 + const suffix = total === 1 ? 'file' : 'files'; 286 + const allDone = count === total; 287 + el.classList.toggle('text-green-600', allDone); 288 + el.classList.toggle('dark:text-green-400', allDone); 289 + el.classList.toggle('text-gray-600', !allDone); 290 + el.classList.toggle('dark:text-gray-400', !allDone); 291 + el.textContent = count > 0 292 + ? `${count}/${total} ${suffix} reviewed` 293 + : `${total} changed ${suffix}`; 294 + }; 295 + 296 + const reviewed = load(); 297 + 298 + const toggleReview = (fileId) => { 299 + const detail = document.getElementById(fileId); 300 + if (!detail) return; 301 + const isNowReviewed = !reviewed.has(fileId); 302 + if (isNowReviewed) { 303 + reviewed.add(fileId); 304 + detail.open = false; 305 + } else { 306 + reviewed.delete(fileId); 307 + } 308 + save(reviewed); 309 + applyOne(fileId, isNowReviewed); 310 + updateProgress(reviewed); 311 + }; 312 + 313 + document.getElementById('diff-list').addEventListener('change', (e) => { 314 + const checkbox = e.target.closest('.review-checkbox'); 315 + if (!checkbox) return; 316 + const fileId = checkbox.dataset.fileId; 317 + if (fileId) toggleReview(fileId); 318 + }); 319 + 320 + document.querySelectorAll('.review-btn').forEach(btn => { 321 + btn.classList.remove('hidden'); 322 + btn.classList.add('flex'); 323 + }); 324 + 325 + allFiles().forEach(detail => { 326 + if (reviewed.has(detail.id)) { 327 + applyOne(detail.id, true); 328 + detail.open = false; 329 + } 330 + }); 331 + updateProgress(reviewed); 332 + })(); 333 + </script> 334 + {{ end }}
+18
appview/pages/templates/repo/pulls/fragments/diffFile.html
··· 1 + {{ define "repo/pulls/fragments/diffFile" }} 2 + <details id="file-{{ .Id }}" class="group w-full drop-shadow-sm rounded overflow-clip"> 3 + <summary class="list-none cursor-pointer bg-slate-100 dark:bg-gray-900"> 4 + <div class="flex justify-between rounded border bg-white dark:bg-gray-800 group-open:rounded-b-none border-gray-200 dark:border-gray-700"> 5 + <div class="p-2 flex gap-2 items-center"> 6 + <span class="group-open:hidden inline">{{ i "chevron-right" "size-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 8 + {{ template "repo/fragments/diffStatPill" .Stats }} 9 + <span>{{ .Path }}</span> 10 + </div> 11 + <div></div> 12 + </div> 13 + </summary> 14 + <div class="rounded-b overflow-clip border-x border-b border-gray-200 dark:border-gray-700"> 15 + <pre>todo: diff content</pre> 16 + </div> 17 + </details> 18 + {{ end }}
+29
appview/pages/templates/repo/pulls/fragments/diffSettings.html
··· 1 + {{ define "repo/pulls/fragments/diffSettings" }} 2 + {{ $diffUrl := .DiffUrl }} 3 + {{ $unified := .Unified }} 4 + <div class="btn-group"> 5 + <button 6 + type="button" 7 + hx-get="{{ $diffUrl }}" 8 + hx-vals='{"view": "unified"}' 9 + hx-target="#diff-list" 10 + hx-swap="outerHTML" 11 + class="group btn-group-item {{ if $unified }}active{{ end }}" 12 + > 13 + <span class="inline group-[.htmx-request]:hidden">{{ i "square-split-vertical" "size-4" }}</span> 14 + <span class="hidden group-[.htmx-request]:inline animate-spin">{{ i "loader-circle" "size-4" }}</span> 15 + Unified 16 + </button> 17 + <button 18 + type="button" 19 + hx-get="{{ $diffUrl }}" 20 + hx-target="#diff-list" 21 + hx-swap="outerHTML" 22 + class="group btn-group-item {{ if not $unified }}active{{ end }}" 23 + > 24 + <span class="inline group-[.htmx-request]:hidden">{{ i "square-split-horizontal" "size-4" }}</span> 25 + <span class="hidden group-[.htmx-request]:inline animate-spin">{{ i "loader-circle" "size-4" }}</span> 26 + Split 27 + </button> 28 + </div> 29 + {{ end }}
+10 -25
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 1 1 {{ define "repo/pulls/fragments/pullActions" }} 2 - {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 2 + {{ $lastIdx := .Pull.LatestVersionNumber }} 3 3 {{ $roundNumber := .RoundNumber }} 4 - {{ $stack := .Stack }} 5 4 {{ $loading := .Loading }} 6 5 7 - {{ $totalPulls := sub 0 1 }} 8 - {{ $below := sub 0 1 }} 9 - {{ $stackCount := "" }} 10 - {{ if (gt (len .Stack) 1) }} 11 - {{ $totalPulls = len $stack }} 12 - {{ $below = $stack.Below .Pull }} 13 - {{ $mergeable := len $below.Mergeable }} 14 - {{ $stackCount = printf "%d/%d" $mergeable $totalPulls }} 15 - {{ end }} 16 - 17 6 {{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }} 18 7 {{ $isMerged := .Pull.State.IsMerged }} 19 8 {{ $isClosed := .Pull.State.IsClosed }} ··· 21 10 {{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }} 22 11 {{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }} 23 12 {{ $isLastRound := eq $roundNumber $lastIdx }} 24 - {{ $isSameRepoBranch := .Pull.IsBranchBased }} 25 13 {{ $isUpToDate := .ResubmitCheck.No }} 26 14 {{ $isForkBased := .Pull.IsForkBased }} 27 15 {{ $showRunCI := and (not $loading) $isPushAllowed $isOpen $isLastRound $isForkBased (not .HasPipeline) (ne .RepoInfo.Spindle "") }} 28 16 29 17 <div id="action-card-{{$roundNumber}}" class="{{ if .LoggedInUser }}relative p-2 flex flex-col gap-2{{ else }}hidden{{ end }}" 30 18 {{ if $loading }} 31 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/actions" 19 + hx-get="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ $roundNumber }}/_/actions" 32 20 hx-trigger="load" 33 21 hx-swap="outerHTML" 34 22 hx-target="this" ··· 46 34 hx-target="#pull-comments-{{ .RoundNumber }}" 47 35 hx-swap="beforeend" 48 36 hx-disabled-elt="find button[type='submit']" 49 - hx-on::after-request="if(event.target === this && event.detail.successful) htmx.ajax('GET', '/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions', {target: '#action-card-{{ .RoundNumber }}', swap: 'outerHTML'})" 37 + hx-on::after-request="if(event.target === this && event.detail.successful) htmx.ajax('GET', '/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ .RoundNumber }}/_/actions', {target: '#action-card-{{ .RoundNumber }}', swap: 'outerHTML'})" 50 38 > 51 39 <input name="subject-uri" type="hidden" value="{{ .Pull.AtUri }}"> 40 + <input name="subject-cid" type="hidden" value="{{ .Pull.Cid }}"> 52 41 <input name="pull-round-idx" type="hidden" value="{{ .RoundNumber }}"> 53 42 {{ template "fragments/markdownEditor" 54 43 (dict "Name" "body" ··· 111 100 {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 112 101 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 102 {{ end }} 114 - Merge{{if $stackCount}} {{$stackCount}}{{end}} 103 + Merge 115 104 </button> 116 105 {{ end }} 117 106 118 107 {{ if and $isPullAuthor $isOpen $isLastRound }} 119 108 <button id="resubmitBtn" 120 - {{ if not .Pull.IsPatchBased }} 121 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 122 - hx-swap="none" 123 - {{ else }} 124 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 125 - {{ end }} 109 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/resubmit" 110 + hx-swap="none" 126 111 127 112 hx-disabled-elt="#resubmitBtn" 128 113 class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" ··· 149 134 150 135 {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 151 136 <button 152 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 137 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/close" 153 138 hx-swap="none" 154 139 class="btn-flat p-2 flex items-center gap-2 group"> 155 140 {{ i "ban" "w-4 h-4 inline group-[.htmx-request]:hidden" }} ··· 160 145 161 146 {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 162 147 <button 163 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 148 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/reopen" 164 149 hx-swap="none" 165 150 class="btn-flat p-2 flex items-center gap-2 group"> 166 151 {{ i "refresh-ccw-dot" "w-4 h-4 inline group-[.htmx-request]:hidden" }} ··· 225 210 class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 w-full md:w-96 p-4 rounded drop-shadow overflow-visible" 226 211 > 227 212 <form 228 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/trigger-ci?confirm=1" 213 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/trigger-ci?confirm=1" 229 214 hx-swap="none" 230 215 hx-indicator="#run-ci-spinner-{{ .Pull.PullId }}" 231 216 class="flex flex-col gap-2"
+1 -1
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
··· 3 3 <select 4 4 id="sourceBranch" 5 5 name="sourceBranch" 6 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 6 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 7 7 hx-include="closest form" 8 8 hx-target="#pr-compose-host" 9 9 hx-swap="outerHTML"
+1 -1
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 6 6 name="fork" 7 7 required 8 8 class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 9 - hx-post="/{{ $.RepoInfo.FullName }}/pulls/new/refresh" 9 + hx-post="/{{ $.RepoInfo.RepoDid }}/pulls/new/refresh" 10 10 hx-include="closest form" 11 11 hx-target="#pr-compose-host" 12 12 hx-swap="outerHTML"
+1 -1
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
··· 2 2 <div class="flex flex-wrap gap-2 items-center"> 3 3 <select 4 4 name="sourceBranch" 5 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 5 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 6 6 hx-include="closest form" 7 7 hx-target="#pr-compose-host" 8 8 hx-swap="outerHTML"
+3 -7
appview/pages/templates/repo/pulls/fragments/pullComposeHost.html
··· 7 7 </div> 8 8 {{ end }} 9 9 10 - {{ $hasCommits := and .Comparison .Comparison.FormatPatch }} 11 - {{ $hasDiff := false }} 12 - {{ if .Diff }}{{ if .Diff.Diff }}{{ $hasDiff = true }}{{ end }}{{ end }} 13 - {{ $showDetails := and (or $hasCommits $hasDiff) (not .IsStacked) }} 10 + {{ $showDetails := .StepReviewParams }} 14 11 15 12 <form 16 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 13 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new" 17 14 hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:(#patch,#title,#body)" 18 15 hx-indicator="#create-pull-spinner" 19 - hx-target="body" 20 - hx-swap="innerHTML show:top" 16 + hx-swap="none" 21 17 class="flex flex-col gap-6" 22 18 > 23 19 <section class="relative flex flex-col gap-3">
+1 -1
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
··· 11 11 </div> 12 12 <textarea 13 13 hx-trigger="paste delay:100ms, change" 14 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 14 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 15 15 hx-include="closest form" 16 16 hx-target="#pr-compose-host" 17 17 hx-swap="outerHTML"
+2 -2
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
··· 16 16 17 17 <div class="mt-4 flex flex-col"> 18 18 <form 19 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 19 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/{{ .Pull.PullId }}/resubmit" 20 20 hx-swap="none" 21 21 class="w-full flex flex-wrap gap-2" 22 22 hx-indicator="#resubmit-spinner" ··· 45 45 <button 46 46 type="button" 47 47 class="btn flex items-center gap-2" 48 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 48 + hx-get="/{{ .RepoInfo.RepoDid }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 49 49 hx-swap="outerHTML" 50 50 hx-target="#resubmit-pull-card" 51 51 hx-indicator="#cancel-resubmit-spinner"
+1
appview/pages/templates/repo/pulls/fragments/pullStepDetails.html
··· 38 38 oninput="document.getElementById('titleDirty').value='1'" 39 39 class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700" 40 40 placeholder="One-line summary of your change." 41 + required 41 42 /> 42 43 </div> 43 44
+54 -364
appview/pages/templates/repo/pulls/fragments/pullStepReview.html
··· 1 1 {{ define "repo/pulls/fragments/pullStepReview" }} 2 + {{ $root := . }} 3 + {{ $params := .StepReviewParams }} 2 4 <section class="flex flex-col gap-3"> 3 - {{ if not .Comparison }} 5 + {{ if not $params }} 4 6 <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800/30 text-sm text-gray-600 dark:text-gray-400"> 5 - {{ if eq .Source "patch" }} 7 + {{ if eq $root.Source "patch" }} 6 8 Paste a patch above to see a comparison. 7 9 {{ else }} 8 10 Pick a source and target above to see a comparison. 9 11 {{ end }} 10 12 </div> 13 + {{ else if eq .Source "patch" }} 14 + <!-- TODO: implement patch based PR --> 11 15 {{ else }} 12 - {{ $commits := .Comparison.FormatPatch }} 13 - {{ if $commits }} 16 + {{ if $params.Commits }} 17 + {{ $commits := $params.Commits }} 14 18 <div class="flex flex-col gap-2"> 15 19 <div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400"> 16 - {{ if .IsStacked }} 17 - <span class="inline-flex items-center gap-2 flex-shrink-0 text-gray-900 dark:text-gray-100"> 18 - {{ i "chevrons-down-up" "w-4 h-4" }} 19 - <span>Stack</span> 20 - <span class="text-gray-500 dark:text-gray-400">{{ len $commits }} pull request{{ if ne (len $commits) 1 }}s{{ end }}</span> 21 - </span> 22 - {{ else }} 23 - <span class="flex-shrink-0">{{ len $commits }} commit{{ if ne (len $commits) 1 }}s{{ end }}</span> 24 - {{ end }} 25 - {{ if and .SourceBranch .TargetBranch }} 20 + <span class="flex-shrink-0">{{ len $commits }} commit{{ if ne (len $commits) 1 }}s{{ end }}</span> 21 + {{ if and $root.SourceBranch $root.TargetBranch }} 26 22 <span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate"> 27 - <span>{{ .TargetBranch }}</span> 23 + <span>{{ $root.TargetBranch }}</span> 28 24 {{ i "arrow-left-right" "w-4 h-4 flex-shrink-0" }} 29 - <span>{{ .SourceBranch }}</span> 25 + <span>{{ $root.SourceBranch }}</span> 30 26 </span> 31 27 {{ end }} 32 28 </div> 33 - {{ if .IsStacked }} 34 - {{ template "pullReviewStackedCommits" . }} 35 - {{ else }} 36 - {{ template "pullReviewFlatCommits" . }} 37 - {{ end }} 29 + {{ template "pullReviewCommits" (list $root.SourceRepo $params.Commits) }} 38 30 </div> 39 - {{ else if ne .Source "patch" }} 31 + 32 + <!-- query diff on load --> 33 + <div class="w-full relative min-w-0"> 34 + <div 35 + id="diff-area" 36 + hx-get="/{{ $root.SourceRepo }}/pulls/_/composediff?baseRepo={{ $root.RepoInfo.RepoDid }}&base={{ $root.TargetBranch }}&headRepo={{ $root.SourceRepo }}&head={{ $root.SourceBranch }}&view=unified" 37 + hx-target="this" 38 + hx-swap="outerHTML" 39 + hx-trigger="load" 40 + > 41 + Loading... 42 + </div> 43 + </div> 44 + {{ else }} 40 45 <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800/30 text-sm text-gray-600 dark:text-gray-400"> 41 46 {{ if and .SourceBranch .TargetBranch (eq .SourceBranch .TargetBranch) }} 42 47 Source and target are the same branch, nothing to merge. ··· 45 50 {{ end }} 46 51 </div> 47 52 {{ end }} 48 - 49 - {{ if and .Diff (not .IsStacked) }} 50 - {{ template "pullComposeFlatDiff" (list .Diff .DiffOpts) }} 51 - {{ end }} 52 - 53 - {{ if and .IsStacked $commits }} 54 - {{ template "pullSubmitRow" . }} 55 - {{ template "pullStackApplyAllScript" }} 56 - {{ end }} 57 53 {{ end }} 58 54 </section> 59 55 {{ end }} 60 56 61 - {{ define "pullStackApplyAllScript" }} 62 - <script> 63 - (() => { 64 - const checkbox = document.getElementById('stack-apply-all'); 65 - if (!checkbox || checkbox.dataset.applyAllWired === '1') return; 66 - checkbox.dataset.applyAllWired = '1'; 67 - 68 - const update = () => { 69 - const panels = document.querySelectorAll('[data-stack-labels]'); 70 - panels.forEach((panel, idx) => { 71 - if (idx === 0) return; 72 - panel.classList.toggle('hidden', checkbox.checked); 73 - }); 74 - }; 75 - checkbox.addEventListener('change', update); 76 - update(); 77 - })(); 78 - </script> 79 - {{ end }} 80 - 81 - {{ define "pullReviewFlatCommits" }} 82 - {{ $commits := .Comparison.FormatPatch }} 57 + {{ define "pullReviewCommits" }} 58 + {{ $sourceRepo := index . 0 }} 59 + {{ $commits := index . 1 }} 83 60 <ul class="flex flex-col gap-2"> 84 61 {{ range $commits }} 62 + {{- $messageParts := splitN .Message "\n\n" 2 -}} 63 + {{- $title := index $messageParts 0 -}} 85 64 <li class="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 px-3 py-2 flex items-center gap-3"> 86 65 <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 87 - {{ i "git-pull-request-create" "w-4 h-4" }} 66 + {{ i "git-commit-vertical" "size-4" }} 88 67 </span> 89 - <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ .Title }}</span> 90 - {{ template "pullReviewCommitTimestamp" (dict "Patch" .) }} 91 - <span class="-my-2 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 92 - {{ template "pullReviewCommitActions" (dict "Patch" . "RepoInfo" $.RepoInfo) }} 93 - </li> 94 - {{ end }} 95 - </ul> 96 - {{ end }} 97 - 98 - {{ define "pullReviewStackedCommits" }} 99 - {{ $root := . }} 100 - {{ $commits := .Comparison.FormatPatch }} 101 - {{ $hasSidePanel := and $root.LabelDefs $root.RepoInfo.Roles.IsPushAllowed }} 102 - <ul class="flex flex-col gap-2"> 103 - {{ range $idx, $p := $commits }} 104 - {{ $cid := $p.ChangeIdOrEmpty }} 105 - {{ $titleOverride := index $root.StackTitles $cid }} 106 - {{ $bodyOverride := index $root.StackBodies $cid }} 107 - {{ $displayTitle := $p.Title }} 108 - {{ if $titleOverride }}{{ $displayTitle = $titleOverride }}{{ end }} 109 - {{ $bodyValue := $p.Body }} 110 - {{ if $bodyOverride }}{{ $bodyValue = $bodyOverride }}{{ end }} 111 - {{ $perDiff := "" }} 112 - {{ $perOpts := dict }} 113 - {{ if lt $idx (len $root.StackedDiffs) }} 114 - {{ $sd := index $root.StackedDiffs $idx }} 115 - {{ $perDiff = $sd.Diff }} 116 - {{ $perOpts = $sd.Opts }} 117 - {{ end }} 118 - <li> 119 - <details class="group/stacked border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 120 - <summary class="p-3 cursor-pointer flex items-center gap-3 list-none"> 121 - <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 122 - {{ i "chevron-right" "w-4 h-4 group-open/stacked:hidden inline" }} 123 - {{ i "chevron-down" "w-4 h-4 hidden group-open/stacked:inline" }} 124 - </span> 125 - <span class="-my-3 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 126 - <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 127 - {{ i "git-pull-request-create" "w-4 h-4" }} 128 - </span> 129 - <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ $displayTitle }}</span> 130 - {{ template "pullReviewCommitTimestamp" (dict "Patch" $p) }} 131 - <span class="-my-3 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 132 - {{ template "pullReviewCommitActions" (dict "Patch" $p "RepoInfo" $root.RepoInfo) }} 133 - </summary> 134 - {{ if $cid }} 135 - <div class="px-3 pb-3 pt-1 flex flex-col gap-3 border-t border-gray-100 dark:border-gray-700"> 136 - {{ $titleName := printf "stackTitle[%s]" $cid }} 137 - {{ $bodyName := printf "stackBody[%s]" $cid }} 138 - <div class="flex flex-col md:flex-row gap-6"> 139 - <div class="flex-1 min-w-0 flex flex-col gap-3"> 140 - <div class="flex flex-col gap-1"> 141 - <label class="text-xs tracking-wide text-gray-800 dark:text-gray-200">Title</label> 142 - <input 143 - type="text" 144 - name="{{ $titleName }}" 145 - value="{{ $displayTitle }}" 146 - class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700" 147 - placeholder="{{ $p.Title }}" 148 - /> 149 - </div> 150 - {{ template "fragments/markdownEditor" (dict 151 - "Name" $bodyName 152 - "Value" $bodyValue 153 - "Rows" 4 154 - "Placeholder" "Describe this pull request. Markdown is supported." 155 - ) }} 156 - </div> 157 - {{ if $hasSidePanel }} 158 - <aside data-stack-labels="{{ $cid }}" class="w-full md:w-72 md:flex-shrink-0 flex flex-col gap-4"> 159 - {{ $labelState := $root.LabelState }} 160 - {{ if $root.StackLabelStates }} 161 - {{ $perCid := index $root.StackLabelStates $cid }} 162 - {{ if $perCid }}{{ $labelState = $perCid }}{{ end }} 163 - {{ end }} 164 - {{ $labelCtx := dict "Defs" $root.LabelDefs "State" $labelState "RepoInfo" $root.RepoInfo "Subject" "" "LoggedInUser" $root.LoggedInUser "Prefix" (printf "stackLabel[%s]" $cid) }} 165 - {{ template "editBasicLabels" $labelCtx }} 166 - {{ template "editKvLabels" $labelCtx }} 167 - {{ if eq $idx 0 }} 168 - <label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 self-start"> 169 - <input type="checkbox" id="stack-apply-all" name="applyLabelsToAll" autocomplete="off" /> 170 - Apply labels and assignees to all PRs in stack 171 - </label> 172 - {{ end }} 173 - </aside> 174 - {{ end }} 175 - </div> 176 - {{ if $perDiff }} 177 - <hr class="border-gray-200 dark:border-gray-700 my-1" /> 178 - <div id="stack-diff-{{ $cid }}"> 179 - <input type="hidden" name="stackSplit[{{ $cid }}]" value="{{ if $perOpts.Split }}split{{ else }}unified{{ end }}" /> 180 - {{ template "pullStackedDiffArea" (dict "Diff" $perDiff "DiffOpts" $perOpts "Cid" $cid) }} 181 - </div> 182 - {{ end }} 183 - </div> 184 - {{ else }} 185 - <div class="px-3 pb-3 pt-1 text-sm text-yellow-700 dark:text-yellow-300 border-t border-gray-100 dark:border-gray-700"> 186 - This commit has no <span class="font-mono">Change-Id</span> header and can't be stacked. Set one on the commit and re-push. 187 - </div> 68 + <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ $title }}</span> 69 + <span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> 70 + {{ if not .Author.When.IsZero }} 71 + {{ template "repo/fragments/shortTimeAgo" .Author.When }} 188 72 {{ end }} 189 - </details> 190 - </li> 191 - {{ end }} 192 - </ul> 193 - {{ end }} 194 - 195 - {{ define "pullReviewCommitTimestamp" }} 196 - {{ $p := .Patch }} 197 - {{ if or (not $p.AuthorDate.IsZero) $p.SHA }} 198 - <span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> 199 - {{ if not $p.AuthorDate.IsZero }} 200 - {{ template "repo/fragments/shortTimeAgo" $p.AuthorDate }} 201 - {{ end }} 202 - {{ if $p.SHA }} 203 - <span class="font-mono bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 px-2 py-0.5 rounded">{{ slice $p.SHA 0 8 }}</span> 204 - {{ end }} 205 - </span> 206 - {{ end }} 207 - {{ end }} 208 - 209 - {{ define "pullReviewCommitActions" }} 210 - {{ $p := .Patch }} 211 - {{ $repoInfo := .RepoInfo }} 212 - {{ if $p.SHA }} 213 - <span class="flex items-center gap-1 text-gray-700 dark:text-gray-300 flex-shrink-0"> 214 - <button type="button" 215 - class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 216 - title="Copy SHA" 217 - onclick="event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText('{{ $p.SHA }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 218 - {{ i "copy" "w-4 h-4" }} 219 - </button> 220 - <a href="/{{ $repoInfo.FullName }}/tree/{{ $p.SHA }}" 221 - onclick="event.stopPropagation()" 222 - class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 223 - title="Browse repository at this commit"> 224 - {{ i "folder-code" "w-4 h-4" }} 225 - </a> 226 - </span> 227 - {{ end }} 228 - {{ end }} 229 - 230 - {{ define "pullStackedDiffArea" }} 231 - {{ $diff := .Diff }} 232 - {{ $opts := .DiffOpts }} 233 - {{ $cid := .Cid }} 234 - {{ $togId := printf "stack-%s-filesToggle" $cid }} 235 - {{ $colId := printf "stack-%s-collapseToggle" $cid }} 236 - {{ $filesId := printf "stack-%s-files" $cid }} 237 - {{ $diffAreaId := printf "stack-%s-diff-area" $cid }} 238 - {{ $filePrefix := printf "stack-%s-file-" $cid }} 239 - {{ $stat := $diff.Stats }} 240 - {{ $count := len $diff.ChangedFiles }} 241 - 242 - <style> 243 - #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .show-text { display: none; } 244 - #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .hide-text { display: inline; } 245 - #{{ $togId }}:not(:checked) ~ * label[for="{{ $togId }}"] .hide-text { display: none; } 246 - #{{ $togId }}:checked ~ * div#{{ $filesId }} { width: fit-content; max-width: 15vw; } 247 - #{{ $togId }}:not(:checked) ~ * div#{{ $filesId }} { width: 0; display: none; margin-right: 0; } 248 - </style> 249 - 250 - <input type="checkbox" id="{{ $togId }}" class="hidden"/> 251 - 252 - <div id="{{ $diffAreaId }}"> 253 - <div class="bg-slate-100 dark:bg-gray-700/50 flex items-center gap-2 h-12 p-2 rounded-t-md border border-b-0 border-gray-200 dark:border-gray-700"> 254 - <label title="Toggle filetree panel" for="{{ $togId }}" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer text-normal font-normal normal-case"> 255 - <span class="show-text">{{ i "panel-left-open" "size-4" }}</span> 256 - <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span> 257 - </label> 258 - 259 - {{ template "repo/fragments/diffStatPill" $stat }} 260 - <span class="text-xs text-gray-600 dark:text-gray-300 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 261 - 262 - <div class="flex-grow"></div> 263 - 264 - <label title="Expand/Collapse diffs" for="{{ $colId }}" class="btn font-normal normal-case"> 265 - <input type="checkbox" id="{{ $colId }}" class="peer/collapse hidden"/> 266 - <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 267 - {{ i "unfold-vertical" "w-4 h-4" }} 268 - <span class="hidden md:inline">Expand all</span> 73 + <span class="font-mono bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 px-2 py-0.5 rounded">{{ slice .Hash.String 0 8 }}</span> 269 74 </span> 270 - <span class="peer-checked/collapse:inline-flex hidden flex items-center gap-2"> 271 - {{ i "fold-vertical" "w-4 h-4" }} 272 - <span class="hidden md:inline">Collapse all</span> 75 + <span class="-my-2 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 76 + <span class="flex items-center gap-1 text-gray-700 dark:text-gray-300 flex-shrink-0"> 77 + <button type="button" 78 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 79 + title="Copy SHA" 80 + onclick='event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText(`{{ .Hash.String }}`); this.innerHTML=`{{ i "copy-check" "size-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "size-4" }}`, 1500)'> 81 + {{ i "copy" "size-4" }} 82 + </button> 83 + <a href="/{{ $sourceRepo }}/tree/{{ .Hash.String }}" 84 + onclick="event.stopPropagation()" 85 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 86 + title="Browse repository at this commit"> 87 + {{ i "folder-code" "size-4" }} 88 + </a> 273 89 </span> 274 - </label> 275 - 276 - {{ template "repo/fragments/diffOpts" $opts }} 277 - </div> 278 - 279 - <div class="flex border border-gray-200 dark:border-gray-700 rounded-b-md"> 280 - <div id="{{ $filesId }}" class="hidden md:block overflow-hidden max-h-[60vh] overflow-y-auto border-r border-gray-200 dark:border-gray-700"> 281 - <section class="overflow-x-auto text-sm px-3 py-2 w-full mx-auto"> 282 - {{ template "repo/fragments/fileTreePrefixed" (dict "Tree" $diff.FileTree "Prefix" $filePrefix) }} 283 - </section> 284 - </div> 285 - 286 - <div class="flex-1 min-w-0 p-2"> 287 - <div class="flex flex-col gap-2"> 288 - {{ if eq $count 0 }} 289 - <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 290 - <p>No differences found.</p> 291 - </div> 292 - {{ else if le $count 5 }} 293 - {{ range $idx, $file := $diff.ChangedFiles }} 294 - {{ template "stackedDiffFile" (dict "Idx" $idx "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 295 - {{ end }} 296 - {{ else }} 297 - {{ range $idx, $file := slice $diff.ChangedFiles 0 5 }} 298 - {{ template "stackedDiffFile" (dict "Idx" $idx "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 299 - {{ end }} 300 - {{ $remaining := sub $count 5 }} 301 - <details class="group/showmore"> 302 - <summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-300 py-2 px-1 flex items-center gap-2 select-none hover:text-gray-900 dark:hover:text-gray-100 list-none"> 303 - <span class="group-open/showmore:hidden inline-flex items-center gap-2"> 304 - {{ i "chevron-right" "w-4 h-4" }} 305 - Show {{ $remaining }} more file{{ if ne $remaining 1 }}s{{ end }} 306 - </span> 307 - <span class="hidden group-open/showmore:inline-flex items-center gap-2"> 308 - {{ i "chevron-down" "w-4 h-4" }} 309 - Hide {{ $remaining }} file{{ if ne $remaining 1 }}s{{ end }} 310 - </span> 311 - </summary> 312 - <div class="flex flex-col gap-2 mt-2"> 313 - {{ range $idx, $file := slice $diff.ChangedFiles 5 }} 314 - {{ template "stackedDiffFile" (dict "Idx" (add $idx 5) "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 315 - {{ end }} 316 - </div> 317 - </details> 318 - {{ end }} 319 - </div> 320 - </div> 321 - </div> 322 - </div> 323 - 324 - <script> 325 - (() => { 326 - const cb = document.getElementById('{{ $colId }}'); 327 - const area = document.getElementById('{{ $diffAreaId }}'); 328 - if (!cb || !area) return; 329 - const all = () => area.querySelectorAll('details[id^="{{ $filePrefix }}"]'); 330 - cb.addEventListener('change', () => { 331 - all().forEach(d => { d.open = cb.checked; }); 332 - }); 333 - area.addEventListener('toggle', (e) => { 334 - if (!e.target.matches('details[id^="{{ $filePrefix }}"]')) return; 335 - const dets = Array.from(all()); 336 - const allOpen = dets.every(d => d.open); 337 - const allClosed = dets.every(d => !d.open); 338 - if (allOpen) cb.checked = true; 339 - else if (allClosed) cb.checked = false; 340 - }, true); 341 - })(); 342 - </script> 343 - {{ end }} 344 - 345 - {{ define "stackedDiffFile" }} 346 - {{ $idx := .Idx }} 347 - {{ $file := .File }} 348 - {{ $isSplit := .IsSplit }} 349 - {{ $prefix := .Prefix }} 350 - {{ $isGenerated := false }} 351 - {{ $isDeleted := false }} 352 - {{ with $file }} 353 - {{ $n := .Names }} 354 - {{ $isDeleted = and (eq $n.New "") (ne $n.Old "") }} 355 - {{ if $n.New }} 356 - {{ $isGenerated = isGenerated $n.New }} 357 - {{ else if $n.Old }} 358 - {{ $isGenerated = isGenerated $n.Old }} 90 + </li> 359 91 {{ end }} 360 - <details id="{{ $prefix }}{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 361 - <summary class="list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700"> 362 - <div class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 363 - <div class="p-2 flex gap-2 items-center overflow-x-auto"> 364 - <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 365 - <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 366 - {{ template "repo/fragments/diffStatPill" .Stats }} 367 - <div class="flex gap-2 items-center overflow-x-auto"> 368 - {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 369 - {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 370 - {{ else if $n.New }} 371 - {{ $n.New }} 372 - {{ else }} 373 - {{ $n.Old }} 374 - {{ end }} 375 - {{ if $isDeleted }} 376 - <span class="text-gray-400 dark:text-gray-500" title="Deleted files are collapsed by default"> 377 - {{ i "circle-question-mark" "size-4" }} 378 - </span> 379 - {{ else if $isGenerated }} 380 - <span class="text-gray-400 dark:text-gray-500" title="Generated files are collapsed by default"> 381 - {{ i "circle-question-mark" "size-4" }} 382 - </span> 383 - {{ end }} 384 - </div> 385 - </div> 386 - </div> 387 - </summary> 388 - 389 - <div class="transition-all duration-700 ease-in-out"> 390 - {{ $reason := .CanRender }} 391 - {{ if $reason }} 392 - <p class="text-center text-gray-400 dark:text-gray-500 p-4">{{ $reason }}</p> 393 - {{ else }} 394 - {{ if $isSplit }} 395 - {{- template "repo/fragments/splitDiff" .Split -}} 396 - {{ else }} 397 - {{- template "repo/fragments/unifiedDiff" . -}} 398 - {{ end }} 399 - {{- end -}} 400 - </div> 401 - </details> 402 - {{ end }} 92 + </ul> 403 93 {{ end }} 404 94 405 95 {{ define "pullComposeFlatDiff" }}
+8 -34
appview/pages/templates/repo/pulls/fragments/pullStepSource.html
··· 28 28 {{ end }} 29 29 30 30 <div id="patch-error" class="error dark:text-red-300 empty:hidden"></div> 31 - 32 - {{ if ne .Source "patch" }} 33 - <div class="flex items-center gap-2"> 34 - <input type="checkbox" id="mode-stack" name="mode" value="stack" autocomplete="off" {{ if .IsStacked }}checked{{ end }} 35 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 36 - hx-include="closest form" 37 - hx-target="#pr-compose-host" 38 - hx-swap="outerHTML" 39 - hx-trigger="change" 40 - hx-indicator="this" 41 - class="peer"> 42 - <label for="mode-stack" class="my-0 py-0 normal-case font-normal dark:text-white"> 43 - Submit as stacked PRs 44 - </label> 45 - <a 46 - href="https://blog.tangled.org/stacking" 47 - target="_blank" 48 - rel="noopener noreferrer" 49 - aria-label="What are stacked PRs?" 50 - class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white peer-[.htmx-request]:hidden" 51 - > 52 - {{ i "circle-question-mark" "size-4" }} 53 - </a> 54 - {{ i "loader-circle" "size-4 animate-spin hidden peer-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 55 - </div> 56 - {{ end }} 57 31 </section> 58 32 {{ end }} 59 33 ··· 63 37 id="targetBranch" 64 38 name="targetBranch" 65 39 required 66 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 40 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 67 41 hx-include="closest form" 68 42 hx-target="#pr-compose-host" 69 43 hx-swap="outerHTML" ··· 99 73 {{ $shared := "group flex-1 p-3 text-left hover:no-underline flex flex-col gap-1 rounded" }} 100 74 {{ $titleCls := "font-medium text-sm dark:text-white flex items-center gap-2" }} 101 75 {{ $descCls := "text-xs text-gray-500 dark:text-gray-400" }} 102 - {{ $fullName := .RepoInfo.FullName }} 103 76 <div class="flex gap-1 p-1.5 rounded-md bg-slate-100 dark:bg-gray-900 border dark:border-gray-700 items-center justify-stretch" 104 77 hx-on::before-request="const t=event.target.closest('button'); if(!t||t.classList.contains('shadow-sm'))return event.preventDefault();"> 105 78 {{ if .RepoInfo.Roles.IsPushAllowed }} 106 79 <button 107 80 type="button" 108 - class="{{ $shared }} {{ if eq .Source "branch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 109 - hx-post="/{{ $fullName }}/pulls/new/refresh" 81 + class='{{ $shared }} {{ if eq .Source "branch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}' 82 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 110 83 hx-vals='{"source": "branch"}' 111 84 hx-include="closest form" 112 85 hx-target="#pr-compose-host" ··· 122 95 {{ end }} 123 96 <button 124 97 type="button" 125 - class="{{ $shared }} {{ if eq .Source "fork" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 126 - hx-post="/{{ $fullName }}/pulls/new/refresh" 98 + class='{{ $shared }} {{ if eq .Source "fork" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}' 99 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 127 100 hx-vals='{"source": "fork"}' 128 101 hx-include="closest form" 129 102 hx-target="#pr-compose-host" ··· 138 111 </button> 139 112 <button 140 113 type="button" 141 - class="{{ $shared }} {{ if eq .Source "patch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 142 - hx-post="/{{ $fullName }}/pulls/new/refresh" 114 + disabled 115 + class='{{ $shared }} cursor-not-allowed {{ if eq .Source "patch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}' 116 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 143 117 hx-vals='{"source": "patch"}' 144 118 hx-include="closest form" 145 119 hx-target="#pr-compose-host"
-629
appview/pages/templates/repo/pulls/pull.html
··· 1 - {{ define "title" }} 2 - {{ .Pull.Title }} &middot; Pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} &middot; Tangled 3 - {{ end }} 4 - 5 - {{ define "extrameta" }} 6 - {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 - {{ end }} 8 - 9 - {{ define "mainLayout" }} 10 - <div class="px-1 flex-grow flex flex-col gap-4"> 11 - <div class="max-w-full md:max-w-screen-lg mx-auto"> 12 - {{ block "contentLayout" . }} 13 - {{ block "content" . }}{{ end }} 14 - {{ end }} 15 - </div> 16 - {{ block "contentAfterLayout" . }} 17 - <main> 18 - {{ block "contentAfter" . }}{{ end }} 19 - </main> 20 - {{ end }} 21 - </div> 22 - <script> 23 - (function() { 24 - const details = document.getElementById('bottomSheet'); 25 - const backdrop = document.getElementById('bottomSheetBackdrop'); 26 - const isDesktop = () => window.matchMedia('(min-width: 1024px)').matches; 27 - 28 - // function to update backdrop 29 - const updateBackdrop = () => { 30 - if (backdrop) { 31 - if (details.open && !isDesktop()) { 32 - backdrop.classList.remove('opacity-0', 'pointer-events-none'); 33 - backdrop.classList.add('opacity-100', 'pointer-events-auto'); 34 - document.body.style.overflow = 'hidden'; 35 - } else { 36 - backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 37 - backdrop.classList.add('opacity-0', 'pointer-events-none'); 38 - document.body.style.overflow = ''; 39 - } 40 - } 41 - }; 42 - 43 - // close on mobile initially 44 - if (!isDesktop()) { 45 - details.open = false; 46 - } 47 - updateBackdrop(); // initialize backdrop 48 - 49 - // prevent closing on desktop 50 - details.addEventListener('toggle', function(e) { 51 - if (isDesktop() && !this.open) { 52 - this.open = true; 53 - } 54 - updateBackdrop(); 55 - }); 56 - 57 - const mediaQuery = window.matchMedia('(min-width: 1024px)'); 58 - mediaQuery.addEventListener('change', function(e) { 59 - if (e.matches) { 60 - // switched to desktop - keep open 61 - details.open = true; 62 - } else { 63 - // switched to mobile - close 64 - details.open = false; 65 - } 66 - updateBackdrop(); 67 - }); 68 - 69 - // close when clicking backdrop 70 - if (backdrop) { 71 - backdrop.addEventListener('click', () => { 72 - if (!isDesktop()) { 73 - details.open = false; 74 - } 75 - }); 76 - } 77 - })(); 78 - </script> 79 - <script> 80 - (function() { 81 - const isPermalink = (id) => id.startsWith('comment-') || id.startsWith('round-'); 82 - 83 - const reveal = () => { 84 - const raw = window.location.hash.slice(1); 85 - if (!raw) return; 86 - let id; 87 - try { id = decodeURIComponent(raw); } catch (e) { return; } 88 - if (!isPermalink(id)) return; 89 - const target = document.getElementById(id); 90 - if (!target) return; 91 - 92 - for (let el = target.parentElement; el; el = el.parentElement) { 93 - if (el.tagName === 'DETAILS') el.open = true; 94 - } 95 - 96 - requestAnimationFrame(() => requestAnimationFrame(() => { 97 - target.scrollIntoView({ block: 'center' }); 98 - const card = target.closest('.group\\/comment') || target; 99 - card.classList.add('comment-hl'); 100 - })); 101 - }; 102 - 103 - if (document.readyState === 'loading') { 104 - document.addEventListener('DOMContentLoaded', reveal); 105 - } else { 106 - reveal(); 107 - } 108 - window.addEventListener('hashchange', reveal); 109 - })(); 110 - </script> 111 - {{ end }} 112 - 113 - {{ define "repoContentLayout" }} 114 - <div class="grid grid-cols-1 lg:grid-cols-10 gap-4 w-full"> 115 - <div class="col-span-1 lg:col-span-8 flex flex-col gap-4"> 116 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 117 - {{ block "repoContent" . }}{{ end }} 118 - </section> 119 - {{ template "repo/pulls/fragments/pullVouchNudge" . }} 120 - </div> 121 - <div class="flex flex-col gap-6 col-span-1 lg:col-span-2"> 122 - {{ template "repo/fragments/labelPanel" 123 - (dict "RepoInfo" $.RepoInfo 124 - "Defs" $.LabelDefs 125 - "Subject" $.Pull.AtUri 126 - "State" $.Pull.Labels) }} 127 - {{ template "repo/fragments/participants" $.Pull.Participants }} 128 - {{ if $.LoggedInUser }} 129 - {{ template "repo/pulls/fragments/subscribeButton" 130 - (dict "RepoInfo" $.RepoInfo 131 - "PullId" $.Pull.PullId 132 - "IsSubscribed" $.IsSubscribed) }} 133 - {{ end }} 134 - {{ template "repo/fragments/backlinks" 135 - (dict "RepoInfo" $.RepoInfo 136 - "Backlinks" $.Backlinks) }} 137 - {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 138 - </div> 139 - </div> 140 - {{ end }} 141 - 142 - {{ define "contentAfter" }} 143 - <input type="hidden" id="round-link-base" value="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .ActiveRound }}" /> 144 - {{ if .IsInterdiff }} 145 - <input type="hidden" id="is-interdiff" value="1" /> 146 - {{ end }} 147 - {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 148 - {{ end }} 149 - 150 - {{ define "repoContent" }} 151 - {{ template "repo/pulls/fragments/pullHeader" . }} 152 - {{ if (gt (len .Stack) 1) }} 153 - <div class="mt-8"> 154 - {{ template "repo/pulls/fragments/pullStack" . }} 155 - </div> 156 - {{ end }} 157 - {{ end }} 158 - 159 - {{ define "resize-grip" }} 160 - {{ $id := index . 0 }} 161 - {{ $target := index . 1 }} 162 - {{ $direction := index . 2 }} 163 - <div id="{{ $id }}" 164 - data-resizer="vertical" 165 - data-target="{{ $target }}" 166 - data-direction="{{ $direction }}" 167 - class="resizer-vertical hidden lg:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group"> 168 - <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> 169 - </div> 170 - {{ end }} 171 - 172 - {{ define "diffLayout" }} 173 - {{ $diff := index . 0 }} 174 - {{ $opts := index . 1 }} 175 - {{ $root := index . 2 }} 176 - 177 - <div class="flex col-span-full"> 178 - <!-- left panel --> 179 - <div id="files" class="w-0 hidden lg:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 180 - <section class="overflow-x-auto text-sm px-6 py-2 border-b border-x border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded-b rounded-t-none bg-white dark:bg-gray-800 shadow-sm"> 181 - {{ template "repo/fragments/fileTree" $diff.FileTree }} 182 - </section> 183 - </div> 184 - 185 - {{ template "resize-grip" (list "resize-files" "files" "before") }} 186 - 187 - <!-- main content --> 188 - <div id="diff-files" class="flex-1 min-w-0 sticky top-12 pb-12"> 189 - {{ template "diffFiles" (list $diff $opts) }} 190 - </div> 191 - 192 - {{ template "resize-grip" (list "resize-subs" "subs" "after") }} 193 - 194 - <!-- right panel --> 195 - {{ template "subsPanel" $ }} 196 - </div> 197 - {{ end }} 198 - 199 - {{ define "subsPanel" }} 200 - {{ $root := index . 2 }} 201 - {{ $pull := $root.Pull }} 202 - {{ $bgColor := "bg-gray-600 dark:bg-gray-700" }} 203 - 204 - {{ if $pull.State.IsOpen }} 205 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 206 - {{ else if $pull.State.IsMerged }} 207 - {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 208 - {{ else if $pull.State.IsAbandoned }} 209 - {{ $bgColor = "bg-red-600 dark:bg-red-700" }} 210 - {{ end }} 211 - 212 - <!-- backdrop overlay - only visible on mobile when open --> 213 - <div id="bottomSheetBackdrop" class="fixed inset-0 bg-black/50 lg:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-20"></div> 214 - <!-- right panel - bottom sheet on mobile, side panel on desktop --> 215 - <div id="subs" class="fixed bottom-0 left-0 right-0 z-30 w-full lg:static lg:z-auto lg:max-h-screen lg:sticky lg:top-12 overflow-hidden"> 216 - <details open id="bottomSheet" class="rounded-t-2xl lg:rounded-t shadow-lg lg:shadow-none group/panel"> 217 - <summary class=" 218 - flex gap-4 items-center justify-between 219 - rounded-t-2xl lg:rounded-t cursor-pointer list-none p-4 lg:h-12 220 - text-white lg:text-black lg:dark:text-white 221 - {{ $bgColor }} 222 - lg:bg-white lg:dark:bg-gray-800 223 - shadow-sm border-t lg:border-x border-gray-200 dark:border-gray-700 224 - lg:pointer-events-none 225 - "> 226 - <h2 class="">History</h2> 227 - <span class="pointer-events-auto cursor-text"> 228 - {{ template "subsPanelSummary" $ }} 229 - </span> 230 - </summary> 231 - <div class="max-h-[85vh] lg:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 lg:bg-transparent"> 232 - {{ template "submissions" $root }} 233 - </div> 234 - </details> 235 - </div> 236 - {{ end }} 237 - 238 - {{ define "subsPanelSummary" }} 239 - {{ $root := index . 2 }} 240 - {{ $pull := $root.Pull }} 241 - {{ $rounds := len $pull.Submissions }} 242 - {{ $comments := $pull.TotalComments }} 243 - <div class="flex items-center gap-2 text-sm"> 244 - <span> 245 - {{ $rounds }} round{{ if ne $rounds 1 }}s{{ end }} 246 - </span> 247 - <span class="select-none before:content-['\00B7']"></span> 248 - <span> 249 - {{ $comments }} comment{{ if ne $comments 1 }}s{{ end }} 250 - </span> 251 - 252 - <span class="lg:hidden inline"> 253 - <span class="inline group-open:hidden">{{ i "chevron-up" "size-4" }}</span> 254 - <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 255 - </span> 256 - </div> 257 - {{ end }} 258 - 259 - {{ define "subsCheckbox" }} 260 - <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 261 - {{ end }} 262 - 263 - {{ define "subsToggle" }} 264 - <style> 265 - #subsToggle:checked ~ div div#subs { 266 - width: 100%; 267 - margin-left: 0; 268 - } 269 - #subsToggle:checked ~ div label[for="subsToggle"] .show-toggle { display: none; } 270 - #subsToggle:checked ~ div label[for="subsToggle"] .hide-toggle { display: flex; } 271 - #subsToggle:not(:checked) ~ div label[for="subsToggle"] .hide-toggle { display: none; } 272 - 273 - @media (min-width: 1024px) { 274 - #subsToggle:checked ~ div div#subs { 275 - width: 25vw; 276 - max-width: 50vw; 277 - } 278 - #subsToggle:not(:checked) ~ div div#subs { 279 - width: 0; 280 - display: none; 281 - margin-left: 0; 282 - } 283 - #subsToggle:not(:checked) ~ div div#resize-subs { 284 - display: none; 285 - } 286 - } 287 - </style> 288 - <label title="Toggle review panel" for="subsToggle" class="hidden lg:flex items-center justify-end pointer-events-none"> 289 - <span class="show-toggle hit-area hit-area-4 hit-area-x-2 pointer-events-auto cursor-pointer">{{ i "message-square-more" "size-4" }}</span> 290 - <span class="hide-toggle w-[25vw] justify-end"><span class="hit-area hit-area-4 hit-area-x-2 pointer-events-auto cursor-pointer">{{ i "message-square" "size-4" }}</span></span> 291 - </label> 292 - {{ end }} 293 - 294 - 295 - {{ define "submissions" }} 296 - {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 297 - {{ if not .LoggedInUser }} 298 - {{ template "loginPrompt" $ }} 299 - {{ end }} 300 - {{ range $ridx, $item := reverse .Pull.Submissions }} 301 - {{ $idx := sub $lastIdx $ridx }} 302 - {{ template "submission" (list $item $idx $lastIdx $) }} 303 - {{ end }} 304 - {{ end }} 305 - 306 - {{ define "submission" }} 307 - {{ $item := index . 0 }} 308 - {{ $idx := index . 1 }} 309 - {{ $lastIdx := index . 2 }} 310 - {{ $root := index . 3 }} 311 - {{ $round := $item.RoundNumber }} 312 - <div id="round-{{ $round }}" class=" 313 - w-full shadow-sm border overflow-clip 314 - 315 - {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 316 - {{ if eq $round $root.ActiveRound }} 317 - bg-blue-50/25 dark:bg-blue-900/10 border-blue-200 dark:border-blue-900 318 - {{ else }} 319 - bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700 320 - {{ end }} 321 - "> 322 - {{ template "submissionHeader" $ }} 323 - {{ template "submissionComments" $ }} 324 - </div> 325 - {{ end }} 326 - 327 - {{ define "submissionHeader" }} 328 - {{ $item := index . 0 }} 329 - {{ $lastIdx := index . 2 }} 330 - {{ $root := index . 3 }} 331 - {{ $round := $item.RoundNumber }} 332 - <div class=" 333 - {{ if ne $round 0 }}rounded-t{{ end }} 334 - px-6 py-4 pr-2 pt-2 335 - {{ if eq $round $root.ActiveRound }} 336 - bg-blue-50 dark:bg-blue-950 337 - {{ else }} 338 - bg-white dark:bg-gray-800 339 - {{ end }} 340 - 341 - flex gap-2 sticky top-0 z-20"> 342 - <!-- left column: just profile picture --> 343 - <div class="flex-shrink-0 pt-2"> 344 - {{ template "user/fragments/picLink" (list $root.Pull.OwnerDid "size-8" (index $root.VouchRelationships (did $root.Pull.OwnerDid))) }} 345 - </div> 346 - <!-- right column --> 347 - <div class="flex-1 min-w-0 flex flex-col gap-1"> 348 - {{ template "submissionInfo" $ }} 349 - {{ template "submissionCommits" $ }} 350 - {{ template "submissionPipeline" $ }} 351 - {{ if eq $lastIdx $round }} 352 - <div id="mergecheck-banner"> 353 - {{ if $root.Pull.State.IsOpen }} 354 - <div class="flex items-center gap-2 text-gray-500 dark:text-gray-400"> 355 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 356 - <span>Checking mergeability…</span> 357 - </div> 358 - {{ end }} 359 - </div> 360 - {{ end }} 361 - </div> 362 - </div> 363 - {{ end }} 364 - 365 - {{ define "submissionInfo" }} 366 - {{ $item := index . 0 }} 367 - {{ $idx := index . 1 }} 368 - {{ $root := index . 3 }} 369 - {{ $round := $item.RoundNumber }} 370 - <div class="flex gap-2 items-center justify-between mb-1"> 371 - <span class="inline-flex items-center gap-2 text-sm 372 - {{ if eq $round $root.ActiveRound }} 373 - text-gray-600 dark:text-gray-300 374 - {{ else }} 375 - text-gray-500 dark:text-gray-400 376 - {{ end }} 377 - pt-2"> 378 - {{ $handle := resolve $root.Pull.OwnerDid }} 379 - <a class=" 380 - {{ if eq $round $root.ActiveRound }} 381 - text-gray-800 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-200 382 - {{ else }} 383 - text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 384 - {{ end }} 385 - " href="/{{ $handle }}">{{ $handle }}</a> 386 - submitted 387 - <span class="px-2 py-0.5 rounded font-mono text-xs border 388 - {{ if eq $round $root.ActiveRound }} 389 - text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500 390 - {{ else }} 391 - text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 392 - {{ end }} 393 - "> 394 - #{{ $round }} 395 - </span> 396 - <span class="select-none before:content-['\00B7']"></span> 397 - <a class=" 398 - {{ if eq $round $root.ActiveRound }} 399 - text-gray-600 dark:text-gray-300 hover:text-gray-600 dark:hover:text-gray-200 400 - {{ else }} 401 - text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 402 - {{ end }} 403 - " href="#round-{{ $round }}"> 404 - {{ template "repo/fragments/shortTime" $item.Created }} 405 - </a> 406 - </span> 407 - <div class="flex gap-2 items-center"> 408 - {{ if or $root.IsInterdiff (ne $root.ActiveRound $round) }} 409 - <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 410 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}#round-{{ $round }}"> 411 - {{ i "diff" "w-4 h-4" }} 412 - Diff 413 - </a> 414 - {{ end }} 415 - {{ if and (ne $idx 0) (or (not $root.IsInterdiff) (ne $root.ActiveRound $round)) }} 416 - <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 417 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}/interdiff?{{ safeUrl $root.DiffOpts.Encode }}"> 418 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 419 - Interdiff 420 - </a> 421 - {{ end }} 422 - </div> 423 - </div> 424 - {{ end }} 425 - 426 - {{ define "submissionCommits" }} 427 - {{ $item := index . 0 }} 428 - {{ $root := index . 3 }} 429 - {{ $round := $item.RoundNumber }} 430 - {{ $patches := $item.AsFormatPatch }} 431 - {{ if $patches }} 432 - <details class="group/commit"> 433 - <summary class="list-none cursor-pointer flex items-center gap-2"> 434 - <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 435 - {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 436 - <div class="text-sm text-gray-500 dark:text-gray-400"> 437 - <span class="group-open/commit:hidden inline">Expand</span> 438 - <span class="hidden group-open/commit:inline">Collapse</span> 439 - </div> 440 - </summary> 441 - {{ range $patches }} 442 - {{ template "submissionCommit" (list . $item $root) }} 443 - {{ end }} 444 - </details> 445 - {{ end }} 446 - {{ end }} 447 - 448 - {{ define "submissionCommit" }} 449 - {{ $patch := index . 0 }} 450 - {{ $item := index . 1 }} 451 - {{ $root := index . 2 }} 452 - {{ $round := $item.RoundNumber }} 453 - {{ with $patch }} 454 - <div id="commit-{{.SHA}}" class="py-1 relative w-full md:max-w-3/5 md:w-fit flex flex-col text-gray-600 dark:text-gray-300"> 455 - <div class="flex items-baseline gap-2"> 456 - <div class="text-xs"> 457 - <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 458 - {{ $fullRepo := "" }} 459 - {{ if and $root.Pull.IsForkBased $root.Pull.PullSource.Repo }} 460 - {{ $fullRepo = printf "%s/%s" (resolve $root.Pull.PullSource.Repo.Did) $root.Pull.PullSource.Repo.Slug }} 461 - {{ else if $root.Pull.IsBranchBased }} 462 - {{ $fullRepo = $root.RepoInfo.FullName }} 463 - {{ end }} 464 - 465 - <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 466 - {{ if $fullRepo }} 467 - <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 468 - {{ else }} 469 - <span class="font-mono">{{ slice .SHA 0 8 }}</span> 470 - {{ end }} 471 - </div> 472 - 473 - <div> 474 - <span>{{ .Title | description }}</span> 475 - {{ if gt (len .Body) 0 }} 476 - <button 477 - class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 478 - hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 479 - > 480 - {{ i "ellipsis" "w-3 h-3" }} 481 - </button> 482 - {{ end }} 483 - {{ if gt (len .Body) 0 }} 484 - <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 pb-2">{{ nl2br .Body }}</p> 485 - {{ end }} 486 - </div> 487 - </div> 488 - </div> 489 - {{ end }} 490 - {{ end }} 491 - 492 - {{ define "mergeStatus" }} 493 - {{ if .Pull.State.IsClosed }} 494 - <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded shadow-sm px-6 py-2 relative"> 495 - <div class="flex items-center gap-2 text-black dark:text-white"> 496 - {{ i "ban" "w-4 h-4" }} 497 - <span class="font-medium">Closed without merging</span 498 - > 499 - </div> 500 - </div> 501 - {{ else if .Pull.State.IsMerged }} 502 - <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded shadow-sm px-6 py-2 relative"> 503 - <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 504 - {{ i "git-merge" "w-4 h-4" }} 505 - <span class="font-medium">Pull request successfully merged</span 506 - > 507 - </div> 508 - </div> 509 - {{ else if .Pull.State.IsAbandoned }} 510 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded shadow-sm px-6 py-2 relative"> 511 - <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 512 - {{ i "git-pull-request-closed" "w-4 h-4" }} 513 - <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 514 - </div> 515 - </div> 516 - {{ end }} 517 - {{ end }} 518 - 519 - {{ define "submissionPipeline" }} 520 - {{ $item := index . 0 }} 521 - {{ $root := index . 3 }} 522 - {{ $pipeline := index $root.Pipelines $item.SourceRev }} 523 - {{ if and $pipeline $pipeline.Statuses }} 524 - {{ $id := $pipeline.Id }} 525 - <details class="group/pipeline"> 526 - <summary class="cursor-pointer list-none flex items-center gap-2"> 527 - {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }} 528 - <div class="text-sm text-gray-500 dark:text-gray-400"> 529 - <span class="group-open/pipeline:hidden inline">Expand</span> 530 - <span class="hidden group-open/pipeline:inline">Collapse</span> 531 - </div> 532 - </summary> 533 - <div class="my-2 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 534 - {{ range $name, $all := $pipeline.Statuses }} 535 - <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 536 - <div 537 - class="flex gap-2 items-center justify-between p-2"> 538 - {{ $lastStatus := $all.Latest }} 539 - {{ $kind := $lastStatus.Status.String }} 540 - 541 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 542 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 543 - {{ $name }} 544 - </div> 545 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 546 - <span class="font-bold">{{ $kind }}</span> 547 - {{ if .TimeTaken }} 548 - {{ template "repo/fragments/duration" .TimeTaken }} 549 - {{ else }} 550 - {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 551 - {{ end }} 552 - </div> 553 - </div> 554 - </a> 555 - {{ end }} 556 - </div> 557 - </details> 558 - {{ end }} 559 - {{ end }} 560 - 561 - {{ define "submissionComments" }} 562 - {{ $item := index . 0 }} 563 - {{ $idx := index . 1 }} 564 - {{ $lastIdx := index . 2 }} 565 - {{ $root := index . 3 }} 566 - {{ $round := $item.RoundNumber }} 567 - {{ $c := len $item.Comments }} 568 - <details class="relative ml-10 group/comments group/collapse" {{ if or (eq $c 0) (eq $root.ActiveRound $round) }}open{{ end }}> 569 - <summary class="cursor-pointer list-none"> 570 - <div class="collapse-trigger hidden group-open/comments:block absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center group/border z-4"> 571 - <div class="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-0.5 group-open/comments:bg-gray-200 dark:group-open/comments:bg-gray-700 group-has-[.collapse-trigger:hover]/collapse:bg-gray-400 dark:group-has-[.collapse-trigger:hover]/collapse:bg-gray-500 transition-colors"> </div> 572 - </div> 573 - <div class="group-open/comments:hidden block relative group/summary py-4"> 574 - <div class="absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center z-4"> 575 - <div class="absolute left-1/2 -translate-x-1/2 h-1/3 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-hover/summary:bg-gray-400 dark:group-hover/summary:bg-gray-500 transition-colors"></div> 576 - </div> 577 - <span class="text-gray-500 dark:text-gray-400 text-sm group-hover/summary:text-gray-600 dark:group-hover/summary:text-gray-300 transition-colors flex items-center gap-2 -ml-2 relative"> 578 - {{ i "circle-plus" "size-4 z-5" }} 579 - Expand {{ $c }} comment{{ if ne $c 1 }}s{{ end }} 580 - </span> 581 - </div> 582 - </summary> 583 - <div id="pull-comments-{{ $round }}"> 584 - {{ range $item.Comments }} 585 - {{ template "fragments/comment/pullComment" 586 - (dict "LoggedInUser" $root.LoggedInUser 587 - "Reactions" (index (asReactionMapMap $root.Reactions) .FeedCommentAtUri) 588 - "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) .FeedCommentAtUri) 589 - "Comment" .) }} 590 - {{ end }} 591 - </div> 592 - {{ if gt $c 0}} 593 - <button class="collapse-trigger flex items-center gap-2 -ml-2 relative cursor-pointer text-sm text-gray-500 dark:text-gray-400 group-has-[.collapse-trigger:hover]/collapse:text-gray-600 dark:group-has-[.collapse-trigger:hover]/collapse:text-gray-300 mt-4 pb-4 transition-colors" hx-on:click="this.closest('details').open = false"> 594 - <span class="bg-gray-50 dark:bg-slate-900 dark:rounded-full">{{ i "circle-chevron-up" "size-4 z-5" }}</span> Collapse comment{{ if ne $c 1 }}s{{ end }} 595 - </button> 596 - {{ end }} 597 - 598 - <div class="relative -ml-10"> 599 - {{ if eq $lastIdx $item.RoundNumber }} 600 - {{ block "mergeStatus" $root }} {{ end }} 601 - <div id="pull-action-error" class="error empty:hidden"></div> 602 - {{ end }} 603 - </div> 604 - <div class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 605 - {{ template "repo/pulls/fragments/pullActions" 606 - (dict 607 - "LoggedInUser" $root.LoggedInUser 608 - "Pull" $root.Pull 609 - "RepoInfo" $root.RepoInfo 610 - "RoundNumber" $item.RoundNumber 611 - "MergeCheck" $root.MergeCheck 612 - "ResubmitCheck" $root.ResubmitCheck 613 - "BranchDeleteStatus" $root.BranchDeleteStatus 614 - "Stack" $root.Stack 615 - "Loading" (eq $lastIdx $item.RoundNumber)) }} 616 - </div> 617 - </details> 618 - {{ end }} 619 - 620 - {{ define "loginPrompt" }} 621 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded shadow-sm p-2 relative flex gap-2 items-center"> 622 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 623 - Sign up 624 - </a> 625 - <span class="text-gray-500 dark:text-gray-400">or</span> 626 - <a href="/login" class="underline">Login</a> 627 - to add to the discussion 628 - </div> 629 - {{ end }}
+4 -47
appview/pages/templates/repo/pulls/pulls.html
··· 69 69 70 70 {{ define "repoAfter" }} 71 71 <div class="flex flex-col gap-2 mt-2"> 72 - {{ range $stack := .Stacks }} 73 - {{ $topPR := index $stack 0 }} 72 + {{ range $topPR := .Pulls }} 74 73 <div class="rounded bg-white dark:bg-gray-800"> 75 74 <div class="px-6 py-4 z-5"> 76 75 <div class="pb-2"> ··· 82 81 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 83 82 {{ template "repo/pulls/fragments/pullState" $topPR.State }} 84 83 <span class="ml-1 flex items-center gap-1"> 85 - {{ template "user/fragments/picLink" (list $topPR.OwnerDid "size-6" (index $.VouchRelationships (did $topPR.OwnerDid))) }} 86 - <a href="/{{ resolve $topPR.OwnerDid }}">{{ resolve $topPR.OwnerDid }}</a> 84 + {{ template "user/fragments/picLink" (list $topPR.OwnerDid.String "size-6" (index $.VouchRelationships $topPR.OwnerDid)) }} 85 + <a href="/{{ resolve $topPR.OwnerDid.String }}">{{ resolve $topPR.OwnerDid.String }}</a> 87 86 </span> 88 87 89 88 <span class="before:content-['·']"> ··· 98 97 <span class="before:content-['·']"> 99 98 Round 100 99 <span class="font-mono"> 101 - #{{ $topPR.LastRoundNumber }} 100 + #{{ $topPR.LatestVersionNumber }} 102 101 </span> 103 102 </span> 104 103 ··· 112 111 {{ end }} 113 112 </div> 114 113 </div> 115 - {{ if gt (len $stack) 1 }} 116 - <details class="group"> 117 - <summary class="px-6 pb-4 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 118 - <span class="flex items-center gap-2"> 119 - <span class="group-open:hidden"> 120 - {{ i "chevrons-up-down" "size-3" }} 121 - </span> 122 - <span class="hidden group-open:flex"> 123 - {{ i "chevrons-down-up" "size-3" }} 124 - </span> 125 - {{ $rest := sub (len $stack) 1 }} 126 - Expand {{ $rest }} pull{{if ne $rest 1 }}s{{end}} in this stack 127 - </span> 128 - </summary> 129 - {{ template "stackedPullList" (list (slice $stack 1) $) }} 130 - </details> 131 - {{ end }} 132 114 </div> 133 115 {{ end }} 134 116 </div> ··· 140 122 "QueryParams" (queryParams "q" .FilterQuery) 141 123 ) }} 142 124 {{ end }} 143 - {{ if and .Stacks .RepoInfo.Spindle }} 144 - <div 145 - class="hidden" 146 - hx-get="/{{ .RepoInfo.FullName }}/pulls/pipeline-statuses?{{ range .Stacks }}{{ range . }}sha={{ .LatestSha }}&{{ end }}{{ end }}" 147 - hx-trigger="load" 148 - hx-swap="none" 149 - ></div> 150 - {{ end }} 151 - {{ end }} 152 - 153 - {{ define "stackedPullList" }} 154 - {{ $list := index . 0 }} 155 - {{ $root := index . 1 }} 156 - <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 157 - {{ range $pull := $list }} 158 - {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 159 - <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 160 - <div class="flex gap-2 items-center px-6"> 161 - <div class="flex-grow min-w-0 w-full py-2"> 162 - {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline $root.RepoInfo) }} 163 - </div> 164 - </div> 165 - </a> 166 - {{ end }} 167 - </div> 168 125 {{ end }}
+652
appview/pages/templates/repo/pulls/single.html
··· 1 + {{ define "title" }} 2 + {{ .Pull.Title }} &middot; Pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} &middot; Tangled 3 + {{ end }} 4 + 5 + {{ define "mainLayout" }} 6 + {{ template "fragments/resizable" }} 7 + <input type="hidden" id="pull-id" value="{{ .Pull.PullId }}" /> 8 + <input type="hidden" id="pull-target-repo-did" value="{{ .Pull.RepoDid }}" /> 9 + <input type="hidden" id="pull-active-version-id" value="{{ .ActiveVersionId }}" /> 10 + <div class="flex px-1 group/pr-layout"> 11 + <div class="flex-1 min-w-0"> 12 + <div class="max-w-screen-lg mx-auto"> 13 + <!-- repo header --> 14 + <section id="repo-header" class="mb-2 py-2 px-4 dark:text-white"> 15 + <div class="flex flex-col sm:flex-row items-start gap-4 justify-between mb-2"> 16 + <div class="flex flex-col gap-2"> 17 + {{ template "repoOwnerAndName" . }} 18 + {{ template "repoForkInfo" . }} 19 + </div> 20 + <div class="hidden sm:block sm:flex-shrink-0"> 21 + {{ template "repoActions" . }} 22 + </div> 23 + </div> 24 + {{ template "repoMetadata" . }} 25 + 26 + <div class="block sm:hidden mt-4"> 27 + {{ template "repoActions" . }} 28 + </div> 29 + </section> 30 + <!-- repo nav --> 31 + <nav class="w-full pl-4 overflow-auto"> 32 + <div class="flex z-60"> 33 + {{ range $item := .RepoInfo.GetTabs }} 34 + {{ $key := index $item 0 }} 35 + {{ $value := index $item 1 }} 36 + {{ $icon := index $item 2 }} 37 + {{ $meta := index $.RepoInfo.TabMetadata $key }} 38 + {{/* no hx-boost here because PR page can be super large */}} 39 + <a 40 + href="/{{ $.RepoInfo.FullName }}{{ $value }}" 41 + class="relative -mr-px group" 42 + > 43 + <div 44 + class='px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 45 + {{ if eq "pulls" $key }} 46 + -mb-px bg-white dark:bg-gray-800 47 + {{ else }} 48 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 49 + {{ end }} 50 + ' 51 + > 52 + <span class="flex items-center justify-center"> 53 + {{ i $icon "size-4 mr-2" }} 54 + {{ $key | capitalize }} 55 + {{ if $meta }} 56 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ scaleFmt $meta }}</span> 57 + {{ end }} 58 + </span> 59 + </div> 60 + </a> 61 + {{ end }} 62 + </div> 63 + </nav> 64 + <div class="grid grid-cols-1 lg:grid-cols-[4fr_1fr] gap-4 mb-4"> 65 + <div class="min-w-0"> 66 + <section class="bg-white dark:bg-gray-800 p-6 rounded"> 67 + <!-- PR header --> 68 + <header class="pb-2"> 69 + <h1 class="text-2xl"> 70 + {{ .Pull.Title | description }} 71 + <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 72 + </h1> 73 + </header> 74 + <div class="flex items-center gap-2"> 75 + {{ template "repo/pulls/fragments/pullState" .Pull.State }} 76 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 77 + opened by 78 + {{ template "user/fragments/picLink" (list .Pull.OwnerDid.String "size-6" (index .VouchRelationships .Pull.OwnerDid)) }} 79 + <a href="/{{ resolve .Pull.OwnerDid.String }}">{{ resolve .Pull.OwnerDid.String }}</a> 80 + <span class="select-none before:content-['\00B7']"></span> 81 + {{ template "repo/fragments/time" .Pull.Created }} 82 + <span class="select-none before:content-['\00B7']"></span> 83 + targeting 84 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 85 + <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}">{{ .Pull.TargetBranch }}</a> 86 + </span> 87 + 88 + {{ if .Pull.SourceBranch }} 89 + from 90 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 91 + {{ if not .Pull.IsForkBased }} 92 + {{ $repoPath := .RepoInfo.FullName }} 93 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.SourceBranch }}">{{ .Pull.SourceBranch }}</a> 94 + {{ else }} 95 + <a href="/{{ .Pull.SourceRepo }}">fork</a>: 96 + <a href="/{{ .Pull.SourceRepo }}/tree/{{ pathEscape .Pull.SourceBranch }}">{{ .Pull.SourceBranch }}</a> 97 + {{ end }} 98 + </span> 99 + {{ end }} 100 + </span> 101 + </div> 102 + <article class="mt-4 prose dark:prose-invert"> 103 + {{ if .Pull.Body }} 104 + {{ .Pull.Body | markdown }} 105 + {{ else }} 106 + <span class="italic">No description provided</span> 107 + {{ end }} 108 + </article> 109 + <div class="mt-4"> 110 + {{ $aturi := .Pull.AtUri }} 111 + {{ template "repo/fragments/reactions" 112 + (dict "Reactions" (index .Reactions $aturi) 113 + "UserReacted" (index .UserReacted $aturi) 114 + "ThreadAt" $aturi) }} 115 + </div> 116 + </section> 117 + </div> 118 + <div class="lg:row-start-1 lg:row-end-3 lg:col-start-2 min-w-0"> 119 + {{ template "repo/fragments/labelPanel" 120 + (dict "RepoInfo" $.RepoInfo 121 + "Defs" $.LabelDefs 122 + "Subject" $.Pull.AtUri 123 + "State" $.Pull.Labels) }} 124 + {{ template "repo/fragments/participants" $.Pull.Participants }} 125 + {{ if $.LoggedInUser }} 126 + {{ template "repo/pulls/fragments/subscribeButton" 127 + (dict "RepoInfo" $.RepoInfo 128 + "PullId" $.Pull.PullId 129 + "IsSubscribed" $.IsSubscribed) }} 130 + {{ end }} 131 + {{ template "repo/fragments/backlinks" 132 + (dict "RepoInfo" $.RepoInfo 133 + "Backlinks" $.Backlinks) }} 134 + {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 135 + </div> 136 + <div class="lg:row-start-2 lg:col-start-1"> 137 + <h2 id="commits">Commits</h2> 138 + {{ if .ErrorMsg }} 139 + <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700"> 140 + <span>{{ .ErrorMsg }}</span> 141 + </div> 142 + {{ else }} 143 + <div class="grid grid-cols-1 bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 144 + {{ $seeAll := false }} 145 + {{ if .IsInterdiff }} 146 + {{ $seeAll = not .ChangeId }} 147 + {{ else }} 148 + {{ $version := index .Pull.Versions .VersionId }} 149 + {{ $seeAll = and .IsDiffBase .IsDiffHead }} 150 + {{ end }} 151 + <div class="text-sm px-2 {{ if $seeAll }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 152 + <div class="relative"> 153 + {{ if $seeAll }} 154 + <div class="flex-shrink-0 absolute top-3 left-0"> 155 + {{ i "arrow-right" "size-4" }} 156 + </div> 157 + {{ end }} 158 + <div class="py-2 ml-6"> 159 + {{ if .IsInterdiff }} 160 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ .Version1 }}..{{ .Version2 }}/all">See all changes</a> 161 + {{ else }} 162 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ .VersionId }}">See all changes</a> 163 + {{ end }} 164 + </div> 165 + </div> 166 + </div> 167 + {{ range .Commits }} 168 + {{ $messageParts := splitN .Message "\n\n" 2 }} 169 + {{ $active := and (not $seeAll) (eq (shortId $.ActiveCommitId) (shortId .Hash.String)) }} 170 + {{ $toggleId := printf "commit-message-toggle-%s" .Hash }} 171 + {{ $pipeline := index $.Pipelines .Hash.String }} 172 + <div class="text-sm px-2 {{ if $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 173 + <input id="{{ $toggleId }}" type="checkbox" class="peer hidden" /> 174 + <div class="relative flex items-center justify-between"> 175 + {{ if $active }} 176 + <div class="flex-shrink-0 absolute left-0"> 177 + {{ i "arrow-right" "size-4" }} 178 + </div> 179 + {{ end }} 180 + <div class="py-2 ml-6"> 181 + <code class="py-0.5 px-1">{{ shortId .Hash.String }}</code> 182 + {{ if .ChangeId }} 183 + <code class="py-0.5 px-1">{{ shortId .ChangeId }}</code> 184 + {{ end }} 185 + {{ if $.IsInterdiff }} 186 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/{{ $.Version1 }}..{{ $.Version2 }}/{{ .ChangeId }}">{{ index $messageParts 0 }}</a> 187 + {{ else }} 188 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/{{ $.VersionId }}/{{ shortId .FirstParentHash.String }}..{{ shortId .Hash.String }}">{{ index $messageParts 0 }}</a> 189 + {{ end }} 190 + <label 191 + for="{{ $toggleId }}" 192 + class="cursor-pointer inline-flex py-0.5 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 193 + > 194 + {{ i "ellipsis" "size-3" }} 195 + </label> 196 + </div> 197 + {{ if and $pipeline $pipeline.Id }} 198 + <div> 199 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 200 + </div> 201 + {{ end }} 202 + </div> 203 + {{ if gt (len $messageParts) 1 }} 204 + <pre class="ml-6 mt-1 mb-2 hidden peer-checked:block">{{ index $messageParts 1 }}</pre> 205 + {{ end }} 206 + </div> 207 + {{ end }} 208 + </div> 209 + {{ end }} 210 + </div> 211 + </div> 212 + </div> 213 + <!-- diff --> 214 + <div hx-include="this" class="min-h-[50vh] group/diff-layout"> 215 + {{ $diffUrl := "" }} 216 + {{ if not .IsInterdiff }} 217 + {{ $diffUrl = (printf "/%s/pulls/%d/_/diff?view=unified" .Pull.RepoDid .Pull.PullId) }} 218 + {{ $diff := .DiffParams }} 219 + <input type="hidden" name="baseRepo" value="{{ .Pull.RepoDid }}"> 220 + <input type="hidden" name="base" value="{{ $diff.Base }}"> 221 + <input type="hidden" name="headRepo" value="{{ .Pull.SourceRepo }}"> 222 + <input type="hidden" name="head" value="{{ $diff.Head }}"> 223 + {{ else if .DiffParams.Diff }} 224 + {{ $diffUrl = (printf "/%s/pulls/%d/_/diff?view=unified" .Pull.RepoDid .Pull.PullId) }} 225 + {{ $diff := .DiffParams.Diff }} 226 + <input type="hidden" name="baseRepo" value="{{ .Pull.SourceRepo }}"> 227 + <input type="hidden" name="base" value="{{ $diff.Base }}"> 228 + <input type="hidden" name="headRepo" value="{{ .Pull.SourceRepo }}"> 229 + <input type="hidden" name="head" value="{{ $diff.Head }}"> 230 + {{ else }} 231 + {{ $diffUrl = (printf "/%s/pulls/%d/_/interdiff?view=unified" .Pull.RepoDid .Pull.PullId) }} 232 + {{ $diff := .DiffParams.Interdiff }} 233 + <input type="hidden" name="repo" value="{{ .Pull.SourceRepo }}"> 234 + <input type="hidden" name="base1" value="{{ $diff.From.Base }}"> 235 + <input type="hidden" name="head1" value="{{ $diff.From.Head }}"> 236 + <input type="hidden" name="base2" value="{{ $diff.To.Base }}"> 237 + <input type="hidden" name="head2" value="{{ $diff.To.Head }}"> 238 + {{ end }} 239 + <!-- diff header --> 240 + <header class="bg-slate-100 dark:bg-gray-900 sticky px-1 top-0 z-20 flex flex-col md:flex-row md:justify-between"> 241 + <!-- left --> 242 + <div class="h-12 flex items-center gap-2"> 243 + <div class="md:block hidden"> 244 + <label for="diff-tree-toggle" title="Toggle filetree panel" class="btn-flat"> 245 + <span class="group-has-[#diff-tree-toggle:checked]/diff-layout:hidden inline">{{ i "panel-left-open" "size-4" }}</span> 246 + <span class="hidden group-has-[#diff-tree-toggle:checked]/diff-layout:inline">{{ i "panel-left-close" "size-4" }}</span> 247 + </label> 248 + </div> 249 + <div id="diff-stats"> 250 + <div class="flex items-center font-mono text-sm"> 251 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ i "loader-circle" "size-3 inline-flex animate-spin" }}</span> 252 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ i "loader-circle" "size-3 inline-flex animate-spin" }}</span> 253 + </div> 254 + </div> 255 + {{ if .IsInterdiff }} 256 + <span class="text-sm"> 257 + Interdiff 258 + <span class="ml-1 text-xs after:content-['|'] after:ml-1 after:text-gray-300 dark:after:text-gray-600"> 259 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .Version1 }}</code> 260 + {{ i "arrow-right" "size-3 inline-flex" }} 261 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .Version2 }}</code> 262 + </span> 263 + <a href="#commits" class="btn-flat text-xs hover:bg-gray-50 dark:hover:bg-gray-800"> 264 + {{ with .ChangeId }} 265 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ shortId . }}</code> 266 + {{ else }} 267 + <span class="px-1">All changes</span> 268 + {{ end }} 269 + </a> 270 + </span> 271 + {{ else }} 272 + <span class="text-sm"> 273 + Diff 274 + <span class="ml-1 text-xs after:content-['|'] after:ml-1 after:text-gray-300 dark:after:text-gray-600"> 275 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .VersionId }}</code> 276 + </span> 277 + <a href="#commits" class="btn-flat text-xs hover:bg-gray-50 dark:hover:bg-gray-800"> 278 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ .DisplayDiffBase }}</code> 279 + {{ i "arrow-right" "size-3 inline-flex" }} 280 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ .DisplayDiffHead }}</code> 281 + </a> 282 + </span> 283 + {{ end }} 284 + </div> 285 + <!-- right --> 286 + <div class="h-12 flex items-center gap-2"> 287 + <label title="Expand/Collapse diffs" class="btn font-normal normal-case"> 288 + <input type="checkbox" id="diff-collapse-toggle" class="peer/collapse hidden" checked/> 289 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 290 + {{ i "unfold-vertical" "size-4" }} 291 + <span class="hidden md:inline">Expand all</span> 292 + </span> 293 + <span class="peer-checked/collapse:inline-flex hidden items-center gap-2"> 294 + {{ i "fold-vertical" "size-4" }} 295 + <span class="hidden md:inline">Collapse all</span> 296 + </span> 297 + </label> 298 + 299 + <div class="md:hidden flex-grow"></div> 300 + 301 + <!-- diff settings --> 302 + <div id="diff-settings"> 303 + {{ template "repo/pulls/fragments/diffSettings" (dict "DiffUrl" $diffUrl "Unified" true) }} 304 + </div> 305 + 306 + {{ $isInterdiff := .IsInterdiff }} 307 + {{ $canInterdiff := gt .ActiveVersionId 0 }} 308 + <div class="btn-group"> 309 + <button 310 + type="button" 311 + role="link" 312 + onclick="window.location.href='/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ .ActiveVersionId }}';" 313 + class="btn-group-item {{ if not $isInterdiff }}active{{ end }}" 314 + > 315 + {{ i "diff" "size-4" }} 316 + Diff 317 + </button> 318 + <button 319 + type="button" 320 + role="link" 321 + onclick="window.location.href='/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ sub .ActiveVersionId 1 }}..{{ .ActiveVersionId }}';" 322 + class="btn-group-item {{ if $isInterdiff }}active{{ end }}" 323 + {{ if not $canInterdiff }}disabled{{ end }} 324 + > 325 + {{ i "chevrons-left-right-ellipsis" "size-4 rotate-90" }} 326 + Interdiff 327 + </button> 328 + </div> 329 + <label 330 + for="discussion-toggle" 331 + title="Toggle discussion panel" 332 + class="btn-flat hidden lg:inline-flex group-has-[#discussion-toggle:checked]/pr-layout:hidden" 333 + > 334 + {{ i "message-square-more" "size-4" }} 335 + </label> 336 + </div> 337 + </header> 338 + <div class="flex"> 339 + <input type="checkbox" id="diff-tree-toggle" class="peer hidden" checked> 340 + <aside id="diff-files" class="hidden peer-checked:md:block w-[20%] max-w-[40%]"> 341 + <div class="sticky top-12 bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700"> 342 + <div class=" 343 + max-h-[calc(100vh-3rem-0.5rem)] pb-2 344 + overflow-y-auto 345 + "> 346 + <section class="text-sm px-6 py-2 w-full"> 347 + <div class="mb-8"> 348 + <div id="diff-files-content"> 349 + {{ i "loader-circle" "size-4 inline-flex animate-spin" }} loading... 350 + </div> 351 + </div> 352 + </section> 353 + </div> 354 + </div> 355 + </aside> 356 + 357 + {{ template "resize-grip" (list "resize-diff-files" "diff-files" "before" "hidden peer-checked:md:flex") }} 358 + 359 + <div id="diff-list-container" class="flex-1 min-w-0"> 360 + <div 361 + id="diff-list" 362 + hx-get="{{ $diffUrl }}" 363 + hx-swap="outerHTML" 364 + hx-trigger="load" 365 + > 366 + {{ i "loader-circle" "size-4 inline-flex animate-spin" }} loading... 367 + </div> 368 + </div> 369 + <script> 370 + (() => { 371 + const checkbox = document.getElementById('diff-collapse-toggle'); 372 + const diffList = document.getElementById('diff-list-container'); 373 + 374 + checkbox.addEventListener('change', () => { 375 + console.debug("checked", checkbox.checked); 376 + diffList.querySelectorAll('details[id^="file-"]').forEach(detail => { 377 + detail.open = checkbox.checked; 378 + }); 379 + }); 380 + 381 + if (window.__collapseToggleHandler) { 382 + diffList.removeEventListener('toggle', window.__collapseToggleHandler, true); 383 + } 384 + 385 + const handler = (e) => { 386 + if (!e.target.matches('details[id^="file-"]')) return; 387 + const details = document.querySelectorAll('details[id^="file-"]'); 388 + const allOpen = Array.from(details).every(d => d.open); 389 + const allClosed = Array.from(details).every(d => !d.open); 390 + 391 + if (allOpen) checkbox.checked = true; 392 + else if (allClosed) checkbox.checked = false; 393 + }; 394 + 395 + window.__collapseToggleHandler = handler; 396 + diffList.addEventListener('toggle', handler, true); 397 + })(); 398 + </script> 399 + </div> 400 + </div> 401 + </div> 402 + 403 + <div id="bottom-sheet-backdrop" class="fixed inset-0 bg-black/50 lg:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-20"></div> 404 + 405 + <input type="checkbox" id="discussion-toggle" class="peer hidden" checked> 406 + {{ template "resize-grip" (list "resize-discussion" "discussion" "after" "hidden peer-checked:lg:flex") }} 407 + 408 + <div id="discussion" class=" 409 + fixed z-30 bottom-0 right-0 w-full max-h-[calc(100vh-10rem)] rounded-t-2xl 410 + bg-slate-100 dark:bg-gray-900 411 + lg:sticky lg:top-0 lg:hidden peer-checked:lg:block lg:w-[25vw] lg:max-w-[50vw] lg:max-h-screen 412 + lg:bg-transparent 413 + "> 414 + <details open id="bottom-sheet" class=" 415 + flex flex-col group/history 416 + {{ if .Pull.State.IsOpen -}} *:border-green-600 *:dark:border-green-900 417 + {{ else if .Pull.State.IsMerged -}} *:border-purple-600 *:dark:border-purple-900 418 + {{ else if .Pull.State.IsAbandoned -}} *:border-red-600 *:dark:border-red-900 419 + {{ else -}} *:border-gray-600 *:dark:border-gray-900 420 + {{ end }} 421 + "> 422 + {{ $versions := len .Pull.Versions }} 423 + {{ $comments := .Pull.TotalComments }} 424 + <summary class=" 425 + list-none z-20 flex items-center justify-between 426 + pl-3 pr-4 py-2 rounded-t-2xl 427 + relative 428 + border-t-4 border-x-4 429 + group-open/history:bg-transparent 430 + lg:border-0 431 + lg:h-12 lg:px-1 lg:pointer-events-none 432 + lg:bg-transparent 433 + "> 434 + <div class="flex items-center gap-2"> 435 + <span class="lg:hidden"> 436 + {{ template "repo/pulls/fragments/pullState" .Pull.State }} 437 + </span> 438 + <h2>History</h2> 439 + </div> 440 + <div class="flex items-center gap-2 text-sm"> 441 + <span> 442 + {{ $versions }} version{{ if ne $versions 1 }}s{{ end }} 443 + </span> 444 + <span class="select-none before:content-['\00B7']"></span> 445 + <span> 446 + {{ $comments }} comment{{ if ne $comments 1 }}s{{ end }} 447 + </span> 448 + <label for="discussion-toggle" title="Toggle discussion panel" class="hidden lg:inline-flex btn-flat pointer-events-auto"> 449 + {{ i "message-square" "size-4" }} 450 + </label> 451 + <span class="lg:hidden"> 452 + <span class="group-open/history:hidden inline">{{ i "chevron-up" "size-4" }}</span> 453 + <span class="group-open/history:inline hidden">{{ i "chevron-down" "size-4" }}</span> 454 + </span> 455 + </div> 456 + </summary> 457 + <div class=" 458 + max-h-[calc(100vh-3rem-0.5rem)] pb-32 overflow-y-auto space-y-4 459 + px-1 border-x-4 460 + lg:px-0 lg:border-0 461 + "> 462 + {{ range .Pull.Versions }} 463 + {{ $active := eq .ID $.ActiveVersionId }} 464 + <div 465 + id="version-{{ .ID }}" 466 + class=" 467 + {{ if $active }}active{{ end }} 468 + w-full shadow-sm rounded overflow-clip bg-gray-50 dark:bg-gray-900 469 + " 470 + > 471 + <!-- header --> 472 + <header class="sticky top-0 z-20 bg-slate-100 dark:bg-gray-900"> 473 + <div class="pt-2 pr-2 pb-4 pl-4 lg:pl-6 flex gap-2 474 + rounded-t border-t border-x border-gray-200 dark:border-gray-700 475 + {{ if $active }} 476 + bg-blue-50 dark:bg-blue-950 477 + {{ else }} 478 + bg-white dark:bg-gray-800 479 + {{ end }} 480 + "> 481 + <!-- left column: just profile picture --> 482 + <div class="flex-shrink-0 pt-2"> 483 + {{ template "user/fragments/picLink" (list $.Pull.OwnerDid.String "size-8" (index $.VouchRelationships $.Pull.OwnerDid)) }} 484 + </div> 485 + <!-- right column --> 486 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 487 + <!-- submission info --> 488 + {{ $handle := resolve $.Pull.OwnerDid.String }} 489 + <div class="flex gap-2 items-center justify-between mb-1"> 490 + <span class="inline-flex items-center gap-2 text-sm pt-2 491 + {{ if $active }} 492 + text-gray-600 dark:text-gray-300 493 + {{ else }} 494 + text-gray-500 dark:text-gray-400 495 + {{ end }}" 496 + > 497 + <a href="/{{ $handle }}">{{ $handle }}</a> 498 + submitted 499 + <span class=" 500 + px-2 py-0.5 rounded font-mono text-xs border 501 + {{ if $active }} 502 + text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500 503 + {{ else }} 504 + text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 505 + {{ end }} 506 + ">#{{ .ID }}</span> 507 + <span class="select-none before:content-['\00B7']"></span> 508 + <a href="/{{ $.Pull.RepoDid }}/pulls/{{ $.Pull.PullId }}/{{ .ID }}">{{ template "repo/fragments/shortTime" .Created }}</a> 509 + </span> 510 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 511 + href="/{{ $.Pull.RepoDid }}/pulls/{{ $.Pull.PullId }}/{{ .ID }}"> 512 + {{ i "diff" "size-4" }} 513 + Diff 514 + </a> 515 + </div> 516 + {{ if eq .ID $.Pull.LatestVersionNumber }} 517 + <div id="mergecheck-banner"> 518 + {{ if $.Pull.State.IsOpen }} 519 + <div class="flex items-center gap-2 text-gray-500 dark:text-gray-400"> 520 + {{ i "loader-circle" "size-4 animate-spin" }} 521 + <span>Checking mergeability…</span> 522 + </div> 523 + {{ end }} 524 + </div> 525 + {{ end }} 526 + </div> 527 + </div> 528 + </header> 529 + <!-- comments --> 530 + {{ $count := len .Comments }} 531 + <details 532 + class="relative pl-8 lg:pl-10 rounded-b border-x border-b border-gray-200 dark:border-gray-700 group/comments group/collapse" 533 + {{ if or $active (eq $count 0) }}open{{ end }} 534 + > 535 + <summary class="cursor-pointer list-none"> 536 + <div class="collapse-trigger hidden group-open/comments:block absolute left-2 top-0 bottom-0 w-12 lg:w-16 transition-colors flex items-center justify-center group/border z-4"> 537 + <div class="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-0.5 group-open/comments:bg-gray-200 dark:group-open/comments:bg-gray-700 group-has-[.collapse-trigger:hover]/collapse:bg-gray-400 dark:group-has-[.collapse-trigger:hover]/collapse:bg-gray-500 transition-colors"> </div> 538 + </div> 539 + <div class="group-open/comments:hidden block relative group/summary py-4"> 540 + <div class="absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center z-4"> 541 + <div class="absolute left-1/2 -translate-x-1/2 h-1/3 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-hover/summary:bg-gray-400 dark:group-hover/summary:bg-gray-500 transition-colors"></div> 542 + </div> 543 + <span class="text-gray-500 dark:text-gray-400 text-sm group-hover/summary:text-gray-600 dark:group-hover/summary:text-gray-300 transition-colors flex items-center gap-2 -ml-2 relative"> 544 + {{ i "circle-plus" "size-4 z-5" }} 545 + Expand {{ $count }} comment{{ if ne $count 1 }}s{{ end }} 546 + </span> 547 + </div> 548 + </summary> 549 + <div id="pull-comments-{{ .ID }}"> 550 + {{ range .Comments }} 551 + {{ template "fragments/comment/pullComment" 552 + (dict "LoggedInUser" $.LoggedInUser 553 + "Reactions" (index (asReactionMapMap $.Reactions) .FeedCommentAtUri) 554 + "UserReacted" (index (asReactionStatusMapMap $.UserReacted) .FeedCommentAtUri) 555 + "Comment" .) }} 556 + {{ end }} 557 + </div> 558 + {{ if gt $count 0}} 559 + <button 560 + class="collapse-trigger flex items-center gap-2 -ml-2 relative cursor-pointer text-sm text-gray-500 dark:text-gray-400 group-has-[.collapse-trigger:hover]/collapse:text-gray-600 dark:group-has-[.collapse-trigger:hover]/collapse:text-gray-300 mt-4 pb-4 transition-colors" 561 + hx-on:click="this.closest('details').open = false" 562 + > 563 + <span class="bg-gray-50 dark:bg-slate-900 dark:rounded-full">{{ i "circle-chevron-up" "size-4 z-5" }}</span> Collapse comment{{ if ne $count 1 }}s{{ end }} 564 + </button> 565 + {{ end }} 566 + 567 + <div hx-include="this" class="relative -ml-8 lg:-ml-10 rounded-b bg-gray-50 dark:bg-gray-900"> 568 + {{ if eq .ID $.Pull.LatestVersionNumber }} 569 + {{ block "mergeStatus" $ }} {{ end }} 570 + <div id="pull-action-error" class="error"></div> 571 + {{ end }} 572 + <input name="subject-uri" type="hidden" value="{{ $.Pull.AtUri }}"> 573 + <input name="pull-round-idx" type="hidden" value="{{ .ID }}"> 574 + {{ template "repo/pulls/fragments/pullActions" 575 + (dict 576 + "LoggedInUser" $.LoggedInUser 577 + "Pull" $.Pull 578 + "RepoInfo" $.RepoInfo 579 + "RoundNumber" .ID 580 + "MergeCheck" $.MergeCheck 581 + "ResubmitCheck" $.ResubmitCheck 582 + "BranchDeleteStatus" $.BranchDeleteStatus 583 + "Loading" (eq .ID $.Pull.LatestVersionNumber)) }} 584 + </div> 585 + </details> 586 + </div> 587 + {{ end }} 588 + </div> 589 + <div class="hidden sticky bottom-0 h-12"> 590 + <!-- TODO: bottom PR actions --> 591 + </div> 592 + </details> 593 + </div> 594 + <script> 595 + (function() { 596 + const details = document.getElementById('bottom-sheet'); 597 + const backdrop = document.getElementById('bottom-sheet-backdrop'); 598 + const isDesktop = () => window.matchMedia('(min-width: 1024px)').matches; 599 + 600 + // function to update backdrop 601 + const updateBackdrop = () => { 602 + if (backdrop) { 603 + if (details.open && !isDesktop()) { 604 + backdrop.classList.remove('opacity-0', 'pointer-events-none'); 605 + backdrop.classList.add('opacity-100', 'pointer-events-auto'); 606 + document.body.style.overflow = 'hidden'; 607 + } else { 608 + backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 609 + backdrop.classList.add('opacity-0', 'pointer-events-none'); 610 + document.body.style.overflow = ''; 611 + } 612 + } 613 + }; 614 + 615 + // close on mobile initially 616 + if (!isDesktop()) { 617 + details.open = false; 618 + } 619 + updateBackdrop(); // initialize backdrop 620 + 621 + // prevent closing on desktop 622 + details.addEventListener('toggle', function(e) { 623 + if (isDesktop() && !this.open) { 624 + this.open = true; 625 + } 626 + updateBackdrop(); 627 + }); 628 + 629 + const mediaQuery = window.matchMedia('(min-width: 1024px)'); 630 + mediaQuery.addEventListener('change', function(e) { 631 + if (e.matches) { 632 + // switched to desktop - keep open 633 + details.open = true; 634 + } else { 635 + // switched to mobile - close 636 + details.open = false; 637 + } 638 + updateBackdrop(); 639 + }); 640 + 641 + // close when clicking backdrop 642 + if (backdrop) { 643 + backdrop.addEventListener('click', () => { 644 + if (!isDesktop()) { 645 + details.open = false; 646 + } 647 + }); 648 + } 649 + })(); 650 + </script> 651 + </div> 652 + {{ end }}
+2 -2
appview/pages/url.go
··· 69 69 } 70 70 71 71 func (p *Pages) MakePullUrl(ctx context.Context, uri syntax.ATURI, roundIdx int) (string, error) { 72 - pull, err := db.GetPull(p.db, orm.FilterEq("at_uri", uri)) 72 + pull, err := db.GetPull(ctx, p.db, orm.FilterEq("at_uri", uri)) 73 73 if err != nil { 74 74 return "", fmt.Errorf("failed to get pull: %w", err) 75 75 } ··· 77 77 if err != nil { 78 78 return "", fmt.Errorf("failed to make repo url: %w", err) 79 79 } 80 - return path.Join(repoUrl, "pulls", strconv.Itoa(pull.PullId), "rounds", strconv.Itoa(roundIdx)), nil 80 + return path.Join(repoUrl, "pulls", strconv.FormatInt(pull.PullId, 10), "rounds", strconv.Itoa(roundIdx)), nil 81 81 } 82 82 83 83 func (p *Pages) makeRepoUrlInner(ctx context.Context, repo *models.Repo) (string, error) {
+160
appview/pulls/actions.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "strconv" 8 + 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pages" 13 + "tangled.org/core/xrpc/xrpcclient" 14 + 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/go-chi/chi/v5" 17 + ) 18 + 19 + // htmx fragment 20 + func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 21 + l := s.logger.With("handler", "PullActions") 22 + 23 + switch r.Method { 24 + case http.MethodGet: 25 + user := s.oauth.GetMultiAccountUser(r) 26 + if user != nil { 27 + l = l.With("user", user.Did) 28 + } 29 + 30 + f, err := s.repoResolver.Resolve(r) 31 + if err != nil { 32 + l.Error("failed to get repo and knot", "err", err) 33 + return 34 + } 35 + 36 + pull, ok := r.Context().Value("pull").(*models.Pull) 37 + if !ok { 38 + l.Error("failed to get pull") 39 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 40 + return 41 + } 42 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 43 + 44 + versionNumber, err := strconv.Atoi(chi.URLParam(r, "version")) 45 + if err != nil { 46 + versionNumber = pull.LatestVersionNumber() 47 + } 48 + if versionNumber >= len(pull.Versions) { 49 + http.Error(w, "bad round id", http.StatusBadRequest) 50 + l.Error("failed to parse round id", "err", err, "round_number", versionNumber) 51 + return 52 + } 53 + 54 + isLastVersion := versionNumber == pull.LatestVersionNumber() 55 + 56 + var workflowsChanged bool 57 + var changedWorkflows []string 58 + hasPipeline := false 59 + if isLastVersion && f.Spindle != "" { 60 + pipelines := fetchPipelines(r.Context(), l, f, []string{pull.LatestSha()}) 61 + _, hasPipeline = pipelines[pull.LatestSha()] 62 + 63 + if pull.IsForkBased() && !hasPipeline { 64 + latest := pull.LatestVersion() 65 + changedWorkflows, err = func(ctx context.Context) ([]string, error) { 66 + base, err := s.resolveRev(ctx, pull.RepoDid, pull.TargetBranch) 67 + if err != nil { 68 + return nil, fmt.Errorf("failed to resolve target branch: %w", err) 69 + } 70 + return s.changedWorkflowFiles(ctx, pull.RepoDid, base, pull.SourceRepo, latest.Head) 71 + }(r.Context()) 72 + if err != nil { 73 + l.Error("failed to inspect latest round's patch for workflow changes", "err", err) 74 + } 75 + workflowsChanged = len(changedWorkflows) > 0 76 + } 77 + } 78 + 79 + // only the last round's buttons and banners use merge/resubmit checks 80 + var mergeCheckParams pages.MergeCheckParams 81 + var resubmitResult = pages.Unknown 82 + if isLastVersion { 83 + mergeCheckParams = s.composeMergeCheck(r.Context(), f, pull.TargetBranch, pull.SourceRepo, pull.LatestVersion().Head) 84 + if user != nil && syntax.DID(user.Did) == pull.OwnerDid { 85 + resubmitResult = s.resubmitCheck(r.Context(), pull) 86 + } 87 + } 88 + 89 + s.pages.PullActionsFragment(w, pages.PullActionsParams{ 90 + BaseParams: pages.BaseParamsFromContext(r.Context()), 91 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 92 + Pull: pull, 93 + RoundNumber: versionNumber, 94 + MergeCheck: mergeCheckParams, 95 + ResubmitCheck: resubmitResult, 96 + BranchDeleteStatus: s.branchDeleteStatus(r, f, pull), 97 + 98 + WorkflowsChanged: workflowsChanged, 99 + ChangedWorkflowFiles: changedWorkflows, 100 + HasPipeline: hasPipeline, 101 + }) 102 + return 103 + } 104 + } 105 + 106 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *pages.BranchDeleteStatus { 107 + if pull.State != models.PullMerged || pull.SourceBranch == nil { 108 + return nil 109 + } 110 + 111 + user := s.oauth.GetMultiAccountUser(r) 112 + if user == nil { 113 + return nil 114 + } 115 + 116 + branch := *pull.SourceBranch 117 + 118 + if syntax.DID(repo.RepoDid) != pull.SourceRepo { 119 + var err error 120 + repo, err = db.GetRepoByDid(s.db, pull.SourceRepo.String()) 121 + if err != nil { 122 + return nil 123 + } 124 + } 125 + 126 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 127 + if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 128 + return nil 129 + } 130 + 131 + xrpcc := s.knotMirrorXRPC 132 + resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 133 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 134 + s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 135 + return nil 136 + } 137 + 138 + return &pages.BranchDeleteStatus{ 139 + Repo: repo, 140 + Branch: resp.Name, 141 + } 142 + } 143 + 144 + func (s *Pulls) resubmitCheck(ctx context.Context, pull *models.Pull) pages.ResubmitResult { 145 + if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.SourceBranch == nil { 146 + return pages.Unknown 147 + } 148 + 149 + sourceSha, err := s.resolveRev(ctx, pull.SourceRepo, *pull.SourceBranch) 150 + if err != nil { 151 + s.logger.Error("failed to resolve source branch", "err", err, "pull_id", pull.PullId, "branch", *pull.SourceBranch) 152 + return pages.Unknown 153 + } 154 + 155 + if pull.LatestVersion().Head != sourceSha { 156 + return pages.ShouldResubmit 157 + } 158 + 159 + return pages.ShouldNotResubmit 160 + }
+207 -307
appview/pulls/compose.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "cmp" 4 5 "context" 5 6 "database/sql" 6 7 "encoding/json" 7 8 "errors" 8 9 "fmt" 10 + "log/slog" 9 11 "net/http" 10 12 "net/url" 11 13 "slices" ··· 14 16 15 17 "tangled.org/core/api/tangled" 16 18 "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/knotcompat" 17 20 "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/oauth" 19 21 "tangled.org/core/appview/pages" 20 22 "tangled.org/core/appview/pages/markup/sanitizer" 21 - "tangled.org/core/patchutil" 23 + "tangled.org/core/consts" 24 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 22 25 "tangled.org/core/types" 23 - "tangled.org/core/xrpc/xrpcclient" 24 26 25 27 "github.com/bluesky-social/indigo/atproto/syntax" 26 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 27 28 ) 28 29 29 30 func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { ··· 49 50 s.pages.Error503(w) 50 51 return 51 52 } 52 - s.pages.RepoNewPull(w, params) 53 + if err := s.pages.RepoNewPull(w, params); err != nil { 54 + l.Error("failed to render", "err", err) 55 + } 53 56 54 57 case http.MethodPost: 55 - title := r.FormValue("title") 56 - body := r.FormValue("body") 57 - targetBranch := r.FormValue("targetBranch") 58 - fromFork := r.FormValue("fork") 58 + userDid := syntax.DID(user.Did) 59 + var ( 60 + title = r.FormValue("title") 61 + body = r.FormValue("body") 62 + targetBranch = r.FormValue("targetBranch") 63 + sourceRepoRaw = cmp.Or(r.FormValue("fork"), f.RepoDid) 64 + ) 65 + sourceRepoDid, err := syntax.ParseDID(sourceRepoRaw) 66 + if err != nil { 67 + s.pages.Notice(w, "pull", fmt.Sprintf("Source repo is invalid: %q", sourceRepoRaw)) 68 + return 69 + } 59 70 sourceBranch := r.FormValue("sourceBranch") 60 71 patch := r.FormValue("patch") 61 - userDid := syntax.DID(user.Did) 72 + 73 + if title == "" { 74 + s.pages.Notice(w, "pull", "Title is required") 75 + return 76 + } 77 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 78 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 79 + return 80 + } 62 81 63 82 if targetBranch == "" { 64 83 s.pages.Notice(w, "pull", "Target branch is required.") 65 84 return 66 85 } 67 86 68 - // Determine PR type based on input parameters 69 - roles := s.acl.RolesInRepo(r.Context(), f, userDid.String()) 70 - isPushAllowed := roles.IsPushAllowed() 71 - isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 72 - isForkBased := fromFork != "" && sourceBranch != "" 73 - isPatchBased := patch != "" && !isBranchBased && !isForkBased 74 - isStacked := r.FormValue("mode") == "stack" && !isPatchBased 75 - 76 - if isPatchBased && !patchutil.IsFormatPatch(patch) { 77 - if title == "" { 78 - s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 79 - return 80 - } 81 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 82 - s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 83 - return 84 - } 85 - } 86 - 87 87 // Validate we have at least one valid PR creation method 88 - if !isBranchBased && !isPatchBased && !isForkBased { 88 + if sourceBranch == "" && patch == "" { 89 89 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 90 90 return 91 91 } 92 - 93 92 // Can't mix branch-based and patch-based approaches 94 - if isBranchBased && patch != "" { 93 + if sourceBranch != "" && patch != "" { 95 94 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 96 95 return 97 96 } 98 97 99 - if isBranchBased && sourceBranch == targetBranch { 98 + var sourceRepo *models.Repo 99 + if sourceRepoDid == syntax.DID(f.RepoDid) { 100 + sourceRepo = f 101 + } else { 102 + var err error 103 + sourceRepo, err = db.GetRepoByDid(s.db, sourceRepoDid.String()) 104 + if err != nil { 105 + s.pages.Notice(w, "pull", fmt.Sprintf("Unknown source repository: %q", sourceRepoDid)) 106 + return 107 + } 108 + } 109 + 110 + if sourceBranch != "" { 111 + roles := s.acl.RolesInRepo(r.Context(), sourceRepo, userDid.String()) 112 + if !roles.IsPushAllowed() { 113 + s.pages.Notice(w, "pull", "Cannot select forbidden branch.") 114 + return 115 + } 116 + } 117 + 118 + if sourceRepoDid == syntax.DID(f.RepoDid) && sourceBranch == targetBranch { 100 119 s.pages.Notice(w, "pull", "Source and target branch must be different.") 101 120 return 102 121 } 103 122 104 - // TODO: make capabilities an xrpc call 105 - caps := struct { 106 - PullRequests struct { 107 - FormatPatch bool 108 - BranchSubmissions bool 109 - ForkSubmissions bool 110 - PatchSubmissions bool 111 - } 112 - }{ 113 - PullRequests: struct { 114 - FormatPatch bool 115 - BranchSubmissions bool 116 - ForkSubmissions bool 117 - PatchSubmissions bool 118 - }{ 119 - FormatPatch: true, 120 - BranchSubmissions: true, 121 - ForkSubmissions: true, 122 - PatchSubmissions: true, 123 - }, 123 + if ok := knotcompat.KnotHasCapability(r.Context(), f.Knot, s.config.Core.Dev, consts.CapKeepCommit); !ok { 124 + s.pages.Notice(w, "pull", "Source repo's knot doesn't support ref-based pull requests. Try another way?") 125 + return 124 126 } 125 127 126 - if !caps.PullRequests.FormatPatch { 127 - s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 128 + if sourceBranch != "" { 129 + s.handlePull(w, r, userDid, f, targetBranch, sourceRepo, sourceBranch, title, body) 130 + return 131 + } else if patch != "" { 132 + s.pages.Notice(w, "pull", "Patch based PR is currently unsupported.") 128 133 return 129 134 } 135 + } 136 + } 137 + 138 + func (s *Pulls) PullComposeDiffFragment(w http.ResponseWriter, r *http.Request) { 139 + l := s.logger.With("handler", "PullComposeDiffFragment") 140 + ctx := r.Context() 130 141 131 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 132 - stackBodies := parseBracketedForm(r.Form, "stackBody") 142 + var ( 143 + baseRepoRaw = r.URL.Query().Get("baseRepo") 144 + baseBranch = r.URL.Query().Get("base") // base branch name 145 + headRepoRaw = r.URL.Query().Get("headRepo") 146 + headBranch = r.URL.Query().Get("head") // head branch name 147 + unified = r.URL.Query().Get("view") == "unified" 148 + ) 149 + baseRepo, err := syntax.ParseDID(baseRepoRaw) 150 + if err != nil { 151 + http.Error(w, "invalid base repo DID", http.StatusBadRequest) 152 + return 153 + } 154 + headRepo, err := syntax.ParseDID(headRepoRaw) 155 + if err != nil { 156 + http.Error(w, "invalid head repo DID", http.StatusBadRequest) 157 + return 158 + } 159 + l.Debug( 160 + "compose diff fragment", 161 + "base.repo", baseRepo, 162 + "base.commit", baseBranch, 163 + "head.repo", headRepo, 164 + "head.commit", headBranch, 165 + ) 133 166 134 - // Handle the PR creation based on the type 135 - if isBranchBased { 136 - if !caps.PullRequests.BranchSubmissions { 137 - s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 138 - return 139 - } 140 - s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 141 - } else if isForkBased { 142 - if !caps.PullRequests.ForkSubmissions { 143 - s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 144 - return 145 - } 146 - s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 147 - } else if isPatchBased { 148 - if !caps.PullRequests.PatchSubmissions { 149 - s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 150 - return 151 - } 152 - s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 153 - } 167 + // resolve branch to commit IDs 168 + base, err := s.resolveRev(ctx, baseRepo, baseBranch) 169 + if err != nil { 170 + l.Error("failed to resolve base branch", "branch", baseBranch, "repo", baseRepo, "err", err) 171 + s.renderComposeDiffErr(w, l, "Failed to resolve base branch.") 154 172 return 155 173 } 174 + head, err := s.resolveRev(ctx, headRepo, headBranch) 175 + if err != nil { 176 + l.Error("failed to resolve head branch", "branch", headBranch, "repo", headRepo, "err", err) 177 + s.renderComposeDiffErr(w, l, "Failed to resolve head branch.") 178 + return 179 + } 180 + 181 + var params pages.PullDiffFragmentParams 182 + params.BaseRepo = baseRepo 183 + params.HeadRepo = headRepo 184 + params.DiffBase = baseBranch 185 + params.DiffHead = headBranch 186 + params.DiffUrl = r.URL.Path 187 + params.Unified = unified 188 + params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified) 189 + if err := s.pages.PullComposeDiffFragment(w, params); err != nil { 190 + l.Error("failed to render", "err", err) 191 + } 192 + } 193 + 194 + func (s *Pulls) resolveRev(ctx context.Context, repo syntax.DID, rev string) (string, error) { 195 + out, err := s.gitmirror.ResolveRev(ctx, &gitmirrorv1.ResolveRevRequest{ 196 + Repo: repo.String(), 197 + Rev: []byte(rev), 198 + }) 199 + if err != nil { 200 + return "", err 201 + } 202 + return out.GetCommit(), nil 203 + } 204 + 205 + func (s *Pulls) renderComposeDiffErr(w http.ResponseWriter, l *slog.Logger, msg string) { 206 + if err := s.pages.PullComposeDiffFragment(w, pages.PullDiffFragmentParams{ErrorMsg: msg}); err != nil { 207 + l.Error("failed to render", "err", err) 208 + } 156 209 } 157 210 158 211 func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { ··· 178 231 func composeCanonicalURL(params pages.RepoNewPullParams) string { 179 232 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 180 233 q := url.Values{} 181 - if params.IsStacked { 182 - q.Set("mode", "stack") 183 - } 184 234 if params.Source != "" && params.Source != pages.SourceBranch { 185 235 q.Set("source", string(params.Source)) 186 236 } ··· 205 255 206 256 branches, err := s.listBranches(r.Context(), repo) 207 257 if err != nil { 208 - return pages.RepoNewPullParams{}, err 258 + return pages.RepoNewPullParams{}, fmt.Errorf("failed to list branches: %w", err) 209 259 } 210 260 211 261 var forks []models.Repo ··· 218 268 forks = slices.DeleteFunc(forks, func(f models.Repo) bool { 219 269 return f.RepoDid == "" 220 270 }) 271 + 272 + f, err := s.repoResolver.Resolve(r) 273 + if err != nil { 274 + return pages.RepoNewPullParams{}, fmt.Errorf("failed to resolve repo: %w", err) 275 + } 221 276 222 277 repoInfo := s.repoResolver.GetRepoInfo(r, user) 223 278 source, ok := pages.ParseSource(r.FormValue("source")) ··· 237 292 fork = forks[0].RepoDid 238 293 } 239 294 295 + var prefillErr error 296 + 240 297 var forkBranches []types.Branch 241 - var forkBranchesErr error 242 298 if source == pages.SourceFork && fork != "" { 243 - forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 244 - if forkBranchesErr != nil { 245 - l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 299 + forkBranches, err = s.listForkBranches(r.Context(), fork) 300 + if err != nil { 301 + l.Warn("failed to list fork branches", "err", prefillErr, "fork", fork) 302 + prefillErr = errors.Join(prefillErr, err) 246 303 } 247 304 } 248 305 ··· 250 307 targetBranch = defaultTargetBranch(branches, targetBranch) 251 308 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 252 309 253 - comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 254 - var prefillErr string 255 - if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 256 - prefillErr = joined.Error() 310 + var sourceRepo syntax.DID 311 + if fork != "" { 312 + sourceRepo = syntax.DID(fork) 313 + } else { 314 + sourceRepo = syntax.DID(repoInfo.RepoDid) 257 315 } 258 316 259 - mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 317 + if sourceRepo == "" || sourceBranch == "" || targetBranch == "" { 318 + l.Error("params missing", "source", sourceRepo, "source.branch", sourceBranch, "target.branch", targetBranch) 319 + return pages.RepoNewPullParams{ 320 + BaseParams: pages.BaseParamsFromContext(r.Context()), 321 + RepoInfo: repoInfo, 322 + Branches: branches, 323 + SourceBranches: sourceBranchList, 324 + ForkBranches: forkBranches, 325 + Forks: forks, 326 + Source: source, 327 + SourceBranch: sourceBranch, 328 + TargetBranch: targetBranch, 329 + Fork: fork, 330 + Patch: patch, 331 + }, nil 332 + } 260 333 261 - refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 262 - var diffOpts types.DiffOpts 263 - if r.FormValue("diff") == "split" { 264 - diffOpts.Split = true 334 + var stepReviewParams pages.RepoNewPull_StepReviewParams 335 + 336 + commits, err := s.listCommits(r.Context(), sourceRepo, targetBranch, sourceBranch) 337 + if err != nil { 338 + prefillErr = errors.Join(prefillErr, err) 265 339 } 266 - diffOpts.RefreshUrl = refreshUrl 267 - diffOpts.Target = "#diff-area" 340 + stepReviewParams.Commits = commits 341 + 342 + var prefillErrorMsg string 343 + if prefillErr != nil { 344 + prefillErrorMsg = prefillErr.Error() 345 + } 268 346 269 347 labelDefs, err := s.pullLabelDefs(repo) 270 348 if err != nil { 271 - l.Warn("failed to load label definitions", "err", err) 349 + l.Error("failed to load label definitions", "err", err) 272 350 } 273 351 labelState := labelStateFromForm(r.Form, labelDefs) 274 - perCidLabelForms := parseStackLabelForms(r.Form) 275 - stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 276 - for cid, perForm := range perCidLabelForms { 277 - stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 278 - } 279 - 280 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 281 - stackBodies := parseBracketedForm(r.Form, "stackBody") 282 - stackSplits := parseBracketedForm(r.Form, "stackSplit") 283 352 284 353 title := r.FormValue("title") 285 354 body := r.FormValue("body") 286 355 titleDirty := r.FormValue("titleDirty") == "1" 287 356 bodyDirty := r.FormValue("bodyDirty") == "1" 288 - if comparison != nil && len(comparison.FormatPatch) > 0 { 289 - first := comparison.FormatPatch[0] 290 - if !titleDirty && first.PatchHeader != nil { 291 - title = first.Title 357 + if len(commits) == 1 { 358 + message := strings.SplitN(strings.TrimSpace(commits[0].Message), "\n\n", 2) 359 + if !titleDirty { 360 + title = message[0] 292 361 } 293 - if !bodyDirty && first.PatchHeader != nil { 294 - body = first.Body 362 + if !bodyDirty && len(message) > 1 && message[1] != "" { 363 + // TODO: strip trailers? 364 + body = message[1] 295 365 } 296 366 } 297 367 298 - isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 299 - var stackedDiffs []pages.StackedDiff 300 - if isStacked { 301 - stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 368 + l.Debug("label defs", "defs", labelDefs) 369 + 370 + var mergeCheckParams pages.MergeCheckParams 371 + if len(commits) > 0 { 372 + mergeCheckParams = s.composeMergeCheck(r.Context(), f, targetBranch, sourceRepo, commits[0].Hash.String()) 302 373 } 303 374 304 375 return pages.RepoNewPullParams{ ··· 317 388 Body: body, 318 389 TitleDirty: titleDirty, 319 390 BodyDirty: bodyDirty, 320 - IsStacked: isStacked, 321 - Comparison: comparison, 322 - Diff: diff, 323 - DiffOpts: diffOpts, 324 - StackedDiffs: stackedDiffs, 325 - MergeCheck: mergeCheck, 326 - StackTitles: stackTitles, 327 - StackBodies: stackBodies, 328 - PrefillError: prefillErr, 391 + StepReviewParams: &stepReviewParams, 392 + MergeCheck: mergeCheckParams, 393 + PrefillError: prefillErrorMsg, 329 394 LabelDefs: labelDefs, 330 395 LabelState: labelState, 331 - StackLabelStates: stackLabelStates, 332 396 }, nil 333 397 } 334 398 335 399 func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 336 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 400 + xrpcc := s.knotMirrorXRPC 337 401 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid) 338 402 if err != nil { 339 403 return nil, err ··· 410 474 return out 411 475 } 412 476 413 - func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 414 - var ( 415 - comparison *types.RepoFormatPatchResponse 416 - err error 417 - ) 418 - switch source { 419 - case pages.SourcePatch: 420 - if strings.TrimSpace(patch) == "" { 421 - return nil, nil, nil 422 - } 423 - if verr := validatePatch(&patch); verr != nil { 424 - return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 425 - } 426 - comparison = parsePastedPatch(patch) 427 - case pages.SourceBranch: 428 - if targetBranch == "" || sourceBranch == "" { 429 - return nil, nil, nil 430 - } 431 - comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 432 - case pages.SourceFork: 433 - if fork == "" || targetBranch == "" || sourceBranch == "" { 434 - return nil, nil, nil 435 - } 436 - comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 437 - default: 438 - return nil, nil, nil 439 - } 477 + func (s *Pulls) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams { 478 + l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo.RepoDid, "branch", targetBranch, "source", sourceCommit) 479 + 480 + targetSha, err := s.resolveRev(ctx, syntax.DID(targetRepo.RepoDid), targetBranch) 440 481 if err != nil { 441 - s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 442 - return nil, nil, err 482 + l.Warn("failed to resolve target branch", "err", err) 483 + return pages.MergeCheckParams{Error: "merge check failed"} 443 484 } 444 485 445 - return comparison, deriveDiff(comparison, targetBranch), nil 446 - } 447 - 448 - func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 449 - if comparison == nil || targetBranch == "" { 450 - return nil 451 - } 452 - patch := comparison.CombinedPatchRaw 453 - if patch == "" { 454 - patch = comparison.FormatPatchRaw 486 + out, err := s.gitmirror.MergeCheck(ctx, &gitmirrorv1.MergeCheckRequest{ 487 + Target: &gitmirrorv1.RepoCommit{Repo: targetRepo.RepoDid, Commit: []byte(targetSha)}, 488 + Source: &gitmirrorv1.RepoCommit{Repo: sourceRepoDid.String(), Commit: []byte(sourceCommit)}, 489 + }) 490 + if err != nil { 491 + l.Warn("failed to do merge-check", "err", err) 492 + return pages.MergeCheckParams{Error: "merge check failed"} 455 493 } 456 - if patch == "" { 457 - return nil 494 + return pages.MergeCheckParams{ 495 + IsConflicted: out.IsConflicted, 496 + Conflicts: out.Conflicts, 458 497 } 459 - 460 - xrpcc := s.knotClient(repo.Knot) 461 - 462 - resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 463 - Did: repo.Did, 464 - Name: repo.Name, 465 - Repo: repo.RepoDidPtr(), 466 - Branch: targetBranch, 467 - Patch: patch, 468 - }) 469 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 470 - s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 471 - return &types.MergeCheckResponse{Error: xrpcerr.Error()} 472 - } 473 - 474 - out := mergeCheckResponseFrom(resp) 475 - return &out 476 498 } 477 499 478 500 func bracketComponents(key, prefix string) ([]string, bool) { ··· 525 547 } 526 548 return out 527 549 } 528 - 529 - func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 530 - if patch == "" { 531 - return nil 532 - } 533 - response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 534 - if patchutil.IsFormatPatch(patch) { 535 - if patches, err := patchutil.ExtractPatches(patch); err == nil { 536 - response.FormatPatch = patches 537 - } 538 - } 539 - return response 540 - } 541 - 542 - func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 543 - xrpcc := s.knotClient(repo.Knot) 544 - 545 - xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 546 - if err != nil { 547 - return nil, err 548 - } 549 - 550 - var comparison types.RepoFormatPatchResponse 551 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 552 - return nil, err 553 - } 554 - return &comparison, nil 555 - } 556 - 557 - func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 558 - if forkRepoDid == "" { 559 - return nil, fmt.Errorf("fork not found") 560 - } 561 - fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 562 - if errors.Is(err, sql.ErrNoRows) { 563 - return nil, fmt.Errorf("fork not found") 564 - } 565 - if err != nil { 566 - return nil, err 567 - } 568 - 569 - client, err := s.oauth.ServiceClient( 570 - r, 571 - oauth.WithService(fork.Knot), 572 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 573 - oauth.WithDev(s.config.Core.Dev), 574 - ) 575 - if err != nil { 576 - return nil, err 577 - } 578 - 579 - resp, err := tangled.RepoHiddenRef( 580 - r.Context(), 581 - client, 582 - &tangled.RepoHiddenRef_Input{ 583 - ForkRef: sourceBranch, 584 - RemoteRef: targetBranch, 585 - Repo: fork.RepoAt().String(), 586 - }, 587 - ) 588 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 589 - return nil, xrpcerr 590 - } 591 - if !resp.Success { 592 - if resp.Error != nil { 593 - return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 594 - } 595 - return nil, fmt.Errorf("hidden ref failed") 596 - } 597 - 598 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 599 - forkXrpcc := s.knotClient(fork.Knot) 600 - 601 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 602 - if err != nil { 603 - return nil, err 604 - } 605 - 606 - var comparison types.RepoFormatPatchResponse 607 - if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 608 - return nil, err 609 - } 610 - return &comparison, nil 611 - } 612 - 613 - func stackPerCommitDiffs( 614 - comparison *types.RepoFormatPatchResponse, 615 - targetBranch, refreshUrl string, 616 - stackSplits map[string]string, 617 - ) []pages.StackedDiff { 618 - if comparison == nil { 619 - return nil 620 - } 621 - out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 622 - for i, p := range comparison.FormatPatch { 623 - nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 624 - out[i].Diff = &nd 625 - cid := p.ChangeIdOrEmpty() 626 - if cid == "" { 627 - continue 628 - } 629 - out[i].Opts = types.DiffOpts{ 630 - Split: stackSplits[cid] == "split", 631 - RefreshUrl: refreshUrl, 632 - Target: fmt.Sprintf("#stack-diff-%s", cid), 633 - Field: fmt.Sprintf("stackSplit[%s]", cid), 634 - } 635 - } 636 - return out 637 - } 638 - 639 - func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 640 - if comparison == nil { 641 - return nil 642 - } 643 - raw := comparison.CombinedPatchRaw 644 - if raw == "" { 645 - raw = comparison.FormatPatchRaw 646 - } 647 - d := patchutil.AsNiceDiff(raw, targetBranch) 648 - return &d 649 - }
-463
appview/pulls/compose_helpers_test.go
··· 1 - package pulls 2 - 3 - import ( 4 - "io" 5 - "log/slog" 6 - "net/url" 7 - "reflect" 8 - "testing" 9 - "time" 10 - 11 - "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.org/core/appview/models" 13 - "tangled.org/core/appview/pages" 14 - "tangled.org/core/appview/pages/repoinfo" 15 - "tangled.org/core/patchutil" 16 - "tangled.org/core/types" 17 - ) 18 - 19 - func TestBracketComponents(t *testing.T) { 20 - cases := []struct { 21 - key, prefix string 22 - want []string 23 - ok bool 24 - }{ 25 - {"foo[a]", "foo", []string{"a"}, true}, 26 - {"foo[a][b]", "foo", []string{"a", "b"}, true}, 27 - {"foo[a][b][c]", "foo", []string{"a", "b", "c"}, true}, 28 - {"foo[]", "foo", []string{""}, true}, 29 - {"foo[a][]", "foo", []string{"a", ""}, true}, 30 - {"foo", "foo", nil, false}, 31 - {"bar[a]", "foo", nil, false}, 32 - {"foo[a", "foo", nil, false}, 33 - {"fooa]", "foo", nil, false}, 34 - {"foo[a]extra", "foo", nil, false}, 35 - {"", "foo", nil, false}, 36 - } 37 - for _, c := range cases { 38 - got, ok := bracketComponents(c.key, c.prefix) 39 - if ok != c.ok || !reflect.DeepEqual(got, c.want) { 40 - t.Errorf("bracketComponents(%q, %q) = %v, %v; want %v, %v", c.key, c.prefix, got, ok, c.want, c.ok) 41 - } 42 - } 43 - } 44 - 45 - func TestParseBracketedForm(t *testing.T) { 46 - form := url.Values{ 47 - "stackTitle[abc]": {"hello"}, 48 - "stackTitle[xyz]": {"world", "ignored"}, 49 - "stackTitle[]": {"empty-id"}, 50 - "stackTitle[a][b]": {"too-deep"}, 51 - "stackTitle": {"no-bracket"}, 52 - "unrelated[abc]": {"skip"}, 53 - "stackTitle[noval]": {}, 54 - } 55 - got := parseBracketedForm(form, "stackTitle") 56 - want := map[string]string{ 57 - "abc": "hello", 58 - "xyz": "world", 59 - } 60 - if !reflect.DeepEqual(got, want) { 61 - t.Errorf("parseBracketedForm = %v; want %v", got, want) 62 - } 63 - } 64 - 65 - func TestParseStackLabelForms(t *testing.T) { 66 - form := url.Values{ 67 - "stackLabel[c1][at://uri/a]": {"v1"}, 68 - "stackLabel[c1][at://uri/b]": {"v2"}, 69 - "stackLabel[c2][at://uri/a]": {"v3", "v4"}, 70 - "stackLabel[c1][]": {"empty-uri"}, 71 - "stackLabel[][at://uri/a]": {"empty-cid"}, 72 - "stackLabel[c1]": {"missing-second-bracket"}, 73 - "stackLabel[c1][a][b]": {"too-deep"}, 74 - "stackTitle[c1]": {"wrong-prefix"}, 75 - } 76 - got := parseStackLabelForms(form) 77 - want := map[string]url.Values{ 78 - "c1": { 79 - "at://uri/a": {"v1"}, 80 - "at://uri/b": {"v2"}, 81 - }, 82 - "c2": { 83 - "at://uri/a": {"v3", "v4"}, 84 - }, 85 - } 86 - if !reflect.DeepEqual(got, want) { 87 - t.Errorf("parseStackLabelForms = %v; want %v", got, want) 88 - } 89 - } 90 - 91 - func TestDefaultTargetBranch(t *testing.T) { 92 - branches := []types.Branch{ 93 - {Reference: types.Reference{Name: "feature"}}, 94 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 95 - } 96 - cases := []struct { 97 - name string 98 - branches []types.Branch 99 - current string 100 - want string 101 - }{ 102 - {"current is valid", branches, "feature", "feature"}, 103 - {"current is default", branches, "main", "main"}, 104 - {"current invalid, falls to default", branches, "ghost", "main"}, 105 - {"current empty, falls to default", branches, "", "main"}, 106 - {"no default, no match returns empty", []types.Branch{{Reference: types.Reference{Name: "only"}}}, "ghost", ""}, 107 - {"empty branches returns empty", nil, "anything", ""}, 108 - } 109 - for _, c := range cases { 110 - t.Run(c.name, func(t *testing.T) { 111 - if got := defaultTargetBranch(c.branches, c.current); got != c.want { 112 - t.Errorf("defaultTargetBranch = %q; want %q", got, c.want) 113 - } 114 - }) 115 - } 116 - } 117 - 118 - func TestDefaultSourceBranch(t *testing.T) { 119 - choices := []types.Branch{ 120 - {Reference: types.Reference{Name: "feature"}}, 121 - {Reference: types.Reference{Name: "wip"}}, 122 - } 123 - forks := []types.Branch{ 124 - {Reference: types.Reference{Name: "fork-feature"}}, 125 - } 126 - cases := []struct { 127 - name string 128 - source pages.Source 129 - current string 130 - want string 131 - }{ 132 - {"branch source, valid current", pages.SourceBranch, "feature", "feature"}, 133 - {"branch source, invalid falls to first", pages.SourceBranch, "ghost", "feature"}, 134 - {"branch source, empty falls to first", pages.SourceBranch, "", "feature"}, 135 - {"fork source, valid current", pages.SourceFork, "fork-feature", "fork-feature"}, 136 - {"fork source, invalid falls to first fork", pages.SourceFork, "ghost", "fork-feature"}, 137 - {"patch source preserves current", pages.SourcePatch, "anything", "anything"}, 138 - } 139 - for _, c := range cases { 140 - t.Run(c.name, func(t *testing.T) { 141 - if got := defaultSourceBranch(c.source, c.current, choices, forks); got != c.want { 142 - t.Errorf("defaultSourceBranch = %q; want %q", got, c.want) 143 - } 144 - }) 145 - } 146 - if got := defaultSourceBranch(pages.SourceBranch, "", nil, nil); got != "" { 147 - t.Errorf("empty choices should return empty, got %q", got) 148 - } 149 - } 150 - 151 - func TestSortBranchesByRecency(t *testing.T) { 152 - mk := func(name string, when *time.Time) types.Branch { 153 - b := types.Branch{Reference: types.Reference{Name: name}} 154 - if when != nil { 155 - b.Commit = &object.Commit{Committer: object.Signature{When: *when}} 156 - } 157 - return b 158 - } 159 - t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) 160 - t2 := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) 161 - t3 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) 162 - 163 - in := []types.Branch{ 164 - mk("oldest", &t1), 165 - mk("newest", &t3), 166 - mk("nil-commit", nil), 167 - mk("middle", &t2), 168 - } 169 - got := sortBranchesByRecency(in) 170 - wantNames := []string{"newest", "middle", "oldest", "nil-commit"} 171 - for i, want := range wantNames { 172 - if got[i].Reference.Name != want { 173 - t.Errorf("position %d: got %q, want %q", i, got[i].Reference.Name, want) 174 - } 175 - } 176 - 177 - if &got[0] == &in[0] { 178 - t.Error("expected new slice, got aliased input") 179 - } 180 - } 181 - 182 - func TestComposeCanonicalURL(t *testing.T) { 183 - repo := repoinfo.RepoInfo{OwnerDid: "did:plc:abc", Name: "demo", Rkey: "demo"} 184 - cases := []struct { 185 - name string 186 - p pages.RepoNewPullParams 187 - want string 188 - }{ 189 - { 190 - "defaults", 191 - pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch}, 192 - "/did:plc:abc/demo/pulls/new", 193 - }, 194 - { 195 - "stacked", 196 - pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch, IsStacked: true}, 197 - "/did:plc:abc/demo/pulls/new?mode=stack", 198 - }, 199 - { 200 - "fork with selection", 201 - pages.RepoNewPullParams{ 202 - RepoInfo: repo, 203 - Source: pages.SourceFork, 204 - Fork: "did:plc:limpet", 205 - SourceBranch: "feature", 206 - TargetBranch: "main", 207 - }, 208 - "/did:plc:abc/demo/pulls/new?fork=did%3Aplc%3Alimpet&source=fork&sourceBranch=feature&targetBranch=main", 209 - }, 210 - { 211 - "branch with selection drops source param", 212 - pages.RepoNewPullParams{ 213 - RepoInfo: repo, 214 - Source: pages.SourceBranch, 215 - SourceBranch: "feature", 216 - TargetBranch: "main", 217 - }, 218 - "/did:plc:abc/demo/pulls/new?sourceBranch=feature&targetBranch=main", 219 - }, 220 - { 221 - "fork field skipped when source != fork", 222 - pages.RepoNewPullParams{ 223 - RepoInfo: repo, 224 - Source: pages.SourceBranch, 225 - Fork: "stale", 226 - }, 227 - "/did:plc:abc/demo/pulls/new", 228 - }, 229 - } 230 - for _, c := range cases { 231 - t.Run(c.name, func(t *testing.T) { 232 - if got := composeCanonicalURL(c.p); got != c.want { 233 - t.Errorf("composeCanonicalURL = %q; want %q", got, c.want) 234 - } 235 - }) 236 - } 237 - } 238 - 239 - func TestLabelStateFromForm(t *testing.T) { 240 - bug := &models.LabelDefinition{ 241 - Did: "did:plc:test", Rkey: "bug", Name: "bug", 242 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 243 - Scope: []string{"sh.tangled.repo.pull"}, 244 - } 245 - priority := &models.LabelDefinition{ 246 - Did: "did:plc:test", Rkey: "priority", Name: "priority", 247 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 248 - Scope: []string{"sh.tangled.repo.pull"}, 249 - } 250 - defs := map[string]*models.LabelDefinition{ 251 - bug.AtUri().String(): bug, 252 - priority.AtUri().String(): priority, 253 - } 254 - 255 - form := url.Values{ 256 - bug.AtUri().String(): {"null"}, 257 - priority.AtUri().String(): {"high", ""}, 258 - "unrelated": {"ignored"}, 259 - } 260 - state := labelStateFromForm(form, defs) 261 - if !state.ContainsLabel(bug.AtUri().String()) { 262 - t.Error("expected bug label in state") 263 - } 264 - if !state.ContainsLabel(priority.AtUri().String()) { 265 - t.Error("expected priority label in state") 266 - } 267 - 268 - emptyState := labelStateFromForm(url.Values{}, defs) 269 - if emptyState.ContainsLabel(bug.AtUri().String()) { 270 - t.Error("empty form should produce empty state") 271 - } 272 - } 273 - 274 - func TestStackPerCommitDiffs(t *testing.T) { 275 - if got := stackPerCommitDiffs(nil, "main", "", nil); got != nil { 276 - t.Errorf("nil comparison should return nil, got %v", got) 277 - } 278 - 279 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 280 - From: Test <t@e.st> 281 - Date: Tue, 1 Jan 2020 00:00:00 +0000 282 - Subject: [PATCH] one 283 - Change-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 284 - 285 - --- 286 - a.txt | 1 + 287 - 1 file changed, 1 insertion(+) 288 - 289 - diff --git a/a.txt b/a.txt 290 - index 0000000..1111111 100644 291 - --- a/a.txt 292 - +++ b/a.txt 293 - @@ -0,0 +1 @@ 294 - +hello 295 - ` 296 - patches, err := patchutil.ExtractPatches(formatPatch) 297 - if err != nil { 298 - t.Fatalf("extract: %v", err) 299 - } 300 - if len(patches) != 1 { 301 - t.Fatalf("expected 1 patch, got %d", len(patches)) 302 - } 303 - if cid, err := patches[0].ChangeId(); err != nil || cid == "" { 304 - t.Fatalf("change-id missing from fixture: %v %q", err, cid) 305 - } 306 - comp := &types.RepoFormatPatchResponse{ 307 - FormatPatchRaw: formatPatch, 308 - FormatPatch: patches, 309 - } 310 - 311 - got := stackPerCommitDiffs(comp, "main", "/repo/pulls/new/refresh", map[string]string{ 312 - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": "split", 313 - }) 314 - if len(got) != 1 { 315 - t.Fatalf("expected 1 entry, got %d", len(got)) 316 - } 317 - if got[0].Diff == nil { 318 - t.Error("Diff should be set") 319 - } 320 - if !got[0].Opts.Split { 321 - t.Error("Split should propagate from stackSplits") 322 - } 323 - if got[0].Opts.RefreshUrl != "/repo/pulls/new/refresh" { 324 - t.Errorf("RefreshUrl: got %q", got[0].Opts.RefreshUrl) 325 - } 326 - if got[0].Opts.Target != "#stack-diff-Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { 327 - t.Errorf("Target: got %q", got[0].Opts.Target) 328 - } 329 - if got[0].Opts.Field != "stackSplit[Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]" { 330 - t.Errorf("Field: got %q", got[0].Opts.Field) 331 - } 332 - } 333 - 334 - func TestStackPerCommitDiffsNoChangeId(t *testing.T) { 335 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 336 - From: Test <t@e.st> 337 - Date: Tue, 1 Jan 2020 00:00:00 +0000 338 - Subject: [PATCH] no-cid 339 - 340 - --- 341 - a.txt | 1 + 342 - 1 file changed, 1 insertion(+) 343 - 344 - diff --git a/a.txt b/a.txt 345 - index 0000000..1111111 100644 346 - --- a/a.txt 347 - +++ b/a.txt 348 - @@ -0,0 +1 @@ 349 - +hello 350 - ` 351 - patches, err := patchutil.ExtractPatches(formatPatch) 352 - if err != nil { 353 - t.Fatalf("extract: %v", err) 354 - } 355 - comp := &types.RepoFormatPatchResponse{ 356 - FormatPatchRaw: formatPatch, 357 - FormatPatch: patches, 358 - } 359 - got := stackPerCommitDiffs(comp, "main", "/r", nil) 360 - if len(got) != 1 { 361 - t.Fatalf("len: %d", len(got)) 362 - } 363 - if got[0].Diff == nil { 364 - t.Error("Diff still set even without change-id") 365 - } 366 - if got[0].Opts != (types.DiffOpts{}) { 367 - t.Errorf("Opts should be zero without change-id, got %+v", got[0].Opts) 368 - } 369 - } 370 - 371 - func TestPrefetchComparisonPatch(t *testing.T) { 372 - s := &Pulls{ 373 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 374 - } 375 - 376 - cases := []struct { 377 - name string 378 - patch string 379 - wantNil bool 380 - wantErr bool 381 - }{ 382 - {"empty patch returns nil", "", true, false}, 383 - {"whitespace patch returns nil", " \n ", true, false}, 384 - {"garbage patch errors", "not a patch", false, true}, 385 - } 386 - for _, c := range cases { 387 - t.Run(c.name, func(t *testing.T) { 388 - comp, diff, err := s.prefetchComparison(nil, nil, pages.SourcePatch, "", "", "", c.patch) 389 - if c.wantErr { 390 - if err == nil { 391 - t.Fatal("expected error") 392 - } 393 - return 394 - } 395 - if err != nil { 396 - t.Fatalf("unexpected error: %v", err) 397 - } 398 - if c.wantNil { 399 - if comp != nil || diff != nil { 400 - t.Errorf("expected nil, got comp=%v diff=%v", comp, diff) 401 - } 402 - } 403 - }) 404 - } 405 - } 406 - 407 - func TestPrefetchComparisonValidPatch(t *testing.T) { 408 - s := &Pulls{ 409 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 410 - } 411 - patch := `diff --git a/a.txt b/a.txt 412 - index 0000000..1111111 100644 413 - --- a/a.txt 414 - +++ b/a.txt 415 - @@ -0,0 +1 @@ 416 - +hello 417 - ` 418 - comp, diff, err := s.prefetchComparison(nil, nil, pages.SourcePatch, "", "main", "", patch) 419 - if err != nil { 420 - t.Fatalf("err: %v", err) 421 - } 422 - if comp == nil { 423 - t.Fatal("comp nil") 424 - } 425 - if comp.FormatPatchRaw == "" { 426 - t.Error("FormatPatchRaw empty") 427 - } 428 - if diff == nil { 429 - t.Error("diff nil") 430 - } 431 - } 432 - 433 - func TestPrefetchComparisonMissingInputs(t *testing.T) { 434 - s := &Pulls{ 435 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 436 - } 437 - 438 - cases := []struct { 439 - name string 440 - source pages.Source 441 - fork string 442 - targetBranch string 443 - sourceBranch string 444 - }{ 445 - {"branch missing target", pages.SourceBranch, "", "", "feature"}, 446 - {"branch missing source", pages.SourceBranch, "", "main", ""}, 447 - {"fork missing fork", pages.SourceFork, "", "main", "feature"}, 448 - {"fork missing target", pages.SourceFork, "did:plc:limpet", "", "feature"}, 449 - {"fork missing source", pages.SourceFork, "did:plc:limpet", "main", ""}, 450 - {"unknown source", pages.Source("bogus"), "", "", ""}, 451 - } 452 - for _, c := range cases { 453 - t.Run(c.name, func(t *testing.T) { 454 - comp, diff, err := s.prefetchComparison(nil, nil, c.source, c.fork, c.targetBranch, c.sourceBranch, "") 455 - if err != nil { 456 - t.Errorf("expected nil err, got %v", err) 457 - } 458 - if comp != nil || diff != nil { 459 - t.Errorf("expected nil result, got comp=%v diff=%v", comp, diff) 460 - } 461 - }) 462 - } 463 - }
+96 -449
appview/pulls/create.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "context" 5 - "database/sql" 6 - "encoding/json" 7 - "errors" 8 4 "fmt" 9 5 "net/http" 10 - "strings" 11 6 "time" 12 7 8 + "golang.org/x/sync/errgroup" 13 9 "tangled.org/core/api/tangled" 14 10 "tangled.org/core/appview/db" 15 11 "tangled.org/core/appview/knotcompat" 16 12 "tangled.org/core/appview/models" 17 13 "tangled.org/core/appview/oauth" 18 14 "tangled.org/core/appview/reporesolver" 19 - "tangled.org/core/patchutil" 20 15 "tangled.org/core/tid" 21 - "tangled.org/core/types" 22 - "tangled.org/core/xrpc" 23 - "tangled.org/core/xrpc/xrpcclient" 24 16 25 17 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 18 "github.com/bluesky-social/indigo/atproto/syntax" 27 - lexutil "github.com/bluesky-social/indigo/lex/util" 28 19 ) 29 20 30 - func (s *Pulls) handleBranchBasedPull( 21 + func (s *Pulls) handlePull( 31 22 w http.ResponseWriter, 32 23 r *http.Request, 33 - repo *models.Repo, 34 24 userDid syntax.DID, 25 + targetRepo *models.Repo, 26 + targetBranch string, 27 + sourceRepo *models.Repo, 28 + sourceBranch string, 35 29 title, 36 - body, 37 - targetBranch, 38 - sourceBranch string, 39 - isStacked bool, 40 - stackTitles, stackBodies map[string]string, 30 + body string, 41 31 ) { 42 - l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 43 - 44 - xrpcc := s.knotClient(repo.Knot) 45 - 46 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 47 - if err != nil { 48 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 49 - l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 50 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 51 - return 52 - } 53 - l.Error("failed to compare", "err", err) 54 - s.pages.Notice(w, "pull", err.Error()) 55 - return 56 - } 57 - 58 - var comparison types.RepoFormatPatchResponse 59 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 60 - l.Error("failed to decode XRPC compare response", "err", err) 61 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 62 - return 63 - } 64 - 65 - if len(comparison.FormatPatch) == 0 { 66 - s.pages.Notice(w, "pull", "No commits between target and source.") 67 - return 68 - } 69 - 70 - sourceRev := comparison.Rev2 71 - patch := comparison.FormatPatchRaw 72 - combined := comparison.CombinedPatchRaw 73 - 74 - if err := validatePatch(&patch); err != nil { 75 - s.logger.Error("failed to validate patch", "err", err) 76 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 77 - return 78 - } 79 - 80 - pullSource := &models.PullSource{ 81 - Branch: sourceBranch, 82 - } 83 - 84 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 85 - } 86 - 87 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 88 - if err := validatePatch(&patch); err != nil { 89 - s.logger.Error("patch validation failed", "err", err) 90 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 91 - return 92 - } 93 - 94 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 95 - } 96 - 97 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepoDid string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 98 - l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo_did", forkRepoDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 99 - 100 - if forkRepoDid == "" { 101 - s.pages.Notice(w, "pull", "No such fork.") 102 - return 103 - } 104 - fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 105 - if errors.Is(err, sql.ErrNoRows) { 106 - s.pages.Notice(w, "pull", "No such fork.") 107 - return 108 - } else if err != nil { 109 - l.Error("failed to fetch fork", "err", err, "fork_repo_did", forkRepoDid) 110 - s.pages.Notice(w, "pull", "Failed to fetch fork.") 111 - return 112 - } 113 - 114 - client, err := s.oauth.ServiceClient( 115 - r, 116 - oauth.WithService(fork.Knot), 117 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 118 - oauth.WithDev(s.config.Core.Dev), 119 - ) 120 - 121 - resp, err := tangled.RepoHiddenRef( 122 - r.Context(), 123 - client, 124 - &tangled.RepoHiddenRef_Input{ 125 - ForkRef: sourceBranch, 126 - RemoteRef: targetBranch, 127 - Repo: fork.RepoAt().String(), 128 - }, 129 - ) 130 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 131 - s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 132 - s.pages.Notice(w, "pull", xrpcerr.Error()) 133 - return 134 - } 135 - 136 - if !resp.Success { 137 - errorMsg := "Failed to create pull request" 138 - if resp.Error != nil { 139 - errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 140 - } 141 - s.pages.Notice(w, "pull", errorMsg) 142 - return 143 - } 144 - 145 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 146 - // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 147 - // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 148 - // hiddenRef: hidden/feature-1/main (on repo-fork) 149 - // targetBranch: main (on repo-1) 150 - // sourceBranch: feature-1 (on repo-fork) 151 - forkXrpcc := s.knotClient(fork.Knot) 152 - 153 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 154 - if err != nil { 155 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 156 - l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 157 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 158 - return 159 - } 160 - l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 161 - s.pages.Notice(w, "pull", err.Error()) 162 - return 163 - } 164 - 165 - var comparison types.RepoFormatPatchResponse 166 - if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 167 - l.Error("failed to decode XRPC compare response for fork", "err", err) 168 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 169 - return 170 - } 171 - 172 - if len(comparison.FormatPatch) == 0 { 173 - s.pages.Notice(w, "pull", "No commits between target and source.") 174 - return 175 - } 176 - 177 - sourceRev := comparison.Rev2 178 - patch := comparison.FormatPatchRaw 179 - combined := comparison.CombinedPatchRaw 180 - 181 - if err := validatePatch(&patch); err != nil { 182 - s.logger.Error("failed to validate patch", "err", err) 183 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 184 - return 185 - } 186 - 187 - forkDid := syntax.DID(fork.RepoDid) 188 - pullSource := &models.PullSource{ 189 - Branch: sourceBranch, 190 - RepoDid: &forkDid, 191 - } 192 - 193 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 194 - } 195 - 196 - func (s *Pulls) createPullRequest( 197 - w http.ResponseWriter, 198 - r *http.Request, 199 - repo *models.Repo, 200 - userDid syntax.DID, 201 - title, body, targetBranch string, 202 - patch string, 203 - combined string, 204 - sourceRev string, 205 - pullSource *models.PullSource, 206 - isStacked bool, 207 - stackTitles, stackBodies map[string]string, 208 - ) { 209 - l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 210 - 211 - if isStacked { 212 - // creates a series of PRs, each linking to the previous, identified by jj's change-id 213 - s.createStackedPullRequest( 214 - w, 215 - r, 216 - repo, 217 - userDid, 218 - targetBranch, 219 - patch, 220 - sourceRev, 221 - pullSource, 222 - stackTitles, 223 - stackBodies, 224 - ) 225 - return 226 - } 32 + l := s.logger.With("handler", "handlePull", "user", userDid) 33 + ctx := r.Context() 227 34 228 35 client, err := s.oauth.AuthorizedClient(r) 229 36 if err != nil { ··· 232 39 return 233 40 } 234 41 235 - tx, err := s.db.BeginTx(r.Context(), nil) 236 - if err != nil { 237 - l.Error("failed to start tx", "err", err) 238 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 239 - return 240 - } 241 - defer tx.Rollback() 242 - 243 - // We've already checked earlier if it's diff-based and title is empty, 244 - // so if it's still empty now, it's intentionally skipped owing to format-patch. 245 - if title == "" || body == "" { 246 - formatPatches, err := patchutil.ExtractPatches(patch) 247 - if err != nil { 248 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 249 - return 250 - } 251 - if len(formatPatches) == 0 { 252 - s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 42 + // 1. fetch heads of source & target branches 43 + var base, head string 44 + { 45 + xrpcc := s.knotMirrorXRPC 46 + g, gctx := errgroup.WithContext(ctx) 47 + g.Go(func() error { 48 + // find merge-base between targetBranch & sourceBranch 49 + out, err := tangled.GitTempGetMergeBase(gctx, xrpcc, targetBranch, sourceBranch, sourceRepo.RepoDid) 50 + if err != nil { 51 + return err 52 + } 53 + base = out.Commit 54 + return nil 55 + }) 56 + g.Go(func() error { 57 + out, err := tangled.GitTempGetBranch(gctx, xrpcc, sourceBranch, sourceRepo.RepoDid) 58 + if err != nil { 59 + return err 60 + } 61 + head = out.Hash 62 + return nil 63 + }) 64 + if err := g.Wait(); err != nil { 65 + l.Error("failed to fetch branch heads", "err", err) 66 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 253 67 return 254 68 } 255 - 256 - if title == "" { 257 - title = formatPatches[0].Title 258 - } 259 - if body == "" { 260 - body = formatPatches[0].Body 261 - } 262 69 } 263 70 264 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 265 - 266 - rkey := tid.TID() 71 + created := time.Now() 72 + pull := &models.Pull{ 73 + ID: -1, // uninitialized 74 + OwnerDid: userDid, 75 + Rkey: syntax.RecordKey(tid.TID()), 76 + Cid: "", // uninitialized 77 + RepoDid: syntax.DID(targetRepo.RepoDid), 78 + PullId: 0, // uninitialized 267 79 268 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 269 - if err != nil { 270 - l.Error("failed to upload patch", "err", err) 271 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 272 - return 273 - } 274 - 275 - now := time.Now() 276 - 277 - pull := &models.Pull{ 278 80 Title: title, 279 81 Body: body, 280 82 TargetBranch: targetBranch, 281 - OwnerDid: userDid.String(), 282 - RepoDid: syntax.DID(repo.RepoDid), 283 - Rkey: rkey, 284 - Mentions: mentions, 285 - References: references, 286 - Submissions: []*models.PullSubmission{ 83 + SourceRepo: syntax.DID(sourceRepo.RepoDid), 84 + SourceBranch: &sourceBranch, 85 + Versions: []models.PullVersion{ 287 86 { 288 - Patch: patch, 289 - Combined: combined, 290 - SourceRev: sourceRev, 291 - Blob: *blob.Blob, 292 - Created: now, 87 + ID: 0, 88 + Base: base, 89 + Head: head, 90 + Created: created, 293 91 }, 294 92 }, 295 - PullSource: pullSource, 296 - State: models.PullOpen, 297 - Created: now, 298 - Repo: repo, 299 - } 300 - 301 - record := pull.AsRecord() 302 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 303 - Collection: tangled.RepoPullNSID, 304 - Repo: userDid.String(), 305 - Rkey: rkey, 306 - Record: knotcompat.Pull(&record), 307 - }) 308 - if err != nil { 309 - l.Error("failed to create pull request", "err", err) 310 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 311 - return 312 - } 313 - 314 - err = db.PutPull(tx, pull) 315 - if err != nil { 316 - l.Error("failed to create pull request in database", "err", err) 317 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 318 - return 319 - } 320 - pullId, err := db.NextPullId(tx, repo.RepoDid) 321 - if err != nil { 322 - s.logger.Error("failed to get pull id", "err", err) 323 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 324 - return 325 - } 326 - 327 - if err = tx.Commit(); err != nil { 328 - l.Error("failed to commit transaction for pull request", "err", err) 329 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 330 - return 331 - } 332 - 333 - s.notifier.NewPull(r.Context(), pull) 334 - 335 - s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 336 - 337 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 338 - s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 339 - } 340 - 341 - func (s *Pulls) createStackedPullRequest( 342 - w http.ResponseWriter, 343 - r *http.Request, 344 - repo *models.Repo, 345 - userDid syntax.DID, 346 - targetBranch string, 347 - patch string, 348 - sourceRev string, 349 - pullSource *models.PullSource, 350 - stackTitles, stackBodies map[string]string, 351 - ) { 352 - l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 353 - 354 - // run some necessary checks for stacked-prs first 355 - 356 - formatPatches, err := patchutil.ExtractPatches(patch) 357 - if err != nil { 358 - l.Error("failed to extract patches", "err", err) 359 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 360 - return 361 - } 362 - 363 - // must have atleast 1 patch to begin with 364 - if len(formatPatches) == 0 { 365 - l.Error("empty patches") 366 - s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 367 - return 368 - } 93 + Created: created, 94 + State: models.PullOpen, 369 95 370 - client, err := s.oauth.AuthorizedClient(r) 371 - if err != nil { 372 - l.Error("failed to get authorized client", "err", err) 373 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 374 - return 96 + Repo: targetRepo, 375 97 } 376 98 377 - // first upload all blobs 378 - blobs := make([]*lexutil.LexBlob, len(formatPatches)) 379 - for i, p := range formatPatches { 380 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 99 + // 2. call git.keepCommit 100 + { 101 + client, err := s.oauth.ServiceClient( 102 + r, 103 + oauth.WithService(sourceRepo.Knot), 104 + oauth.WithLxm(tangled.GitKeepCommitNSID), 105 + oauth.WithDev(s.config.Core.Dev), 106 + ) 381 107 if err != nil { 382 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 108 + l.Error("failed to comment to knot", "err", err) 383 109 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 384 110 return 385 111 } 386 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 387 - blobs[i] = blob.Blob 388 - } 389 112 390 - // build a stack out of this patch 391 - stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 392 - if err != nil { 393 - l.Error("failed to create stack", "err", err) 394 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 395 - return 396 - } 397 - 398 - // apply all record creations at once 399 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 400 - for _, p := range stack { 401 - record := p.AsRecord() 402 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 403 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 404 - Collection: tangled.RepoPullNSID, 405 - Rkey: &p.Rkey, 406 - Value: knotcompat.Pull(&record), 113 + _, err = tangled.GitKeepCommit(ctx, client, &tangled.GitKeepCommit_Input{ 114 + Repo: sourceRepo.RepoDid, 115 + Record: pull.AtUri().String(), 116 + Source: &tangled.GitKeepCommit_Input_Source{ 117 + GitKeepCommit_Commit: &tangled.GitKeepCommit_Commit{ 118 + Repo: sourceRepo.RepoDid, 119 + Oid: head, 120 + }, 407 121 }, 408 122 }) 123 + if err != nil { 124 + l.Error("failed to keep commit", "err", err) 125 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 126 + return 127 + } 409 128 } 410 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 411 - Repo: userDid.String(), 412 - Writes: writes, 129 + 130 + // 3. create PR record 131 + record := pull.AsRecord() 132 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 133 + Collection: tangled.RepoPullNSID, 134 + Repo: userDid.String(), 135 + Rkey: pull.Rkey.String(), 136 + Record: knotcompat.Pull(&record), 413 137 }) 414 - if err != nil { 415 - l.Error("failed to create stacked pull request", "err", err) 416 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 417 - return 418 - } 138 + pull.Cid = syntax.CID(out.Cid) 419 139 420 - // create all pulls at once 421 140 tx, err := s.db.BeginTx(r.Context(), nil) 422 141 if err != nil { 423 142 l.Error("failed to start tx", "err", err) ··· 426 145 } 427 146 defer tx.Rollback() 428 147 429 - for _, p := range stack { 430 - err = db.PutPull(tx, p) 431 - if err != nil { 432 - l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 433 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 434 - return 435 - } 436 - 148 + var references []syntax.ATURI 149 + if pull.Body != "" { 150 + _, references = s.mentionsResolver.Resolve(ctx, pull.Body) 437 151 } 438 152 439 - if err = tx.Commit(); err != nil { 440 - l.Error("failed to commit transaction for pull requests", "err", err) 153 + if err := db.PutPull(r.Context(), tx, pull, references); err != nil { 154 + l.Error("failed to create pull request in database", "err", err) 441 155 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 442 156 return 443 157 } 444 158 445 - // notify about each pull 446 - // 447 - // this is performed after tx.Commit, because it could result in a locked DB otherwise 448 - for _, p := range stack { 449 - s.notifier.NewPull(r.Context(), p) 159 + if err = tx.Commit(); err != nil { 160 + l.Error("failed to commit transaction for pull request", "err", err) 161 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 162 + return 450 163 } 451 164 452 - s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 453 - 454 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 455 - s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 456 - } 457 - 458 - func (s *Pulls) newStack( 459 - ctx context.Context, 460 - repo *models.Repo, 461 - userDid syntax.DID, 462 - targetBranch string, 463 - pullSource *models.PullSource, 464 - formatPatches []types.FormatPatch, 465 - blobs []*lexutil.LexBlob, 466 - stackTitles, stackBodies map[string]string, 467 - ) (models.Stack, error) { 468 - var stack models.Stack 469 - var parentAtUri *syntax.ATURI 470 - for i, fp := range formatPatches { 471 - // all patches must have a jj change-id 472 - cid, err := fp.ChangeId() 473 - if err != nil { 474 - return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 475 - } 476 - 477 - title := fp.Title 478 - body := fp.Body 479 - if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 480 - title = override 481 - } 482 - if override, ok := stackBodies[cid]; ok { 483 - body = override 484 - } 485 - rkey := tid.TID() 486 - 487 - mentions, references := s.mentionsResolver.Resolve(ctx, body) 488 - 489 - now := time.Now() 490 - 491 - pull := models.Pull{ 492 - Title: title, 493 - Body: body, 494 - TargetBranch: targetBranch, 495 - OwnerDid: userDid.String(), 496 - RepoDid: syntax.DID(repo.RepoDid), 497 - Rkey: rkey, 498 - Mentions: mentions, 499 - References: references, 500 - Submissions: []*models.PullSubmission{ 501 - { 502 - Patch: fp.Raw, 503 - SourceRev: fp.SHA, 504 - Combined: fp.Raw, 505 - Blob: *blobs[i], 506 - Created: now, 507 - }, 508 - }, 509 - PullSource: pullSource, 510 - Created: now, 511 - State: models.PullOpen, 165 + s.notifier.NewPull(r.Context(), pull) 512 166 513 - DependentOn: parentAtUri, 514 - Repo: repo, 515 - } 167 + s.applyCreationLabels(r.Context(), client, userDid, pull, r.Form, targetRepo) 516 168 517 - stack = append(stack, &pull) 518 - 519 - parent := pull.AtUri() 520 - parentAtUri = &parent 521 - } 522 - 523 - return stack, nil 169 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, targetRepo) 170 + s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 524 171 }
+168
appview/pulls/diff.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "html/template" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "strings" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "golang.org/x/sync/errgroup" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 18 + ) 19 + 20 + // htmx fragment. render diff between commits 21 + func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) { 22 + l := s.logger.With("handler", "PullDiffFragment") 23 + ctx := r.Context() 24 + 25 + var ( 26 + baseRepoRaw = r.URL.Query().Get("baseRepo") 27 + headRepoRaw = r.URL.Query().Get("headRepo") 28 + base = r.URL.Query().Get("base") // base commit SHA 29 + head = r.URL.Query().Get("head") // head commit SHA 30 + unified = r.URL.Query().Get("view") == "unified" 31 + ) 32 + baseRepo, err := syntax.ParseDID(baseRepoRaw) 33 + if err != nil { 34 + if err := s.pages.PullDiffFragment(w, pages.PullDiffFragmentParams{ 35 + ErrorMsg: "invalid base repository DID", 36 + }); err != nil { 37 + l.Error("failed to render", "err", err) 38 + } 39 + return 40 + } 41 + headRepo, err := syntax.ParseDID(headRepoRaw) 42 + if err != nil { 43 + if err := s.pages.PullDiffFragment(w, pages.PullDiffFragmentParams{ 44 + ErrorMsg: "invalid head repository DID", 45 + }); err != nil { 46 + l.Error("failed to render", "err", err) 47 + } 48 + return 49 + } 50 + l.Debug("diff fragment", "baseRepo", baseRepo, "base", base, "headRepo", headRepo, "head", head) 51 + 52 + var params pages.PullDiffFragmentParams 53 + params.BaseRepo = baseRepo 54 + params.HeadRepo = headRepo 55 + params.DiffBase = base 56 + params.DiffHead = head 57 + params.DiffUrl = r.URL.Path 58 + params.Unified = unified 59 + params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified) 60 + if err := s.pages.PullDiffFragment(w, params); err != nil { 61 + l.Error("failed to render", "err", err) 62 + } 63 + } 64 + 65 + func (s *Pulls) diffFragmentParams(ctx context.Context, l *slog.Logger, baseRepo syntax.DID, base string, headRepo syntax.DID, head string, unified bool) ([]pages.DiffFile, string) { 66 + var err error 67 + if base != "" && !models.IsHash(base) { 68 + l := l.With("rev", base, "repo", baseRepo) 69 + if base, err = s.resolveRev(ctx, baseRepo, base); err != nil { 70 + l.Error("failed to resolve base revision", "err", err) 71 + return nil, "Failed to resolve base revision." 72 + } 73 + } 74 + if head != "" && !models.IsHash(head) { 75 + l := l.With("rev", head, "repo", headRepo) 76 + if head, err = s.resolveRev(ctx, headRepo, head); err != nil { 77 + l.Error("failed to resolve head revision", "err", err) 78 + return nil, "Failed to resolve head revision." 79 + } 80 + } 81 + 82 + // a. drain the diff stream into one fileDiff per changed file. 83 + req := &gitmirrorv1.DiffRequest{ 84 + Head: &gitmirrorv1.RepoCommit{Repo: headRepo.String(), Commit: []byte(head)}, 85 + } 86 + if base != "" { 87 + req.Base = &gitmirrorv1.RepoCommit{Repo: baseRepo.String(), Commit: []byte(base)} 88 + } 89 + stream, err := s.gitmirror.Diff(ctx, req) 90 + if err != nil { 91 + l.Error("failed to diff", "err", err) 92 + return nil, "Failed to diff. Try again later." 93 + } 94 + var files []*fileDiff 95 + for { 96 + fd, err := stream.Recv() 97 + if errors.Is(err, io.EOF) { 98 + break 99 + } 100 + if err != nil { 101 + l.Error("failed to drain diff response", "err", err) 102 + return nil, "Failed to diff. Try again later." 103 + } 104 + files = append(files, &fileDiff{diff: fd}) 105 + } 106 + 107 + // b. fetch each file's base/head blob in parallel and split into lines. 108 + g, gctx := errgroup.WithContext(ctx) 109 + for _, f := range files { 110 + g.Go(func() error { 111 + lhs, rhs := f.diff.GetLhsSrc(), f.diff.GetRhsSrc() 112 + // Binary/submodule files have no line content to fetch. 113 + if isBinaryOrSubmodule(lhs) || isBinaryOrSubmodule(rhs) { 114 + return nil 115 + } 116 + baseBlob, err := s.getBlob(gctx, baseRepo, lhs.GetOid()) 117 + if err != nil { 118 + return err 119 + } 120 + headBlob, err := s.getBlob(gctx, headRepo, rhs.GetOid()) 121 + if err != nil { 122 + return err 123 + } 124 + 125 + // TODO: highlight each blobs 126 + // pass lhs_positions & rhs_positions so highlighter can apply diff highlights 127 + for line := range strings.SplitSeq(strings.TrimSuffix(string(baseBlob), "\n"), "\n") { 128 + f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 129 + } 130 + for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") { 131 + f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 132 + } 133 + 134 + // f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n") 135 + // f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n") 136 + return nil 137 + }) 138 + } 139 + if err := g.Wait(); err != nil { 140 + l.Error("failed to prepare html", "err", err) 141 + return nil, "Failed to render diff. Try again later." 142 + } 143 + 144 + // c. build the render model 145 + var outFiles []pages.DiffFile 146 + for _, f := range files { 147 + df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()} 148 + 149 + if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) { 150 + df.Note = "binary or submodule" 151 + outFiles = append(outFiles, df) 152 + continue 153 + } 154 + 155 + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { 156 + var dh pages.DiffHunk 157 + if unified { 158 + dh.Lines = buildUnifiedLines(h, f.baseLines, f.headLines) 159 + } else { 160 + dh.Rows = buildSplitRows(h, f.baseLines, f.headLines) 161 + } 162 + df.Hunks = append(df.Hunks, dh) 163 + } 164 + outFiles = append(outFiles, df) 165 + } 166 + 167 + return outFiles, "" 168 + }
+380
appview/pulls/diff_helpers.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "html/template" 7 + "io" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/pages" 11 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 12 + ) 13 + 14 + type fileDiff struct { 15 + diff *gitmirrorv1.FileDiff 16 + baseLines []template.HTML 17 + headLines []template.HTML 18 + } 19 + 20 + const ( 21 + numContextLines = 3 22 + maxDistance = 4 23 + ) 24 + 25 + type linePair struct { 26 + lhs int // 0-based line number, -1 when empty 27 + rhs int // 0-based line number, -1 when empty 28 + } 29 + 30 + type displayHunk struct { 31 + rows []diffRow 32 + } 33 + 34 + type diffRow struct { 35 + lhs int // 0-based line number, -1 when empty 36 + rhs int // 0-based line number, -1 when empty 37 + changed bool 38 + } 39 + 40 + func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk { 41 + var flat []linePair 42 + for _, h := range hunks { 43 + for _, lp := range h.Lines { 44 + flat = append(flat, toPair(lp)) 45 + } 46 + } 47 + 48 + pairs, changed := alignFile(baseLines, headLines, hunks) 49 + merged := mergeAdjacent(linesToHunks(flat), pairs) 50 + 51 + var out []displayHunk 52 + prevEnd := 0 53 + for _, h := range merged { 54 + lo, hi := indexesForHunk(pairs, h, numContextLines) 55 + if lo < prevEnd { 56 + lo = prevEnd // don't re-emit rows shared with the previous hunk's slice 57 + } 58 + var dh displayHunk 59 + for i := lo; i < hi; i++ { 60 + dh.rows = append(dh.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: changed[i]}) 61 + } 62 + out = append(out, dh) 63 + prevEnd = hi 64 + } 65 + return out 66 + } 67 + 68 + // alignFile builds the whole-file aligned list: every displayed line as a pair, 69 + // plus a parallel `changed` flag for pairs that came from a gitmirror hunk. 70 + // Unchanged lines are a 1:1 bijection, so the two cursors advance together 71 + // across gaps. 72 + func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) { 73 + li, ri := 0, 0 74 + emitContext := func(n int) { 75 + for k := range n { 76 + pairs = append(pairs, linePair{lhs: li + k, rhs: ri + k}) 77 + changed = append(changed, false) 78 + } 79 + li += n 80 + ri += n 81 + } 82 + for _, h := range hunks { 83 + lhsStart, _, ok := hunkStart(h, li, ri) 84 + if !ok { 85 + continue 86 + } 87 + emitContext(lhsStart - li) // unchanged gap before this change (== rhsStart-ri) 88 + for _, lp := range h.Lines { 89 + p := toPair(lp) 90 + if p.lhs >= 0 { 91 + li = p.lhs + 1 92 + } 93 + if p.rhs >= 0 { 94 + ri = p.rhs + 1 95 + } 96 + pairs = append(pairs, p) 97 + changed = append(changed, true) 98 + } 99 + } 100 + for li < len(baseLines) && ri < len(headLines) { 101 + pairs = append(pairs, linePair{lhs: li, rhs: ri}) 102 + changed = append(changed, false) 103 + li++ 104 + ri++ 105 + } 106 + return pairs, changed 107 + } 108 + 109 + // indexesForHunk returns the [start,end) slice of the aligned pairs to display 110 + // for a hunk: the span from its smallest to largest novel line, expanded by n 111 + // context lines each side and clamped. 112 + func indexesForHunk(pairs, hunkLines []linePair, n int) (start, end int) { 113 + minLhs, minRhs, maxLhs, maxRhs := -1, -1, -1, -1 114 + for _, lp := range hunkLines { 115 + if lp.lhs >= 0 { 116 + if minLhs < 0 { 117 + minLhs = lp.lhs 118 + } 119 + maxLhs = lp.lhs 120 + } 121 + if lp.rhs >= 0 { 122 + if minRhs < 0 { 123 + minRhs = lp.rhs 124 + } 125 + maxRhs = lp.rhs 126 + } 127 + } 128 + smallest, largest := linePair{minLhs, minRhs}, linePair{maxLhs, maxRhs} 129 + 130 + start = 0 131 + for i, p := range pairs { 132 + if eitherSideEqual(p, smallest) { 133 + start = i 134 + break 135 + } 136 + } 137 + end = len(pairs) 138 + for i := len(pairs) - 1; i >= 0; i-- { 139 + if eitherSideEqual(pairs[i], largest) { 140 + end = i + 1 141 + break 142 + } 143 + } 144 + 145 + start = max(0, start-n) 146 + end = min(len(pairs), end+n) 147 + return start, end 148 + } 149 + 150 + // eitherSideEqual reports whether a and b share a present line number on the same side. 151 + func eitherSideEqual(a, b linePair) bool { 152 + if a.lhs >= 0 && a.lhs == b.lhs { 153 + return true 154 + } 155 + if a.rhs >= 0 && a.rhs == b.rhs { 156 + return true 157 + } 158 + return false 159 + } 160 + 161 + func toPair(lp *gitmirrorv1.LinePair) linePair { 162 + p := linePair{lhs: -1, rhs: -1} 163 + if lp.Lhs != nil { 164 + p.lhs = int(*lp.Lhs) 165 + } 166 + if lp.Rhs != nil { 167 + p.rhs = int(*lp.Rhs) 168 + } 169 + return p 170 + } 171 + 172 + // hunkStart returns the first changed line number on each side, deriving the 173 + // empty side from the cursors (unchanged lines advance both sides equally). ok 174 + // is false for an empty hunk. 175 + func hunkStart(h *gitmirrorv1.Hunk, li, ri int) (lhsStart, rhsStart int, ok bool) { 176 + lhsStart, rhsStart = -1, -1 177 + for _, lp := range h.Lines { 178 + if lp.Lhs != nil && lhsStart < 0 { 179 + lhsStart = int(*lp.Lhs) 180 + } 181 + if lp.Rhs != nil && rhsStart < 0 { 182 + rhsStart = int(*lp.Rhs) 183 + } 184 + } 185 + switch { 186 + case lhsStart < 0 && rhsStart < 0: 187 + return 0, 0, false 188 + case lhsStart < 0: // pure insertion 189 + lhsStart = li + (rhsStart - ri) 190 + case rhsStart < 0: // pure deletion 191 + rhsStart = ri + (lhsStart - li) 192 + } 193 + return lhsStart, rhsStart, true 194 + } 195 + 196 + // enforceIncreasing drops any line number that would go backwards, keeping each 197 + // side monotonically increasing. 198 + func enforceIncreasing(lines []linePair) []linePair { 199 + var out []linePair 200 + maxLhs, maxRhs := -1, -1 201 + for _, lp := range lines { 202 + l, r := lp.lhs, lp.rhs 203 + if maxLhs < 0 { 204 + maxLhs = l 205 + } else if l >= 0 && l > maxLhs { 206 + maxLhs = l 207 + } else { 208 + l = -1 209 + } 210 + if maxRhs < 0 { 211 + maxRhs = r 212 + } else if r >= 0 && r > maxRhs { 213 + maxRhs = r 214 + } else { 215 + r = -1 216 + } 217 + if l >= 0 || r >= 0 { 218 + out = append(out, linePair{lhs: l, rhs: r}) 219 + } 220 + } 221 + return out 222 + } 223 + 224 + // linesAreClose reports whether a line is within maxDistance of the last seen 225 + // line on either side. 226 + func linesAreClose(maxLhs, maxRhs int, lp linePair) bool { 227 + if maxLhs >= 0 && lp.lhs >= 0 && lp.lhs <= maxLhs+maxDistance { 228 + return true 229 + } 230 + if maxRhs >= 0 && lp.rhs >= 0 && lp.rhs <= maxRhs+maxDistance { 231 + return true 232 + } 233 + return false 234 + } 235 + 236 + // linesToHunks splits changed line pairs into hunks by per-side proximity. 237 + func linesToHunks(flat []linePair) [][]linePair { 238 + var hunks [][]linePair 239 + var cur []linePair 240 + maxLhs, maxRhs := -1, -1 241 + for _, lp := range enforceIncreasing(flat) { 242 + if len(cur) == 0 || linesAreClose(maxLhs, maxRhs, lp) { 243 + cur = append(cur, lp) 244 + } else { 245 + hunks = append(hunks, cur) 246 + cur = []linePair{lp} 247 + } 248 + if lp.lhs >= 0 { 249 + maxLhs = lp.lhs 250 + } 251 + if lp.rhs >= 0 { 252 + maxRhs = lp.rhs 253 + } 254 + } 255 + if len(cur) > 0 { 256 + hunks = append(hunks, cur) 257 + } 258 + return hunks 259 + } 260 + 261 + // mergeAdjacent folds consecutive hunks whose context windows overlap in the 262 + // aligned pair list into one group. It pads by numContextLines+1 (one more than 263 + // the displayed context) so hunks separated only by shared context merge. 264 + func mergeAdjacent(hunks [][]linePair, pairs []linePair) [][]linePair { 265 + var merged [][]linePair 266 + prevHi := -1 267 + for _, h := range hunks { 268 + lo, hi := indexesForHunk(pairs, h, numContextLines+1) 269 + if len(merged) > 0 && lo < prevHi { 270 + last := len(merged) - 1 271 + merged[last] = append(merged[last], h...) 272 + if hi > prevHi { 273 + prevHi = hi 274 + } 275 + continue 276 + } 277 + merged = append(merged, h) 278 + prevHi = hi 279 + } 280 + return merged 281 + } 282 + 283 + func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow { 284 + rows := make([]pages.DiffRow, 0, len(h.rows)) 285 + for _, r := range h.rows { 286 + var row pages.DiffRow 287 + if !r.changed { 288 + row.Left = pages.DiffCell{Kind: "ctx", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} 289 + row.Right = pages.DiffCell{Kind: "ctx", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} 290 + } else { 291 + if r.lhs >= 0 { 292 + row.Left = pages.DiffCell{Kind: "del", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} 293 + } else { 294 + row.Left = pages.DiffCell{Kind: "empty", Num: 0} 295 + } 296 + if r.rhs >= 0 { 297 + row.Right = pages.DiffCell{Kind: "add", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} 298 + } else { 299 + row.Right = pages.DiffCell{Kind: "empty", Num: 0} 300 + } 301 + } 302 + rows = append(rows, row) 303 + } 304 + return rows 305 + } 306 + 307 + func buildUnifiedLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine { 308 + var out []pages.DiffLine 309 + for i := 0; i < len(h.rows); { 310 + r := h.rows[i] 311 + if !r.changed { 312 + if r.lhs >= 0 { 313 + out = append(out, pages.DiffLine{Op: " ", Old: r.lhs + 1, New: r.rhs + 1, Content: lineAt(baseLines, r.lhs)}) 314 + } 315 + i++ 316 + continue 317 + } 318 + j := i 319 + for j < len(h.rows) && h.rows[j].changed { 320 + j++ 321 + } 322 + for _, cr := range h.rows[i:j] { 323 + if cr.lhs >= 0 { 324 + out = append(out, pages.DiffLine{Op: "-", Old: cr.lhs + 1, New: 0, Content: lineAt(baseLines, cr.lhs)}) 325 + } 326 + } 327 + for _, cr := range h.rows[i:j] { 328 + if cr.rhs >= 0 { 329 + out = append(out, pages.DiffLine{Op: "+", Old: 0, New: cr.rhs + 1, Content: lineAt(headLines, cr.rhs)}) 330 + } 331 + } 332 + i = j 333 + } 334 + return out 335 + } 336 + 337 + func lineAt(lines []template.HTML, n int) template.HTML { 338 + if n < 0 || n >= len(lines) { 339 + return "" 340 + } 341 + return lines[n] 342 + } 343 + 344 + func (s *Pulls) getBlob(ctx context.Context, repo syntax.DID, oid string) ([]byte, error) { 345 + if isNullOid(oid) { 346 + return nil, nil 347 + } 348 + stream, err := s.gitmirror.GetBlob(ctx, &gitmirrorv1.GetBlobRequest{Repo: repo.String(), Oid: oid}) 349 + if err != nil { 350 + return nil, err 351 + } 352 + var buf []byte 353 + for { 354 + chunk, err := stream.Recv() 355 + if errors.Is(err, io.EOF) { 356 + break 357 + } 358 + if err != nil { 359 + return nil, err 360 + } 361 + buf = append(buf, chunk.GetData()...) 362 + } 363 + return buf, nil 364 + } 365 + 366 + func isBinaryOrSubmodule(fc *gitmirrorv1.FileContent) bool { 367 + return fc != nil && (fc.GetIsBinary() || fc.GetIsSubmodule()) 368 + } 369 + 370 + func isNullOid(oid string) bool { 371 + if oid == "" { 372 + return true 373 + } 374 + for _, c := range oid { 375 + if c != '0' { 376 + return false 377 + } 378 + } 379 + return true 380 + }
+10 -6
appview/pulls/edit.go
··· 4 4 "net/http" 5 5 6 6 comatproto "github.com/bluesky-social/indigo/api/atproto" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 7 8 lexutil "github.com/bluesky-social/indigo/lex/util" 8 9 9 10 "tangled.org/core/api/tangled" ··· 36 37 newPull := *pull 37 38 newPull.Title = r.FormValue("title") 38 39 newPull.Body = r.FormValue("body") 39 - newPull.Mentions, newPull.References = s.mentionsResolver.Resolve(ctx, newPull.Body) 40 + var references []syntax.ATURI 41 + if pull.Body != "" { 42 + _, references = s.mentionsResolver.Resolve(ctx, newPull.Body) 43 + } 40 44 41 45 // edit an atproto record 42 46 client, err := s.oauth.AuthorizedClient(r) ··· 46 50 return 47 51 } 48 52 49 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, newPull.Rkey) 53 + ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, newPull.Rkey.String()) 50 54 if err != nil { 51 55 l.Error("failed to get record", "err", err) 52 56 s.pages.Notice(w, noticeId, "Failed to edit pull, no record found on PDS.") ··· 54 58 } 55 59 56 60 newRecord := newPull.AsRecord() 57 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 61 + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 58 62 Collection: tangled.RepoPullNSID, 59 63 Repo: user.Did, 60 - Rkey: newPull.Rkey, 64 + Rkey: newPull.Rkey.String(), 61 65 SwapRecord: ex.Cid, 62 66 Record: &lexutil.LexiconTypeDecoder{ 63 67 Val: &newRecord, ··· 69 73 return 70 74 } 71 75 72 - tx, err := s.db.BeginTx(r.Context(), nil) 76 + tx, err := s.db.BeginTx(ctx, nil) 73 77 if err != nil { 74 78 l.Error("failed to start tx", "err", err) 75 79 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 77 81 } 78 82 defer tx.Rollback() 79 83 80 - err = db.PutPull(tx, &newPull) 84 + err = db.PutPull(ctx, tx, &newPull, references) 81 85 if err != nil { 82 86 l.Error("failed to create pull request in database", "err", err) 83 87 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+120
appview/pulls/interdiff.go
··· 1 + package pulls 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "html/template" 7 + "io" 8 + "net/http" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "golang.org/x/sync/errgroup" 13 + "tangled.org/core/appview/pages" 14 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 15 + ) 16 + 17 + // htmx fragment. render interdiff between changes 18 + func (s *Pulls) PullInterdiffFragment(w http.ResponseWriter, r *http.Request) { 19 + l := s.logger.With("handler", "PullInterdiffFragment") 20 + ctx := r.Context() 21 + var ( 22 + repoRaw = r.URL.Query().Get("repo") // source repo DID 23 + base1 = r.URL.Query().Get("base1") // base1 commit ID 24 + head1 = r.URL.Query().Get("head1") // head1 commit ID 25 + base2 = r.URL.Query().Get("base2") // base2 commit ID 26 + head2 = r.URL.Query().Get("head2") // head2 commit ID 27 + unified = r.URL.Query().Get("view") == "unified" 28 + ) 29 + repo, err := syntax.ParseDID(repoRaw) 30 + if err != nil { 31 + http.Error(w, "invalid repo DID", http.StatusBadRequest) 32 + return 33 + } 34 + l.Debug("interdiff", "base1", base1, "head1", head1, "base2", base2, "head2", head2) 35 + 36 + var params pages.PullDiffFragmentParams 37 + params.DiffUrl = r.URL.Path 38 + params.Unified = unified 39 + defer func() { 40 + s.pages.PullDiffFragment(w, params) 41 + }() 42 + 43 + stream, err := s.gitmirror.Interdiff(ctx, &gitmirrorv1.InterdiffRequest{ 44 + Repo: repo.String(), 45 + FromBase: []byte(base1), 46 + FromHead: []byte(head1), 47 + ToBase: []byte(base2), 48 + ToHead: []byte(head2), 49 + }) 50 + if err != nil { 51 + l.Error("failed to interdiff", "err", err) 52 + params.ErrorMsg = "Failed to interdiff. Try again later." 53 + return 54 + } 55 + var files []*fileDiff 56 + for { 57 + fd, err := stream.Recv() 58 + if errors.Is(err, io.EOF) { 59 + break 60 + } 61 + if err != nil { 62 + l.Error("failed to drain interdiff response", "err", err) 63 + params.ErrorMsg = "Failed to interdiff. Try again later." 64 + return 65 + } 66 + files = append(files, &fileDiff{diff: fd}) 67 + } 68 + 69 + g, gctx := errgroup.WithContext(ctx) 70 + for _, f := range files { 71 + g.Go(func() error { 72 + lhs, rhs := f.diff.GetLhsSrc(), f.diff.GetRhsSrc() 73 + // Binary/submodule files have no line content to fetch. 74 + if isBinaryOrSubmodule(lhs) || isBinaryOrSubmodule(rhs) { 75 + return nil 76 + } 77 + 78 + headBlob, err := s.getBlob(gctx, syntax.DID(repo), rhs.GetOid()) 79 + if err != nil { 80 + return err 81 + } 82 + 83 + if f.diff.LhsSrc.Content != nil { 84 + for line := range strings.SplitSeq(strings.TrimSuffix(string(f.diff.LhsSrc.Content), "\n"), "\n") { 85 + f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 86 + } 87 + } 88 + 89 + for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") { 90 + f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 91 + } 92 + return nil 93 + }) 94 + } 95 + if err := g.Wait(); err != nil { 96 + l.Error("failed to prepare interdiff UI", "err", err) 97 + params.ErrorMsg = "Failed to render interdiff. Try again later." 98 + return 99 + } 100 + for _, f := range files { 101 + df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()} 102 + 103 + if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) { 104 + df.Note = "binary or submodule" 105 + params.Files = append(params.Files, df) 106 + continue 107 + } 108 + 109 + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { 110 + var dh pages.DiffHunk 111 + if unified { 112 + dh.Lines = buildUnifiedLines(h, f.baseLines, f.headLines) 113 + } else { 114 + dh.Rows = buildSplitRows(h, f.baseLines, f.headLines) 115 + } 116 + df.Hunks = append(df.Hunks, dh) 117 + } 118 + params.Files = append(params.Files, df) 119 + } 120 + }
+51 -76
appview/pulls/labels.go
··· 95 95 ctx context.Context, 96 96 client *atclient.APIClient, 97 97 userDid syntax.DID, 98 - pulls []*models.Pull, 98 + pull *models.Pull, 99 99 form url.Values, 100 100 repo *models.Repo, 101 101 ) { ··· 110 110 return 111 111 } 112 112 113 - perCidForms := parseStackLabelForms(form) 113 + rkey := tid.TID() 114 + raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, form, defs, time.Now()) 114 115 115 - applyAll := form.Get("applyLabelsToAll") == "on" 116 - var firstStackForm url.Values 117 - if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 { 118 - if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" { 119 - if f, ok := perCidForms[firstCid]; ok { 120 - firstStackForm = f 121 - } 116 + valid := make([]models.LabelOp, 0, len(raw)) 117 + for _, op := range raw { 118 + def := defs[op.OperandKey] 119 + 120 + // validate permissions: only collaborators can apply labels currently 121 + // 122 + // TODO: introduce a repo:triage permission 123 + ok, err := s.acl.HasRepoPermissionErr(ctx, repo, op.Did, "repo:push") 124 + if err != nil { 125 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 126 + continue 122 127 } 123 - } 128 + if !ok { 129 + l.Warn("forbidden label op", "subject", op.Subject, "key", op.OperandKey) 130 + continue 131 + } 124 132 125 - performedAt := time.Now() 126 - for _, pull := range pulls { 127 - labelForm := form 128 - if firstStackForm != nil { 129 - labelForm = firstStackForm 130 - } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 131 - if cid := pull.Submissions[0].ChangeId(); cid != "" { 132 - if perForm, ok := perCidForms[cid]; ok { 133 - labelForm = perForm 133 + // resolve Handle to DID 134 + if def.ValueType.IsString() && def.ValueType.IsDidFormat() { 135 + val := syntax.AtIdentifier(op.OperandValue) 136 + if val.IsHandle() { 137 + ident, err := s.idResolver.Directory().Lookup(ctx, val) 138 + if err != nil { 139 + l.Warn("failed to resolve handle", "err", err, "subject", op.Subject, "key", op.OperandKey) 134 140 } 141 + op.OperandValue = ident.DID.String() 135 142 } 136 143 } 137 - rkey := tid.TID() 138 - raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 139 144 140 - valid := make([]models.LabelOp, 0, len(raw)) 141 - for _, op := range raw { 142 - def := defs[op.OperandKey] 143 - 144 - // validate permissions: only collaborators can apply labels currently 145 - // 146 - // TODO: introduce a repo:triage permission 147 - ok, err := s.acl.HasRepoPermissionErr(ctx, repo, op.Did, "repo:push") 148 - if err != nil { 149 - l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 150 - continue 151 - } 152 - if !ok { 153 - l.Warn("forbidden label op", "subject", op.Subject, "key", op.OperandKey) 154 - continue 155 - } 156 - 157 - // resolve Handle to DID 158 - if def.ValueType.IsString() && def.ValueType.IsDidFormat() { 159 - val := syntax.AtIdentifier(op.OperandValue) 160 - if val.IsHandle() { 161 - ident, err := s.idResolver.Directory().Lookup(ctx, val) 162 - if err != nil { 163 - l.Warn("failed to resolve handle", "err", err, "subject", op.Subject, "key", op.OperandKey) 164 - } 165 - op.OperandValue = ident.DID.String() 166 - } 167 - } 168 - 169 - if err := def.ValidateOperandValue(&op); err != nil { 170 - l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 171 - continue 172 - } 173 - valid = append(valid, op) 174 - } 175 - if len(valid) == 0 { 145 + if err := def.ValidateOperandValue(&op); err != nil { 146 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 176 147 continue 177 148 } 149 + valid = append(valid, op) 150 + } 151 + if len(valid) == 0 { 152 + return 153 + } 178 154 179 - record := models.LabelOpsAsRecord(valid) 180 - if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 155 + record := models.LabelOpsAsRecord(valid) 156 + if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 157 + Collection: tangled.LabelOpNSID, 158 + Repo: userDid.String(), 159 + Rkey: rkey, 160 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 161 + }); err != nil { 162 + l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 163 + return 164 + } 165 + 166 + if err := s.indexLabelOps(ctx, valid); err != nil { 167 + l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 168 + if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 181 169 Collection: tangled.LabelOpNSID, 182 170 Repo: userDid.String(), 183 171 Rkey: rkey, 184 - Record: &lexutil.LexiconTypeDecoder{Val: &record}, 185 172 }); err != nil { 186 - l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 187 - continue 173 + l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 188 174 } 175 + return 176 + } 189 177 190 - if err := s.indexLabelOps(ctx, valid); err != nil { 191 - l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 192 - if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 193 - Collection: tangled.LabelOpNSID, 194 - Repo: userDid.String(), 195 - Rkey: rkey, 196 - }); err != nil { 197 - l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 198 - } 199 - continue 200 - } 201 - 202 - s.notifier.NewPullLabelOp(ctx, userDid, pull, valid) 203 - } 178 + s.notifier.NewPullLabelOp(ctx, userDid, pull, valid) 204 179 } 205 180 206 181 func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error {
+17 -40
appview/pulls/lifecycle.go
··· 14 14 15 15 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 16 16 l := s.logger.With("handler", "ClosePull") 17 + l.Debug("request") 17 18 18 19 user := s.oauth.GetMultiAccountUser(r) 19 20 if user == nil { ··· 35 36 s.pages.Notice(w, "pull-action-error", "Failed to close pull. Try again later.") 36 37 return 37 38 } 38 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 39 + l = l.With("pull", pull.AtUri(), "pull_id", pull.PullId, "state", pull.State) 39 40 40 41 // auth filter: only owner or collaborators can close 41 42 roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 42 43 isOwner := roles.IsOwner() 43 44 isCollaborator := roles.IsCollaborator() 44 - isPullAuthor := user.Did == pull.OwnerDid 45 + isPullAuthor := syntax.DID(user.Did) == pull.OwnerDid 45 46 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 46 47 if !isCloseAllowed { 47 48 l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) ··· 49 50 return 50 51 } 51 52 52 - // if this PR is stacked, then we want to close all PRs above this one on the stack 53 - stack := r.Context().Value("stack").(models.Stack) 54 - pullsToClose := stack.Above(pull) 55 - var atUris []syntax.ATURI 56 - for _, p := range pullsToClose { 57 - atUris = append(atUris, p.AtUri()) 58 - p.State = models.PullClosed 59 - } 60 - 61 - if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateClosed); err != nil { 62 - l.Error("failed to write pull status records", "err", err) 63 - s.pages.Notice(w, "pull-action-error", "Failed to close pull. Try again later.") 53 + if err := s.writePullStatusRecord(r, user.Did, pull.AtUri(), models.StateClosed); err != nil { 54 + l.Error("failed to write issue state record", "err", err) 55 + s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 64 56 return 65 57 } 66 58 ··· 74 66 75 67 err = db.ClosePulls( 76 68 tx, 77 - orm.FilterEq("repo_did", string(f.RepoDid)), 78 - orm.FilterIn("at_uri", atUris), 69 + orm.FilterEq("at_uri", pull.AtUri()), 79 70 ) 80 71 if err != nil { 81 - l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 72 + l.Error("failed to close pulls in database", "err", err) 82 73 s.pages.Notice(w, "pull-action-error", "Failed to close pull.") 83 74 return 84 75 } ··· 90 81 return 91 82 } 92 83 93 - for _, p := range pullsToClose { 94 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 95 - } 84 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), pull) 96 85 97 86 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 98 87 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) ··· 122 111 s.pages.Notice(w, "pull-action-error", "Failed to reopen pull. Try again later.") 123 112 return 124 113 } 125 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 114 + l = l.With("pull", pull.AtUri(), "pull_id", pull.PullId, "state", pull.State) 126 115 127 116 // auth filter: only owner or collaborators can close 128 117 roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 129 118 isOwner := roles.IsOwner() 130 119 isCollaborator := roles.IsCollaborator() 131 - isPullAuthor := user.Did == pull.OwnerDid 120 + isPullAuthor := syntax.DID(user.Did) == pull.OwnerDid 132 121 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 133 122 if !isCloseAllowed { 134 123 l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) ··· 136 125 return 137 126 } 138 127 139 - // if this PR is stacked, then we want to reopen all PRs above this one on the stack 140 - stack := r.Context().Value("stack").(models.Stack) 141 - pullsToReopen := stack.Below(pull) 142 - var atUris []syntax.ATURI 143 - for _, p := range pullsToReopen { 144 - atUris = append(atUris, p.AtUri()) 145 - p.State = models.PullOpen 146 - } 147 - 148 - if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateOpen); err != nil { 149 - l.Error("failed to write pull status records", "err", err) 150 - s.pages.Notice(w, "pull-action-error", "Failed to reopen pull. Try again later.") 128 + if err := s.writePullStatusRecord(r, user.Did, pull.AtUri(), models.StateOpen); err != nil { 129 + l.Error("failed to write issue state record", "err", err) 130 + s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 151 131 return 152 132 } 153 133 ··· 161 141 162 142 err = db.ReopenPulls( 163 143 tx, 164 - orm.FilterEq("repo_did", string(f.RepoDid)), 165 - orm.FilterIn("at_uri", atUris), 144 + orm.FilterEq("at_uri", pull.AtUri()), 166 145 ) 167 146 if err != nil { 168 - l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 147 + l.Error("failed to reopen pulls in database", "err", err) 169 148 s.pages.Notice(w, "pull-action-error", "Failed to reopen pull.") 170 149 return 171 150 } ··· 177 156 return 178 157 } 179 158 180 - for _, p := range pullsToReopen { 181 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 182 - } 159 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), pull) 183 160 184 161 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 185 162 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
+8 -72
appview/pulls/list.go
··· 3 3 import ( 4 4 "context" 5 5 "net/http" 6 - "slices" 7 6 8 7 "tangled.org/core/api/tangled" 9 8 "tangled.org/core/appview/db" ··· 132 131 133 132 repoInfo := s.repoResolver.GetRepoInfo(r, user) 134 133 134 + ctx := r.Context() 135 + 135 136 var pulls []*models.Pull 136 137 137 138 if searchOpts.HasSearchFilters() { 138 - res, err := s.indexer.Search(r.Context(), searchOpts) 139 + res, err := s.indexer.Search(ctx, searchOpts) 139 140 if err != nil { 140 141 l.Error("failed to search for pulls", "err", err) 141 142 return ··· 148 149 countOpts.Page = pagination.Page{Limit: 1} 149 150 for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 150 151 countOpts.State = &ps 151 - countRes, err := s.indexer.Search(r.Context(), countOpts) 152 + countRes, err := s.indexer.Search(ctx, countOpts) 152 153 if err != nil { 153 154 continue 154 155 } ··· 163 164 } 164 165 165 166 if len(res.Hits) > 0 { 166 - pulls, err = db.GetPulls( 167 + pulls, err = db.GetPullsPaginated( 168 + ctx, 167 169 s.db, 170 + pagination.Page{Limit: searchOpts.Page.Limit}, 168 171 orm.FilterIn("id", res.Hits), 169 172 ) 170 173 if err != nil { ··· 181 184 filters = append(filters, orm.FilterEq("state", *state)) 182 185 } 183 186 pulls, err = db.GetPullsPaginated( 187 + ctx, 184 188 s.db, 185 189 page, 186 190 filters..., ··· 192 196 } 193 197 } 194 198 195 - for _, p := range pulls { 196 - var pullSourceRepo *models.Repo 197 - if p.PullSource != nil { 198 - if p.PullSource.RepoDid != nil { 199 - pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid)) 200 - if err != nil { 201 - l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String()) 202 - continue 203 - } else { 204 - p.PullSource.Repo = pullSourceRepo 205 - } 206 - } 207 - } 208 - } 209 - 210 - var stacks []models.Stack 211 - 212 - pullMap := make(map[string]*models.Pull) 213 - for _, p := range pulls { 214 - pullMap[p.AtUri().String()] = p 215 - } 216 - 217 - // track which PRs have been added to stacks 218 - visited := make(map[string]bool) 219 - 220 - // group stacked PRs together using dependent_on relationships 221 - for _, p := range pulls { 222 - if visited[p.AtUri().String()] { 223 - continue 224 - } 225 - 226 - root := p 227 - for root.DependentOn != nil { 228 - if parent, ok := pullMap[root.DependentOn.String()]; ok { 229 - root = parent 230 - } else { 231 - break // parent not in current page 232 - } 233 - } 234 - 235 - var stack models.Stack 236 - current := root 237 - for { 238 - if visited[current.AtUri().String()] { 239 - break 240 - } 241 - stack = append(stack, current) 242 - visited[current.AtUri().String()] = true 243 - 244 - found := false 245 - for _, candidate := range pulls { 246 - if candidate.DependentOn != nil && 247 - candidate.DependentOn.String() == current.AtUri().String() { 248 - current = candidate 249 - found = true 250 - break 251 - } 252 - } 253 - if !found { 254 - break 255 - } 256 - } 257 - 258 - slices.Reverse(stack) 259 - stacks = append(stacks, stack) 260 - } 261 - 262 199 labelDefs, err := db.GetLabelDefinitions( 263 200 s.db, 264 201 orm.FilterIn("at_uri", f.Labels), ··· 299 236 LabelDefs: defs, 300 237 FilterState: filterState, 301 238 FilterQuery: query.String(), 302 - Stacks: stacks, 303 239 Page: page, 304 240 PullCount: totalPulls, 305 241 VouchRelationships: vouchRelationships,
+43 -78
appview/pulls/merge.go
··· 5 5 "net/http" 6 6 "time" 7 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "tangled.org/core/api/tangled" 9 10 "tangled.org/core/appview/db" 10 11 "tangled.org/core/appview/models" 11 12 "tangled.org/core/appview/oauth" 12 13 "tangled.org/core/appview/reporesolver" 13 14 "tangled.org/core/orm" 14 - "tangled.org/core/xrpc/xrpcclient" 15 - 16 - "github.com/bluesky-social/indigo/atproto/syntax" 17 15 ) 18 16 19 17 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 33 31 s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 34 32 return 35 33 } 36 - l = l.With("repo_at", f.RepoAt().String()) 34 + l = l.With("repo", f.RepoDid) 37 35 38 36 pull, ok := r.Context().Value("pull").(*models.Pull) 39 37 if !ok { ··· 43 41 } 44 42 l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) 45 43 46 - stack, ok := r.Context().Value("stack").(models.Stack) 47 - if !ok { 48 - l.Error("failed to get stack") 49 - s.pages.Notice(w, "pull-action-error", "Failed to merge patch. Try again later.") 50 - return 51 - } 44 + // merge target branch 45 + { 46 + client, err := s.oauth.ServiceClient( 47 + r, 48 + oauth.WithService(f.Knot), 49 + oauth.WithLxm(tangled.GitMergeCommitNSID), 50 + oauth.WithDev(s.config.Core.Dev), 51 + oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 52 + ) 53 + if err != nil { 54 + l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 55 + s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 56 + return 57 + } 52 58 53 - // combine patches of substack 54 - subStack := stack.Below(pull) 55 - // collect the portion of the stack that is mergeable 56 - pullsToMerge := subStack.Mergeable() 57 - l = l.With("pulls_to_merge", len(pullsToMerge)) 59 + var mergeCommit *tangled.GitMergeCommit_Input_MergeCommit 60 + // TODO: pass custom merge commit body 58 61 59 - patch := pullsToMerge.CombinedPatch() 60 - 61 - ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 62 - if err != nil { 63 - l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid) 64 - w.WriteHeader(http.StatusNotFound) 65 - return 62 + err = tangled.GitMergeCommit(r.Context(), client, &tangled.GitMergeCommit_Input{ 63 + Target: &tangled.GitMergeCommit_Input_Target{ 64 + Repo: pull.RepoDid.String(), 65 + Branch: pull.TargetBranch, 66 + }, 67 + Source: &tangled.GitMergeCommit_Input_Source{ 68 + Repo: pull.SourceRepo.String(), 69 + Commit: pull.LatestVersion().Head, 70 + }, 71 + MergeCommit: mergeCommit, 72 + Style: "rebase", 73 + }) 74 + if err != nil { 75 + s.logger.Error("failed to merge", "err", err) 76 + s.pages.Notice(w, "pull-action-error", err.Error()) 77 + return 78 + } 66 79 } 67 80 68 - email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 69 - if err != nil { 70 - l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid) 71 - } 72 - 73 - authorName := ident.Handle.String() 74 - mergeInput := &tangled.RepoMerge_Input{ 75 - Did: f.Did, 76 - Name: f.Name, 77 - Repo: f.RepoDidPtr(), 78 - Branch: pull.TargetBranch, 79 - Patch: patch, 80 - CommitMessage: &pull.Title, 81 - AuthorName: &authorName, 82 - } 83 - 84 - if pull.Body != "" { 85 - mergeInput.CommitBody = &pull.Body 86 - } 87 - 88 - if email.Address != "" { 89 - mergeInput.AuthorEmail = &email.Address 90 - } 91 - 92 - client, err := s.oauth.ServiceClient( 93 - r, 94 - oauth.WithService(f.Knot), 95 - oauth.WithLxm(tangled.RepoMergeNSID), 96 - oauth.WithDev(s.config.Core.Dev), 97 - oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 98 - ) 99 - if err != nil { 100 - l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 101 - s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 81 + if err := s.writePullStatusRecord(r, user.Did, pull.AtUri(), models.StateMerged); err != nil { 82 + l.Error("failed to write issue state record", "err", err) 83 + s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 102 84 return 103 85 } 104 86 105 - err = tangled.RepoMerge(r.Context(), client, mergeInput) 106 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 107 - s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 108 - s.pages.Notice(w, "pull-action-error", xrpcerr.Error()) 109 - return 110 - } 111 - 112 - var atUris []syntax.ATURI 113 - for _, p := range pullsToMerge { 114 - atUris = append(atUris, p.AtUri()) 115 - p.State = models.PullMerged 116 - } 117 - 118 - if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateMerged); err != nil { 119 - l.Error("failed to write pull status records after merge", "err", err) 120 - } 121 - 122 87 tx, err := s.db.Begin() 123 88 if err != nil { 124 89 l.Error("failed to start transaction", "err", err) ··· 127 92 } 128 93 defer tx.Rollback() 129 94 130 - err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris)) 95 + err = db.MergePulls( 96 + tx, 97 + orm.FilterEq("at_uri", pull.AtUri()), 98 + ) 131 99 if err != nil { 132 100 l.Error("failed to update pull request status in database", "err", err) 133 101 s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") ··· 142 110 return 143 111 } 144 112 145 - // notify about the pull merge 146 - for _, p := range pullsToMerge { 147 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 148 - } 113 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), pull) 149 114 150 115 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 151 116 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
+45
appview/pulls/middleware.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/orm" 11 + ) 12 + 13 + // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 14 + func (s *Pulls) ResolvePullMiddleware(next http.Handler) http.Handler { 15 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + l := s.logger.With("middleware", "ResolvePullMiddleware") 17 + f, err := s.repoResolver.Resolve(r) 18 + if err != nil { 19 + l.Error("failed to fully resolve repo", "err", err) 20 + w.WriteHeader(http.StatusNotFound) 21 + s.pages.ErrorKnot404(w) 22 + return 23 + } 24 + 25 + prId, err := strconv.Atoi(chi.URLParam(r, "pull")) 26 + if err != nil { 27 + l.Debug("failed to parse pr id", "err", err) 28 + w.WriteHeader(http.StatusNotFound) 29 + s.pages.Error404(w) 30 + return 31 + } 32 + 33 + pr, err := db.GetPull(r.Context(), s.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prId)) 34 + if err != nil { 35 + l.Debug("failed to query PR", "err", err) 36 + w.WriteHeader(http.StatusNotFound) 37 + s.pages.Error404(w) 38 + return 39 + } 40 + 41 + ctx := context.WithValue(r.Context(), "pull", pr) 42 + 43 + next.ServeHTTP(w, r.WithContext(ctx)) 44 + }) 45 + }
+13 -15
appview/pulls/opengraph.go
··· 8 8 "tangled.org/core/appview/db" 9 9 "tangled.org/core/appview/models" 10 10 "tangled.org/core/ogre" 11 - "tangled.org/core/patchutil" 12 11 ) 13 12 14 13 func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { ··· 26 25 } 27 26 28 27 ownerHandle := s.pages.DisplayHandle(r.Context(), f.Did) 29 - authorHandle := s.pages.DisplayHandle(r.Context(), pull.OwnerDid) 28 + authorHandle := s.pages.DisplayHandle(r.Context(), pull.OwnerDid.String()) 30 29 31 30 avatarUrl := s.pages.AvatarUrl(f.Did, "256") 32 - authorAvatarUrl := s.pages.AvatarUrl(pull.OwnerDid, "256") 31 + authorAvatarUrl := s.pages.AvatarUrl(pull.OwnerDid.String(), "256") 33 32 34 33 var status string 35 34 if pull.State.IsOpen() { ··· 44 43 var additions int64 45 44 var deletions int64 46 45 47 - if len(pull.Submissions) > 0 { 48 - latestSubmission := pull.LatestSubmission() 49 - niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 50 - filesChanged = niceDiff.Stat.FilesChanged 51 - additions = int64(niceDiff.Stat.Insertions) 52 - deletions = int64(niceDiff.Stat.Deletions) 46 + if len(pull.Versions) > 0 { 47 + latestVersion := pull.LatestVersion() 48 + _ = latestVersion 49 + // niceDiff := patchutil.AsNiceDiff(latestVersion.Patch, pull.TargetBranch) 50 + // filesChanged = niceDiff.Stat.FilesChanged 51 + // additions = niceDiff.Stat.Insertions 52 + // deletions = niceDiff.Stat.Deletions 53 53 } 54 54 55 - commentCount := pull.TotalComments() 56 - 57 55 reactionCount, _ := db.GetReactionCount(s.db, pull.AtUri()) 58 56 59 - rounds := max(1, len(pull.Submissions)) 57 + versions := max(1, len(pull.Versions)) 60 58 61 59 payload := ogre.PullRequestCardPayload{ 62 60 Type: "pullRequest", ··· 66 64 AvatarUrl: avatarUrl, 67 65 AuthorAvatarUrl: authorAvatarUrl, 68 66 Title: pull.Title, 69 - PullRequestNumber: pull.PullId, 67 + PullRequestNumber: int(pull.PullId), 70 68 Status: status, 71 69 FilesChanged: filesChanged, 72 70 Additions: int(additions), 73 71 Deletions: int(deletions), 74 - Rounds: rounds, 75 - CommentCount: commentCount, 72 + Rounds: versions, 73 + CommentCount: pull.TotalComments(), 76 74 ReactionCount: reactionCount, 77 75 CreatedAt: pull.Created.Format(time.RFC3339), 78 76 }
-280
appview/pulls/pull2.go
··· 1 - package pulls 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "strconv" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/bluesky-social/indigo/lex/util" 11 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 - "github.com/go-chi/chi/v5" 13 - "golang.org/x/sync/errgroup" 14 - "tangled.org/core/api/tangled" 15 - "tangled.org/core/appview/models" 16 - "tangled.org/core/appview/pages" 17 - "tangled.org/core/types" 18 - ) 19 - 20 - // NOTE: parsing object in middleware is bad pattern 21 - // you will have to check if object exist in context "just in case" 22 - // so it's better to make helper function that can read the url pattern instead. 23 - 24 - 25 - // A -- B -- C 26 - // (master) (pr/123/0) 27 - // 28 - // A -- B -- C 29 - // \ (pr/123/0) 30 - // `-- D <- B' <- C' 31 - // (master) (pr/123/1) 32 - 33 - // 1. rebase B<-C to D 34 - // 2. compare tree of C and D 35 - 36 - // PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change} 37 - // 38 - // Examples: 39 - // - /pulls/123/0..2/all 40 - // - /pulls/123/0..2/nrpytyzw 41 - func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) { 42 - l := s.logger.With("handler", "PullRound") 43 - ctx := r.Context() 44 - 45 - pull, ok := r.Context().Value("pull").(*models.Pull) 46 - if !ok { 47 - l.Error("failed to get pull") 48 - s.pages.Error500(w) 49 - return 50 - } 51 - 52 - var ( 53 - version1 = 0 54 - version2 = 0 55 - changeId = chi.URLParam(r, "*") 56 - ) 57 - if changeId == "all" { 58 - changeId = "" 59 - } 60 - 61 - // defer render 62 - var params pages.PullInterdiffParams 63 - params.Pull = pull 64 - params.Version1 = version1 65 - params.Version2 = version2 66 - params.ChangeId = changeId 67 - defer s.pages.PullInterdiff(w, params) 68 - 69 - // 1. resolve target branch -> (branch, commit) 70 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 71 - branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String()) 72 - if err != nil { 73 - panic("unimplemented") 74 - } 75 - 76 - base := branch.Hash 77 - head1 := "" // pull.Versions[version1].Head 78 - head2 := "" // pull.Versions[version2].Head 79 - 80 - // 1. log commits from base..head1 and base..head2 81 - var commits1, commits2 []types.Commit 82 - g, gctx := errgroup.WithContext(ctx) 83 - g.Go(func() error { 84 - commits1, err = getTempListCommits(gctx, xrpcc, pull.SourceRepoDid(), base, head1) 85 - return err 86 - }) 87 - g.Go(func() error { 88 - commits2, err = getTempListCommits(gctx, xrpcc, pull.SourceRepoDid(), base, head2) 89 - return err 90 - }) 91 - if err := g.Wait(); err != nil { 92 - params.ErrorMsg = "something something" 93 - panic("unimplemented") 94 - } 95 - 96 - if changeId != "" { 97 - // interdiff by change-id 98 - var old, new *types.Commit 99 - for _, commit := range commits1 { 100 - if commit.ChangeId == changeId { 101 - old = &commit 102 - break 103 - } 104 - } 105 - for _, commit := range commits2 { 106 - if commit.ChangeId == changeId { 107 - new = &commit 108 - break 109 - } 110 - } 111 - _, _ = old, new 112 - panic("unimplemented") 113 - } else { 114 - // interdiff of two commit ranges 115 - panic("unimplemented") 116 - } 117 - } 118 - 119 - // PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit} 120 - // 121 - // Examples: 122 - // - /pulls/123/latest 123 - // - /pulls/123/2/head 124 - // - /pulls/123/2/base..head 125 - // - /pulls/123/2/a53ab251e..d8add468c 126 - // - /pulls/123/2/d8add468c 127 - func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) { 128 - l := s.logger.With("handler", "PullRound") 129 - ctx := r.Context() 130 - 131 - pull, ok := r.Context().Value("pull").(*models.Pull) 132 - if !ok { 133 - l.Error("failed to get pull") 134 - s.pages.Error500(w) 135 - return 136 - } 137 - 138 - var err error 139 - 140 - var version int 141 - var versionRaw = chi.URLParam(r, "version") 142 - if versionRaw == "latest" { 143 - version = pull.LastRoundNumber() 144 - } else { 145 - version, err = strconv.Atoi(versionRaw) 146 - if err != nil { 147 - // invalid version number. redirect 148 - http.Redirect(w, r, 149 - fmt.Sprintf("/%s/pulls/%d/latest", pull.Repo.RepoIdentifier(), pull.ID), 150 - http.StatusSeeOther, 151 - ) 152 - return 153 - } 154 - } 155 - 156 - var range_ = chi.URLParam(r, "*") 157 - base, head, err := parseRevRange(range_) 158 - if err != nil { 159 - http.Redirect(w, r, 160 - fmt.Sprintf("/%s/pulls/%d/%s", pull.Repo.RepoIdentifier(), pull.ID, versionRaw), 161 - http.StatusSeeOther, 162 - ) 163 - return 164 - } 165 - 166 - // defer render 167 - var params pages.PullDiffParams 168 - params.Pull = pull 169 - params.Version = version 170 - defer s.pages.PullDiff(w, params) 171 - 172 - // 1. resolve target branch -> (branch, commit) 173 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 174 - branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String()) 175 - if err != nil { 176 - l.Warn("Failed to resolve target branch", "branch", pull.TargetBranch, "err", err) 177 - params.ErrorMsg = fmt.Sprintf("Failed to resolve target branch %q", pull.TargetBranch) 178 - return 179 - } 180 - 181 - if base == "base" { 182 - base = branch.Hash 183 - } 184 - if head == "head" { 185 - head = pull.HEAD() 186 - } 187 - 188 - sourceRepoDid := pull.SourceRepoDid() 189 - 190 - // 2. list diverged commits using knotmirror (BASE..HEAD) -> ([]commit) 191 - // - knotmirror needs on-demand fetch implementation for this 192 - commits, err := getTempListCommits(ctx, xrpcc, sourceRepoDid, base, head) 193 - if err != nil { 194 - panic("unimplemented") 195 - } 196 - 197 - // 3. list every commits in UI. They will be lazy-loaded 198 - params.Commits = commits 199 - } 200 - 201 - // htmx fragment. render diff between commits 202 - func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) { 203 - // var ( 204 - // base = r.URL.Query().Get("base") // base commit ID 205 - // head = r.URL.Query().Get("head") // head commit ID 206 - // unified = r.URL.Query().Get("view") == "unified" 207 - // ) 208 - 209 - // 1. get commit object 210 - // 2. get diff between parent..commit (knotmirror), parse that diff 211 - // 3. fetch each file entries (& run syntax highlight) <- skip this part for stage 1. we will do this at stage 2. 212 - // 4. render diff 213 - } 214 - 215 - // htmx fragment. render interdiff between changes 216 - func (s *Pulls) PullInterdiffFragment(w http.ResponseWriter, r *http.Request) { 217 - // var ( 218 - // base1 = r.URL.Query().Get("base1") // base1 commit ID 219 - // base2 = r.URL.Query().Get("base2") // base2 commit ID 220 - // head1 = r.URL.Query().Get("head1") // head1 commit ID 221 - // head2 = r.URL.Query().Get("head2") // head1 commit ID 222 - // unified = r.URL.Query().Get("view") == "unified" 223 - // ) 224 - 225 - // 1. compute interdiff. (knotmirror) 226 - // 2. return rich diff data. (knotmirror) 227 - // 3. load old/new blobs & run syntax highlight 228 - // 4. render diff 229 - } 230 - 231 - // parseRevRange parses <head>..<base> string. 232 - // base and head will default to "base" and "head" when omitted. 233 - func parseRevRange(range_ string) (base string, head string, err error) { 234 - panic("unimplemented") 235 - } 236 - 237 - // parseVersionRange parses <version>..<version> string. 238 - // Each versions will default to "base" and "latest" when omitted. 239 - func parseVersionRange(range_ string) (base string, head string, err error) { 240 - panic("unimplemented") 241 - } 242 - 243 - func getTempListCommits(ctx context.Context, xrpcc util.LexClient, repo syntax.DID, base, head string) ([]types.Commit, error) { 244 - panic("unimplemented") 245 - // raw, err := tangled.GitTempListCommits(ctx, xrpcc, "", 1000, head, repo.String()) 246 - // if err != nil { 247 - // return nil, err 248 - // } 249 - // 250 - // var xrpcResp types.RepoLogResponse 251 - // if err := json.Unmarshal(raw, &xrpcResp); err != nil { 252 - // return nil, fmt.Errorf("failed to decode XRPC response: %w", err) 253 - // } 254 - // 255 - // return xrpcResp.Commits, nil 256 - } 257 - 258 - // htmx fragment. render interdiff between commits 259 - func (s *Pulls) PullInterDiffFragment(w http.ResponseWriter, r *http.Request) { 260 - panic("unimplemented") 261 - } 262 - 263 - // gitmirror 264 - // - git.ListCommitsSinceMergeBase(repo, base, head) 265 - // - git.Diff(repo, base, head, mode) 266 - // - git.Interdiff(repo, 267 - 268 - // for interdiff, we want: from{start,end}, to{start,end} 269 - // 1. squash from.start ~ from.end into one commit 270 - // 2. rebase that commit to to.start.parent() 271 - // 3. diff from_squashed.tree and to.end.tree 272 - 273 - // we want git log BASE..HEAD (only commits in HEAD) diverged=false 274 - // and git diff BASE...HEAD (changes from HEAD since merge-base) absolute=false 275 - 276 - // commands.go:56 picks comparison type: 277 - // - diff BASE..HEAD (COMPARISON_TYPE_ONLY_IN_HEAD) = direct 278 - // - diff BASE...HEAD (COMPARISON_TYPE_INTERSECTION) = merge-base. Server resolves merge-base via g.MergeBase() first (diff.go:43) then diffs. 279 - // we want second one. we should compute merge-base first. 280 - // we can have
+7 -38
appview/pulls/pulls.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "bytes" 5 - "compress/gzip" 6 - "fmt" 7 - "io" 8 4 "log/slog" 9 - "strings" 10 5 "time" 11 6 12 7 "tangled.org/core/appview/config" ··· 22 17 knotmirror "tangled.org/core/gitmirror/proto/gen" 23 18 "tangled.org/core/idresolver" 24 19 "tangled.org/core/ogre" 25 - "tangled.org/core/patchutil" 26 20 "tangled.org/core/types" 27 21 22 + "github.com/bluesky-social/indigo/util" 28 23 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 29 24 "github.com/hashicorp/golang-lru/v2/expirable" 30 25 ) ··· 51 46 ogreClient *ogre.Client 52 47 diffCache *expirable.LRU[string, types.DiffRenderer] 53 48 gitmirror knotmirror.GitMirrorServiceClient 49 + 50 + knotMirrorXRPC *indigoxrpc.Client 54 51 } 55 52 56 53 func New( ··· 82 79 ogreClient: ogre.NewClient(config.Ogre.Host), 83 80 diffCache: expirable.NewLRU[string, types.DiffRenderer](diffCacheSize, nil, diffCacheTTL), 84 81 gitmirror: gitmirror, 85 - } 86 - } 87 82 88 - func (s *Pulls) knotClient(host string) *indigoxrpc.Client { 89 - scheme := "https" 90 - if s.config.Core.Dev { 91 - scheme = "http" 83 + knotMirrorXRPC: &indigoxrpc.Client{ 84 + Host: config.KnotMirror.Url, 85 + Client: util.RobustHTTPClient(), 86 + }, 92 87 } 93 - return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)} 94 - } 95 - 96 - func gz(s string) io.Reader { 97 - var b bytes.Buffer 98 - w := gzip.NewWriter(&b) 99 - w.Write([]byte(s)) 100 - w.Close() 101 - return &b 102 88 } 103 89 104 90 func ptrPullState(s models.PullState) *models.PullState { return &s } 105 - 106 - func validatePatch(patch *string) error { 107 - if patch == nil || *patch == "" { 108 - return fmt.Errorf("patch is empty") 109 - } 110 - 111 - // add newline if not present to diff style patches 112 - if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 113 - *patch = *patch + "\n" 114 - } 115 - 116 - if err := patchutil.IsPatchValid(*patch); err != nil { 117 - return err 118 - } 119 - 120 - return nil 121 - }
+93 -548
appview/pulls/resubmit.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "encoding/json" 4 + "context" 5 5 "fmt" 6 6 "net/http" 7 7 "time" 8 8 9 9 "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/db" 11 - "tangled.org/core/appview/knotcompat" 12 11 "tangled.org/core/appview/models" 13 12 "tangled.org/core/appview/oauth" 14 - "tangled.org/core/appview/pages" 15 13 "tangled.org/core/appview/reporesolver" 16 - "tangled.org/core/orm" 17 - "tangled.org/core/patchutil" 18 - "tangled.org/core/types" 19 - "tangled.org/core/xrpc" 20 - "tangled.org/core/xrpc/xrpcclient" 21 14 22 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 23 16 "github.com/bluesky-social/indigo/atproto/syntax" ··· 26 19 27 20 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 28 21 l := s.logger.With("handler", "ResubmitPull") 22 + noticeId := "pull-action-error" 29 23 30 24 user := s.oauth.GetMultiAccountUser(r) 31 25 if user != nil { 32 26 l = l.With("user", user.Did) 33 27 } 34 28 35 - pull, ok := r.Context().Value("pull").(*models.Pull) 36 - if !ok { 37 - l.Error("failed to get pull") 38 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 39 - return 40 - } 41 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 42 - 43 - switch r.Method { 44 - case http.MethodGet: 45 - s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 46 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 47 - Pull: pull, 48 - }) 49 - return 50 - case http.MethodPost: 51 - if pull.IsPatchBased() { 52 - s.resubmitPatch(w, r) 53 - return 54 - } else if pull.IsBranchBased() { 55 - s.resubmitBranch(w, r) 56 - return 57 - } else if pull.IsForkBased() { 58 - s.resubmitFork(w, r) 59 - return 60 - } 61 - } 62 - } 63 - 64 - func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 65 - l := s.logger.With("handler", "resubmitPatch") 66 - 67 - user := s.oauth.GetMultiAccountUser(r) 68 - if user != nil { 69 - l = l.With("user", user.Did) 70 - } 71 - 72 - pull, ok := r.Context().Value("pull").(*models.Pull) 73 - if !ok { 74 - l.Error("failed to get pull") 75 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 76 - return 77 - } 78 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 79 - 80 - if user == nil || user.Did != pull.OwnerDid { 81 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 82 - w.WriteHeader(http.StatusUnauthorized) 83 - return 84 - } 85 - 86 - f, err := s.repoResolver.Resolve(r) 29 + repo, err := s.repoResolver.Resolve(r) 87 30 if err != nil { 88 31 l.Error("failed to get repo and knot", "err", err) 89 32 return 90 33 } 91 34 92 - patch := r.FormValue("patch") 93 - 94 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "") 95 - } 96 - 97 - func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 98 - l := s.logger.With("handler", "resubmitBranch") 99 - 100 - user := s.oauth.GetMultiAccountUser(r) 101 - if user != nil { 102 - l = l.With("user", user.Did) 103 - } 104 - 105 35 pull, ok := r.Context().Value("pull").(*models.Pull) 106 36 if !ok { 107 37 l.Error("failed to get pull") 108 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 38 + s.pages.Notice(w, noticeId, "Failed to get PR. Try again later.") 109 39 return 110 40 } 111 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 41 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 112 42 113 - if user == nil || user.Did != pull.OwnerDid { 114 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 115 - w.WriteHeader(http.StatusUnauthorized) 116 - return 117 - } 118 - 119 - f, err := s.repoResolver.Resolve(r) 120 - if err != nil { 121 - l.Error("failed to get repo and knot", "err", err) 43 + if pull.OwnerDid != syntax.DID(user.Did) { 44 + s.pages.Notice(w, noticeId, "Unauthorized user. Try again later.") 122 45 return 123 46 } 124 47 125 - roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 126 - if !roles.IsPushAllowed() { 127 - l.Warn("unauthorized user - no push permission") 128 - w.WriteHeader(http.StatusUnauthorized) 48 + if pull.SourceBranch == nil { 49 + // can't resubmit if source is unknown. fail earlier 50 + s.pages.Notice(w, noticeId, "PR source branch is unknown.") 129 51 return 130 52 } 131 - 132 - xrpcc := s.knotClient(f.Knot) 53 + sourceBranch := *pull.SourceBranch 133 54 134 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 135 - if err != nil { 136 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 137 - l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch) 138 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 55 + var sourceRepo *models.Repo 56 + if pull.SourceRepo == syntax.DID(repo.RepoDid) { 57 + sourceRepo = repo 58 + } else { 59 + var err error 60 + sourceRepo, err = db.GetRepoByDid(s.db, pull.SourceRepo.String()) 61 + if err != nil { 62 + s.pages.Notice(w, noticeId, fmt.Sprintf("Unknown source repository: %q", pull.SourceRepo)) 139 63 return 140 64 } 141 - l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch) 142 - s.pages.Notice(w, "resubmit-error", err.Error()) 143 - return 144 65 } 145 66 146 - var comparison types.RepoFormatPatchResponse 147 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 148 - l.Error("failed to decode XRPC compare response", "err", err) 149 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 150 - return 151 - } 152 - 153 - sourceRev := comparison.Rev2 154 - patch := comparison.FormatPatchRaw 155 - combined := comparison.CombinedPatchRaw 156 - 157 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 158 - } 159 - 160 - func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 161 - l := s.logger.With("handler", "resubmitFork") 162 - 163 - user := s.oauth.GetMultiAccountUser(r) 164 - if user != nil { 165 - l = l.With("user", user.Did) 166 - } 167 - 168 - pull, ok := r.Context().Value("pull").(*models.Pull) 169 - if !ok { 170 - l.Error("failed to get pull") 171 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 172 - return 173 - } 174 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 175 - 176 - if user == nil || user.Did != pull.OwnerDid { 177 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 178 - w.WriteHeader(http.StatusUnauthorized) 179 - return 180 - } 67 + ctx := r.Context() 181 68 182 - f, err := s.repoResolver.Resolve(r) 183 - if err != nil { 184 - l.Error("failed to get repo and knot", "err", err) 185 - return 186 - } 187 - 188 - forkRepo, err := db.GetRepoByDid(s.db, string(*pull.PullSource.RepoDid)) 189 - if err != nil { 190 - l.Error("failed to get source repo", "err", err, "repo_did", pull.PullSource.RepoDid.String()) 191 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 192 - return 193 - } 194 - 195 - // update the hidden tracking branch to latest 196 - client, err := s.oauth.ServiceClient( 197 - r, 198 - oauth.WithService(forkRepo.Knot), 199 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 200 - oauth.WithDev(s.config.Core.Dev), 201 - ) 202 - if err != nil { 203 - l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot) 204 - return 205 - } 206 - 207 - resp, err := tangled.RepoHiddenRef( 208 - r.Context(), 209 - client, 210 - &tangled.RepoHiddenRef_Input{ 211 - ForkRef: pull.PullSource.Branch, 212 - RemoteRef: pull.TargetBranch, 213 - Repo: forkRepo.RepoAt().String(), 214 - }, 215 - ) 216 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 217 - s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 218 - s.pages.Notice(w, "resubmit-error", xrpcerr.Error()) 219 - return 220 - } 221 - if !resp.Success { 222 - l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch) 223 - s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 224 - return 225 - } 226 - 227 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 228 - // extract patch by performing compare 229 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 230 - if err != nil { 231 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 232 - l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 233 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 69 + var base, head string 70 + { 71 + xrpcc := s.knotMirrorXRPC 72 + branch, err := tangled.GitTempGetBranch(ctx, xrpcc, sourceBranch, pull.RepoDid.String()) 73 + if err != nil { 74 + s.pages.Notice(w, noticeId, "Failed to get source branch") 234 75 return 235 76 } 236 - l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 237 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 238 - return 239 - } 240 - 241 - var forkComparison types.RepoFormatPatchResponse 242 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 243 - l.Error("failed to decode XRPC compare response for fork", "err", err) 244 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 245 - return 246 - } 247 - 248 - // Use the fork comparison we already made 249 - comparison := forkComparison 250 - 251 - sourceRev := comparison.Rev2 252 - patch := comparison.FormatPatchRaw 253 - combined := comparison.CombinedPatchRaw 254 - 255 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 256 - } 257 - 258 - func (s *Pulls) resubmitPullHelper( 259 - w http.ResponseWriter, 260 - r *http.Request, 261 - repo *models.Repo, 262 - userDid syntax.DID, 263 - pull *models.Pull, 264 - patch string, 265 - combined string, 266 - sourceRev string, 267 - ) { 268 - l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 269 - 270 - stack := r.Context().Value("stack").(models.Stack) 271 - if stack != nil && len(stack) != 1 { 272 - l.Info("resubmitting stacked PR", "stack_size", len(stack)) 273 - s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch) 274 - return 275 - } 276 - 277 - if err := validatePatch(&patch); err != nil { 278 - s.pages.Notice(w, "resubmit-error", err.Error()) 279 - return 280 - } 281 - 282 - if patch == pull.LatestPatch() { 283 - s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 284 - return 285 - } 286 - 287 - // validate sourceRev if branch/fork based 288 - if pull.IsBranchBased() || pull.IsForkBased() { 289 - if sourceRev == pull.LatestSha() { 290 - s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 77 + out, err := tangled.GitTempGetMergeBase(ctx, xrpcc, pull.TargetBranch, branch.Hash, pull.RepoDid.String()) 78 + if err != nil { 79 + s.pages.Notice(w, noticeId, "Failed to compute merge-base.") 291 80 return 292 81 } 293 - } 294 82 295 - pullAt := pull.AtUri() 296 - newRoundNumber := len(pull.Submissions) 297 - newPatch := patch 298 - newSourceRev := sourceRev 299 - combinedPatch := combined 300 - 301 - client, err := s.oauth.AuthorizedClient(r) 302 - if err != nil { 303 - l.Error("failed to authorize client", "err", err) 304 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 305 - return 306 - } 307 - 308 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey) 309 - if err != nil { 310 - // failed to get record 311 - l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey) 312 - s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 313 - return 83 + head = branch.Hash 84 + base = out.Commit 314 85 } 315 86 316 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 317 - if err != nil { 318 - l.Error("failed to upload patch blob", "err", err) 319 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 320 - return 87 + newVersion := models.PullVersion{ 88 + ID: pull.LatestVersionNumber() + 1, 89 + Base: base, 90 + Head: head, 91 + Created: time.Now(), 321 92 } 322 - record := pull.AsRecord() 323 - record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 324 - CreatedAt: time.Now().Format(time.RFC3339), 325 - PatchBlob: blob.Blob, 326 - }) 93 + pull.Versions = append(pull.Versions, newVersion) 327 94 328 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 329 - Collection: tangled.RepoPullNSID, 330 - Repo: userDid.String(), 331 - Rkey: pull.Rkey, 332 - SwapRecord: ex.Cid, 333 - Record: knotcompat.Pull(&record), 334 - }) 335 - if err != nil { 336 - l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) 337 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 338 - return 339 - } 340 - 341 - err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 342 - if err != nil { 343 - l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 344 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 345 - return 346 - } 347 - 348 - pull.Submissions = append(pull.Submissions, &models.PullSubmission{ 349 - PullAt: pullAt, 350 - RoundNumber: newRoundNumber, 351 - Patch: newPatch, 352 - Combined: combinedPatch, 353 - SourceRev: newSourceRev, 354 - Created: time.Now(), 355 - }) 356 - s.notifier.ResubmitPull(r.Context(), pull) 357 - 358 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 359 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 360 - } 361 - 362 - func (s *Pulls) resubmitStackedPullHelper( 363 - w http.ResponseWriter, 364 - r *http.Request, 365 - repo *models.Repo, 366 - userDid syntax.DID, 367 - pull *models.Pull, 368 - patch string, 369 - ) { 370 - l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 371 - 372 - targetBranch := pull.TargetBranch 373 - 374 - origStack, _ := r.Context().Value("stack").(models.Stack) 375 - 376 - formatPatches, err := patchutil.ExtractPatches(patch) 377 - if err != nil { 378 - l.Error("failed to extract patches", "err", err) 379 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 380 - return 381 - } 382 - 383 - // must have atleast 1 patch to begin with 384 - if len(formatPatches) == 0 { 385 - l.Error("no patches found in the generated format-patch") 386 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 387 - return 388 - } 389 - 390 - client, err := s.oauth.AuthorizedClient(r) 391 - if err != nil { 392 - l.Error("failed to get authorized client", "err", err) 393 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 394 - return 395 - } 396 - 397 - // first upload all blobs 398 - blobs := make([]*lexutil.LexBlob, len(formatPatches)) 399 - for i, p := range formatPatches { 400 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 95 + // keep new version head in Knot 96 + { 97 + client, err := s.oauth.ServiceClient( 98 + r, 99 + oauth.WithService(sourceRepo.Knot), 100 + oauth.WithLxm(tangled.GitKeepCommitNSID), 101 + oauth.WithDev(s.config.Core.Dev), 102 + ) 401 103 if err != nil { 402 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 403 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 104 + l.Error("failed to create service auth", "err", err) 105 + s.pages.Notice(w, noticeId, "Failed to create service auth. Try again later.") 404 106 return 405 107 } 406 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 407 - blobs[i] = blob.Blob 408 - } 409 108 410 - newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 411 - if err != nil { 412 - l.Error("failed to create resubmitted stack", "err", err) 413 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 414 - return 415 - } 416 - 417 - // find the diff between the stacks, first, map them by changeId 418 - origById := make(map[string]*models.Pull) 419 - newById := make(map[string]*models.Pull) 420 - for _, p := range origStack { 421 - origById[p.LatestSubmission().ChangeId()] = p 422 - } 423 - for _, p := range newStack { 424 - newById[p.LatestSubmission().ChangeId()] = p 425 - } 426 - 427 - // commits that got deleted: corresponding pull is closed 428 - // commits that got added: new pull is created 429 - // commits that got updated: corresponding pull is resubmitted & new round begins 430 - additions := make(map[string]*models.Pull) 431 - deletions := make(map[string]*models.Pull) 432 - updated := make(map[string]struct{}) 433 - 434 - // pulls in original stack but not in new one 435 - for _, op := range origStack { 436 - if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 437 - deletions[op.LatestSubmission().ChangeId()] = op 438 - } 439 - } 440 - 441 - // pulls in new stack but not in original one 442 - for _, np := range newStack { 443 - if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 444 - additions[np.LatestSubmission().ChangeId()] = np 445 - } 446 - } 447 - 448 - // NOTE: this loop can be written in any of above blocks, 449 - // but is written separately in the interest of simpler code 450 - for _, np := range newStack { 451 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 452 - // pull exists in both stacks 453 - updated[op.LatestSubmission().ChangeId()] = struct{}{} 454 - } 455 - } 456 - 457 - // NOTE: we can go through the newStack and update dependent relations and 458 - // rkeys now that we know which ones have been updated 459 - // update dependentOn relations for the entire stack 460 - var parentAt *syntax.ATURI 461 - for _, np := range newStack { 462 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 463 - // pull exists in both stacks 464 - np.Rkey = op.Rkey 465 - } 466 - np.DependentOn = parentAt 467 - x := np.AtUri() 468 - parentAt = &x 469 - } 470 - 471 - l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 472 - 473 - tx, err := s.db.Begin() 474 - if err != nil { 475 - l.Error("failed to start transaction", "err", err) 476 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 477 - return 478 - } 479 - defer tx.Rollback() 480 - 481 - // pds updates to make 482 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 483 - 484 - // pulls to notify for after the transaction commits 485 - var resubmitted []*models.Pull 486 - 487 - // deleted pulls are marked as deleted in the DB 488 - for _, p := range deletions { 489 - // do not do delete already merged PRs 490 - if p.State == models.PullMerged { 491 - continue 492 - } 493 - 494 - err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri())) 109 + _, err = tangled.GitKeepCommit(ctx, client, &tangled.GitKeepCommit_Input{ 110 + Repo: sourceRepo.RepoDid, 111 + Record: pull.AtUri().String(), 112 + Source: &tangled.GitKeepCommit_Input_Source{ 113 + GitKeepCommit_Commit: &tangled.GitKeepCommit_Commit{ 114 + Repo: sourceRepo.RepoDid, 115 + Oid: head, 116 + }, 117 + }, 118 + }) 495 119 if err != nil { 496 - l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 497 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 120 + l.Error("failed to keep commit", "err", err) 121 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 498 122 return 499 123 } 500 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 501 - RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 502 - Collection: tangled.RepoPullNSID, 503 - Rkey: p.Rkey, 504 - }, 505 - }) 506 124 } 507 125 508 - // new pulls are created 509 - for _, p := range additions { 510 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 126 + // update PDS record 127 + { 128 + client, err := s.oauth.AuthorizedClient(r) 511 129 if err != nil { 512 - l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 513 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 130 + s.pages.Notice(w, noticeId, "Unauthorized user. Try again later.") 514 131 return 515 132 } 516 - p.Submissions[0].Blob = *blob.Blob 517 133 518 - if err = db.PutPull(tx, p); err != nil { 519 - l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 520 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 134 + // NOTE: some old PR records are missing CID 135 + if err := s.ensurePullCid(ctx, client, pull); err != nil { 136 + s.pages.Notice(w, noticeId, "Failed to get existing PR record. Is PR deleted from PDS?") 521 137 return 522 138 } 523 139 524 - record := p.AsRecord() 525 - record.Rounds = []*tangled.RepoPull_Round{ 526 - { 527 - CreatedAt: time.Now().Format(time.RFC3339), 528 - PatchBlob: blob.Blob, 529 - }, 530 - } 531 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 532 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 533 - Collection: tangled.RepoPullNSID, 534 - Rkey: &p.Rkey, 535 - Value: knotcompat.Pull(&record), 536 - }, 140 + record := pull.AsRecord() 141 + exCid := pull.Cid.String() 142 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 143 + Collection: tangled.RepoPullNSID, 144 + Repo: pull.OwnerDid.String(), 145 + Rkey: pull.Rkey.String(), 146 + SwapRecord: &exCid, 147 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 537 148 }) 538 - } 539 - 540 - // updated pulls are, well, updated; to start a new round 541 - for id := range updated { 542 - op, _ := origById[id] 543 - np, _ := newById[id] 544 - 545 - // do not update already merged PRs 546 - if op.State == models.PullMerged { 547 - continue 548 - } 549 - 550 - // resubmit the new pull 551 - np.Rkey = op.Rkey 552 - pullAt := op.AtUri() 553 - newRoundNumber := len(op.Submissions) 554 - newPatch := np.LatestPatch() 555 - combinedPatch := np.LatestSubmission().Combined 556 - newSourceRev := np.LatestSha() 557 - 558 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 559 149 if err != nil { 560 - l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 561 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 562 - return 563 - } 564 - 565 - // create new round 566 - err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 567 - if err != nil { 568 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 569 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 150 + l.Error("failed to create PDS record", "err", err) 151 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 570 152 return 571 153 } 572 - 573 - // update dependent-on relation 574 - if np.DependentOn != nil { 575 - err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 576 - if err != nil { 577 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 578 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 579 - return 580 - } 581 - } 582 - 583 - record := np.AsRecord() 584 - record.Rounds = op.AsRecord().Rounds 585 - record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 586 - CreatedAt: time.Now().Format(time.RFC3339), 587 - PatchBlob: blob.Blob, 588 - }) 589 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 590 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 591 - Collection: tangled.RepoPullNSID, 592 - Rkey: op.Rkey, 593 - Value: knotcompat.Pull(&record), 594 - }, 595 - }) 596 - 597 - op.Submissions = append(op.Submissions, &models.PullSubmission{ 598 - PullAt: pullAt, 599 - RoundNumber: newRoundNumber, 600 - Patch: newPatch, 601 - Combined: combinedPatch, 602 - SourceRev: newSourceRev, 603 - Created: time.Now(), 604 - }) 605 - resubmitted = append(resubmitted, op) 606 - } 607 - 608 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 609 - Repo: userDid.String(), 610 - Writes: writes, 611 - }) 612 - if err != nil { 613 - l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 614 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 615 - return 154 + pull.Cid = syntax.CID(out.Cid) 616 155 } 617 156 618 - err = tx.Commit() 619 - if err != nil { 620 - l.Error("failed to commit resubmit transaction", "err", err) 621 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 157 + if err := db.SubmitPullVersion(ctx, s.db, pull.AtUri(), newVersion); err != nil { 158 + l.Error("failed to update PR in DB", "err", err) 159 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 622 160 return 623 161 } 624 162 625 - for _, p := range additions { 626 - s.notifier.NewPull(r.Context(), p) 627 - } 628 - for _, p := range resubmitted { 629 - s.notifier.ResubmitPull(r.Context(), p) 630 - } 163 + s.notifier.ResubmitPull(r.Context(), pull) 631 164 632 165 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 633 166 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 634 167 } 168 + 169 + func (s *Pulls) ensurePullCid(ctx context.Context, client lexutil.LexClient, pull *models.Pull) error { 170 + out, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, pull.OwnerDid.String(), pull.Rkey.String()) 171 + if err != nil { 172 + return err 173 + } 174 + if out.Cid == nil { 175 + return fmt.Errorf("record CID is empty") 176 + } 177 + pull.Cid = syntax.CID(*out.Cid) 178 + return nil 179 + }
+22 -51
appview/pulls/resubmit_check_test.go
··· 59 59 } 60 60 } 61 61 62 - func newForkPull(state models.PullState) (*models.Pull, *models.Repo, models.Stack) { 62 + func newForkPull(state models.PullState) (*models.Pull, *models.Repo) { 63 63 sourceRepoDid := syntax.DID(resubmitTestRepoDID) 64 + sourceBranch := resubmitTestBranch 64 65 pull := &models.Pull{ 65 66 State: state, 66 67 OwnerDid: resubmitTestOwnerDID, 67 68 TargetBranch: "main", 68 - Submissions: []*models.PullSubmission{ 69 - {SourceRev: resubmitTestSourceRev}, 69 + Versions: []models.PullVersion{ 70 + {Head: resubmitTestSourceRev}, 70 71 }, 71 - PullSource: &models.PullSource{ 72 - Branch: resubmitTestBranch, 73 - RepoDid: &sourceRepoDid, 74 - }, 72 + SourceBranch: &sourceBranch, 73 + SourceRepo: sourceRepoDid, 75 74 } 76 75 repo := &models.Repo{RepoDid: resubmitTestRepoDID} 77 - stack := models.Stack{pull} 78 - return pull, repo, stack 76 + return pull, repo 79 77 } 80 78 81 79 func TestResubmitCheck_BranchAdvanced(t *testing.T) { ··· 83 81 defer srv.Close() 84 82 85 83 s := newPullsFromKnotURL(srv.URL) 86 - req := httptest.NewRequest(http.MethodGet, "/", nil) 87 - pull, repo, stack := newForkPull(models.PullOpen) 84 + ctx := t.Context() 85 + pull, _ := newForkPull(models.PullOpen) 88 86 89 - got := s.resubmitCheck(req, repo, pull, stack) 87 + got := s.resubmitCheck(ctx, pull) 90 88 if got != pages.ShouldResubmit { 91 89 t.Errorf("resubmitCheck() = %v, want ShouldResubmit", got) 92 90 } ··· 97 95 defer srv.Close() 98 96 99 97 s := newPullsFromKnotURL(srv.URL) 100 - req := httptest.NewRequest(http.MethodGet, "/", nil) 101 - pull, repo, stack := newForkPull(models.PullOpen) 98 + ctx := t.Context() 99 + pull, _ := newForkPull(models.PullOpen) 102 100 103 - got := s.resubmitCheck(req, repo, pull, stack) 101 + got := s.resubmitCheck(ctx, pull) 104 102 if got != pages.ShouldNotResubmit { 105 103 t.Errorf("resubmitCheck() = %v, want ShouldNotResubmit", got) 106 104 } ··· 108 106 109 107 func TestResubmitCheck_MergedReturnsUnknown(t *testing.T) { 110 108 s := newPullsFromKnotURL("http://unused") 111 - req := httptest.NewRequest(http.MethodGet, "/", nil) 112 - pull, repo, stack := newForkPull(models.PullMerged) 109 + ctx := t.Context() 110 + pull, _ := newForkPull(models.PullMerged) 113 111 114 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.Unknown { 112 + if got := s.resubmitCheck(ctx, pull); got != pages.Unknown { 115 113 t.Errorf("resubmitCheck() = %v, want Unknown for merged pull", got) 116 114 } 117 115 } 118 116 119 117 func TestResubmitCheck_PatchBasedReturnsUnknown(t *testing.T) { 120 118 s := newPullsFromKnotURL("http://unused") 121 - req := httptest.NewRequest(http.MethodGet, "/", nil) 122 - pull, repo, stack := newForkPull(models.PullOpen) 123 - pull.PullSource = nil 119 + ctx := t.Context() 120 + pull, _ := newForkPull(models.PullOpen) 121 + pull.SourceBranch = nil 124 122 125 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.Unknown { 123 + if got := s.resubmitCheck(ctx, pull); got != pages.Unknown { 126 124 t.Errorf("resubmitCheck() = %v, want Unknown for patch-based pull", got) 127 125 } 128 126 } ··· 131 129 s := newPullsFromKnotURL("http://127.0.0.1:1") 132 130 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) 133 131 defer cancel() 134 - req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) 135 - pull, repo, stack := newForkPull(models.PullOpen) 132 + pull, _ := newForkPull(models.PullOpen) 136 133 137 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.Unknown { 134 + if got := s.resubmitCheck(ctx, pull); got != pages.Unknown { 138 135 t.Errorf("resubmitCheck() = %v, want Unknown when knot unreachable", got) 139 136 } 140 137 } 141 - 142 - func TestResubmitCheck_NonForkUsesRepoDid(t *testing.T) { 143 - const targetRepoDID = "did:plc:scallop" 144 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 145 - if got := r.URL.Query().Get("repo"); got != targetRepoDID { 146 - t.Errorf("repo param = %q, want %q for non-fork pull", got, targetRepoDID) 147 - } 148 - w.Header().Set("Content-Type", "application/json") 149 - _ = json.NewEncoder(w).Encode(tangled.GitTempGetBranch_Output{ 150 - Name: resubmitTestBranch, 151 - Hash: resubmitTestSourceRev, 152 - When: time.Now().UTC().Format(time.RFC3339), 153 - }) 154 - })) 155 - defer srv.Close() 156 - 157 - s := newPullsFromKnotURL(srv.URL) 158 - req := httptest.NewRequest(http.MethodGet, "/", nil) 159 - pull, _, stack := newForkPull(models.PullOpen) 160 - pull.PullSource.RepoDid = nil 161 - repo := &models.Repo{RepoDid: targetRepoDID} 162 - 163 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.ShouldNotResubmit { 164 - t.Errorf("resubmitCheck() = %v, want ShouldNotResubmit", got) 165 - } 166 - }
+15 -18
appview/pulls/router.go
··· 14 14 r.Get("/pipeline-statuses", pipelines.StatusesHandler(s.oauth, s.repoResolver, s.pages, s.logger)) 15 15 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 16 16 r.Get("/", s.NewPull) 17 - r.Get("/refresh", s.RefreshCompose) 18 - r.Post("/refresh", s.RefreshCompose) 19 17 r.Post("/", s.NewPull) 18 + r.Post("/refresh", s.RefreshCompose) // TODO: remove this. we just have to refresh source & review steps 20 19 }) 20 + r.Get("/_/composediff", s.PullComposeDiffFragment) 21 21 22 22 r.Route("/{pull}", func(r chi.Router) { 23 - r.Use(mw.ResolvePull()) 24 - r.Get("/", s.RepoSinglePull) 23 + r.Use(s.ResolvePullMiddleware) 25 24 r.Get("/opengraph", s.PullOpenGraphSummary) 26 25 27 - r.Route("/round/{round}", func(r chi.Router) { 28 - r.Get("/", s.RepoPullPatch) 29 - r.Get("/interdiff", s.RepoPullInterdiff) 30 - r.Get("/actions", s.PullActions) 31 - }) 26 + // PR routes 27 + r.Get("/", s.RedirectLatestVersion) 28 + r.Get("/{version}", s.PullSingle) 29 + r.Get("/{version}/{revspec}", s.PullSingle) 30 + r.Get("/{version}.patch", s.PullPatchRaw) 31 + 32 + // htmx fragments 33 + r.Get("/{version}/_/actions", s.PullActions) 32 34 33 - r.Route("/round/{round}.patch", func(r chi.Router) { 34 - r.Get("/", s.RepoPullPatchRaw) 35 - }) 35 + r.Get("/_/diff", s.PullDiffFragment) 36 + r.Get("/_/interdiff", s.PullInterdiffFragment) 36 37 37 38 r.Group(func(r chi.Router) { 38 39 r.Use(middleware.AuthMiddleware(s.oauth)) 39 40 r.Get("/edit", s.EditPull) 40 41 r.Post("/edit", s.EditPull) 41 - r.Route("/resubmit", func(r chi.Router) { 42 - r.Get("/", s.ResubmitPull) 43 - r.Post("/", s.ResubmitPull) 44 - }) 45 - // permissions here require us to know pull author 46 - // it is handled within the route 42 + r.Post("/resubmit", s.ResubmitPull) 47 43 r.Post("/close", s.ClosePull) 48 44 r.Post("/reopen", s.ReopenPull) 49 45 r.Post("/subscribe", s.SubscribePull) ··· 60 56 }) 61 57 }) 62 58 }) 59 + 63 60 return r 64 61 65 62 }
+371 -356
appview/pulls/single.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "cmp" 4 5 "context" 6 + "errors" 5 7 "fmt" 8 + "io" 9 + "log/slog" 6 10 "net/http" 7 11 "strconv" 12 + "strings" 8 13 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/go-chi/chi/v5" 17 + "github.com/go-git/go-git/v5/plumbing" 18 + "github.com/go-git/go-git/v5/plumbing/object" 19 + "golang.org/x/sync/errgroup" 9 20 "tangled.org/core/api/tangled" 10 21 "tangled.org/core/appview/db" 11 22 "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/oauth" 12 24 "tangled.org/core/appview/pages" 25 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 26 + "tangled.org/core/hostutil" 13 27 "tangled.org/core/orm" 14 - "tangled.org/core/patchutil" 15 28 "tangled.org/core/types" 16 - "tangled.org/core/xrpc/xrpcclient" 17 - 18 - "github.com/bluesky-social/indigo/atproto/syntax" 19 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 20 - "github.com/go-chi/chi/v5" 21 - "tangled.org/core/hostutil" 22 29 ) 23 30 24 - // htmx fragment 25 - func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 26 - l := s.logger.With("handler", "PullActions") 31 + func (s *Pulls) RedirectLatestVersion(w http.ResponseWriter, r *http.Request) { 32 + pull, ok := r.Context().Value("pull").(*models.Pull) 33 + if !ok { 34 + s.logger.Error("failed to get pull") 35 + s.pages.Error500(w) 36 + return 37 + } 38 + u := r.URL.JoinPath(strconv.Itoa(pull.LatestVersionNumber())) 39 + http.Redirect(w, r, u.String(), http.StatusFound) 40 + } 27 41 28 - switch r.Method { 29 - case http.MethodGet: 30 - user := s.oauth.GetMultiAccountUser(r) 31 - if user != nil { 32 - l = l.With("user", user.Did) 33 - } 42 + func (s *Pulls) PullSingle(w http.ResponseWriter, r *http.Request) { 43 + if strings.Contains(chi.URLParam(r, "version"), "..") { 44 + s.PullInterDiff(w, r) 45 + } else { 46 + s.PullDiff(w, r) 47 + } 48 + } 34 49 35 - f, err := s.repoResolver.Resolve(r) 36 - if err != nil { 37 - l.Error("failed to get repo and knot", "err", err) 38 - return 39 - } 50 + // PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit} 51 + // 52 + // Examples: 53 + // - /pulls/123/latest 54 + // - /pulls/123/2/head 55 + // - /pulls/123/2/base..head 56 + // - /pulls/123/2/a53ab251e..d8add468c 57 + // - /pulls/123/2/d8add468c 58 + func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) { 59 + l := s.logger.With("handler", "PullDiff") 60 + ctx := r.Context() 40 61 41 - pull, ok := r.Context().Value("pull").(*models.Pull) 42 - if !ok { 43 - l.Error("failed to get pull") 44 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 45 - return 46 - } 47 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 62 + user := s.oauth.GetMultiAccountUser(r) 63 + if user != nil { 64 + l = l.With("user", user.Did) 65 + } 48 66 49 - // can be nil if this pull is not stacked 50 - stack, _ := r.Context().Value("stack").(models.Stack) 67 + pull, ok := r.Context().Value("pull").(*models.Pull) 68 + if !ok { 69 + l.Error("failed to get pull") 70 + http.Error(w, "failed to get PR", http.StatusInternalServerError) 71 + return 72 + } 51 73 52 - roundNumberStr := chi.URLParam(r, "round") 53 - roundNumber, err := strconv.Atoi(roundNumberStr) 74 + var version models.PullVersion 75 + var versionIdRaw = chi.URLParam(r, "version") 76 + if versionIdRaw == "latest" { 77 + version = pull.LatestVersion() 78 + } else { 79 + versionId, err := strconv.Atoi(versionIdRaw) 54 80 if err != nil { 55 - roundNumber = pull.LastRoundNumber() 81 + // invalid version number. redirect 82 + http.Redirect(w, r, 83 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 84 + http.StatusSeeOther, 85 + ) 86 + return 56 87 } 57 - if roundNumber >= len(pull.Submissions) { 58 - http.Error(w, "bad round id", http.StatusBadRequest) 59 - l.Error("failed to parse round id", "err", err, "round_number", roundNumber) 88 + var ok bool 89 + version, ok = pull.GetVersion(versionId) 90 + if !ok { 91 + // invalid version number. redirect 92 + http.Redirect(w, r, 93 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 94 + http.StatusSeeOther, 95 + ) 60 96 return 61 97 } 98 + } 62 99 63 - // only the last round's buttons and banners use merge/resubmit checks 64 - isLastRound := roundNumber == pull.LastRoundNumber() 65 - branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 100 + diffBase, diffHead, err := parseRange(chi.URLParam(r, "revspec")) 101 + if err != nil { 102 + http.Redirect(w, r, 103 + fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.PullId, versionIdRaw), 104 + http.StatusSeeOther, 105 + ) 106 + return 107 + } 66 108 67 - var workflowsChanged bool 68 - var changedWorkflows []string 69 - hasPipeline := false 70 - if isLastRound && f.Spindle != "" { 71 - pipelines, err := s.fetchPipelines(r.Context(), f.Spindle, f.RepoDid, []string{pull.LatestSha()}) 72 - if err != nil { 73 - l.Error("failed to fetch latest pipeline", "err", err) 74 - } else if pipelines != nil { 75 - _, hasPipeline = pipelines[pull.LatestSha()] 76 - } 77 - 78 - if pull.IsForkBased() && !hasPipeline { 79 - changedWorkflows, err = changedWorkflowFiles(pull.LatestSubmission().CombinedPatch()) 80 - if err != nil { 81 - l.Error("failed to inspect latest round's patch for workflow changes", "err", err) 82 - } 83 - workflowsChanged = len(changedWorkflows) > 0 84 - } 109 + // defer render 110 + var params pages.PullDiffParams 111 + params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull) 112 + params.VersionId = version.ID 113 + defer func() { 114 + if err := s.pages.PullDiff(w, params); err != nil { 115 + l.Error("Failed to render", "err", err) 85 116 } 117 + }() 86 118 87 - mergeCheckResponse := types.MergeCheckResponse{} 88 - resubmitResult := pages.Unknown 89 - if isLastRound { 90 - mergeCheckResponse = s.mergeCheck(r, f, pull, stack) 91 - if user != nil && user.Did == pull.OwnerDid { 92 - resubmitResult = s.resubmitCheck(r, f, pull, stack) 93 - } 94 - } 119 + // special cases 120 + // default to {current.base}..{current.head} 121 + if diffBase == "" || diffBase == "base" { 122 + params.IsDiffBase = true 123 + // NOTE: We fallback to target-branch for legacy reason. 124 + // Old PRs before ref-based-PR refactor doesn't have `version.base`. 125 + diffBase = cmp.Or(version.Base, pull.TargetBranch) 126 + } 127 + if diffHead == "" || diffHead == "head" { 128 + params.IsDiffHead = true 129 + diffHead = version.Head 130 + } 131 + params.DiffParams.Base = diffBase 132 + params.DiffParams.Head = diffHead 95 133 96 - s.pages.PullActionsFragment(w, pages.PullActionsParams{ 97 - BaseParams: pages.BaseParamsFromContext(r.Context()), 98 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 99 - Pull: pull, 100 - RoundNumber: roundNumber, 101 - MergeCheck: mergeCheckResponse, 102 - ResubmitCheck: resubmitResult, 103 - BranchDeleteStatus: branchDeleteStatus, 104 - Stack: stack, 105 - WorkflowsChanged: workflowsChanged, 106 - ChangedWorkflowFiles: changedWorkflows, 107 - HasPipeline: hasPipeline, 108 - }) 134 + commits, err := s.listCommits(ctx, pull.SourceRepo, version.Base, version.Head) 135 + if err != nil { 136 + l.Error("failed to list commits", "err", err) 137 + params.ErrorMsg = "Failed to list commits. Try again later." 109 138 return 110 139 } 140 + params.Commits = commits 141 + 142 + // commitId -> latest pipeline 143 + shas := make([]string, len(params.Commits)) 144 + for i, commit := range params.Commits { 145 + shas[i] = commit.Hash.String() 146 + } 147 + params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas) 111 148 } 112 149 113 - func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 114 - l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 150 + // PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change} 151 + // 152 + // Examples: 153 + // - /pulls/123/0..2/all 154 + // - /pulls/123/0..2/nrpytyzw 155 + func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) { 156 + l := s.logger.With("handler", "PullInterDiff") 157 + ctx := r.Context() 115 158 116 159 user := s.oauth.GetMultiAccountUser(r) 117 160 if user != nil { 118 161 l = l.With("user", user.Did) 119 162 } 120 163 121 - f, err := s.repoResolver.Resolve(r) 122 - if err != nil { 123 - l.Error("failed to get repo and knot", "err", err) 124 - return 125 - } 126 - 127 164 pull, ok := r.Context().Value("pull").(*models.Pull) 128 165 if !ok { 129 - l.Error("failed to get pull") 130 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 166 + s.logger.Error("failed to get pull") 167 + s.pages.Error500(w) 131 168 return 132 169 } 133 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 134 170 135 - if user != nil { 136 - userDid := user.Did 137 - repoDid := f.RepoDid 138 - pullId := pull.PullId 139 - atUri := pull.AtUri().String() 140 - focusing := pages.BaseParamsFromContext(r.Context()).FocusParams.Focusing 141 - go func() { 142 - if !focusing { 143 - if err := db.MarkNotificationsReadForPull(s.db, userDid, repoDid, pullId); err != nil { 144 - l.Error("failed to mark pull notifications as read", "err", err) 145 - } 146 - } 147 - if err := db.UpsertRecentLink(s.db, userDid, models.RecentLinkTypePull, atUri); err != nil { 148 - l.Error("failed to upsert recent link", "err", err) 149 - } 150 - }() 171 + version1Raw, version2Raw, err := parseRange(chi.URLParam(r, "version")) 172 + if err != nil { 173 + http.Redirect(w, r, 174 + fmt.Sprintf("/%s/pulls/%d/0", pull.RepoDid, pull.PullId), 175 + http.StatusSeeOther, 176 + ) 177 + return 151 178 } 152 - 153 - backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 179 + version1, err := strconv.Atoi(version1Raw) 180 + version2, err := strconv.Atoi(version2Raw) 154 181 if err != nil { 155 - l.Error("failed to get pull backlinks", "err", err) 156 - s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 182 + http.Redirect(w, r, 183 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 184 + http.StatusSeeOther, 185 + ) 157 186 return 158 187 } 159 188 160 - roundId := chi.URLParam(r, "round") 161 - roundIdInt := pull.LastRoundNumber() 162 - if r, err := strconv.Atoi(roundId); err == nil { 163 - roundIdInt = r 189 + changeId := chi.URLParam(r, "revspec") 190 + if changeId == "all" { 191 + changeId = "" 164 192 } 165 - if roundIdInt < 0 || roundIdInt >= len(pull.Submissions) { 166 - http.Error(w, "bad round id", http.StatusBadRequest) 167 - l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 193 + 194 + // defer render 195 + var params pages.PullInterdiffParams 196 + params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull) 197 + params.Version1 = version1 198 + params.Version2 = version2 199 + params.ChangeId = changeId 200 + defer func() { 201 + if err := s.pages.PullInterdiff(w, params); err != nil { 202 + l.Error("Failed to render", "err", err) 203 + } 204 + }() 205 + 206 + var commits1, commits2 []types.Commit 207 + g, gctx := errgroup.WithContext(ctx) 208 + if changeId != "" { 209 + g.Go(func() error { 210 + commits1, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version1].Base, pull.Versions[version1].Head) 211 + return err 212 + }) 213 + } 214 + g.Go(func() error { 215 + commits2, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version2].Base, pull.Versions[version2].Head) 216 + return err 217 + }) 218 + if err := g.Wait(); err != nil { 219 + l.Error("failed to list commits", "err", err) 220 + params.ErrorMsg = "Failed to list commits. Try again later." 168 221 return 169 222 } 223 + params.Commits = commits2 170 224 171 - var diffOpts types.DiffOpts 172 - if d := r.URL.Query().Get("diff"); d == "split" { 173 - diffOpts.Split = true 225 + // commitId -> latest pipeline 226 + shas := make([]string, len(params.Commits)) 227 + for i, commit := range params.Commits { 228 + shas[i] = commit.Hash.String() 229 + } 230 + params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas) 231 + 232 + if changeId != "" { 233 + // interdiff by change-id 234 + var from, to *types.Commit 235 + for _, commit := range commits1 { 236 + if commit.ChangeId == changeId { 237 + from = &commit 238 + break 239 + } 240 + } 241 + for _, commit := range commits2 { 242 + if commit.ChangeId == changeId { 243 + to = &commit 244 + break 245 + } 246 + } 247 + l.Debug("commits", "old", from, "new", to) 248 + 249 + switch { 250 + case to == nil: 251 + // can't find change-id from v2 branch. 252 + // NOTE: This can't happen because user selected from v2's commits 253 + params.ErrorMsg = "Can't find commit with given change-id." 254 + case from == nil: 255 + // new commit -> diff <parent1>..<new> 256 + params.ActiveCommitId = to.Hash.String() 257 + params.DiffParams.Diff = &pages.DiffParams_Diff{ 258 + Base: to.FirstParentHash().String(), 259 + Head: to.Hash.String(), 260 + } 261 + default: 262 + // interdiff 263 + params.ActiveCommitId = to.Hash.String() 264 + // TODO: use merged tree of all parents 265 + params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ 266 + From: pages.DiffParams_Diff{ 267 + Base: from.FirstParentHash().String(), 268 + Head: from.Hash.String(), 269 + }, 270 + To: pages.DiffParams_Diff{ 271 + Base: to.FirstParentHash().String(), 272 + Head: to.Hash.String(), 273 + }, 274 + } 275 + } 276 + } else { 277 + // interdiff of two versions 278 + params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ 279 + From: pages.DiffParams_Diff{ 280 + Base: pull.Versions[version1].Base, 281 + Head: pull.Versions[version1].Head, 282 + }, 283 + To: pages.DiffParams_Diff{ 284 + Base: pull.Versions[version2].Base, 285 + Head: pull.Versions[version2].Head, 286 + }, 287 + } 288 + 289 + // TODO: if any of them is "", show error message 174 290 } 291 + } 175 292 176 - // can be nil if this pull is not stacked 177 - stack, _ := r.Context().Value("stack").(models.Stack) 293 + func (s *Pulls) PullPatchRaw(w http.ResponseWriter, r *http.Request) { 294 + l := s.logger.With("handler", "RepoPullPatchRaw") 178 295 179 - var shas []string 180 - for _, s := range pull.Submissions { 181 - shas = append(shas, s.SourceRev) 296 + pull, ok := r.Context().Value("pull").(*models.Pull) 297 + if !ok { 298 + l.Error("failed to get pull") 299 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 300 + return 182 301 } 183 - for _, p := range stack { 184 - shas = append(shas, p.LatestSha()) 302 + l = l.With("pull_id", pull.PullId) 303 + 304 + var version models.PullVersion 305 + var versionIdRaw = chi.URLParam(r, "version") 306 + if versionIdRaw == "latest" { 307 + version = pull.LatestVersion() 308 + } else { 309 + versionId, err := strconv.Atoi(versionIdRaw) 310 + if err != nil { 311 + http.Error(w, "bad version id", http.StatusBadRequest) 312 + return 313 + } 314 + var ok bool 315 + version, ok = pull.GetVersion(versionId) 316 + if !ok { 317 + http.Error(w, "unknown version", http.StatusNotFound) 318 + return 319 + } 185 320 } 186 321 187 - pipelines, err := s.fetchPipelines(r.Context(), f.Spindle, f.RepoDid, shas) 322 + xrpcc := s.knotMirrorXRPC 323 + rawOut, err := tangled.GitTempFormatPatch(r.Context(), xrpcc, version.Base, pull.RepoDid.String(), version.Head) 188 324 if err != nil { 189 - l.Error("failed to fetch pipelines", "err", err) 325 + http.Error(w, "Failed to compute patch", http.StatusInternalServerError) 326 + return 190 327 } 191 - if pipelines == nil { 192 - pipelines = make(map[string]types.Pipeline) 193 - } 328 + 329 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 330 + w.Write(rawOut) 331 + } 332 + 333 + func (s *Pulls) makePullPageBaseParams(r *http.Request, user *oauth.MultiAccountUser, pull *models.Pull) pages.PullPageBaseParams { 334 + l := s.logger 335 + ctx := r.Context() 194 336 195 337 entities := []syntax.ATURI{pull.AtUri()} 196 - for _, s := range pull.Submissions { 197 - for _, c := range s.Comments { 338 + for _, v := range pull.Versions { 339 + for _, c := range v.Comments { 198 340 entities = append(entities, c.FeedCommentAtUri()) 199 341 } 200 342 } 201 343 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) 202 344 if err != nil { 203 - l.Error("failed to get pull reactions", "err", err) 345 + l.Error("failed to get reactions", "err", err) 204 346 } 205 347 206 348 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool ··· 213 355 214 356 labelDefs, err := db.GetLabelDefinitions( 215 357 s.db, 216 - orm.FilterIn("at_uri", f.Labels), 358 + orm.FilterIn("at_uri", pull.Repo.Labels), 217 359 orm.FilterContains("scope", tangled.RepoPullNSID), 218 360 ) 219 361 if err != nil { 220 362 l.Error("failed to fetch labels", "err", err) 221 - s.pages.Error503(w) 222 - return 223 363 } 224 - 225 364 defs := make(map[string]*models.LabelDefinition) 226 365 for _, l := range labelDefs { 227 366 defs[l.AtUri().String()] = &l ··· 236 375 l.Error("failed to fetch vouch relationships", "err", err) 237 376 } 238 377 ownerDid := syntax.DID(pull.OwnerDid) 239 - skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid) 378 + skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid.String()) 240 379 if err != nil { 241 380 l.Error("failed to check vouch skip", "err", err) 242 381 } 243 382 vouchSkips[ownerDid] = skipped 244 383 } 245 384 246 - var diff types.DiffRenderer 247 - if interdiff { 248 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 249 - if err != nil { 250 - l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 251 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 252 - return 253 - } 254 - 255 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 256 - if err != nil { 257 - l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 258 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 259 - return 260 - } 261 - 262 - diff = patchutil.Interdiff(previousPatch, currentPatch) 263 - } else { 264 - diff = s.combinedDiff(pull, roundIdInt) 265 - } 266 - 267 385 var isSubscribed *bool 268 386 if user != nil { 269 387 pullDbId := int64(pull.ID) ··· 273 391 isSubscribed = &sub 274 392 } else { 275 393 // Implicitly subscribed if author or participant. 276 - isAuthorOrParticipant := pull.OwnerDid == user.Did 394 + isAuthorOrParticipant := pull.OwnerDid == syntax.DID(user.Did) 277 395 if !isAuthorOrParticipant { 278 396 for _, p := range pull.Participants() { 279 397 if p.String() == user.Did { ··· 290 408 } 291 409 } 292 410 293 - err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 294 - BaseParams: pages.BaseParamsFromContext(r.Context()), 295 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 296 - Pull: pull, 297 - Stack: stack, 298 - Backlinks: backlinks, 299 - BranchDeleteStatus: nil, 300 - MergeCheck: types.MergeCheckResponse{}, 301 - ResubmitCheck: pages.Unknown, 302 - Pipelines: pipelines, 303 - Diff: diff, 304 - DiffOpts: diffOpts, 305 - ActiveRound: roundIdInt, 306 - IsInterdiff: interdiff, 307 - 308 - Reactions: reactions, 309 - UserReacted: userReactions, 411 + params := pages.PullPageBaseParams{} 412 + params.BaseParams = pages.BaseParamsFromContext(ctx) 413 + params.RepoInfo = s.repoResolver.GetRepoInfo(r, user) 414 + params.Pull = pull 415 + params.Backlinks = nil 416 + params.LabelDefs = defs 417 + params.Reactions = reactions 418 + params.UserReacted = userReactions 419 + params.VouchRelationships = vouchRelationships 420 + params.VouchSkips = vouchSkips 421 + params.IsSubscribed = isSubscribed 422 + return params 423 + } 310 424 311 - LabelDefs: defs, 312 - VouchRelationships: vouchRelationships, 313 - VouchSkips: vouchSkips, 314 - IsSubscribed: isSubscribed, 425 + func (s *Pulls) listCommits(ctx context.Context, repo syntax.DID, base, head string) ([]types.Commit, error) { 426 + s.logger.Debug("logging commits", "repo", repo, "base", base, "head", head) 427 + stream, err := s.gitmirror.CommitLog(ctx, &gitmirrorv1.CommitLogRequest{ 428 + Repo: repo.String(), 429 + Ranges: [][]byte{fmt.Appendf(nil, "%s..%s", base, head)}, 430 + AllRefs: false, 315 431 }) 316 432 if err != nil { 317 - l.Error("failed to render page", "err", err) 433 + return nil, err 434 + } 435 + var commits []types.Commit 436 + for { 437 + res, err := stream.Recv() 438 + if errors.Is(err, io.EOF) { 439 + break 440 + } 441 + if err != nil { 442 + return nil, err 443 + } 444 + for _, commit := range res.Commits { 445 + commits = append(commits, types.Commit{ 446 + Hash: plumbing.NewHash(commit.Oid), 447 + Author: object.Signature{ 448 + Name: string(commit.Author.GetName()), 449 + Email: string(commit.Author.GetEmail()), 450 + When: commit.Author.Date.AsTime(), 451 + }, 452 + Committer: object.Signature{ 453 + Name: string(commit.Committer.GetName()), 454 + Email: string(commit.Committer.GetEmail()), 455 + When: commit.Committer.Date.AsTime(), 456 + }, 457 + Message: string(commit.Message), 458 + ParentHashes: func() []plumbing.Hash { 459 + var parents []plumbing.Hash 460 + for _, hash := range commit.Parents { 461 + parents = append(parents, plumbing.NewHash(hash)) 462 + } 463 + return parents 464 + }(), 465 + ChangeId: commit.ExtraHeaders["change-id"], 466 + }) 467 + } 318 468 } 469 + return commits, err 319 470 } 320 471 321 472 // SubscribePull handles subscribe/unsubscribe for a specific pull request. ··· 351 502 }) 352 503 } 353 504 354 - func (s *Pulls) combinedDiff(pull *models.Pull, round int) types.DiffRenderer { 355 - submission := pull.Submissions[round] 356 - key := fmt.Sprintf("%s|%d|%s", pull.AtUri(), round, submission.SourceRev) 357 - if cached, ok := s.diffCache.Get(key); ok { 358 - return cached 359 - } 360 - 361 - diff := patchutil.AsNiceDiff(submission.CombinedPatch(), pull.TargetBranch) 362 - s.diffCache.Add(key, diff) 363 - return diff 364 - } 365 - 366 505 func (s *Pulls) fetchPipelines(ctx context.Context, spindle string, repoDid string, shas []string) (map[string]types.Pipeline, error) { 367 - if spindle == "" { 506 + if spindle == "" || len(shas) == 0 { 368 507 return nil, nil 369 508 } 370 509 spindleUrl, err := hostutil.EnsureHttpScheme(spindle) ··· 379 518 return types.PipelinesByCommit(out.Pipelines), nil 380 519 } 381 520 382 - func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 383 - l := s.logger.With("handler", "RepoSinglePull") 384 - 385 - pull, ok := r.Context().Value("pull").(*models.Pull) 386 - if !ok { 387 - l.Error("failed to get pull") 388 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 389 - return 521 + func fetchPipelines(ctx context.Context, l *slog.Logger, f *models.Repo, shas []string) map[string]types.Pipeline { 522 + m := make(map[string]types.Pipeline) 523 + if f.Spindle == "" || len(shas) == 0 { 524 + return m 525 + } 526 + spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) 527 + if err != nil { 528 + l.Error("invalid spindle host", "host", f.Spindle, "err", err) 529 + return m 390 530 } 391 - 392 - http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 393 - } 394 - 395 - func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 396 - if pull.State == models.PullMerged { 397 - return types.MergeCheckResponse{} 531 + xrpcc := &indigoxrpc.Client{Host: spindleUrl} 532 + out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, f.RepoDid) 533 + if err != nil { 534 + l.Error("failed to fetch pipelines", "err", err) 535 + return m 398 536 } 399 537 400 - xrpcc := s.knotClient(f.Knot) 401 - 402 - // combine patches of substack 403 - subStack := stack.Below(pull) 404 - // collect the portion of the stack that is mergeable 405 - mergeable := subStack.Mergeable() 406 - // combine each patch 407 - patch := mergeable.CombinedPatch() 408 - 409 - resp, err := tangled.RepoMergeCheck( 410 - r.Context(), 411 - xrpcc, 412 - &tangled.RepoMergeCheck_Input{ 413 - Did: f.Did, 414 - Name: f.Name, 415 - Repo: f.RepoDidPtr(), 416 - Branch: pull.TargetBranch, 417 - Patch: patch, 418 - }, 419 - ) 420 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 421 - s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 422 - return types.MergeCheckResponse{ 423 - Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 538 + for _, pipeline := range out.Pipelines { 539 + if pipeline == nil { 540 + continue 424 541 } 542 + m[pipeline.Commit] = types.Pipeline{CiPipeline: pipeline} 425 543 } 426 - 427 - return mergeCheckResponseFrom(resp) 544 + return m 428 545 } 429 546 430 - func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 431 - conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 432 - for i, c := range resp.Conflicts { 433 - conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 434 - } 435 - out := types.MergeCheckResponse{ 436 - IsConflicted: resp.Is_conflicted, 437 - Conflicts: conflicts, 547 + // parseRange parses <base>..<base> string 548 + func parseRange(input string) (base string, head string, err error) { 549 + input = strings.TrimSpace(input) 550 + if input == "" { 551 + return "", "", nil 438 552 } 439 - if resp.Message != nil { 440 - out.Message = *resp.Message 441 - } 442 - if resp.Error != nil { 443 - out.Error = *resp.Error 444 - } 445 - return out 446 - } 447 553 448 - func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 449 - if pull.State != models.PullMerged { 450 - return nil 554 + if strings.Count(input, "..") > 1 || strings.Contains(input, "...") { 555 + return "", "", fmt.Errorf("invalid revspec format: %q", input) 451 556 } 452 557 453 - user := s.oauth.GetMultiAccountUser(r) 454 - if user == nil { 455 - return nil 558 + // /{head} 559 + if !strings.Contains(input, "..") { 560 + return "", input, nil 456 561 } 457 562 458 - var branch string 459 - // check if the branch exists 460 - // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 461 - if pull.IsBranchBased() { 462 - branch = pull.PullSource.Branch 463 - } else if pull.IsForkBased() { 464 - branch = pull.PullSource.Branch 465 - repo = pull.PullSource.Repo 466 - } else { 467 - return nil 563 + // /{base}..{head} 564 + parts := strings.SplitN(input, "..", 2) 565 + base = strings.TrimSpace(parts[0]) 566 + head = strings.TrimSpace(parts[1]) 567 + if base == "" && head == "" { 568 + return "", "", fmt.Errorf("invalid empty range: \"..\"") 468 569 } 469 570 470 - // deleted fork 471 - if repo == nil { 472 - return nil 473 - } 474 - 475 - // user can only delete branch if they are a collaborator in the repo that the branch belongs to 476 - if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 477 - return nil 478 - } 479 - 480 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 481 - resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 482 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 483 - s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 484 - return nil 485 - } 486 - 487 - return &models.BranchDeleteStatus{ 488 - Repo: repo, 489 - Branch: resp.Name, 490 - } 491 - } 492 - 493 - func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 494 - if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 495 - return pages.Unknown 496 - } 497 - 498 - var sourceRepoDid string 499 - if pull.PullSource.RepoDid != nil { 500 - sourceRepoDid = string(*pull.PullSource.RepoDid) 501 - } else { 502 - sourceRepoDid = repo.RepoDid 503 - } 504 - 505 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 506 - branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 507 - if err != nil { 508 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 509 - s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 510 - return pages.Unknown 511 - } 512 - s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 513 - return pages.Unknown 514 - } 515 - 516 - targetBranch := branchResp 517 - 518 - top := stack[0] 519 - latestSourceRev := top.LatestSha() 520 - 521 - if latestSourceRev != targetBranch.Hash { 522 - return pages.ShouldResubmit 523 - } 524 - 525 - return pages.ShouldNotResubmit 526 - } 527 - 528 - func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 529 - s.repoPullHelper(w, r, false) 530 - } 531 - 532 - func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 533 - s.repoPullHelper(w, r, true) 534 - } 535 - 536 - func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 537 - l := s.logger.With("handler", "RepoPullPatchRaw") 538 - 539 - pull, ok := r.Context().Value("pull").(*models.Pull) 540 - if !ok { 541 - l.Error("failed to get pull") 542 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 543 - return 544 - } 545 - l = l.With("pull_id", pull.PullId) 546 - 547 - roundId := chi.URLParam(r, "round") 548 - roundIdInt, err := strconv.Atoi(roundId) 549 - if err != nil || roundIdInt >= len(pull.Submissions) { 550 - http.Error(w, "bad round id", http.StatusBadRequest) 551 - l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 552 - return 553 - } 554 - 555 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 556 - w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 571 + return base, head, nil 557 572 }
+9 -23
appview/pulls/state.go
··· 7 7 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/samber/lo" 11 10 12 11 "tangled.org/core/api/tangled" 13 12 "tangled.org/core/appview/models" 14 13 "tangled.org/core/tid" 15 14 ) 16 15 17 - func (s *Pulls) writePullStatusRecords(r *http.Request, actorDid string, subjects []syntax.ATURI, value models.StateValue) error { 18 - if len(subjects) == 0 { 19 - return nil 20 - } 21 - 16 + func (s *Pulls) writePullStatusRecord(r *http.Request, actorDid string, subject syntax.ATURI, value models.StateValue) error { 22 17 client, err := s.oauth.AuthorizedClient(r) 23 18 if err != nil { 24 19 return err 25 20 } 26 21 27 - records, err := models.AsPullStatusRecords(subjects, value, time.Now()) 22 + record, err := models.AsPullStatusRecord(subject, value, time.Now()) 28 23 if err != nil { 29 24 return err 30 25 } 31 26 32 - writes := lo.Map(records, func(record tangled.RepoPullStatus, _ int) *comatproto.RepoApplyWrites_Input_Writes_Elem { 33 - rkey := tid.TID() 34 - return &comatproto.RepoApplyWrites_Input_Writes_Elem{ 35 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 36 - Collection: tangled.RepoPullStatusNSID, 37 - Rkey: &rkey, 38 - Value: &lexutil.LexiconTypeDecoder{ 39 - Val: &record, 40 - }, 41 - }, 42 - } 43 - }) 44 - 45 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 46 - Repo: actorDid, 47 - Writes: writes, 27 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 28 + Collection: tangled.RepoPullStatusNSID, 29 + Repo: actorDid, 30 + Rkey: tid.TID(), 31 + Record: &lexutil.LexiconTypeDecoder{ 32 + Val: &record, 33 + }, 48 34 }) 49 35 return err 50 36 }
+36 -17
appview/pulls/trigger_ci.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "context" 5 + "errors" 4 6 "fmt" 7 + "io" 5 8 "net/http" 6 9 "strings" 7 10 11 + "github.com/bluesky-social/indigo/atproto/syntax" 8 12 "tangled.org/core/api/tangled" 9 13 "tangled.org/core/appview/db" 10 14 "tangled.org/core/appview/models" 11 - "tangled.org/core/patchutil" 15 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 12 16 "tangled.org/core/workflow" 13 17 ) 14 18 15 - func changedWorkflowFiles(patch string) ([]string, error) { 16 - files, err := patchutil.AsDiff(patch) 19 + func (s *Pulls) changedWorkflowFiles(ctx context.Context, baseRepo syntax.DID, base string, headRepo syntax.DID, head string) ([]string, error) { 20 + req := &gitmirrorv1.DiffRequest{ 21 + Head: &gitmirrorv1.RepoCommit{Repo: headRepo.String(), Commit: []byte(head)}, 22 + } 23 + if base != "" { 24 + req.Base = &gitmirrorv1.RepoCommit{Repo: baseRepo.String(), Commit: []byte(base)} 25 + } 26 + stream, err := s.gitmirror.Diff(ctx, req) 17 27 if err != nil { 18 - return nil, err 28 + return nil, fmt.Errorf("failed to diff: %w", err) 19 29 } 20 - 21 30 var changed []string 22 - for _, f := range files { 23 - if f == nil { 24 - continue 31 + for { 32 + fd, err := stream.Recv() 33 + if errors.Is(err, io.EOF) { 34 + break 35 + } 36 + if err != nil { 37 + return nil, fmt.Errorf("failed to drain diff response: %w", err) 25 38 } 26 - for _, name := range []string{f.NewName, f.OldName} { 39 + for _, name := range []string{fd.LhsSrc.Path, fd.RhsSrc.Path} { 27 40 if name != "" && strings.HasPrefix(name, workflow.WorkflowDir+"/") { 28 41 changed = append(changed, name) 29 42 break ··· 72 85 return 73 86 } 74 87 75 - latest := pull.LatestSubmission() 76 - if latest.SourceRev == "" { 88 + latest := pull.LatestVersion() 89 + if latest.Base == "" || latest.Head == "" { 77 90 fail("cannot trigger ci: this round has no commit to run", nil) 78 91 return 79 92 } 80 93 81 - changedFiles, err := changedWorkflowFiles(latest.CombinedPatch()) 94 + changedFiles, err := func(ctx context.Context) ([]string, error) { 95 + base, err := s.resolveRev(ctx, pull.RepoDid, pull.TargetBranch) 96 + if err != nil { 97 + return nil, fmt.Errorf("failed to resolve target branch: %w", err) 98 + } 99 + return s.changedWorkflowFiles(ctx, pull.RepoDid, base, pull.SourceRepo, latest.Head) 100 + }(r.Context()) 82 101 if err != nil { 83 102 fail("failed to inspect the latest round's patch", err) 84 103 return ··· 88 107 return 89 108 } 90 109 91 - forkRepo, err := db.GetRepoByDid(s.db, pull.PullSource.RepoDid.String()) 110 + forkRepo, err := db.GetRepoByDid(s.db, pull.SourceRepo.String()) 92 111 if err != nil { 93 112 fail("failed to resolve the fork this pull request comes from", err) 94 113 return ··· 101 120 } 102 121 103 122 pullAt := pull.AtUri().String() 104 - sourceBranch := pull.PullSource.Branch 123 + sourceBranch := pull.SourceBranch 105 124 targetBranch := pull.TargetBranch 106 125 out, err := tangled.CiTriggerPipeline( 107 126 r.Context(), ··· 111 130 Trigger: &tangled.CiTriggerPipeline_Input_Trigger{ 112 131 CiTrigger_PullRequest: &tangled.CiTrigger_PullRequest{ 113 132 Pull: &pullAt, 114 - SourceBranch: &sourceBranch, 133 + SourceBranch: sourceBranch, 115 134 SourceRepo: &forkRepo.RepoDid, 116 - SourceSha: latest.SourceRev, 135 + SourceSha: latest.Head, 117 136 TargetBranch: targetBranch, 118 137 }, 119 138 }, ··· 127 146 128 147 user := s.oauth.GetMultiAccountUser(r) 129 148 repoInfo := s.repoResolver.GetRepoInfo(r, user) 130 - dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LastRoundNumber()) 149 + dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LatestVersionNumber()) 131 150 if r.Header.Get("HX-Request") == "true" { 132 151 w.Header().Set("HX-Redirect", dest) 133 152 w.WriteHeader(http.StatusOK)
+11 -11
appview/repo/feed.go
··· 73 73 74 74 // fetch and add pull requests if requested 75 75 if opts.IncludePulls { 76 - pulls, err := db.GetPullsPaginated(rp.db, feedPagePerType, orm.FilterEq("repo_did", repo.RepoDid)) 76 + pulls, err := db.GetPullsPaginated(ctx, rp.db, feedPagePerType, orm.FilterEq("repo_did", repo.RepoDid)) 77 77 if err != nil { 78 78 return nil, err 79 79 } ··· 148 148 } 149 149 150 150 func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, ownerSlashRepo string) ([]*feeds.Item, error) { 151 - owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 151 + owner, err := rp.idResolver.Directory().LookupDID(ctx, pull.OwnerDid) 152 152 if err != nil { 153 153 return nil, err 154 154 } 155 155 156 156 var items []*feeds.Item 157 157 158 - state := rp.getPullState(pull) 159 - description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo) 158 + description := rp.buildPullDescription(owner.Handle, pull, ownerSlashRepo) 160 159 161 160 mainItem := &feeds.Item{ 162 161 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), ··· 167 166 } 168 167 items = append(items, mainItem) 169 168 170 - for _, round := range pull.Submissions { 171 - if round == nil || round.RoundNumber == 0 { 169 + for _, round := range pull.Versions { 170 + if round.ID == 0 { 172 171 continue 173 172 } 174 173 175 174 roundItem := &feeds.Item{ 176 - Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 177 - Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 178 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.RoundNumber)}, 175 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.ID), 176 + Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.ID, pull.PullId, ownerSlashRepo), 177 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.ID)}, 179 178 Created: round.Created, 180 179 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 181 180 } ··· 293 292 return pull.State.String() 294 293 } 295 294 296 - func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string { 295 + func (rp *Repo) buildPullDescription(handle syntax.Handle, pull *models.Pull, repoName string) string { 296 + state := rp.getPullState(pull) 297 297 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 298 298 299 299 if pull.State == models.PullMerged { 300 - return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 300 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LatestVersionNumber(), repoName) 301 301 } 302 302 303 303 return fmt.Sprintf("%s in %s", base, repoName)
+2 -2
appview/state/profile.go
··· 502 502 503 503 evidencePulls := make(map[syntax.ATURI]*models.Pull) 504 504 if len(pullAts) > 0 { 505 - pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts)) 505 + pulls, err := db.GetPullsPaginated(r.Context(), s.db, pagination.Page{}, orm.FilterIn("at_uri", pullAts)) 506 506 if err != nil { 507 507 l.Error("failed to get evidence pulls", "err", err) 508 508 } else { ··· 731 731 732 732 func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 733 733 for _, pull := range pulls { 734 - owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 734 + owner, err := s.idResolver.Directory().LookupDID(ctx, pull.RepoDid) 735 735 if err != nil { 736 736 return err 737 737 }
+4 -3
appview/timeline/timeline.go
··· 1 1 package timeline 2 2 3 3 import ( 4 + "context" 4 5 "net/http" 5 6 "sort" 6 7 ··· 99 100 100 101 var recents []pages.RecentItem 101 102 if user != nil { 102 - recents, err = t.buildRecents(user.Did) 103 + recents, err = t.buildRecents(r.Context(), user.Did) 103 104 if err != nil { 104 105 t.logger.Error("failed to build recents for timeline", "err", err) 105 106 } ··· 138 139 } 139 140 } 140 141 141 - func (t *Timeline) buildRecents(userDid string) ([]pages.RecentItem, error) { 142 + func (t *Timeline) buildRecents(ctx context.Context, userDid string) ([]pages.RecentItem, error) { 142 143 links, err := db.GetRecentLinks(t.db, orm.FilterEq("user_did", userDid)) 143 144 if err != nil { 144 145 return nil, err ··· 187 188 // fetch pulls by aturi 188 189 pullByAtUri := make(map[string]*models.Pull) 189 190 if len(pullAtUris) > 0 { 190 - fetched, err := db.GetPulls(t.db, orm.FilterIn("at_uri", pullAtUris)) 191 + fetched, err := db.GetPullsPaginated(ctx, t.db, pagination.Page{}, orm.FilterIn("at_uri", pullAtUris)) 191 192 if err != nil { 192 193 return nil, err 193 194 }
-38
cmd/interdiff/main.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - "os" 6 - 7 - "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.org/core/patchutil" 9 - ) 10 - 11 - func main() { 12 - if len(os.Args) != 3 { 13 - fmt.Println("Usage: interdiff <patch1> <patch2>") 14 - os.Exit(1) 15 - } 16 - 17 - patch1, err := os.Open(os.Args[1]) 18 - if err != nil { 19 - fmt.Println(err) 20 - } 21 - patch2, err := os.Open(os.Args[2]) 22 - if err != nil { 23 - fmt.Println(err) 24 - } 25 - 26 - files1, _, err := gitdiff.Parse(patch1) 27 - if err != nil { 28 - fmt.Println(err) 29 - } 30 - 31 - files2, _, err := gitdiff.Parse(patch2) 32 - if err != nil { 33 - fmt.Println(err) 34 - } 35 - 36 - interDiffResult := patchutil.Interdiff(files1, files2) 37 - fmt.Println(interDiffResult) 38 - }
+53
input.css
··· 335 335 } 336 336 } 337 337 338 + .diff { 339 + @apply font-mono bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400; 340 + font-size: 12px; 341 + content-visibility: auto; 342 + content-intrinsic-size: 32rem; 343 + } 344 + .diff-line { 345 + @apply flex; 346 + } 347 + .diff-side { 348 + @apply flex flex-1 min-w-0; 349 + } 350 + .diff-side + .diff-side { 351 + @apply border-l border-gray-200 dark:border-gray-700; 352 + } 353 + .diff-num { 354 + @apply contents text-gray-400 dark:text-gray-500; 355 + } 356 + .diff-num > span { 357 + @apply flex-none pr-2 text-right select-none; 358 + @apply bg-white dark:bg-gray-800; 359 + min-width: calc(6ch + 0.5rem); 360 + } 361 + .diff-indicator { 362 + @apply flex-none w-[2ch] px-1 text-center select-none; 363 + @apply border-l border-gray-200 dark:border-gray-700; 364 + } 365 + .diff-content { 366 + @apply flex-1 min-w-0 whitespace-pre-wrap pl-2 pr-3 relative; 367 + overflow-wrap: anywhere; 368 + } 369 + .diff-content div { 370 + @apply inline; 371 + } 372 + .diff-side.add, 373 + .diff-line.add { 374 + @apply bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400; 375 + } 376 + .diff-side.del, 377 + .diff-line.del { 378 + @apply bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400; 379 + } 380 + .diff-side.empty { 381 + @apply bg-gray-200/30 dark:bg-gray-700/30; 382 + } 383 + .diff-splitter { 384 + @apply bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center; 385 + } 386 + .diff[data-restrict-select="left"] .diff-side:last-child, 387 + .diff[data-restrict-select="right"] .diff-side:first-child { 388 + @apply select-none; 389 + } 390 + 338 391 .prose { 339 392 overflow-wrap: anywhere; 340 393 }
-325
patchutil/interdiff.go
··· 1 - package patchutil 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.org/core/appview/filetree" 9 - "tangled.org/core/types" 10 - ) 11 - 12 - type InterdiffResult struct { 13 - Files []*InterdiffFile 14 - } 15 - 16 - func (i *InterdiffResult) Stats() types.DiffStat { 17 - var ins, del int64 18 - for _, s := range i.ChangedFiles() { 19 - stat := s.Stats() 20 - ins += stat.Insertions 21 - del += stat.Deletions 22 - } 23 - return types.DiffStat{ 24 - Insertions: ins, 25 - Deletions: del, 26 - FilesChanged: len(i.Files), 27 - } 28 - } 29 - 30 - func (i *InterdiffResult) ChangedFiles() []types.DiffFileRenderer { 31 - drs := make([]types.DiffFileRenderer, len(i.Files)) 32 - for i, s := range i.Files { 33 - drs[i] = s 34 - } 35 - return drs 36 - } 37 - 38 - func (i *InterdiffResult) FileTree() *filetree.FileTreeNode { 39 - fs := make([]string, len(i.Files)) 40 - for i, s := range i.Files { 41 - fs[i] = s.Name 42 - } 43 - return filetree.FileTree(fs) 44 - } 45 - 46 - func (i *InterdiffResult) String() string { 47 - var b strings.Builder 48 - for _, f := range i.Files { 49 - b.WriteString(f.String()) 50 - b.WriteString("\n") 51 - } 52 - 53 - return b.String() 54 - } 55 - 56 - type InterdiffFile struct { 57 - *gitdiff.File 58 - Name string 59 - Status InterdiffFileStatus 60 - } 61 - 62 - func (s *InterdiffFile) Id() string { 63 - return s.Name 64 - } 65 - 66 - func (s *InterdiffFile) Split() types.SplitDiff { 67 - fragments := make([]types.SplitFragment, len(s.TextFragments)) 68 - 69 - for i, fragment := range s.TextFragments { 70 - leftLines, rightLines := types.SeparateLines(fragment) 71 - 72 - fragments[i] = types.SplitFragment{ 73 - Header: fragment.Header(), 74 - LeftLines: leftLines, 75 - RightLines: rightLines, 76 - } 77 - } 78 - 79 - return types.SplitDiff{ 80 - Name: s.Id(), 81 - TextFragments: fragments, 82 - } 83 - } 84 - 85 - func (s *InterdiffFile) CanRender() string { 86 - if s.Status.IsUnchanged() { 87 - return "This file has not been changed." 88 - } else if s.Status.IsRebased() { 89 - return "This patch was likely rebased, as context lines do not match." 90 - } else if s.Status.IsError() { 91 - return "Failed to calculate interdiff for this file." 92 - } else { 93 - return "" 94 - } 95 - } 96 - 97 - func (s *InterdiffFile) Names() types.DiffFileName { 98 - var n types.DiffFileName 99 - n.New = s.Name 100 - return n 101 - } 102 - 103 - func (s *InterdiffFile) Stats() types.DiffFileStat { 104 - var ins, del int64 105 - 106 - if s.File != nil { 107 - for _, f := range s.TextFragments { 108 - ins += f.LinesAdded 109 - del += f.LinesDeleted 110 - } 111 - } 112 - 113 - return types.DiffFileStat{ 114 - Insertions: ins, 115 - Deletions: del, 116 - } 117 - } 118 - 119 - func (s *InterdiffFile) String() string { 120 - var b strings.Builder 121 - b.WriteString(s.Status.String()) 122 - b.WriteString(" ") 123 - 124 - if s.File != nil { 125 - b.WriteString(bestName(s.File)) 126 - b.WriteString("\n") 127 - b.WriteString(s.File.String()) 128 - } 129 - 130 - return b.String() 131 - } 132 - 133 - type InterdiffFileStatus struct { 134 - StatusKind StatusKind 135 - Error error 136 - } 137 - 138 - func (s *InterdiffFileStatus) String() string { 139 - kind := s.StatusKind.String() 140 - if s.Error != nil { 141 - return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) 142 - } else { 143 - return kind 144 - } 145 - } 146 - 147 - func (s *InterdiffFileStatus) IsOk() bool { 148 - return s.StatusKind == StatusOk 149 - } 150 - 151 - func (s *InterdiffFileStatus) IsUnchanged() bool { 152 - return s.StatusKind == StatusUnchanged 153 - } 154 - 155 - func (s *InterdiffFileStatus) IsOnlyInOne() bool { 156 - return s.StatusKind == StatusOnlyInOne 157 - } 158 - 159 - func (s *InterdiffFileStatus) IsOnlyInTwo() bool { 160 - return s.StatusKind == StatusOnlyInTwo 161 - } 162 - 163 - func (s *InterdiffFileStatus) IsRebased() bool { 164 - return s.StatusKind == StatusRebased 165 - } 166 - 167 - func (s *InterdiffFileStatus) IsError() bool { 168 - return s.StatusKind == StatusError 169 - } 170 - 171 - type StatusKind int 172 - 173 - func (k StatusKind) String() string { 174 - switch k { 175 - case StatusOnlyInOne: 176 - return "only in one" 177 - case StatusOnlyInTwo: 178 - return "only in two" 179 - case StatusUnchanged: 180 - return "unchanged" 181 - case StatusRebased: 182 - return "rebased" 183 - case StatusError: 184 - return "error" 185 - default: 186 - return "changed" 187 - } 188 - } 189 - 190 - const ( 191 - StatusOk StatusKind = iota 192 - StatusOnlyInOne 193 - StatusOnlyInTwo 194 - StatusUnchanged 195 - StatusRebased 196 - StatusError 197 - ) 198 - 199 - func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { 200 - re1 := CreatePreImage(f1) 201 - re2 := CreatePreImage(f2) 202 - 203 - interdiffFile := InterdiffFile{ 204 - Name: bestName(f1), 205 - } 206 - 207 - merged, err := re1.Merge(&re2) 208 - if err != nil { 209 - interdiffFile.Status = InterdiffFileStatus{ 210 - StatusKind: StatusRebased, 211 - Error: err, 212 - } 213 - return &interdiffFile 214 - } 215 - 216 - rev1, err := merged.Apply(f1) 217 - if err != nil { 218 - interdiffFile.Status = InterdiffFileStatus{ 219 - StatusKind: StatusError, 220 - Error: err, 221 - } 222 - return &interdiffFile 223 - } 224 - 225 - rev2, err := merged.Apply(f2) 226 - if err != nil { 227 - interdiffFile.Status = InterdiffFileStatus{ 228 - StatusKind: StatusError, 229 - Error: err, 230 - } 231 - return &interdiffFile 232 - } 233 - 234 - diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) 235 - if err != nil { 236 - interdiffFile.Status = InterdiffFileStatus{ 237 - StatusKind: StatusError, 238 - Error: err, 239 - } 240 - return &interdiffFile 241 - } 242 - 243 - parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 244 - if err != nil { 245 - interdiffFile.Status = InterdiffFileStatus{ 246 - StatusKind: StatusError, 247 - Error: err, 248 - } 249 - return &interdiffFile 250 - } 251 - 252 - if len(parsed) != 1 { 253 - // files are identical? 254 - interdiffFile.Status = InterdiffFileStatus{ 255 - StatusKind: StatusUnchanged, 256 - } 257 - return &interdiffFile 258 - } 259 - 260 - if interdiffFile.Status.StatusKind == StatusOk { 261 - interdiffFile.File = parsed[0] 262 - } 263 - 264 - return &interdiffFile 265 - } 266 - 267 - func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { 268 - fileToIdx1 := make(map[string]int) 269 - fileToIdx2 := make(map[string]int) 270 - visited := make(map[string]struct{}) 271 - var result InterdiffResult 272 - 273 - for idx, f := range patch1 { 274 - fileToIdx1[bestName(f)] = idx 275 - } 276 - 277 - for idx, f := range patch2 { 278 - fileToIdx2[bestName(f)] = idx 279 - } 280 - 281 - for _, f1 := range patch1 { 282 - var interdiffFile *InterdiffFile 283 - 284 - fileName := bestName(f1) 285 - if idx, ok := fileToIdx2[fileName]; ok { 286 - f2 := patch2[idx] 287 - 288 - // we have f1 and f2, calculate interdiff 289 - interdiffFile = interdiffFiles(f1, f2) 290 - } else { 291 - // only in patch 1, this change would have to be "inverted" to disappear 292 - // from patch 2, so we reverseDiff(f1) 293 - reverseDiff(f1) 294 - 295 - interdiffFile = &InterdiffFile{ 296 - File: f1, 297 - Name: fileName, 298 - Status: InterdiffFileStatus{ 299 - StatusKind: StatusOnlyInOne, 300 - }, 301 - } 302 - } 303 - 304 - result.Files = append(result.Files, interdiffFile) 305 - visited[fileName] = struct{}{} 306 - } 307 - 308 - // for all files in patch2 that remain unvisited; we can just add them into the output 309 - for _, f2 := range patch2 { 310 - fileName := bestName(f2) 311 - if _, ok := visited[fileName]; ok { 312 - continue 313 - } 314 - 315 - result.Files = append(result.Files, &InterdiffFile{ 316 - File: f2, 317 - Name: fileName, 318 - Status: InterdiffFileStatus{ 319 - StatusKind: StatusOnlyInTwo, 320 - }, 321 - }) 322 - } 323 - 324 - return &result 325 - }
-9
patchutil/patchutil_test.go
··· 4 4 "errors" 5 5 "reflect" 6 6 "testing" 7 - 8 - "tangled.org/core/types" 9 7 ) 10 8 11 9 func TestIsPatchValid(t *testing.T) { ··· 406 404 }) 407 405 } 408 406 } 409 - 410 - func TestImplsInterfaces(t *testing.T) { 411 - id := &InterdiffResult{} 412 - _ = isDiffsRenderer(id) 413 - } 414 - 415 - func isDiffsRenderer[S types.DiffRenderer](S) bool { return true }
+5 -52
spindle/tapclient.go
··· 7 7 "errors" 8 8 "fmt" 9 9 "log/slog" 10 - "net/http" 11 - "net/url" 12 10 "sync" 13 11 "time" 14 12 15 13 "github.com/bluesky-social/indigo/atproto/syntax" 16 14 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 15 "tangled.org/core/api/tangled" 18 - avmodels "tangled.org/core/appview/models" 19 16 "tangled.org/core/eventconsumer" 20 17 "tangled.org/core/log" 21 18 "tangled.org/core/rbac" ··· 357 354 return nil 358 355 } 359 356 360 - latestSubmission, err := t.fetchLatestSubmission(ctx, evt.Did.String(), evt.Rkey.String(), &record) 361 - if err != nil { 362 - return err 357 + if len(record.Versions) == 0 { 358 + l.Warn("skipping PR without versions") 359 + return nil 363 360 } 364 - sourceSha := latestSubmission.SourceRev 361 + 362 + sourceSha := record.Versions[len(record.Versions)-1].Head 365 363 366 364 scheme := "https" 367 365 if t.spindle.cfg.Server.Dev { ··· 515 513 t.logger.Warn("expired buffered collaborator events without matching repo arrival", "count", expired, "ttl", pendingCollabTTL) 516 514 } 517 515 } 518 - 519 - func (t *Tap) fetchLatestSubmission(ctx context.Context, did, rkey string, record *tangled.RepoPull) (*avmodels.PullSubmission, error) { 520 - // resolve the PR owner's identity to fetch the blob from their PDS 521 - prOwnerIdent, err := t.spindle.res.ResolveIdent(ctx, did) 522 - if err != nil || prOwnerIdent.Handle.IsInvalidHandle() { 523 - return nil, fmt.Errorf("failed to resolve PR owner handle: %w", err) 524 - } 525 - 526 - if len(record.Rounds) == 0 { 527 - return nil, fmt.Errorf("failed to fetch latest submission, no rounds in record") 528 - } 529 - 530 - roundNumber := len(record.Rounds) - 1 531 - round := record.Rounds[roundNumber] 532 - 533 - // fetch the blob from the PR owner's PDS 534 - prOwnerPds := prOwnerIdent.PDSEndpoint() 535 - blobUrl, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", prOwnerPds)) 536 - if err != nil { 537 - return nil, fmt.Errorf("failed to construct blob URL: %w", err) 538 - } 539 - q := blobUrl.Query() 540 - q.Set("cid", round.PatchBlob.Ref.String()) 541 - q.Set("did", did) 542 - blobUrl.RawQuery = q.Encode() 543 - 544 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, blobUrl.String(), nil) 545 - if err != nil { 546 - return nil, fmt.Errorf("failed to create blob request: %w", err) 547 - } 548 - req.Header.Set("Content-Type", "application/json") 549 - 550 - blobResp, err := http.DefaultClient.Do(req) 551 - if err != nil { 552 - return nil, fmt.Errorf("failed to fetch blob: %w", err) 553 - } 554 - defer blobResp.Body.Close() 555 - 556 - latestSubmission, err := avmodels.PullSubmissionFromRecord(did, rkey, roundNumber, round, blobResp.Body) 557 - if err != nil { 558 - return nil, fmt.Errorf("failed to parse submission: %w", err) 559 - } 560 - 561 - return latestSubmission, nil 562 - }
+7
types/commit.go
··· 197 197 198 198 return coAuthors 199 199 } 200 + 201 + func (commit Commit) FirstParentHash() plumbing.Hash { 202 + if len(commit.ParentHashes) > 0 { 203 + return commit.ParentHashes[0] 204 + } 205 + return plumbing.ZeroHash 206 + }