Monorepo for Tangled
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>

Seongmin Lee (Jul 14, 2026, 5:59 AM +0900) 4f093418 dea62b67

+3898 -5934
+34
appview/db/db.go
··· 2433 2433 return err 2434 2434 }) 2435 2435 2436 + orm.RunMigration(conn, logger, "ref-based-pr", func(tx *sql.Tx) error { 2437 + _, err := tx.Exec(` 2438 + ALTER TABLE pulls ADD COLUMN cid TEXT; 2439 + 2440 + 2441 + CREATE TABLE pull_versions ( 2442 + pull_at TEXT NOT NULL, 2443 + id INTEGER NOT NULL, -- PR local version id 2444 + head TEXT NOT NULL, -- head commit ID 2445 + base TEXT NOT NULL, -- base commit ID (used on interdiff) 2446 + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 2447 + 2448 + UNIQUE(pull_at, id), 2449 + FOREIGN KEY (pull_at) REFERENCES pulls(at_uri) ON DELETE CASCADE 2450 + ); 2451 + 2452 + INSERT INTO pull_versions ( 2453 + pull_at, 2454 + id, 2455 + head, 2456 + base, 2457 + created 2458 + ) 2459 + SELECT 2460 + pull_at, 2461 + round_number, 2462 + '', 2463 + source_rev, 2464 + created 2465 + FROM pull_submissions; 2466 + `) 2467 + return err 2468 + }) 2469 + 2436 2470 return &DB{ 2437 2471 db, 2438 2472 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
+3 -3
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
+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 }
+198 -585
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(` 63 - insert or ignore into repo_pull_seqs (repo_did, next_pull_id) 64 - values (?, 1) 65 - `, pull.RepoDid) 66 - if err != nil { 67 - return err 68 - } 69 - 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: 77 - 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 - } 118 - } 119 - 120 - func createNewPull(tx *sql.Tx, pull *models.Pull) error { 121 - _, err := tx.Exec(` 21 + _, err := tx.ExecContext(ctx, ` 122 22 insert or ignore into repo_pull_seqs (repo_did, next_pull_id) 123 23 values (?, 1) 124 24 `, pull.RepoDid) ··· 126 26 return err 127 27 } 128 28 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 { 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 { 137 34 return err 138 35 } 139 36 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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`, 77 + pull.OwnerDid, 78 + pull.Rkey, 79 + pull.Cid, 168 80 pull.RepoDid, 169 - pull.OwnerDid, 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 248 - } 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 - } 150 + return nil, err 278 151 } 279 - 280 - if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 281 - return fmt.Errorf("put reference_links: %w", err) 152 + if len(pulls) == 0 { 153 + return nil, sql.ErrNoRows 282 154 } 283 - return nil 284 - } 285 - 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 155 + return pulls[0], nil 290 156 } 291 157 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 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 + &pull.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 239 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 - } 384 - } 385 - 386 - if dependentOn.Valid { 387 - x := syntax.ATURI(dependentOn.String) 388 - pull.DependentOn = &x 240 + pull.SourceBranch = &sourceBranch.String 389 241 } 390 242 391 243 pulls[pull.AtUri()] = &pull 392 244 } 393 - 394 - var pullAts []syntax.ATURI 395 - for _, p := range pulls { 396 - pullAts = append(pullAts, p.AtUri()) 245 + if err := rows.Err(); err != nil { 246 + return nil, fmt.Errorf("scanning rows: %w", err) 397 247 } 398 - submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts)) 248 + 249 + pullAts := slices.Collect(maps.Keys(pulls)) 250 + 251 + versionsMap, err := ListVersions(ctx, q, pullAts) 399 252 if err != nil { 400 - return nil, fmt.Errorf("failed to get submissions: %w", err) 253 + return nil, fmt.Errorf("querying versions: %w", err) 401 254 } 402 255 403 - for pullAt, submissions := range submissionsMap { 404 - if p, ok := pulls[pullAt]; ok { 405 - p.Submissions = submissions 256 + for pullAt, p := range pulls { 257 + if versions, ok := versionsMap[pullAt]; ok { 258 + p.Versions = versions 259 + } else { 260 + return nil, fmt.Errorf("find 0 versions for PR %s", pullAt) 406 261 } 407 262 } 408 263 409 - // collect allLabels for each issue 410 - allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts)) 411 - 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 264 + // collect reverse repos 265 + { 266 + repoDids := make([]string, 0, len(pulls)) 267 + for _, issue := range pulls { 268 + repoDids = append(repoDids, string(issue.RepoDid)) 417 269 } 418 - } 419 270 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) 271 + repos, err := GetRepos(q, orm.FilterIn("repo_did", repoDids)) 272 + if err != nil { 273 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 274 + } 275 + repoMap := make(map[syntax.DID]*models.Repo) 276 + for i := range repos { 277 + repoMap[syntax.DID(repos[i].RepoDid)] = &repos[i] 426 278 } 427 - } 428 279 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 - } 438 - 439 - for _, p := range pulls { 440 - if repo, ok := repoMap[p.RepoDid]; ok { 441 - p.Repo = repo 442 - } 443 - if p.PullSource != nil && p.PullSource.RepoDid != nil { 444 - if sourceRepo, ok := repoMap[*p.PullSource.RepoDid]; ok { 445 - p.PullSource.Repo = sourceRepo 280 + for pullAt, p := range pulls { 281 + if r, ok := repoMap[p.RepoDid]; ok { 282 + p.Repo = r 283 + } else { 284 + delete(pulls, pullAt) 446 285 } 447 286 } 448 287 } 449 288 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 289 + // collect allLabels for each PR 290 + { 291 + allLabels, err := GetLabels(q, orm.FilterIn("subject", pullAts)) 292 + if err != nil { 293 + return nil, fmt.Errorf("failed to query labels: %w", err) 294 + } 295 + for pullAt, labels := range allLabels { 296 + if pull, ok := pulls[pullAt]; ok { 297 + pull.Labels = labels 298 + } 457 299 } 458 300 } 459 301 460 - orderedByPullId := []*models.Pull{} 302 + orderedById := []*models.Pull{} 461 303 for _, p := range pulls { 462 - orderedByPullId = append(orderedByPullId, p) 304 + orderedById = append(orderedById, p) 463 305 } 464 - sort.Slice(orderedByPullId, func(i, j int) bool { 465 - return orderedByPullId[i].PullId > orderedByPullId[j].PullId 306 + sort.Slice(orderedById, func(i, j int) bool { 307 + return orderedById[i].PullId > orderedById[j].PullId 466 308 }) 467 309 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 310 + return orderedById, nil 485 311 } 486 312 487 313 // 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 - } 314 + func ListVersions(ctx context.Context, q Execer, pullAts []syntax.ATURI) (map[syntax.ATURI][]models.PullVersion, error) { 315 + filter := orm.FilterIn("pull_at", pullAts) 500 316 501 317 query := fmt.Sprintf(` 502 318 select 319 + pull_at, 503 320 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) 321 + head, 322 + base, 323 + created 324 + from pull_versions 325 + where %s 326 + order by id asc 327 + `, filter.Condition()) 519 328 520 - rows, err := e.Query(query, args...) 329 + rows, err := q.QueryContext(ctx, query, filter.Arg()...) 521 330 if err != nil { 522 - return nil, err 331 + return nil, fmt.Errorf("failed to query: %w", err) 523 332 } 524 333 defer rows.Close() 525 334 526 - pullMap := make(map[syntax.ATURI][]*models.PullSubmission) 335 + versionsMap := make(map[syntax.ATURI][]models.PullVersion) 527 336 528 337 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] 338 + var version models.PullVersion 339 + var pullAt syntax.ATURI 340 + var createdAt string 534 341 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, 342 + &pullAt, 343 + &version.ID, 344 + &version.Head, 345 + &version.Base, 346 + &createdAt, 545 347 ) 546 348 if err != nil { 547 - return nil, err 349 + return nil, fmt.Errorf("scanning row: %w", err) 548 350 } 549 351 550 - if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 551 - submission.Created = t 352 + createdTime, err := time.Parse(time.RFC3339, createdAt) 353 + if err != nil { 354 + return nil, fmt.Errorf("parsing created: %w", err) 552 355 } 553 - 554 - if submissionSourceRev.Valid { 555 - submission.SourceRev = submissionSourceRev.V 556 - } 557 - 558 - if submissionCombined.Valid { 559 - submission.Combined = submissionCombined.V 560 - } 356 + version.Created = createdTime 561 357 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) 358 + versionsMap[pullAt] = append(versionsMap[pullAt], version) 575 359 } 576 - 577 360 if err := rows.Err(); err != nil { 578 - return nil, err 361 + return nil, fmt.Errorf("scanning rows: %w", err) 579 362 } 580 363 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)) 364 + comments, err := GetComments(q, orm.FilterIn("subject_uri", pullAts)) 584 365 if err != nil { 585 366 return nil, fmt.Errorf("failed to get pull comments: %w", err) 586 367 } 587 368 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 - } 369 + if comment.PullRoundIdx == nil { 370 + continue 371 + } 372 + versionIdx := *comment.PullRoundIdx 373 + if versions, ok := versionsMap[syntax.ATURI(comment.Subject.Uri)]; ok { 374 + if versionIdx >= len(versions) { 375 + continue 595 376 } 377 + versions[versionIdx].Comments = append(versions[versionIdx].Comments, comment) 596 378 } 597 379 } 598 380 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 - } 381 + // TODO: reverse-map version.Comments 605 382 606 - return pullMap, nil 383 + return versionsMap, nil 607 384 } 608 385 609 386 // timeframe here is directly passed into the sql query filter, and any 610 387 // timeframe in the past should be negative; e.g.: "-3 months" 611 - func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 388 + func GetPullsByOwnerDid(e Execer, did syntax.DID, timeframe string) ([]models.Pull, error) { 612 389 var pulls []models.Pull 613 390 614 391 rows, err := e.Query(` ··· 683 460 } 684 461 685 462 // use with transaction 686 - func SetPullsState(e Execer, pullState models.PullState, filters ...orm.Filter) error { 463 + func setPullsState(e Execer, pullState models.PullState, filters ...orm.Filter) error { 687 464 var conditions []string 688 465 var args []any 689 466 ··· 707 484 } 708 485 709 486 func ClosePulls(e Execer, filters ...orm.Filter) error { 710 - return SetPullsState(e, models.PullClosed, filters...) 487 + return setPullsState(e, models.PullClosed, filters...) 711 488 } 712 489 713 490 func ReopenPulls(e Execer, filters ...orm.Filter) error { 714 - return SetPullsState(e, models.PullOpen, filters...) 491 + return setPullsState(e, models.PullOpen, filters...) 715 492 } 716 493 717 494 func MergePulls(e Execer, filters ...orm.Filter) error { 718 - return SetPullsState(e, models.PullMerged, filters...) 495 + return setPullsState(e, models.PullMerged, filters...) 719 496 } 720 497 721 498 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 499 + return setPullsState(e, models.PullAbandoned, filters...) 771 500 } 772 501 773 502 func GetPullCount(e Execer, repoDid string) (models.PullCount, error) { ··· 793 522 794 523 return count, nil 795 524 } 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 ··· 1450 1449 } 1451 1450 1452 1451 func (i *Ingester) ingestPull(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { 1453 - did := e.Did 1454 - rkey := e.Commit.RKey 1452 + did := syntax.DID(e.Did) 1453 + rkey := syntax.RecordKey(e.Commit.RKey) 1454 + cid := syntax.CID(e.Commit.CID) 1455 1455 1456 1456 var err error 1457 1457 ··· 1467 1467 return err 1468 1468 } 1469 1469 1470 - ownerId, err := i.IdResolver.ResolveIdent(ctx, did) 1471 - if err != nil { 1472 - l.Error("failed to resolve did", "err", err) 1473 - return err 1474 - } 1470 + versions, err := func() ([]models.PullVersion, error) { 1471 + if len(record.Versions) > 0 { 1472 + versions := make([]models.PullVersion, len(record.Versions)) 1473 + var err error 1474 + for i, v := range record.Versions { 1475 + versions[i], err = models.PullVersionFromRecord(i, v) 1476 + if err != nil { 1477 + return nil, fmt.Errorf("versions[%d]: %w", i, err) 1478 + } 1479 + } 1480 + return versions, nil 1481 + } 1475 1482 1476 - // go through and fetch all blobs in parallel 1477 - blobs := make([]io.Reader, len(record.Rounds)) 1483 + if len(record.Rounds) == 0 { 1484 + return nil, nil 1485 + } 1478 1486 1479 - g, gctx := errgroup.WithContext(ctx) 1487 + ownerId, err := i.IdResolver.Directory().LookupDID(ctx, did) 1488 + if err != nil { 1489 + return nil, fmt.Errorf("failed to resolve did: %w", err) 1490 + } 1480 1491 1481 - for idx, b := range record.Rounds { 1482 - g.Go(func() error { 1483 - // for some reason, a blob is empty 1484 - if b.PatchBlob == nil { 1485 - return fmt.Errorf("missing patchBlob in round %d", idx) 1486 - } 1492 + // go through and fetch all blobs in parallel 1493 + versions := make([]models.PullVersion, len(record.Rounds)) 1494 + var mu sync.Mutex 1487 1495 1488 - ownerPds := ownerId.PDSEndpoint() 1489 - url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 1490 - q := url.Query() 1491 - q.Set("cid", b.PatchBlob.Ref.String()) 1492 - q.Set("did", did) 1493 - url.RawQuery = q.Encode() 1496 + g, gctx := errgroup.WithContext(ctx) 1494 1497 1495 - req, err := http.NewRequestWithContext(gctx, http.MethodGet, url.String(), nil) 1496 - if err != nil { 1497 - l.Error("failed to create request") 1498 - return err 1499 - } 1500 - req.Header.Set("Content-Type", "application/json") 1498 + for idx, b := range record.Rounds { 1499 + g.Go(func() error { 1500 + // for some reason, a blob is empty 1501 + if b.PatchBlob == nil { 1502 + return fmt.Errorf("missing patchBlob in round %d", idx) 1503 + } 1501 1504 1502 - resp, err := http.DefaultClient.Do(req) 1503 - if err != nil { 1504 - l.Error("failed to make request") 1505 - return err 1506 - } 1507 - defer resp.Body.Close() 1505 + ownerPds := ownerId.PDSEndpoint() 1506 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 1507 + q := url.Query() 1508 + q.Set("cid", b.PatchBlob.Ref.String()) 1509 + q.Set("did", did.String()) 1510 + url.RawQuery = q.Encode() 1508 1511 1509 - var buf bytes.Buffer 1510 - if _, err := io.Copy(&buf, io.LimitReader(resp.Body, 16<<20)); err != nil { 1511 - return fmt.Errorf("failed to read blob in round %d: %w", idx, err) 1512 - } 1513 - blobs[idx] = &buf 1512 + req, err := http.NewRequestWithContext(gctx, http.MethodGet, url.String(), nil) 1513 + if err != nil { 1514 + return fmt.Errorf("versions[%d]: failed to create request: %w", idx, err) 1515 + } 1516 + req.Header.Set("Content-Type", "application/json") 1517 + 1518 + resp, err := http.DefaultClient.Do(req) 1519 + if err != nil { 1520 + return fmt.Errorf("versions[%d]: failed to make request: %w", idx, err) 1521 + } 1522 + defer resp.Body.Close() 1523 + 1524 + version, err := models.PullVersionFromLegacy(idx, b, resp.Body) 1525 + if err != nil { 1526 + return fmt.Errorf("versions[%d]: %w", idx, err) 1527 + } 1528 + 1529 + mu.Lock() 1530 + versions[idx] = version 1531 + mu.Unlock() 1514 1532 1515 - return nil 1516 - }) 1517 - } 1533 + return nil 1534 + }) 1535 + } 1536 + 1537 + if err := g.Wait(); err != nil { 1538 + return nil, err 1539 + } 1518 1540 1519 - if err := g.Wait(); err != nil { 1520 - return err 1541 + return versions, nil 1542 + }() 1543 + if err != nil { 1544 + return fmt.Errorf("failed pares pull versions: %w", err) 1521 1545 } 1522 1546 1523 - pull, err := models.PullFromRecord(did, rkey, record, blobs) 1547 + pull, err := models.PullFromRecord(did, rkey, cid, record, versions) 1524 1548 if err != nil { 1525 1549 return fmt.Errorf("failed to parse pull from record: %w", err) 1526 1550 } 1527 1551 if err := pull.Validate(); err != nil { 1528 1552 return fmt.Errorf("failed to validate pull: %w", err) 1529 1553 } 1530 - if pull.DependentOn != nil { 1531 - if err := func() error { 1532 - dependentPull, err := db.GetPull( 1533 - i.Db, 1534 - orm.FilterEq("dependent_on", pull.DependentOn.String()), 1535 - ) 1536 - if errors.Is(err, sql.ErrNoRows) { 1537 - return nil 1538 - } 1539 - if err != nil { 1540 - return fmt.Errorf("failed to fetch pulls with same dependency: %w", err) 1541 - } 1542 - if dependentPull.AtUri() == pull.AtUri() { 1543 - return nil 1544 - } 1545 - return fmt.Errorf("another pull already depends on %s, which would form a DAG, this is presently disallowed", pull.DependentOn.String()) 1546 - }(); err != nil { 1547 - return fmt.Errorf("failed to validate pull stack: %w", err) 1548 - } 1554 + 1555 + var references []syntax.ATURI 1556 + if pull.Body != "" { 1557 + _, references = i.MentionsResolver.Resolve(ctx, pull.Body) 1549 1558 } 1550 1559 1551 1560 tx, err := i.Db.BeginTx(ctx, nil) ··· 1555 1564 } 1556 1565 defer tx.Rollback() 1557 1566 1558 - err = db.PutPull(tx, pull) 1567 + err = db.PutPull(ctx, tx, pull, references) 1559 1568 if err != nil { 1560 1569 l.Error("failed to create pull", "err", err) 1561 1570 return err ··· 1627 1636 type stateIngestSpec struct { 1628 1637 subjectNSID string 1629 1638 parse func(did, rkey string, raw json.RawMessage) (models.StateRecord, error) 1630 - findSubject func(e db.Execer, subject syntax.ATURI) (repo *models.Repo, authorDid string, found bool, err error) 1639 + findSubject func(ctx context.Context, e db.Execer, subject syntax.ATURI) (repo *models.Repo, authorDid string, found bool, err error) 1631 1640 put func(tx *sql.Tx, rec models.StateRecord) (syntax.ATURI, error) 1632 1641 resolve func(tx *sql.Tx, subject syntax.ATURI) error 1633 1642 recompute func(tx *sql.Tx, subject syntax.ATURI) error ··· 1643 1652 } 1644 1653 return models.IssueStateFromRecord(did, rkey, record) 1645 1654 }, 1646 - findSubject: func(e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1655 + findSubject: func(ctx context.Context, e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1647 1656 issues, err := db.GetIssues(e, orm.FilterEq("at_uri", subject)) 1648 1657 if err != nil { 1649 1658 return nil, "", false, err ··· 1668 1677 } 1669 1678 return models.PullStatusFromRecord(did, rkey, record) 1670 1679 }, 1671 - findSubject: func(e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1672 - pulls, err := db.GetPulls(e, orm.FilterEq("at_uri", subject)) 1680 + findSubject: func(ctx context.Context, e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1681 + pull, err := db.GetPull(ctx, e, orm.FilterEq("at_uri", subject)) 1673 1682 if err != nil { 1674 1683 return nil, "", false, err 1675 1684 } 1676 - if len(pulls) != 1 || pulls[0].Repo == nil { 1685 + if pull.Repo == nil { 1677 1686 return nil, "", false, nil 1678 1687 } 1679 - return pulls[0].Repo, pulls[0].OwnerDid, true, nil 1688 + return pull.Repo, pull.OwnerDid.String(), true, nil 1680 1689 }, 1681 1690 put: db.PutPullStatus, 1682 1691 resolve: db.ResolvePullStatus, ··· 1710 1719 return fmt.Errorf("state subject is not %s: %s", spec.subjectNSID, rec.Subject) 1711 1720 } 1712 1721 1713 - repo, authorDid, found, err := spec.findSubject(i.Db, rec.Subject) 1722 + repo, authorDid, found, err := spec.findSubject(ctx, i.Db, rec.Subject) 1714 1723 if err != nil { 1715 1724 return fmt.Errorf("failed to look up state subject: %w", err) 1716 1725 } ··· 2124 2133 return nil 2125 2134 } 2126 2135 2127 - func (i *Ingester) findLabelSubjectRepo(subject syntax.ATURI) (*models.Repo, bool, error) { 2136 + func (i *Ingester) findLabelSubjectRepo(ctx context.Context, subject syntax.ATURI) (*models.Repo, bool, error) { 2128 2137 var spec stateIngestSpec 2129 2138 switch subject.Collection() { 2130 2139 case tangled.RepoIssueNSID: ··· 2134 2143 default: 2135 2144 return nil, false, fmt.Errorf("unsupported label subject: %s", subject.Collection()) 2136 2145 } 2137 - repo, _, found, err := spec.findSubject(i.Db, subject) 2146 + repo, _, found, err := spec.findSubject(ctx, i.Db, subject) 2138 2147 return repo, found, err 2139 2148 } 2140 2149 ··· 2149 2158 return i.parkStateRecord(ctx, did, rkey, tangled.LabelOpNSID, subject, raw, l) 2150 2159 } 2151 2160 2152 - repo, found, err := i.findLabelSubjectRepo(subject) 2161 + repo, found, err := i.findLabelSubjectRepo(ctx, subject) 2153 2162 if err != nil { 2154 2163 return err 2155 2164 }
+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
··· 362 362 } 363 363 } 364 364 365 - // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 366 - func (mw Middleware) ResolvePull() middlewareFunc { 367 - return func(next http.Handler) http.Handler { 368 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 369 - l := mw.logger.With("middleware", "ResolvePull") 370 - f, err := mw.repoResolver.Resolve(r) 371 - if err != nil { 372 - l.Error("failed to fully resolve repo", "err", err) 373 - w.WriteHeader(http.StatusNotFound) 374 - mw.pages.ErrorKnot404(w) 375 - return 376 - } 377 - 378 - prId := chi.URLParam(r, "pull") 379 - prIdInt, err := strconv.Atoi(prId) 380 - if err != nil { 381 - l.Error("failed to parse pr id", "err", err) 382 - mw.pages.Error404(w) 383 - return 384 - } 385 - 386 - pr, err := db.GetPull(mw.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prIdInt)) 387 - if err != nil { 388 - l.Error("failed to get pull and comments", "err", err) 389 - mw.pages.Error404(w) 390 - return 391 - } 392 - 393 - ctx := context.WithValue(r.Context(), "pull", pr) 394 - 395 - stack, err := db.GetStack(mw.db, pr.AtUri()) 396 - if err != nil { 397 - l.Error("failed to get stack", "err", err) 398 - mw.pages.Error404(w) 399 - return 400 - } 401 - 402 - ctx = context.WithValue(ctx, "stack", stack) 403 - 404 - next.ServeHTTP(w, r.WithContext(ctx)) 405 - }) 406 - } 407 - } 408 - 409 365 // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 410 366 func (mw Middleware) ResolveIssue(next http.Handler) http.Handler { 411 367 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 {
+166 -369
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{} 382 - 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)) 331 + participants := make(map[syntax.DID]struct{}) 391 332 392 - for _, s := range p.Submissions { 393 - for _, sp := range s.Participants() { 394 - addParticipant(syntax.DID(sp)) 333 + for _, v := range p.Versions { 334 + for _, sp := range v.Participants() { 335 + participants[sp] = struct{}{} 395 336 } 396 337 } 397 338 398 - return participants 339 + return slices.Collect(maps.Keys(participants)) 399 340 } 400 341 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()) 342 + func (s *PullVersion) Participants() []syntax.DID { 343 + participants := make(map[syntax.DID]struct{}) 442 344 443 345 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 346 + participants[c.Did] = struct{}{} 461 347 } 462 348 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 349 + return slices.Collect(maps.Keys(participants)) 564 350 } 565 351 566 352 func extractGzip(blob io.Reader) (string, error) { ··· 581 367 582 368 return b.String(), nil 583 369 } 370 + 371 + func IsHash(s string) bool { 372 + switch len(s) { 373 + case 40: // SHA1 374 + case 64: // SHA2 375 + default: 376 + return false 377 + } 378 + _, err := hex.DecodeString(s) 379 + return err == nil 380 + }
+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),
+1
appview/oauth/scopes.go
··· 45 45 "rpc:sh.tangled.repo.listSecrets?aud=*", 46 46 "rpc:sh.tangled.repo.merge?aud=*", 47 47 "rpc:sh.tangled.repo.mergeCheck?aud=*", 48 + "rpc:sh.tangled.repo.mergePullRequest?aud=*", 48 49 "rpc:sh.tangled.repo.removeCollaborator?aud=*", 49 50 "rpc:sh.tangled.repo.removeSecret?aud=*", 50 51 "rpc:sh.tangled.repo.setDefaultBranch?aud=*",
-325
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 - {"pullNewComment", []string{"repo/pulls/fragments/pullNewComment"}}, 35 - {"pullComment", []string{"fragments/comment/pullComment"}}, 36 - } 37 - 38 - for _, c := range cases { 39 - t.Run(c.name, func(t *testing.T) { 40 - if _, err := p.rawParse(c.stack...); err != nil { 41 - t.Fatalf("parse %v: %v", c.stack, err) 42 - } 43 - }) 44 - } 45 - } 46 - 47 - func TestPullComposeHostRender(t *testing.T) { 48 - cfg := &config.Config{} 49 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 50 - 51 - base := RepoNewPullParams{ 52 - RepoInfo: repoinfo.RepoInfo{ 53 - OwnerDid: "did:plc:test", 54 - Name: "test-repo", 55 - }, 56 - } 57 - 58 - for _, source := range []Source{"", SourceBranch, SourceFork, SourcePatch} { 59 - for _, stacked := range []bool{false, true} { 60 - if source == SourcePatch && stacked { 61 - continue 62 - } 63 - params := base 64 - params.Source = source 65 - params.IsStacked = stacked 66 - name := string(source) 67 - if name == "" { 68 - name = "default" 69 - } 70 - if stacked { 71 - name += "-stacked" 72 - } 73 - t.Run(name, func(t *testing.T) { 74 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 75 - t.Fatalf("render source=%q stacked=%v: %v", source, stacked, err) 76 - } 77 - }) 78 - } 79 - } 80 - } 81 - 82 - func TestPullComposeHostRenderWithData(t *testing.T) { 83 - cfg := &config.Config{} 84 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 85 - 86 - sampleBranches := []types.Branch{ 87 - {Reference: types.Reference{Name: "feature"}}, 88 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 89 - } 90 - 91 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 92 - From: Test <test@best.fest> 93 - Date: Tue, 1 Jan 2020 00:00:00 +0000 94 - Subject: [PATCH] example commit 95 - 96 - --- 97 - a.txt | 1 + 98 - 1 file changed, 1 insertion(+) 99 - 100 - diff --git a/a.txt b/a.txt 101 - index 0000000..1111111 100644 102 - --- a/a.txt 103 - +++ b/a.txt 104 - @@ -0,0 +1 @@ 105 - +hello 106 - ` 107 - patches, err := patchutil.ExtractPatches(formatPatch) 108 - if err != nil { 109 - t.Fatalf("extract patches: %v", err) 110 - } 111 - comparison := &types.RepoFormatPatchResponse{ 112 - FormatPatchRaw: formatPatch, 113 - FormatPatch: patches, 114 - } 115 - diff := patchutil.AsNiceDiff(formatPatch, "main") 116 - 117 - params := RepoNewPullParams{ 118 - RepoInfo: repoinfo.RepoInfo{ 119 - OwnerDid: "did:plc:test", 120 - Name: "test-repo", 121 - }, 122 - Branches: sampleBranches, 123 - SourceBranches: []types.Branch{sampleBranches[0]}, 124 - ForkBranches: []types.Branch{sampleBranches[0]}, 125 - Source: SourceBranch, 126 - SourceBranch: "feature", 127 - TargetBranch: "main", 128 - Comparison: comparison, 129 - Diff: &diff, 130 - } 131 - 132 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 133 - t.Fatalf("render with data: %v", err) 134 - } 135 - 136 - params.IsStacked = true 137 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 138 - t.Fatalf("render stacked: %v", err) 139 - } 140 - 141 - params.PrefillError = "branch not found" 142 - params.Comparison = nil 143 - params.Diff = nil 144 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 145 - t.Fatalf("render with prefill error: %v", err) 146 - } 147 - 148 - bugDef := &models.LabelDefinition{ 149 - Did: "did:plc:test", 150 - Rkey: "bug", 151 - Name: "bug", 152 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 153 - Scope: []string{"sh.tangled.repo.pull"}, 154 - } 155 - priorityDef := &models.LabelDefinition{ 156 - Did: "did:plc:test", 157 - Rkey: "priority", 158 - Name: "priority", 159 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 160 - Scope: []string{"sh.tangled.repo.pull"}, 161 - } 162 - assigneeDef := &models.LabelDefinition{ 163 - Did: "did:plc:test", 164 - Rkey: "assignee", 165 - Name: "assignee", 166 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Format: models.ValueTypeFormatDid}, 167 - Scope: []string{"sh.tangled.repo.pull"}, 168 - Multiple: true, 169 - } 170 - labelDefs := map[string]*models.LabelDefinition{ 171 - bugDef.AtUri().String(): bugDef, 172 - priorityDef.AtUri().String(): priorityDef, 173 - assigneeDef.AtUri().String(): assigneeDef, 174 - } 175 - 176 - pushRepoInfo := repoinfo.RepoInfo{ 177 - OwnerDid: "did:plc:test", 178 - Name: "test-repo", 179 - Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 180 - } 181 - params = RepoNewPullParams{ 182 - RepoInfo: pushRepoInfo, 183 - Branches: sampleBranches, 184 - SourceBranches: []types.Branch{sampleBranches[0]}, 185 - Source: SourceBranch, 186 - SourceBranch: "feature", 187 - TargetBranch: "main", 188 - Comparison: comparison, 189 - Diff: &diff, 190 - LabelDefs: labelDefs, 191 - LabelState: models.NewLabelState(), 192 - } 193 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 194 - t.Fatalf("render with labels: %v", err) 195 - } 196 - 197 - params.IsStacked = true 198 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 199 - t.Fatalf("render stacked with labels: %v", err) 200 - } 201 - 202 - params.StackedDiffs = []StackedDiff{{ 203 - Diff: &diff, 204 - Opts: types.DiffOpts{Split: true, RefreshUrl: "/r", Target: "#stack-diff-x", Field: "stackSplit[x]"}, 205 - }} 206 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 207 - t.Fatalf("render stacked with per-commit diffs: %v", err) 208 - } 209 - } 210 - 211 - func TestPullComposeLabelStateRoundTrip(t *testing.T) { 212 - cfg := &config.Config{} 213 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 214 - 215 - sampleBranches := []types.Branch{ 216 - {Reference: types.Reference{Name: "feature"}}, 217 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 218 - } 219 - 220 - bugDef := &models.LabelDefinition{ 221 - Did: "did:plc:test", Rkey: "bug", Name: "bug", 222 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 223 - Scope: []string{"sh.tangled.repo.pull"}, 224 - } 225 - priorityDef := &models.LabelDefinition{ 226 - Did: "did:plc:test", Rkey: "priority", Name: "priority", 227 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 228 - Scope: []string{"sh.tangled.repo.pull"}, 229 - } 230 - bugKey := bugDef.AtUri().String() 231 - priorityKey := priorityDef.AtUri().String() 232 - labelDefs := map[string]*models.LabelDefinition{ 233 - bugKey: bugDef, 234 - priorityKey: priorityDef, 235 - } 236 - 237 - state := models.NewLabelState() 238 - actx := &models.LabelApplicationCtx{Defs: labelDefs} 239 - for _, op := range []models.LabelOp{ 240 - {OperandKey: bugKey, OperandValue: "null", Operation: models.LabelOperationAdd}, 241 - {OperandKey: priorityKey, OperandValue: "high", Operation: models.LabelOperationAdd}, 242 - } { 243 - if err := actx.ApplyLabelOp(state, op); err != nil { 244 - t.Fatalf("seed state: %v", err) 245 - } 246 - } 247 - 248 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 249 - From: Test <test@best.fest> 250 - Date: Tue, 1 Jan 2020 00:00:00 +0000 251 - Subject: [PATCH] example commit 252 - 253 - --- 254 - a.txt | 1 + 255 - 1 file changed, 1 insertion(+) 256 - 257 - diff --git a/a.txt b/a.txt 258 - index 0000000..1111111 100644 259 - --- a/a.txt 260 - +++ b/a.txt 261 - @@ -0,0 +1 @@ 262 - +hello 263 - ` 264 - patches, err := patchutil.ExtractPatches(formatPatch) 265 - if err != nil { 266 - t.Fatalf("extract patches: %v", err) 267 - } 268 - comparison := &types.RepoFormatPatchResponse{ 269 - FormatPatchRaw: formatPatch, 270 - FormatPatch: patches, 271 - } 272 - 273 - params := RepoNewPullParams{ 274 - RepoInfo: repoinfo.RepoInfo{ 275 - OwnerDid: "did:plc:test", 276 - Name: "test-repo", 277 - Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 278 - }, 279 - Branches: sampleBranches, 280 - SourceBranches: []types.Branch{sampleBranches[0]}, 281 - Source: SourceBranch, 282 - SourceBranch: "feature", 283 - TargetBranch: "main", 284 - Comparison: comparison, 285 - LabelDefs: labelDefs, 286 - LabelState: state, 287 - } 288 - 289 - var buf bytes.Buffer 290 - if err := p.PullComposeHostFragment(&buf, params); err != nil { 291 - t.Fatalf("render: %v", err) 292 - } 293 - out := buf.String() 294 - for _, want := range []string{ 295 - `value="null" checked`, 296 - `value="high" checked`, 297 - } { 298 - if !strings.Contains(out, want) { 299 - t.Errorf("missing pre-selection %q", want) 300 - } 301 - } 302 - } 303 - 304 - func TestParseSource(t *testing.T) { 305 - cases := []struct { 306 - in string 307 - want Source 308 - wantOk bool 309 - }{ 310 - {"branch", SourceBranch, true}, 311 - {"BRANCH", SourceBranch, true}, 312 - {"fork", SourceFork, true}, 313 - {"patch", SourcePatch, true}, 314 - {"", "", false}, 315 - {"method", "", false}, 316 - {"strategy", "", false}, 317 - {"unknown", "", false}, 318 - } 319 - for _, c := range cases { 320 - got, ok := ParseSource(c.in) 321 - if got != c.want || ok != c.wantOk { 322 - t.Errorf("ParseSource(%q) = %q, %v; want %q, %v", c.in, got, ok, c.want, c.wantOk) 323 - } 324 - } 325 - }
+1 -1
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 }
+236 -79
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" ··· 1345 1346 1346 1347 type RepoNewPullParams struct { 1347 1348 BaseParams 1348 - RepoInfo repoinfo.RepoInfo 1349 - Branches []types.Branch 1350 - SourceBranches []types.Branch 1351 - ForkBranches []types.Branch 1352 - Forks []models.Repo 1353 - Source Source 1354 - SourceBranch string 1355 - TargetBranch string 1356 - Fork string 1357 - Patch string 1358 - Title string 1359 - Body string 1360 - TitleDirty bool 1361 - BodyDirty bool 1362 - IsStacked bool 1363 - Comparison *types.RepoFormatPatchResponse 1364 - Diff *types.NiceDiff 1365 - DiffOpts types.DiffOpts 1366 - StackedDiffs []StackedDiff 1367 - MergeCheck *types.MergeCheckResponse 1368 - StackTitles map[string]string 1369 - StackBodies map[string]string 1370 - PrefillError string 1371 - Active string 1372 - LabelDefs map[string]*models.LabelDefinition 1373 - LabelState models.LabelState 1374 - StackLabelStates map[string]models.LabelState 1349 + RepoInfo repoinfo.RepoInfo 1350 + Active string 1351 + PrefillError string 1352 + 1353 + // step 1. choose source 1354 + // TODO: replace to RepoNewPull_StepSourceParams 1355 + Branches []types.Branch 1356 + SourceBranches []types.Branch 1357 + ForkBranches []types.Branch 1358 + Forks []models.Repo 1359 + // selected values 1360 + Source Source // source kind 1361 + TargetBranch string 1362 + Fork string // fork repo DID 1363 + SourceBranch string 1364 + Patch string 1365 + 1366 + // step 2. review changes 1367 + StepReviewParams *RepoNewPull_StepReviewParams // optional step 2 params 1368 + 1369 + // step 3. fill details 1370 + // TODO: replace to RepoNewPull_StepDetailsParams 1371 + Title string 1372 + Body string 1373 + TitleDirty bool // flag to avoid overwriting users input 1374 + BodyDirty bool 1375 + MergeCheck MergeCheckParams 1376 + LabelDefs map[string]*models.LabelDefinition 1377 + LabelState models.LabelState 1378 + } 1379 + 1380 + func (p RepoNewPullParams) SourceRepo() string { 1381 + if p.Fork != "" { 1382 + return p.Fork 1383 + } 1384 + return p.RepoInfo.RepoDid 1385 + } 1386 + 1387 + type RepoNewPull_StepSourceParams struct { 1388 + Branches []types.Branch 1389 + SourceBranches []types.Branch 1390 + ForkBranches []types.Branch 1391 + Forks []models.Repo 1392 + ErrorMsg string 1393 + // selected values 1394 + Source Source // source kind 1395 + TargetBranch string 1396 + Fork string // fork repo DID 1397 + SourceBranch string 1398 + Patch string 1399 + } 1400 + 1401 + type RepoNewPull_StepReviewParams struct { 1402 + Commits []types.Commit 1403 + } 1404 + 1405 + type RepoNewPull_StepDetailsParams struct { 1406 + Title string 1407 + Body string 1408 + TitleDirty bool 1409 + BodyDirty bool 1410 + } 1411 + 1412 + type MergeCheckParams struct { 1413 + IsConflicted bool 1414 + Conflicts []*tangled.GitMergeCheck_ConflictInfo 1415 + Error string 1375 1416 } 1376 1417 1377 1418 func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { ··· 1395 1436 FilterState string 1396 1437 FilterQuery string 1397 1438 BaseFilterQuery string 1398 - Stacks []models.Stack 1399 1439 Pipelines map[string]types.Pipeline 1400 1440 LabelDefs map[string]*models.LabelDefinition 1401 1441 Page pagination.Page ··· 1426 1466 return r == Unknown 1427 1467 } 1428 1468 1429 - type RepoSinglePullParams struct { 1430 - BaseParams 1431 - RepoInfo repoinfo.RepoInfo 1432 - Active string 1433 - Pull *models.Pull 1434 - Stack models.Stack 1435 - Backlinks []models.RichReferenceLink 1436 - BranchDeleteStatus *models.BranchDeleteStatus 1437 - MergeCheck types.MergeCheckResponse 1438 - ResubmitCheck ResubmitResult 1439 - Pipelines map[string]types.Pipeline 1440 - Diff types.DiffRenderer 1441 - DiffOpts types.DiffOpts 1442 - ActiveRound int 1443 - IsInterdiff bool 1444 - 1445 - // WorkflowsChanged and ChangedWorkflowFiles describe whether the latest 1446 - // round's patch touches .tangled/workflows/, for warning maintainers 1447 - // before they manually trigger CI on a fork-based pull request. 1448 - WorkflowsChanged bool 1449 - ChangedWorkflowFiles []string 1450 - 1451 - Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1452 - UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1453 - 1454 - LabelDefs map[string]*models.LabelDefinition 1455 - VouchRelationships map[syntax.DID]*models.VouchRelationship 1456 - VouchSkips map[syntax.DID]bool 1469 + type BranchDeleteStatus struct { 1470 + Repo *models.Repo 1471 + Branch string 1457 1472 } 1458 1473 1459 1474 type PullPageBaseParams struct { 1460 1475 BaseParams 1461 - Pull *models.Pull 1476 + RepoInfo repoinfo.RepoInfo 1477 + Pull *models.Pull 1462 1478 1463 1479 Backlinks []models.RichReferenceLink 1464 - Comments []models.Comment 1465 1480 Commits []types.Commit // all commits between <target>..<pr/head> 1481 + Pipelines map[string]types.Pipeline 1482 + 1483 + MergeCheck MergeCheckParams 1484 + ResubmitCheck ResubmitResult 1485 + BranchDeleteStatus *BranchDeleteStatus 1466 1486 1467 1487 LabelDefs map[string]*models.LabelDefinition 1468 1488 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData ··· 1476 1496 // /pulls/123/2/1a2b3c..d4e5f6 1477 1497 type PullDiffParams struct { 1478 1498 PullPageBaseParams 1479 - Version int 1480 - BaseCommitId string 1481 - HeadCommitId string 1499 + VersionId int 1500 + 1501 + DiffParams DiffParams_Diff 1482 1502 1483 1503 ErrorMsg string 1484 1504 } 1485 1505 1506 + func (p PullDiffParams) ActiveVersionId() int { 1507 + return p.VersionId 1508 + } 1509 + 1510 + func (p PullDiffParams) ActiveCommitId() string { 1511 + return p.DiffParams.Head 1512 + } 1513 + 1514 + func (p PullDiffParams) IsInterdiff() bool { 1515 + return false 1516 + } 1517 + 1486 1518 // /pulls/123/1..2/abcdef 1487 1519 type PullInterdiffParams struct { 1488 1520 PullPageBaseParams ··· 1490 1522 Version2 int 1491 1523 ChangeId string // optional change-id filter 1492 1524 1525 + DiffParams DiffParams 1526 + ActiveCommitId string 1527 + 1493 1528 ErrorMsg string 1494 1529 } 1495 1530 1496 - func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { 1497 - panic("unimplemented") 1531 + func (p PullInterdiffParams) ActiveVersionId() int { 1532 + return p.Version2 1533 + } 1534 + 1535 + func (p PullInterdiffParams) IsInterdiff() bool { 1536 + return true 1537 + } 1538 + 1539 + type DiffParams struct { 1540 + Diff *DiffParams_Diff 1541 + Interdiff *DiffParams_Interdiff 1542 + } 1543 + 1544 + type DiffParams_Diff struct { 1545 + Base string 1546 + Head string 1547 + } 1548 + 1549 + type DiffParams_Interdiff struct { 1550 + From DiffParams_Diff 1551 + To DiffParams_Diff 1552 + } 1553 + 1554 + // DiffLine is one row of a unified (inline) diff. Old/New are 1-based line numbers into the 1555 + // base/head blob, or 0 when that side has no line here. Content is pre-rendered, safe HTML. 1556 + type DiffLine struct { 1557 + Op string // " " context, "-" removed, "+" added 1558 + Old int 1559 + New int 1560 + Content template.HTML 1561 + } 1562 + 1563 + // DiffCell is one side of a side-by-side row. Kind is "ctx", "del", "add", or "empty" (a 1564 + // blank padding cell). Num is the 1-based line number, or 0 when empty. 1565 + type DiffCell struct { 1566 + Kind string 1567 + Num int 1568 + Content template.HTML 1569 + } 1570 + 1571 + // DiffRow is one side-by-side row: the left (base) and right (head) cells. 1572 + type DiffRow struct { 1573 + Left DiffCell 1574 + Right DiffCell 1575 + } 1576 + 1577 + // DiffHunk holds a hunk's rows; exactly one of Lines (unified) / Rows (split) is populated, 1578 + // depending on PullDiffFragmentParams.Split. 1579 + type DiffHunk struct { 1580 + Lines []DiffLine 1581 + Rows []DiffRow 1582 + } 1583 + 1584 + func (h DiffHunk) AtFileStart() bool { 1585 + if len(h.Rows) > 0 { 1586 + r := h.Rows[0] 1587 + return r.Left.Num == 1 || r.Right.Num == 1 1588 + } 1589 + if len(h.Lines) > 0 { 1590 + l := h.Lines[0] 1591 + return l.Old == 1 || l.New == 1 1592 + } 1593 + return false 1594 + } 1595 + 1596 + // DiffFile is one changed file. Note is set (and Hunks empty) for binary/submodule files. 1597 + type DiffFile struct { 1598 + Path string 1599 + Note string 1600 + Hunks []DiffHunk 1601 + } 1602 + 1603 + type PullDiffFragmentParams struct { 1604 + Repo syntax.DID 1605 + DiffBase string 1606 + DiffHead string 1607 + DiffUrl string 1608 + Unified bool 1609 + Files []DiffFile 1610 + 1611 + ErrorMsg string 1498 1612 } 1499 1613 1500 - func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error { 1501 - panic("unimplemented") 1614 + func (f *DiffFile) Id() string { 1615 + return f.Path 1502 1616 } 1503 1617 1504 - func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1505 - params.Active = "pulls" 1506 - return p.executeRepo("repo/pulls/pull", w, params) 1618 + func (f *DiffFile) Stats() types.DiffFileStat { 1619 + var ins, del int64 1620 + for _, hunk := range f.Hunks { 1621 + for _, line := range hunk.Lines { 1622 + switch line.Op { 1623 + case "+": 1624 + ins++ 1625 + case "-": 1626 + del++ 1627 + } 1628 + } 1629 + for _, row := range hunk.Rows { 1630 + if row.Left.Kind == "del" { 1631 + del++ 1632 + } 1633 + if row.Right.Kind == "add" { 1634 + ins++ 1635 + } 1636 + } 1637 + } 1638 + return types.DiffFileStat{ 1639 + Insertions: ins, 1640 + Deletions: del, 1641 + } 1507 1642 } 1508 1643 1509 - type PullResubmitParams struct { 1510 - BaseParams 1511 - RepoInfo repoinfo.RepoInfo 1512 - Pull *models.Pull 1513 - SubmissionId int 1644 + func (p PullDiffFragmentParams) FileTree() *filetree.FileTreeNode { 1645 + fs := make([]string, len(p.Files)) 1646 + for i, s := range p.Files { 1647 + fs[i] = s.Id() 1648 + } 1649 + return filetree.FileTree(fs) 1514 1650 } 1515 1651 1516 - func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 1517 - return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 1652 + func (p PullDiffFragmentParams) Stats() types.DiffStat { 1653 + var stat types.DiffStat 1654 + for _, df := range p.Files { 1655 + fileStats := df.Stats() 1656 + stat.Insertions += fileStats.Insertions 1657 + stat.Deletions += fileStats.Deletions 1658 + } 1659 + stat.FilesChanged = len(p.Files) 1660 + return stat 1661 + } 1662 + 1663 + func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { 1664 + return p.executeRepo("repo/pulls/single", w, params) 1665 + } 1666 + 1667 + func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error { 1668 + return p.executeRepo("repo/pulls/single", w, params) 1669 + } 1670 + 1671 + func (p *Pages) PullDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1672 + return p.executePlain("repo/pulls/fragments/diff", w, params) 1673 + } 1674 + 1675 + func (p *Pages) PullComposeDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1676 + return p.executePlain("repo/pulls/fragments/composediff", w, params) 1518 1677 } 1519 1678 1520 1679 type PullActionsParams struct { ··· 1522 1681 RepoInfo repoinfo.RepoInfo 1523 1682 Pull *models.Pull 1524 1683 RoundNumber int 1525 - MergeCheck types.MergeCheckResponse 1684 + MergeCheck MergeCheckParams 1526 1685 ResubmitCheck ResubmitResult 1527 - BranchDeleteStatus *models.BranchDeleteStatus 1528 - Stack models.Stack 1686 + BranchDeleteStatus *BranchDeleteStatus 1529 1687 1530 1688 // renders buttons in a pre-check state and attaches the hx-trigger="load" 1531 1689 // that fetches the real, checked fragment ··· 1538 1696 1539 1697 type PullNewCommentParams struct { 1540 1698 BaseParams 1541 - RepoInfo repoinfo.RepoInfo 1542 1699 Pull *models.Pull 1543 1700 RoundNumber int 1544 1701 }
+28 -28
appview/pages/templates/fragments/line-quote-button.html
··· 20 20 const textarea = () => 21 21 document.getElementById('comment-textarea'); 22 22 23 - const lineOf = (el) => 24 - el?.closest?.('span[id*="-O"]') 25 - || el?.closest?.('span[id*="-N"]'); 23 + const lineOf = (el) => el?.closest?.('[id*="-O"], [id*="-N"]'); 26 24 27 - const anchorOf = (el) => { 28 - const link = el.querySelector('a[href^="#"]'); 29 - return link ? link.getAttribute('href').slice(1) : el.id || null; 30 - }; 25 + const anchorOf = (el) => 26 + el.querySelector('a[href^="#"]')?.getAttribute('href').slice(1) ?? null; 31 27 32 28 const fileOf = (el) => { 33 29 const d = el.closest('details[id^="file-"]'); 34 30 return d ? d.id.replace(/^file-/, '') : null; 35 31 }; 36 32 37 - const lineNumOf = (el) => anchorOf(el)?.match(/(\d+)(?:-[ON]?\d+)?$/)?.[1]; 33 + const lineNumOf = (el) => anchorOf(el)?.match(/-[ON](\d+)$/)?.[1]; 38 34 39 - const columnOf = (el) => el.closest('.flex-col'); 35 + // Column key: split has two flex sides per row (left = first child / -O, 36 + // right = last child / -N); unified is a single column per file. 37 + const columnOf = (el) => { 38 + const file = fileOf(el); 39 + if (file == null) return null; 40 + const side = el.classList.contains('diff-side') 41 + ? (el === el.parentElement.firstElementChild ? 'L' : 'R') 42 + : 'U'; 43 + return file + '-' + side; 44 + }; 40 45 41 - const linesInColumn = (col) => 42 - Array.from(col.querySelectorAll('span[id*="-O"], span[id*="-N"]')) 43 - .filter(s => s.querySelector('a[href^="#"]')); 46 + const linesInColumn = (el) => { 47 + const diff = el.closest('.diff'); 48 + if (!diff) return []; 49 + if (el.classList.contains('diff-side')) { 50 + const pos = el === el.parentElement.firstElementChild 51 + ? ':first-child' : ':last-child'; 52 + return Array.from(diff.querySelectorAll(`.diff-line > .diff-side[id]${pos}`)); 53 + } 54 + return Array.from(diff.querySelectorAll('.diff-line[id]')); 55 + }; 44 56 45 57 let dragLines = null; 46 58 47 59 const rangeBetween = (a, b) => { 48 60 const col = columnOf(a); 49 61 if (!col || col !== columnOf(b)) return []; 50 - const all = dragLines || linesInColumn(col); 62 + const all = dragLines || linesInColumn(a); 51 63 const ai = all.indexOf(a); 52 64 const bi = all.indexOf(b); 53 65 if (ai === -1 || bi === -1) return []; ··· 72 84 if (hash.startsWith('comment-') || hash.startsWith('round-')) return; 73 85 const parts = hash.split('~'); 74 86 const startEl = document.getElementById(parts[0]); 75 - 76 - if (!startEl) { 77 - const params = new URLSearchParams(window.location.search); 78 - const hasCombined = parts.some(p => /-O\d+-N\d+$/.test(p)); 79 - if (hasCombined && params.get('diff') !== 'unified') { 80 - params.set('diff', 'unified'); 81 - window.location.replace( 82 - `${window.location.pathname}?${params}${window.location.hash}` 83 - ); 84 - } 85 - return; 86 - } 87 + if (!startEl) return; 87 88 88 89 const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl; 89 90 if (!endEl) return; ··· 109 110 let hoverTarget = null; 110 111 111 112 const commentBtn = () => 112 - document.querySelector('[hx-get$="/comment"]:not(form *)'); 113 + document.querySelector('div.active [hx-get$="/comment"]:not(form *)'); 113 114 114 115 const openCommentForm = () => { 115 116 const ta = textarea(); ··· 191 192 e.preventDefault(); 192 193 dragging = true; 193 194 dragAnchor = dragCurrent = hoverTarget; 194 - const col = columnOf(hoverTarget); 195 - dragLines = col ? linesInColumn(col) : null; 195 + dragLines = linesInColumn(hoverTarget); 196 196 applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 197 197 btn.style.pointerEvents = 'none'; 198 198 document.body.style.userSelect = 'none';
+1 -1
appview/pages/templates/layouts/base.html
··· 105 105 </head> 106 106 <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 }}"> 107 107 {{ block "topbarLayout" . }} 108 - <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;"> 108 + <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;"> 109 109 110 110 {{ if .LoggedInUser }} 111 111 <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?repo=%s&base=%s&head=%s" .Repo .DiffUrl .DiffBase .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 -26
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 <div id="actions-{{$roundNumber}}" hx-target="this" class="{{ if .LoggedInUser }}flex flex-wrap gap-2 relative p-2{{ else }}hidden{{ end }}" 27 15 {{ if $loading }} 28 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/actions" 16 + hx-get="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ $roundNumber }}/_/actions" 29 17 hx-trigger="load" 30 18 hx-swap="outerHTML" 31 19 {{ end }} 32 20 > 33 21 {{ if .LoggedInUser }} 34 22 <button 35 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 23 + hx-get="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ $roundNumber }}/_/comment" 36 24 class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group"> 37 25 {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 38 26 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 52 40 {{ end }} 53 41 {{ if and $isPushAllowed $isOpen $isLastRound }} 54 42 <button 55 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 43 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/merge" 56 44 hx-swap="none" 57 45 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 58 46 class="btn-flat p-2 flex items-center gap-2 group" ··· 64 52 {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 65 53 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 66 54 {{ end }} 67 - Merge{{if $stackCount}} {{$stackCount}}{{end}} 55 + Merge 68 56 </button> 69 57 {{ end }} 70 58 71 - {{ if and $isPullAuthor $isOpen $isLastRound }} 59 + {{ if and $isPullAuthor $isOpen $isLastRound (not .ResubmitCheck.Unknown) }} 72 60 <button id="resubmitBtn" 73 - {{ if not .Pull.IsPatchBased }} 74 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 75 - hx-swap="none" 76 - {{ else }} 77 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 78 - {{ end }} 61 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/resubmit" 62 + hx-swap="none" 79 63 80 64 hx-disabled-elt="#resubmitBtn" 81 65 class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" ··· 102 86 103 87 {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 104 88 <button 105 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 89 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/close" 106 90 hx-swap="none" 107 91 class="btn-flat p-2 flex items-center gap-2 group"> 108 92 {{ i "ban" "w-4 h-4 inline group-[.htmx-request]:hidden" }} ··· 113 97 114 98 {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 115 99 <button 116 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 100 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/reopen" 117 101 hx-swap="none" 118 102 class="btn-flat p-2 flex items-center gap-2 group"> 119 103 {{ i "refresh-ccw-dot" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
+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">
+4 -2
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 8 8 hx-target="#pull-comments-{{ .RoundNumber }}" 9 9 hx-swap="beforeend" 10 10 hx-disabled-elt="find button[type='submit']" 11 - hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions', {target: '#actions-{{ .RoundNumber }}', swap: 'outerHTML'})" 11 + hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ .RoundNumber }}/_/actions', {target: '#actions-{{ .RoundNumber }}', swap: 'outerHTML'})" 12 12 > 13 13 <input name="subject-uri" type="hidden" value="{{ .Pull.AtUri }}"> 14 + <input name="subject-cid" type="hidden" value="{{ .Pull.Cid }}"> 14 15 <input name="pull-round-idx" type="hidden" value="{{ .RoundNumber }}"> 15 16 <textarea 16 17 id="comment-textarea" ··· 36 37 <button 37 38 type="button" 38 39 class="btn-flat text-red-500 dark:text-red-400 flex gap-2 items-center group" 39 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 40 + hx-get="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ .RoundNumber }}/_/actions" 40 41 hx-swap="outerHTML" 42 + hx-disabled-elt="this" 41 43 hx-target="#actions-{{.RoundNumber}}" 42 44 > 43 45 {{ i "x" "w-4 h-4" }}
+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"
+7 -11
appview/pages/templates/repo/pulls/fragments/pullStepDetails.html
··· 1 1 {{ define "repo/pulls/fragments/pullStepDetails" }} 2 2 {{ $hasSidePanel := and .LabelDefs .RepoInfo.Roles.IsPushAllowed }} 3 - {{ $previewUrl := printf "/%s/pulls/new/preview" .RepoInfo.FullName }} 4 3 {{ $labelCtx := dict "Defs" .LabelDefs "State" .LabelState "RepoInfo" .RepoInfo "Subject" "" "LoggedInUser" .LoggedInUser }} 5 4 6 5 <section class="flex flex-col md:flex-row gap-6"> 7 6 <div class="flex-1 min-w-0 flex flex-col gap-4"> 8 - {{ template "pullStepDetailsSingle" (dict "Root" . "PreviewUrl" $previewUrl) }} 7 + {{ template "pullStepDetailsSingle" . }} 9 8 {{ template "pullSubmitRow" . }} 10 9 </div> 11 10 ··· 21 20 {{ end }} 22 21 23 22 {{ define "pullStepDetailsSingle" }} 24 - {{ $root := .Root }} 25 - {{ $previewUrl := .PreviewUrl }} 26 - <input type="hidden" name="titleDirty" id="titleDirty" value="{{ if $root.TitleDirty }}1{{ end }}" /> 27 - <input type="hidden" name="bodyDirty" id="bodyDirty" value="{{ if $root.BodyDirty }}1{{ end }}" /> 23 + <input type="hidden" name="titleDirty" id="titleDirty" value="{{ if .TitleDirty }}1{{ end }}" /> 24 + <input type="hidden" name="bodyDirty" id="bodyDirty" value="{{ if .BodyDirty }}1{{ end }}" /> 28 25 <div class="flex flex-col gap-1"> 29 26 <label for="title" class="text-xs tracking-wide text-gray-800 dark:text-gray-200">Title</label> 30 27 <input 31 28 type="text" 32 29 name="title" 33 30 id="title" 34 - value="{{ $root.Title }}" 31 + value="{{ .Title }}" 35 32 oninput="document.getElementById('titleDirty').value='1'" 36 33 class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700" 37 34 placeholder="One-line summary of your change." 35 + required 38 36 /> 39 37 </div> 40 38 41 39 {{ template "markdownEditor" (dict 42 40 "Id" "pull-body" 43 41 "Name" "body" 44 - "Value" $root.Body 42 + "Value" .Body 45 43 "Rows" 6 46 44 "Placeholder" "Describe your change. Markdown is supported." 47 - "PreviewUrl" $previewUrl 48 45 "DirtyFlag" "bodyDirty" 49 46 ) }} 50 47 {{ end }} ··· 55 52 {{ $value := .Value }} 56 53 {{ $rows := .Rows }} 57 54 {{ $placeholder := .Placeholder }} 58 - {{ $previewUrl := .PreviewUrl }} 59 55 {{ $dirtyFlag := .DirtyFlag }} 60 56 <div class="flex flex-col gap-2" data-md-editor="{{ $id }}"> 61 57 <div class="btn-group self-start text-gray-600 dark:text-gray-300"> ··· 65 61 Write 66 62 </button> 67 63 <button type="button" data-md-mode="preview" 68 - hx-post="{{ $previewUrl }}" 64 + hx-post="/markup/preview" 69 65 hx-vals='js:{body: document.querySelector("[data-md-editor=\"{{ $id }}\"] textarea").value}' 70 66 hx-params="body" 71 67 hx-target="[data-md-editor='{{ $id }}'] [data-md-preview]"
+54 -368
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?repo={{ $root.SourceRepo }}&base={{ $root.TargetBranch }}&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 - {{ $previewUrl := printf "/%s/pulls/new/preview" .RepoInfo.FullName }} 102 - {{ $hasSidePanel := and $root.LabelDefs $root.RepoInfo.Roles.IsPushAllowed }} 103 - <ul class="flex flex-col gap-2"> 104 - {{ range $idx, $p := $commits }} 105 - {{ $cid := $p.ChangeIdOrEmpty }} 106 - {{ $titleOverride := index $root.StackTitles $cid }} 107 - {{ $bodyOverride := index $root.StackBodies $cid }} 108 - {{ $displayTitle := $p.Title }} 109 - {{ if $titleOverride }}{{ $displayTitle = $titleOverride }}{{ end }} 110 - {{ $bodyValue := $p.Body }} 111 - {{ if $bodyOverride }}{{ $bodyValue = $bodyOverride }}{{ end }} 112 - {{ $perDiff := "" }} 113 - {{ $perOpts := dict }} 114 - {{ if lt $idx (len $root.StackedDiffs) }} 115 - {{ $sd := index $root.StackedDiffs $idx }} 116 - {{ $perDiff = $sd.Diff }} 117 - {{ $perOpts = $sd.Opts }} 118 - {{ end }} 119 - <li> 120 - <details class="group/stacked border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 121 - <summary class="p-3 cursor-pointer flex items-center gap-3 list-none"> 122 - <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 123 - {{ i "chevron-right" "w-4 h-4 group-open/stacked:hidden inline" }} 124 - {{ i "chevron-down" "w-4 h-4 hidden group-open/stacked:inline" }} 125 - </span> 126 - <span class="-my-3 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 127 - <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 128 - {{ i "git-pull-request-create" "w-4 h-4" }} 129 - </span> 130 - <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ $displayTitle }}</span> 131 - {{ template "pullReviewCommitTimestamp" (dict "Patch" $p) }} 132 - <span class="-my-3 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 133 - {{ template "pullReviewCommitActions" (dict "Patch" $p "RepoInfo" $root.RepoInfo) }} 134 - </summary> 135 - {{ if $cid }} 136 - <div class="px-3 pb-3 pt-1 flex flex-col gap-3 border-t border-gray-100 dark:border-gray-700"> 137 - {{ $titleName := printf "stackTitle[%s]" $cid }} 138 - {{ $bodyName := printf "stackBody[%s]" $cid }} 139 - <div class="flex flex-col md:flex-row gap-6"> 140 - <div class="flex-1 min-w-0 flex flex-col gap-3"> 141 - <div class="flex flex-col gap-1"> 142 - <label class="text-xs tracking-wide text-gray-800 dark:text-gray-200">Title</label> 143 - <input 144 - type="text" 145 - name="{{ $titleName }}" 146 - value="{{ $displayTitle }}" 147 - class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700" 148 - placeholder="{{ $p.Title }}" 149 - /> 150 - </div> 151 - {{ template "markdownEditor" (dict 152 - "Id" (printf "stack-body-%s" $cid) 153 - "Name" $bodyName 154 - "Value" $bodyValue 155 - "Rows" 4 156 - "Placeholder" "Describe this pull request. Markdown is supported." 157 - "LabelText" "description" 158 - "PreviewUrl" $previewUrl 159 - ) }} 160 - </div> 161 - {{ if $hasSidePanel }} 162 - <aside data-stack-labels="{{ $cid }}" class="w-full md:w-72 md:flex-shrink-0 flex flex-col gap-4"> 163 - {{ $labelState := $root.LabelState }} 164 - {{ if $root.StackLabelStates }} 165 - {{ $perCid := index $root.StackLabelStates $cid }} 166 - {{ if $perCid }}{{ $labelState = $perCid }}{{ end }} 167 - {{ end }} 168 - {{ $labelCtx := dict "Defs" $root.LabelDefs "State" $labelState "RepoInfo" $root.RepoInfo "Subject" "" "LoggedInUser" $root.LoggedInUser "Prefix" (printf "stackLabel[%s]" $cid) }} 169 - {{ template "editBasicLabels" $labelCtx }} 170 - {{ template "editKvLabels" $labelCtx }} 171 - {{ if eq $idx 0 }} 172 - <label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 self-start"> 173 - <input type="checkbox" id="stack-apply-all" name="applyLabelsToAll" autocomplete="off" /> 174 - Apply labels and assignees to all PRs in stack 175 - </label> 176 - {{ end }} 177 - </aside> 178 - {{ end }} 179 - </div> 180 - {{ if $perDiff }} 181 - <hr class="border-gray-200 dark:border-gray-700 my-1" /> 182 - <div id="stack-diff-{{ $cid }}"> 183 - <input type="hidden" name="stackSplit[{{ $cid }}]" value="{{ if $perOpts.Split }}split{{ else }}unified{{ end }}" /> 184 - {{ template "pullStackedDiffArea" (dict "Diff" $perDiff "DiffOpts" $perOpts "Cid" $cid) }} 185 - </div> 186 - {{ end }} 187 - </div> 188 - {{ else }} 189 - <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"> 190 - 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. 191 - </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 }} 192 72 {{ end }} 193 - </details> 194 - </li> 195 - {{ end }} 196 - </ul> 197 - {{ end }} 198 - 199 - {{ define "pullReviewCommitTimestamp" }} 200 - {{ $p := .Patch }} 201 - {{ if or (not $p.AuthorDate.IsZero) $p.SHA }} 202 - <span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> 203 - {{ if not $p.AuthorDate.IsZero }} 204 - {{ template "repo/fragments/shortTimeAgo" $p.AuthorDate }} 205 - {{ end }} 206 - {{ if $p.SHA }} 207 - <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> 208 - {{ end }} 209 - </span> 210 - {{ end }} 211 - {{ end }} 212 - 213 - {{ define "pullReviewCommitActions" }} 214 - {{ $p := .Patch }} 215 - {{ $repoInfo := .RepoInfo }} 216 - {{ if $p.SHA }} 217 - <span class="flex items-center gap-1 text-gray-700 dark:text-gray-300 flex-shrink-0"> 218 - <button type="button" 219 - class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 220 - title="Copy SHA" 221 - 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)"> 222 - {{ i "copy" "w-4 h-4" }} 223 - </button> 224 - <a href="/{{ $repoInfo.FullName }}/tree/{{ $p.SHA }}" 225 - onclick="event.stopPropagation()" 226 - class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 227 - title="Browse repository at this commit"> 228 - {{ i "folder-code" "w-4 h-4" }} 229 - </a> 230 - </span> 231 - {{ end }} 232 - {{ end }} 233 - 234 - {{ define "pullStackedDiffArea" }} 235 - {{ $diff := .Diff }} 236 - {{ $opts := .DiffOpts }} 237 - {{ $cid := .Cid }} 238 - {{ $togId := printf "stack-%s-filesToggle" $cid }} 239 - {{ $colId := printf "stack-%s-collapseToggle" $cid }} 240 - {{ $filesId := printf "stack-%s-files" $cid }} 241 - {{ $diffAreaId := printf "stack-%s-diff-area" $cid }} 242 - {{ $filePrefix := printf "stack-%s-file-" $cid }} 243 - {{ $stat := $diff.Stats }} 244 - {{ $count := len $diff.ChangedFiles }} 245 - 246 - <style> 247 - #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .show-text { display: none; } 248 - #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .hide-text { display: inline; } 249 - #{{ $togId }}:not(:checked) ~ * label[for="{{ $togId }}"] .hide-text { display: none; } 250 - #{{ $togId }}:checked ~ * div#{{ $filesId }} { width: fit-content; max-width: 15vw; } 251 - #{{ $togId }}:not(:checked) ~ * div#{{ $filesId }} { width: 0; display: none; margin-right: 0; } 252 - </style> 253 - 254 - <input type="checkbox" id="{{ $togId }}" class="hidden"/> 255 - 256 - <div id="{{ $diffAreaId }}"> 257 - <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"> 258 - <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"> 259 - <span class="show-text">{{ i "panel-left-open" "size-4" }}</span> 260 - <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span> 261 - </label> 262 - 263 - {{ template "repo/fragments/diffStatPill" $stat }} 264 - <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> 265 - 266 - <div class="flex-grow"></div> 267 - 268 - <label title="Expand/Collapse diffs" for="{{ $colId }}" class="btn font-normal normal-case"> 269 - <input type="checkbox" id="{{ $colId }}" class="peer/collapse hidden"/> 270 - <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 271 - {{ i "unfold-vertical" "w-4 h-4" }} 272 - <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> 273 74 </span> 274 - <span class="peer-checked/collapse:inline-flex hidden flex items-center gap-2"> 275 - {{ i "fold-vertical" "w-4 h-4" }} 276 - <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> 277 89 </span> 278 - </label> 279 - 280 - {{ template "repo/fragments/diffOpts" $opts }} 281 - </div> 282 - 283 - <div class="flex border border-gray-200 dark:border-gray-700 rounded-b-md"> 284 - <div id="{{ $filesId }}" class="hidden md:block overflow-hidden max-h-[60vh] overflow-y-auto border-r border-gray-200 dark:border-gray-700"> 285 - <section class="overflow-x-auto text-sm px-3 py-2 w-full mx-auto"> 286 - {{ template "repo/fragments/fileTreePrefixed" (dict "Tree" $diff.FileTree "Prefix" $filePrefix) }} 287 - </section> 288 - </div> 289 - 290 - <div class="flex-1 min-w-0 p-2"> 291 - <div class="flex flex-col gap-2"> 292 - {{ if eq $count 0 }} 293 - <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 294 - <p>No differences found.</p> 295 - </div> 296 - {{ else if le $count 5 }} 297 - {{ range $idx, $file := $diff.ChangedFiles }} 298 - {{ template "stackedDiffFile" (dict "Idx" $idx "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 299 - {{ end }} 300 - {{ else }} 301 - {{ range $idx, $file := slice $diff.ChangedFiles 0 5 }} 302 - {{ template "stackedDiffFile" (dict "Idx" $idx "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 303 - {{ end }} 304 - {{ $remaining := sub $count 5 }} 305 - <details class="group/showmore"> 306 - <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"> 307 - <span class="group-open/showmore:hidden inline-flex items-center gap-2"> 308 - {{ i "chevron-right" "w-4 h-4" }} 309 - Show {{ $remaining }} more file{{ if ne $remaining 1 }}s{{ end }} 310 - </span> 311 - <span class="hidden group-open/showmore:inline-flex items-center gap-2"> 312 - {{ i "chevron-down" "w-4 h-4" }} 313 - Hide {{ $remaining }} file{{ if ne $remaining 1 }}s{{ end }} 314 - </span> 315 - </summary> 316 - <div class="flex flex-col gap-2 mt-2"> 317 - {{ range $idx, $file := slice $diff.ChangedFiles 5 }} 318 - {{ template "stackedDiffFile" (dict "Idx" (add $idx 5) "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 319 - {{ end }} 320 - </div> 321 - </details> 322 - {{ end }} 323 - </div> 324 - </div> 325 - </div> 326 - </div> 327 - 328 - <script> 329 - (() => { 330 - const cb = document.getElementById('{{ $colId }}'); 331 - const area = document.getElementById('{{ $diffAreaId }}'); 332 - if (!cb || !area) return; 333 - const all = () => area.querySelectorAll('details[id^="{{ $filePrefix }}"]'); 334 - cb.addEventListener('change', () => { 335 - all().forEach(d => { d.open = cb.checked; }); 336 - }); 337 - area.addEventListener('toggle', (e) => { 338 - if (!e.target.matches('details[id^="{{ $filePrefix }}"]')) return; 339 - const dets = Array.from(all()); 340 - const allOpen = dets.every(d => d.open); 341 - const allClosed = dets.every(d => !d.open); 342 - if (allOpen) cb.checked = true; 343 - else if (allClosed) cb.checked = false; 344 - }, true); 345 - })(); 346 - </script> 347 - {{ end }} 348 - 349 - {{ define "stackedDiffFile" }} 350 - {{ $idx := .Idx }} 351 - {{ $file := .File }} 352 - {{ $isSplit := .IsSplit }} 353 - {{ $prefix := .Prefix }} 354 - {{ $isGenerated := false }} 355 - {{ $isDeleted := false }} 356 - {{ with $file }} 357 - {{ $n := .Names }} 358 - {{ $isDeleted = and (eq $n.New "") (ne $n.Old "") }} 359 - {{ if $n.New }} 360 - {{ $isGenerated = isGenerated $n.New }} 361 - {{ else if $n.Old }} 362 - {{ $isGenerated = isGenerated $n.Old }} 90 + </li> 363 91 {{ end }} 364 - <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 }}"> 365 - <summary class="list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700"> 366 - <div class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 367 - <div class="p-2 flex gap-2 items-center overflow-x-auto"> 368 - <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 369 - <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 370 - {{ template "repo/fragments/diffStatPill" .Stats }} 371 - <div class="flex gap-2 items-center overflow-x-auto"> 372 - {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 373 - {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 374 - {{ else if $n.New }} 375 - {{ $n.New }} 376 - {{ else }} 377 - {{ $n.Old }} 378 - {{ end }} 379 - {{ if $isDeleted }} 380 - <span class="text-gray-400 dark:text-gray-500" title="Deleted files are collapsed by default"> 381 - {{ i "circle-question-mark" "size-4" }} 382 - </span> 383 - {{ else if $isGenerated }} 384 - <span class="text-gray-400 dark:text-gray-500" title="Generated files are collapsed by default"> 385 - {{ i "circle-question-mark" "size-4" }} 386 - </span> 387 - {{ end }} 388 - </div> 389 - </div> 390 - </div> 391 - </summary> 392 - 393 - <div class="transition-all duration-700 ease-in-out"> 394 - {{ $reason := .CanRender }} 395 - {{ if $reason }} 396 - <p class="text-center text-gray-400 dark:text-gray-500 p-4">{{ $reason }}</p> 397 - {{ else }} 398 - {{ if $isSplit }} 399 - {{- template "repo/fragments/splitDiff" .Split -}} 400 - {{ else }} 401 - {{- template "repo/fragments/unifiedDiff" . -}} 402 - {{ end }} 403 - {{- end -}} 404 - </div> 405 - </details> 406 - {{ end }} 92 + </ul> 407 93 {{ end }} 408 94 409 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"
+1 -1
appview/pages/templates/repo/pulls/fragments/triggerCi.html
··· 1 1 {{ define "repo/pulls/fragments/runCiButton" }} 2 2 <button 3 3 class="btn-flat p-2 flex items-center gap-2 flex-shrink-0 group" 4 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/trigger-ci{{ if .Confirm }}?confirm=1{{ end }}" 4 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/{{ .Pull.PullId }}/trigger-ci{{ if .Confirm }}?confirm=1{{ end }}" 5 5 hx-swap="none" 6 6 hx-disabled-elt="this" 7 7 {{ if .Confirm }}hx-confirm="{{ .Confirm }}"{{ end }}
-630
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: 768px)').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: 768px)'); 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 md:grid-cols-10 gap-4 w-full"> 115 - <div class="col-span-1 md: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 md: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 - {{ template "repo/fragments/backlinks" 129 - (dict "RepoInfo" $.RepoInfo 130 - "Backlinks" $.Backlinks) }} 131 - {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 132 - </div> 133 - </div> 134 - {{ end }} 135 - 136 - {{ define "contentAfter" }} 137 - <input type="hidden" id="round-link-base" value="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .ActiveRound }}" /> 138 - {{ if .IsInterdiff }} 139 - <input type="hidden" id="is-interdiff" value="1" /> 140 - {{ end }} 141 - {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 142 - {{ end }} 143 - 144 - {{ define "repoContent" }} 145 - {{ template "repo/pulls/fragments/pullHeader" . }} 146 - {{ if (gt (len .Stack) 1) }} 147 - <div class="mt-8"> 148 - {{ template "repo/pulls/fragments/pullStack" . }} 149 - </div> 150 - {{ end }} 151 - {{ end }} 152 - 153 - {{ define "resize-grip" }} 154 - {{ $id := index . 0 }} 155 - {{ $target := index . 1 }} 156 - {{ $direction := index . 2 }} 157 - <div id="{{ $id }}" 158 - data-resizer="vertical" 159 - data-target="{{ $target }}" 160 - data-direction="{{ $direction }}" 161 - class="resizer-vertical hidden md:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group"> 162 - <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> 163 - </div> 164 - {{ end }} 165 - 166 - {{ define "diffLayout" }} 167 - {{ $diff := index . 0 }} 168 - {{ $opts := index . 1 }} 169 - {{ $root := index . 2 }} 170 - 171 - <div class="flex col-span-full"> 172 - <!-- left panel --> 173 - <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 174 - <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"> 175 - {{ template "repo/fragments/fileTree" $diff.FileTree }} 176 - </section> 177 - </div> 178 - 179 - {{ template "resize-grip" (list "resize-files" "files" "before") }} 180 - 181 - <!-- main content --> 182 - <div id="diff-files" class="flex-1 min-w-0 sticky top-12 pb-12"> 183 - {{ template "diffFiles" (list $diff $opts) }} 184 - </div> 185 - 186 - {{ template "resize-grip" (list "resize-subs" "subs" "after") }} 187 - 188 - <!-- right panel --> 189 - {{ template "subsPanel" $ }} 190 - </div> 191 - {{ end }} 192 - 193 - {{ define "subsPanel" }} 194 - {{ $root := index . 2 }} 195 - {{ $pull := $root.Pull }} 196 - {{ $bgColor := "bg-gray-600 dark:bg-gray-700" }} 197 - 198 - {{ if $pull.State.IsOpen }} 199 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 200 - {{ else if $pull.State.IsMerged }} 201 - {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 202 - {{ else if $pull.State.IsAbandoned }} 203 - {{ $bgColor = "bg-red-600 dark:bg-red-700" }} 204 - {{ end }} 205 - 206 - <!-- backdrop overlay - only visible on mobile when open --> 207 - <div id="bottomSheetBackdrop" class="fixed inset-0 bg-black/50 md:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-20"></div> 208 - <!-- right panel - bottom sheet on mobile, side panel on desktop --> 209 - <div id="subs" class="fixed bottom-0 left-0 right-0 z-30 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden"> 210 - <details open id="bottomSheet" class="rounded-t-2xl md:rounded-t shadow-lg md:shadow-none group/panel"> 211 - <summary class=" 212 - flex gap-4 items-center justify-between 213 - rounded-t-2xl md:rounded-t cursor-pointer list-none p-4 md:h-12 214 - text-white md:text-black md:dark:text-white 215 - {{ $bgColor }} 216 - md:bg-white md:dark:bg-gray-800 217 - shadow-sm border-t md:border-x border-gray-200 dark:border-gray-700 218 - md:pointer-events-none 219 - "> 220 - <h2 class="">History</h2> 221 - <span class="pointer-events-auto cursor-text"> 222 - {{ template "subsPanelSummary" $ }} 223 - </span> 224 - </summary> 225 - <div class="max-h-[85vh] md:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 md:bg-transparent"> 226 - {{ template "submissions" $root }} 227 - </div> 228 - </details> 229 - </div> 230 - {{ end }} 231 - 232 - {{ define "subsPanelSummary" }} 233 - {{ $root := index . 2 }} 234 - {{ $pull := $root.Pull }} 235 - {{ $rounds := len $pull.Submissions }} 236 - {{ $comments := $pull.TotalComments }} 237 - <div class="flex items-center gap-2 text-sm"> 238 - <span> 239 - {{ $rounds }} round{{ if ne $rounds 1 }}s{{ end }} 240 - </span> 241 - <span class="select-none before:content-['\00B7']"></span> 242 - <span> 243 - {{ $comments }} comment{{ if ne $comments 1 }}s{{ end }} 244 - </span> 245 - 246 - <span class="md:hidden inline"> 247 - <span class="inline group-open:hidden">{{ i "chevron-up" "size-4" }}</span> 248 - <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 249 - </span> 250 - </div> 251 - {{ end }} 252 - 253 - {{ define "subsCheckbox" }} 254 - <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 255 - {{ end }} 256 - 257 - {{ define "subsToggle" }} 258 - <style> 259 - #subsToggle:checked ~ div div#subs { 260 - width: 100%; 261 - margin-left: 0; 262 - } 263 - #subsToggle:checked ~ div label[for="subsToggle"] .show-toggle { display: none; } 264 - #subsToggle:checked ~ div label[for="subsToggle"] .hide-toggle { display: flex; } 265 - #subsToggle:not(:checked) ~ div label[for="subsToggle"] .hide-toggle { display: none; } 266 - 267 - @media (min-width: 768px) { 268 - #subsToggle:checked ~ div div#subs { 269 - width: 25vw; 270 - max-width: 50vw; 271 - } 272 - #subsToggle:not(:checked) ~ div div#subs { 273 - width: 0; 274 - display: none; 275 - margin-left: 0; 276 - } 277 - #subsToggle:not(:checked) ~ div div#resize-subs { 278 - display: none; 279 - } 280 - } 281 - </style> 282 - <label title="Toggle review panel" for="subsToggle" class="hidden md:flex items-center justify-end pointer-events-none"> 283 - <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> 284 - <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> 285 - </label> 286 - {{ end }} 287 - 288 - 289 - {{ define "submissions" }} 290 - {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 291 - {{ if not .LoggedInUser }} 292 - {{ template "loginPrompt" $ }} 293 - {{ end }} 294 - {{ range $ridx, $item := reverse .Pull.Submissions }} 295 - {{ $idx := sub $lastIdx $ridx }} 296 - {{ template "submission" (list $item $idx $lastIdx $) }} 297 - {{ end }} 298 - {{ end }} 299 - 300 - {{ define "submission" }} 301 - {{ $item := index . 0 }} 302 - {{ $idx := index . 1 }} 303 - {{ $lastIdx := index . 2 }} 304 - {{ $root := index . 3 }} 305 - {{ $round := $item.RoundNumber }} 306 - <div id="round-{{ $round }}" class=" 307 - w-full shadow-sm border overflow-clip 308 - 309 - {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 310 - {{ if eq $round $root.ActiveRound }} 311 - bg-blue-50/25 dark:bg-blue-900/10 border-blue-200 dark:border-blue-900 312 - {{ else }} 313 - bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700 314 - {{ end }} 315 - "> 316 - {{ template "submissionHeader" $ }} 317 - {{ template "submissionComments" $ }} 318 - </div> 319 - {{ end }} 320 - 321 - {{ define "submissionHeader" }} 322 - {{ $item := index . 0 }} 323 - {{ $lastIdx := index . 2 }} 324 - {{ $root := index . 3 }} 325 - {{ $round := $item.RoundNumber }} 326 - <div class=" 327 - {{ if ne $round 0 }}rounded-t{{ end }} 328 - px-6 py-4 pr-2 pt-2 329 - {{ if eq $round $root.ActiveRound }} 330 - bg-blue-50 dark:bg-blue-950 331 - {{ else }} 332 - bg-white dark:bg-gray-800 333 - {{ end }} 334 - 335 - flex gap-2 sticky top-0 z-20"> 336 - <!-- left column: just profile picture --> 337 - <div class="flex-shrink-0 pt-2"> 338 - {{ template "user/fragments/picLink" (list $root.Pull.OwnerDid "size-8" (index $root.VouchRelationships (did $root.Pull.OwnerDid))) }} 339 - </div> 340 - <!-- right column --> 341 - <div class="flex-1 min-w-0 flex flex-col gap-1"> 342 - {{ template "submissionInfo" $ }} 343 - {{ template "submissionCommits" $ }} 344 - {{ template "submissionPipeline" $ }} 345 - {{ if eq $lastIdx $round }} 346 - <div id="mergecheck-banner"> 347 - {{ if $root.Pull.State.IsOpen }} 348 - <div class="flex items-center gap-2 text-gray-500 dark:text-gray-400"> 349 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 350 - <span>Checking mergeability…</span> 351 - </div> 352 - {{ end }} 353 - </div> 354 - {{ end }} 355 - </div> 356 - </div> 357 - {{ end }} 358 - 359 - {{ define "submissionInfo" }} 360 - {{ $item := index . 0 }} 361 - {{ $idx := index . 1 }} 362 - {{ $root := index . 3 }} 363 - {{ $round := $item.RoundNumber }} 364 - <div class="flex gap-2 items-center justify-between mb-1"> 365 - <span class="inline-flex items-center gap-2 text-sm 366 - {{ if eq $round $root.ActiveRound }} 367 - text-gray-600 dark:text-gray-300 368 - {{ else }} 369 - text-gray-500 dark:text-gray-400 370 - {{ end }} 371 - pt-2"> 372 - {{ $handle := resolve $root.Pull.OwnerDid }} 373 - <a class=" 374 - {{ if eq $round $root.ActiveRound }} 375 - text-gray-800 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-200 376 - {{ else }} 377 - text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 378 - {{ end }} 379 - " href="/{{ $handle }}">{{ $handle }}</a> 380 - submitted 381 - <span class="px-2 py-0.5 rounded font-mono text-xs border 382 - {{ if eq $round $root.ActiveRound }} 383 - text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500 384 - {{ else }} 385 - text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 386 - {{ end }} 387 - "> 388 - #{{ $round }} 389 - </span> 390 - <span class="select-none before:content-['\00B7']"></span> 391 - <a class=" 392 - {{ if eq $round $root.ActiveRound }} 393 - text-gray-600 dark:text-gray-300 hover:text-gray-600 dark:hover:text-gray-200 394 - {{ else }} 395 - text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 396 - {{ end }} 397 - " href="#round-{{ $round }}"> 398 - {{ template "repo/fragments/shortTime" $item.Created }} 399 - </a> 400 - </span> 401 - <div class="flex gap-2 items-center"> 402 - {{ if or $root.IsInterdiff (ne $root.ActiveRound $round) }} 403 - <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 404 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}#round-{{ $round }}"> 405 - {{ i "diff" "w-4 h-4" }} 406 - Diff 407 - </a> 408 - {{ end }} 409 - {{ if and (ne $idx 0) (or (not $root.IsInterdiff) (ne $root.ActiveRound $round)) }} 410 - <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 411 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}/interdiff?{{ safeUrl $root.DiffOpts.Encode }}"> 412 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 413 - Interdiff 414 - </a> 415 - {{ end }} 416 - </div> 417 - </div> 418 - {{ end }} 419 - 420 - {{ define "submissionCommits" }} 421 - {{ $item := index . 0 }} 422 - {{ $root := index . 3 }} 423 - {{ $round := $item.RoundNumber }} 424 - {{ $patches := $item.AsFormatPatch }} 425 - {{ if $patches }} 426 - <details class="group/commit"> 427 - <summary class="list-none cursor-pointer flex items-center gap-2"> 428 - <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 429 - {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 430 - <div class="text-sm text-gray-500 dark:text-gray-400"> 431 - <span class="group-open/commit:hidden inline">Expand</span> 432 - <span class="hidden group-open/commit:inline">Collapse</span> 433 - </div> 434 - </summary> 435 - {{ range $patches }} 436 - {{ template "submissionCommit" (list . $item $root) }} 437 - {{ end }} 438 - </details> 439 - {{ end }} 440 - {{ end }} 441 - 442 - {{ define "submissionCommit" }} 443 - {{ $patch := index . 0 }} 444 - {{ $item := index . 1 }} 445 - {{ $root := index . 2 }} 446 - {{ $round := $item.RoundNumber }} 447 - {{ with $patch }} 448 - <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"> 449 - <div class="flex items-baseline gap-2"> 450 - <div class="text-xs"> 451 - <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 452 - {{ $fullRepo := "" }} 453 - {{ if and $root.Pull.IsForkBased $root.Pull.PullSource.Repo }} 454 - {{ $fullRepo = printf "%s/%s" (resolve $root.Pull.PullSource.Repo.Did) $root.Pull.PullSource.Repo.Slug }} 455 - {{ else if $root.Pull.IsBranchBased }} 456 - {{ $fullRepo = $root.RepoInfo.FullName }} 457 - {{ end }} 458 - 459 - <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 460 - {{ if $fullRepo }} 461 - <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 462 - {{ else }} 463 - <span class="font-mono">{{ slice .SHA 0 8 }}</span> 464 - {{ end }} 465 - </div> 466 - 467 - <div> 468 - <span>{{ .Title | description }}</span> 469 - {{ if gt (len .Body) 0 }} 470 - <button 471 - 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" 472 - hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 473 - > 474 - {{ i "ellipsis" "w-3 h-3" }} 475 - </button> 476 - {{ end }} 477 - {{ if gt (len .Body) 0 }} 478 - <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 pb-2">{{ nl2br .Body }}</p> 479 - {{ end }} 480 - </div> 481 - </div> 482 - </div> 483 - {{ end }} 484 - {{ end }} 485 - 486 - {{ define "mergeStatus" }} 487 - {{ if .Pull.State.IsClosed }} 488 - <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded shadow-sm px-6 py-2 relative"> 489 - <div class="flex items-center gap-2 text-black dark:text-white"> 490 - {{ i "ban" "w-4 h-4" }} 491 - <span class="font-medium">Closed without merging</span 492 - > 493 - </div> 494 - </div> 495 - {{ else if .Pull.State.IsMerged }} 496 - <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded shadow-sm px-6 py-2 relative"> 497 - <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 498 - {{ i "git-merge" "w-4 h-4" }} 499 - <span class="font-medium">Pull request successfully merged</span 500 - > 501 - </div> 502 - </div> 503 - {{ else if .Pull.State.IsAbandoned }} 504 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded shadow-sm px-6 py-2 relative"> 505 - <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 506 - {{ i "git-pull-request-closed" "w-4 h-4" }} 507 - <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 508 - </div> 509 - </div> 510 - {{ end }} 511 - {{ end }} 512 - 513 - {{ define "submissionPipeline" }} 514 - {{ $item := index . 0 }} 515 - {{ $root := index . 3 }} 516 - {{ $pipeline := index $root.Pipelines $item.SourceRev }} 517 - {{ if and $pipeline $pipeline.Statuses }} 518 - {{ $id := $pipeline.Id }} 519 - <details class="group/pipeline"> 520 - <summary class="cursor-pointer list-none flex items-center gap-2"> 521 - {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }} 522 - <div class="text-sm text-gray-500 dark:text-gray-400"> 523 - <span class="group-open/pipeline:hidden inline">Expand</span> 524 - <span class="hidden group-open/pipeline:inline">Collapse</span> 525 - </div> 526 - </summary> 527 - <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"> 528 - {{ range $name, $all := $pipeline.Statuses }} 529 - <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"> 530 - <div 531 - class="flex gap-2 items-center justify-between p-2"> 532 - {{ $lastStatus := $all.Latest }} 533 - {{ $kind := $lastStatus.Status.String }} 534 - 535 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 536 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 537 - {{ $name }} 538 - </div> 539 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 540 - <span class="font-bold">{{ $kind }}</span> 541 - {{ if .TimeTaken }} 542 - {{ template "repo/fragments/duration" .TimeTaken }} 543 - {{ else }} 544 - {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 545 - {{ end }} 546 - </div> 547 - </div> 548 - </a> 549 - {{ end }} 550 - </div> 551 - </details> 552 - {{ else if and $root.Pull.IsForkBased (eq $item.RoundNumber $root.Pull.LastRoundNumber) $root.RepoInfo.Roles.IsOwner }} 553 - {{ template "repo/pulls/fragments/triggerCi" $root }} 554 - {{ end }} 555 - {{ end }} 556 - 557 - {{ define "submissionComments" }} 558 - {{ $item := index . 0 }} 559 - {{ $idx := index . 1 }} 560 - {{ $lastIdx := index . 2 }} 561 - {{ $root := index . 3 }} 562 - {{ $round := $item.RoundNumber }} 563 - {{ $c := len $item.Comments }} 564 - <details class="relative ml-10 group/comments group/collapse" {{ if or (eq $c 0) (eq $root.ActiveRound $round) }}open{{ end }}> 565 - <summary class="cursor-pointer list-none"> 566 - <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"> 567 - <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> 568 - </div> 569 - <div class="group-open/comments:hidden block relative group/summary py-4"> 570 - <div class="absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center z-4"> 571 - <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> 572 - </div> 573 - <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"> 574 - {{ i "circle-plus" "size-4 z-5" }} 575 - Expand {{ $c }} comment{{ if ne $c 1 }}s{{ end }} 576 - </span> 577 - </div> 578 - </summary> 579 - <div id="pull-comments-{{ $round }}"> 580 - {{ range $item.Comments }} 581 - {{ template "fragments/comment/pullComment" 582 - (dict "LoggedInUser" $root.LoggedInUser 583 - "Reactions" (index (asReactionMapMap $root.Reactions) .FeedCommentAtUri) 584 - "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) .FeedCommentAtUri) 585 - "Comment" .) }} 586 - {{ end }} 587 - </div> 588 - {{ if gt $c 0}} 589 - <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"> 590 - <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 }} 591 - </button> 592 - {{ end }} 593 - 594 - <div class="relative -ml-10"> 595 - {{ if eq $lastIdx $item.RoundNumber }} 596 - {{ block "mergeStatus" $root }} {{ end }} 597 - <div id="resubmit-banner"></div> 598 - <div id="pull-action-error" class="error empty:hidden"></div> 599 - {{ end }} 600 - </div> 601 - <div hx-include="this" class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 602 - {{ if $root.LoggedInUser }} 603 - <input name="subject-uri" type="hidden" value="{{ $root.Pull.AtUri }}"> 604 - <input name="pull-round-idx" type="hidden" value="{{ $item.RoundNumber }}"> 605 - {{ end }} 606 - {{ template "repo/pulls/fragments/pullActions" 607 - (dict 608 - "LoggedInUser" $root.LoggedInUser 609 - "Pull" $root.Pull 610 - "RepoInfo" $root.RepoInfo 611 - "RoundNumber" $item.RoundNumber 612 - "MergeCheck" $root.MergeCheck 613 - "ResubmitCheck" $root.ResubmitCheck 614 - "BranchDeleteStatus" $root.BranchDeleteStatus 615 - "Stack" $root.Stack 616 - "Loading" (eq $lastIdx $item.RoundNumber)) }} 617 - </div> 618 - </details> 619 - {{ end }} 620 - 621 - {{ define "loginPrompt" }} 622 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded shadow-sm p-2 relative flex gap-2 items-center"> 623 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 624 - Sign up 625 - </a> 626 - <span class="text-gray-500 dark:text-gray-400">or</span> 627 - <a href="/login" class="underline">Login</a> 628 - to add to the discussion 629 - </div> 630 - {{ end }}
+5 -40
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 105 - {{ $pipeline := index $.Pipelines $topPR.LatestSha }} 104 + {{ $pipeline := index $.Pipelines $topPR.LatestVersion.Head }} 106 105 {{ if and $pipeline $pipeline.Id }} 107 106 <span class="before:content-['·']"></span> 108 107 {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} ··· 116 115 {{ end }} 117 116 </div> 118 117 </div> 119 - {{ if gt (len $stack) 1 }} 120 - <details class="group"> 121 - <summary class="px-6 pb-4 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 122 - <span class="flex items-center gap-2"> 123 - <span class="group-open:hidden"> 124 - {{ i "chevrons-up-down" "size-3" }} 125 - </span> 126 - <span class="hidden group-open:flex"> 127 - {{ i "chevrons-down-up" "size-3" }} 128 - </span> 129 - {{ $rest := sub (len $stack) 1 }} 130 - Expand {{ $rest }} pull{{if ne $rest 1 }}s{{end}} in this stack 131 - </span> 132 - </summary> 133 - {{ template "stackedPullList" (list (slice $stack 1) $) }} 134 - </details> 135 - {{ end }} 136 118 </div> 137 119 {{ end }} 138 120 </div> ··· 145 127 ) }} 146 128 {{ end }} 147 129 {{ end }} 148 - 149 - {{ define "stackedPullList" }} 150 - {{ $list := index . 0 }} 151 - {{ $root := index . 1 }} 152 - <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"> 153 - {{ range $pull := $list }} 154 - {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 155 - <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"> 156 - <div class="flex gap-2 items-center px-6"> 157 - <div class="flex-grow min-w-0 w-full py-2"> 158 - {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 159 - </div> 160 - </div> 161 - </a> 162 - {{ end }} 163 - </div> 164 - {{ end }}
+635
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 + {{ template "repo/fragments/backlinks" 126 + (dict "RepoInfo" $.RepoInfo 127 + "Backlinks" $.Backlinks) }} 128 + {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 129 + </div> 130 + <div class="lg:row-start-2 lg:col-start-1"> 131 + <h2 id="commits">Commits</h2> 132 + <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"> 133 + {{ $seeAll := false }} 134 + {{ if .IsInterdiff }} 135 + {{ $seeAll = not .ChangeId }} 136 + {{ else }} 137 + {{ $version := index .Pull.Versions .VersionId }} 138 + {{ $seeAll = and (eq .DiffParams.Base $version.Base) (eq .DiffParams.Head $version.Head) }} 139 + {{ end }} 140 + <div class="text-sm px-2 {{ if $seeAll }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 141 + <div class="relative"> 142 + {{ if $seeAll }} 143 + <div class="flex-shrink-0 absolute top-3 left-0"> 144 + {{ i "arrow-right" "size-4" }} 145 + </div> 146 + {{ end }} 147 + <div class="py-2 ml-6"> 148 + {{ if .IsInterdiff }} 149 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ .Version1 }}..{{ .Version2 }}/all">See all changes</a> 150 + {{ else }} 151 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ .VersionId }}">See all changes</a> 152 + {{ end }} 153 + </div> 154 + </div> 155 + </div> 156 + {{ range .Commits }} 157 + {{ $messageParts := splitN .Message "\n\n" 2 }} 158 + {{ $active := and (not $seeAll) (eq $.ActiveCommitId .Hash.String) }} 159 + {{ $toggleId := printf "commit-message-toggle-%s" .Hash }} 160 + {{ $pipeline := index $.Pipelines .Hash.String }} 161 + <div class="text-sm px-2 {{ if $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 162 + <input id="{{ $toggleId }}" type="checkbox" class="peer hidden" /> 163 + <div class="relative flex items-center justify-between"> 164 + {{ if $active }} 165 + <div class="flex-shrink-0 absolute left-0"> 166 + {{ i "arrow-right" "size-4" }} 167 + </div> 168 + {{ end }} 169 + <div class="py-2 ml-6"> 170 + <code class="py-0.5 px-1">{{ slice .Hash.String 0 8 }}</code> 171 + {{ if .ChangeId }} 172 + <code class="py-0.5 px-1">{{ slice .ChangeId 0 8 }}</code> 173 + {{ end }} 174 + {{ if $.IsInterdiff }} 175 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/{{ $.Version1 }}..{{ $.Version2 }}/{{ .ChangeId }}">{{ index $messageParts 0 }}</a> 176 + {{ else }} 177 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/{{ $.VersionId }}/{{ shortId .FirstParentHash.String }}..{{ shortId .Hash.String }}">{{ index $messageParts 0 }}</a> 178 + {{ end }} 179 + <label 180 + for="{{ $toggleId }}" 181 + 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" 182 + > 183 + {{ i "ellipsis" "size-3" }} 184 + </label> 185 + </div> 186 + {{ if and $pipeline $pipeline.Id }} 187 + <div> 188 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 189 + </div> 190 + {{ end }} 191 + </div> 192 + <pre class="ml-6 mt-1 mb-2 hidden peer-checked:block">{{ index $messageParts 1 }}</pre> 193 + </div> 194 + {{ end }} 195 + </div> 196 + </div> 197 + </div> 198 + </div> 199 + <!-- diff --> 200 + <div hx-include="this" class="min-h-[50vh] group/diff-layout"> 201 + <input type="hidden" name="repo" value="{{ .Pull.SourceRepo }}"> 202 + {{ $diffUrl := "" }} 203 + {{ if not .IsInterdiff }} 204 + {{ $diffUrl = (printf "/%s/pulls/%d/diff?view=unified" .Pull.RepoDid .Pull.PullId) }} 205 + {{ $diff := .DiffParams }} 206 + <input type="hidden" name="base" value="{{ $diff.Base }}"> 207 + <input type="hidden" name="head" value="{{ $diff.Head }}"> 208 + {{ else if .DiffParams.Diff }} 209 + {{ $diffUrl = (printf "/%s/pulls/%d/diff?view=unified" .Pull.RepoDid .Pull.PullId) }} 210 + {{ $diff := .DiffParams.Diff }} 211 + <input type="hidden" name="base" value="{{ $diff.Base }}"> 212 + <input type="hidden" name="head" value="{{ $diff.Head }}"> 213 + {{ else }} 214 + {{ $diffUrl = (printf "/%s/pulls/%d/interdiff?view=unified" .Pull.RepoDid .Pull.PullId) }} 215 + {{ $diff := .DiffParams.Interdiff }} 216 + <input type="hidden" name="base1" value="{{ $diff.From.Base }}"> 217 + <input type="hidden" name="head1" value="{{ $diff.From.Head }}"> 218 + <input type="hidden" name="base2" value="{{ $diff.To.Base }}"> 219 + <input type="hidden" name="head2" value="{{ $diff.To.Head }}"> 220 + {{ end }} 221 + <!-- diff header --> 222 + <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"> 223 + <!-- left --> 224 + <div class="h-12 flex items-center gap-2"> 225 + <div class="md:block hidden"> 226 + <label for="diff-tree-toggle" title="Toggle filetree panel" class="btn-flat"> 227 + <span class="group-has-[#diff-tree-toggle:checked]/diff-layout:hidden inline">{{ i "panel-left-open" "size-4" }}</span> 228 + <span class="hidden group-has-[#diff-tree-toggle:checked]/diff-layout:inline">{{ i "panel-left-close" "size-4" }}</span> 229 + </label> 230 + </div> 231 + <div id="diff-stats"> 232 + <div class="flex items-center font-mono text-sm"> 233 + <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> 234 + <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> 235 + </div> 236 + </div> 237 + {{ if .IsInterdiff }} 238 + <span class="text-sm"> 239 + Interdiff 240 + <span class="ml-1 text-xs after:content-['|'] after:ml-1 after:text-gray-300 dark:after:text-gray-600"> 241 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .Version1 }}</code> 242 + {{ i "arrow-right" "size-3 inline-flex" }} 243 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .Version2 }}</code> 244 + </span> 245 + <a href="#commits" class="btn-flat text-xs hover:bg-gray-50 dark:hover:bg-gray-800"> 246 + {{ with .ChangeId }} 247 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ shortId . }}</code> 248 + {{ else }} 249 + <span class="px-1">All changes</span> 250 + {{ end }} 251 + </a> 252 + </span> 253 + {{ else }} 254 + <span class="text-sm"> 255 + Diff 256 + <span class="ml-1 text-xs after:content-['|'] after:ml-1 after:text-gray-300 dark:after:text-gray-600"> 257 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .VersionId }}</code> 258 + </span> 259 + <a href="#commits" class="btn-flat text-xs hover:bg-gray-50 dark:hover:bg-gray-800"> 260 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ shortId .DiffParams.Base }}</code> 261 + {{ i "arrow-right" "size-3 inline-flex" }} 262 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ shortId .DiffParams.Head }}</code> 263 + </a> 264 + </span> 265 + {{ end }} 266 + </div> 267 + <!-- right --> 268 + <div class="h-12 flex items-center gap-2"> 269 + <label title="Expand/Collapse diffs" class="btn font-normal normal-case"> 270 + <input type="checkbox" id="diff-collapse-toggle" class="peer/collapse hidden" checked/> 271 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 272 + {{ i "unfold-vertical" "size-4" }} 273 + <span class="hidden md:inline">Expand all</span> 274 + </span> 275 + <span class="peer-checked/collapse:inline-flex hidden items-center gap-2"> 276 + {{ i "fold-vertical" "size-4" }} 277 + <span class="hidden md:inline">Collapse all</span> 278 + </span> 279 + </label> 280 + 281 + <div class="md:hidden flex-grow"></div> 282 + 283 + <!-- diff settings --> 284 + <div id="diff-settings"> 285 + {{ template "repo/pulls/fragments/diffSettings" (dict "DiffUrl" $diffUrl "Unified" true) }} 286 + </div> 287 + 288 + {{ $isInterdiff := .IsInterdiff }} 289 + {{ $canInterdiff := gt .ActiveVersionId 0 }} 290 + <div class="btn-group"> 291 + <button 292 + type="button" 293 + role="link" 294 + onclick="window.location.href='/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ .ActiveVersionId }}';" 295 + class="btn-group-item {{ if not $isInterdiff }}active{{ end }}" 296 + > 297 + {{ i "diff" "size-4" }} 298 + Diff 299 + </button> 300 + <button 301 + type="button" 302 + role="link" 303 + onclick="window.location.href='/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ sub .ActiveVersionId 1 }}..{{ .ActiveVersionId }}';" 304 + class="btn-group-item {{ if $isInterdiff }}active{{ end }}" 305 + {{ if not $canInterdiff }}disabled{{ end }} 306 + > 307 + {{ i "chevrons-left-right-ellipsis" "size-4 rotate-90" }} 308 + Interdiff 309 + </button> 310 + </div> 311 + <label 312 + for="discussion-toggle" 313 + title="Toggle discussion panel" 314 + class="btn-flat hidden lg:inline-flex group-has-[#discussion-toggle:checked]/pr-layout:hidden" 315 + > 316 + {{ i "message-square-more" "size-4" }} 317 + </label> 318 + </div> 319 + </header> 320 + <div class="flex"> 321 + <input type="checkbox" id="diff-tree-toggle" class="peer hidden" checked> 322 + <aside id="diff-files" class="hidden peer-checked:md:block w-[20%] max-w-[40%]"> 323 + <div class=" 324 + sticky top-12 325 + max-h-[calc(100vh-3rem-0.5rem)] relative pb-2 326 + overflow-y-auto 327 + bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700 328 + "> 329 + <section class="text-sm px-6 py-2 w-full"> 330 + <div class="mb-8"> 331 + <div id="diff-files-content"> 332 + {{ i "loader-circle" "size-4 inline-flex animate-spin" }} loading... 333 + </div> 334 + </div> 335 + </section> 336 + </div> 337 + </aside> 338 + 339 + {{ template "resize-grip" (list "resize-diff-files" "diff-files" "before" "hidden peer-checked:md:flex") }} 340 + 341 + <div id="diff-list-container" class="flex-1 min-w-0"> 342 + <div 343 + id="diff-list" 344 + hx-get="{{ $diffUrl }}" 345 + hx-swap="outerHTML" 346 + hx-trigger="load" 347 + > 348 + {{ i "loader-circle" "size-4 inline-flex animate-spin" }} loading... 349 + </div> 350 + </div> 351 + <script> 352 + (() => { 353 + const checkbox = document.getElementById('diff-collapse-toggle'); 354 + const diffList = document.getElementById('diff-list-container'); 355 + 356 + checkbox.addEventListener('change', () => { 357 + console.debug("checked", checkbox.checked); 358 + diffList.querySelectorAll('details[id^="file-"]').forEach(detail => { 359 + detail.open = checkbox.checked; 360 + }); 361 + }); 362 + 363 + if (window.__collapseToggleHandler) { 364 + diffList.removeEventListener('toggle', window.__collapseToggleHandler, true); 365 + } 366 + 367 + const handler = (e) => { 368 + if (!e.target.matches('details[id^="file-"]')) return; 369 + const details = document.querySelectorAll('details[id^="file-"]'); 370 + const allOpen = Array.from(details).every(d => d.open); 371 + const allClosed = Array.from(details).every(d => !d.open); 372 + 373 + if (allOpen) checkbox.checked = true; 374 + else if (allClosed) checkbox.checked = false; 375 + }; 376 + 377 + window.__collapseToggleHandler = handler; 378 + diffList.addEventListener('toggle', handler, true); 379 + })(); 380 + </script> 381 + </div> 382 + </div> 383 + </div> 384 + 385 + <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> 386 + 387 + <input type="checkbox" id="discussion-toggle" class="peer hidden" checked> 388 + {{ template "resize-grip" (list "resize-discussion" "discussion" "after" "hidden peer-checked:lg:flex") }} 389 + 390 + <div id="discussion" class=" 391 + fixed z-30 bottom-0 right-0 w-full max-h-[calc(100vh-10rem)] rounded-t-2xl 392 + bg-slate-100 dark:bg-gray-900 393 + lg:sticky lg:top-0 lg:hidden peer-checked:lg:block lg:w-[25vw] lg:max-w-[50vw] lg:max-h-screen 394 + lg:bg-transparent 395 + "> 396 + <details open id="bottom-sheet" class=" 397 + flex flex-col group/history 398 + {{ if .Pull.State.IsOpen -}} *:border-green-600 *:dark:border-green-900 399 + {{ else if .Pull.State.IsMerged -}} *:border-purple-600 *:dark:border-purple-900 400 + {{ else if .Pull.State.IsAbandoned -}} *:border-red-600 *:dark:border-red-900 401 + {{ else -}} *:border-gray-600 *:dark:border-gray-900 402 + {{ end }} 403 + "> 404 + {{ $versions := len .Pull.Versions }} 405 + {{ $comments := .Pull.TotalComments }} 406 + <summary class=" 407 + list-none z-20 flex items-center justify-between 408 + pl-3 pr-4 py-2 rounded-t-2xl 409 + relative 410 + border-t-4 border-x-4 411 + group-open/history:bg-transparent 412 + lg:border-0 413 + lg:h-12 lg:px-1 lg:pointer-events-none 414 + lg:bg-transparent 415 + "> 416 + <div class="flex items-center gap-2"> 417 + <span class="lg:hidden"> 418 + {{ template "repo/pulls/fragments/pullState" .Pull.State }} 419 + </span> 420 + <h2>History</h2> 421 + </div> 422 + <div class="flex items-center gap-2 text-sm"> 423 + <span> 424 + {{ $versions }} version{{ if ne $versions 1 }}s{{ end }} 425 + </span> 426 + <span class="select-none before:content-['\00B7']"></span> 427 + <span> 428 + {{ $comments }} comment{{ if ne $comments 1 }}s{{ end }} 429 + </span> 430 + <label for="discussion-toggle" title="Toggle discussion panel" class="hidden lg:inline-flex btn-flat pointer-events-auto"> 431 + {{ i "message-square" "size-4" }} 432 + </label> 433 + <span class="lg:hidden"> 434 + <span class="group-open/history:hidden inline">{{ i "chevron-up" "size-4" }}</span> 435 + <span class="group-open/history:inline hidden">{{ i "chevron-down" "size-4" }}</span> 436 + </span> 437 + </div> 438 + </summary> 439 + <div class=" 440 + max-h-[calc(100vh-3rem-0.5rem)] pb-32 overflow-y-auto space-y-4 441 + px-1 border-x-4 442 + lg:px-0 lg:border-0 443 + "> 444 + {{ range .Pull.Versions }} 445 + {{ $active := eq .ID $.ActiveVersionId }} 446 + <div 447 + id="version-{{ .ID }}" 448 + class=" 449 + {{ if $active }}active{{ end }} 450 + w-full shadow-sm rounded overflow-clip bg-gray-50 dark:bg-gray-900 451 + " 452 + > 453 + <!-- header --> 454 + <header class="sticky top-0 z-20 bg-slate-100 dark:bg-gray-900"> 455 + <div class="pt-2 pr-2 pb-4 pl-4 lg:pl-6 flex gap-2 456 + rounded-t border-t border-x border-gray-200 dark:border-gray-700 457 + {{ if $active }} 458 + bg-blue-50 dark:bg-blue-950 459 + {{ else }} 460 + bg-white dark:bg-gray-800 461 + {{ end }} 462 + "> 463 + <!-- left column: just profile picture --> 464 + <div class="flex-shrink-0 pt-2"> 465 + {{ template "user/fragments/picLink" (list $.Pull.OwnerDid.String "size-8" (index $.VouchRelationships $.Pull.OwnerDid)) }} 466 + </div> 467 + <!-- right column --> 468 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 469 + <!-- submission info --> 470 + {{ $handle := resolve $.Pull.OwnerDid.String }} 471 + <div class="flex gap-2 items-center justify-between mb-1"> 472 + <span class="inline-flex items-center gap-2 text-sm pt-2 473 + {{ if $active }} 474 + text-gray-600 dark:text-gray-300 475 + {{ else }} 476 + text-gray-500 dark:text-gray-400 477 + {{ end }}" 478 + > 479 + <a href="/{{ $handle }}">{{ $handle }}</a> 480 + submitted 481 + <span class=" 482 + px-2 py-0.5 rounded font-mono text-xs border 483 + {{ if $active }} 484 + text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500 485 + {{ else }} 486 + text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 487 + {{ end }} 488 + ">#{{ .ID }}</span> 489 + <span class="select-none before:content-['\00B7']"></span> 490 + <a href="/{{ $.Pull.RepoDid }}/pulls/{{ $.Pull.PullId }}/{{ .ID }}">{{ template "repo/fragments/shortTime" .Created }}</a> 491 + </span> 492 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 493 + href="/{{ $.Pull.RepoDid }}/pulls/{{ $.Pull.PullId }}/{{ .ID }}"> 494 + {{ i "diff" "size-4" }} 495 + Diff 496 + </a> 497 + </div> 498 + {{ if eq .ID $.Pull.LatestVersionNumber }} 499 + <div id="mergecheck-banner"> 500 + {{ if $.Pull.State.IsOpen }} 501 + <div class="flex items-center gap-2 text-gray-500 dark:text-gray-400"> 502 + {{ i "loader-circle" "size-4 animate-spin" }} 503 + <span>Checking mergeability…</span> 504 + </div> 505 + {{ end }} 506 + </div> 507 + {{ end }} 508 + </div> 509 + </div> 510 + </header> 511 + <!-- comments --> 512 + {{ $count := len .Comments }} 513 + <details 514 + class="relative pl-8 lg:pl-10 rounded-b border-x border-b border-gray-200 dark:border-gray-700 group/comments group/collapse" 515 + {{ if or $active (eq $count 0) }}open{{ end }} 516 + > 517 + <summary class="cursor-pointer list-none"> 518 + <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"> 519 + <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> 520 + </div> 521 + <div class="group-open/comments:hidden block relative group/summary py-4"> 522 + <div class="absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center z-4"> 523 + <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> 524 + </div> 525 + <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"> 526 + {{ i "circle-plus" "size-4 z-5" }} 527 + Expand {{ $count }} comment{{ if ne $count 1 }}s{{ end }} 528 + </span> 529 + </div> 530 + </summary> 531 + <div id="pull-comments-{{ .ID }}"> 532 + {{ range .Comments }} 533 + {{ template "fragments/comment/pullComment" 534 + (dict "LoggedInUser" $.LoggedInUser 535 + "Reactions" (index (asReactionMapMap $.Reactions) .FeedCommentAtUri) 536 + "UserReacted" (index (asReactionStatusMapMap $.UserReacted) .FeedCommentAtUri) 537 + "Comment" .) }} 538 + {{ end }} 539 + </div> 540 + {{ if gt $count 0}} 541 + <button 542 + 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" 543 + hx-on:click="this.closest('details').open = false" 544 + > 545 + <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 }} 546 + </button> 547 + {{ end }} 548 + 549 + <div hx-include="this" class="relative -ml-8 lg:-ml-10 rounded-b bg-gray-50 dark:bg-gray-900"> 550 + {{ if eq .ID $.Pull.LatestVersionNumber }} 551 + {{ block "mergeStatus" $ }} {{ end }} 552 + <div id="resubmit-banner"></div> 553 + <div id="pull-action-error" class="error"></div> 554 + {{ end }} 555 + <input name="subject-uri" type="hidden" value="{{ $.Pull.AtUri }}"> 556 + <input name="pull-round-idx" type="hidden" value="{{ .ID }}"> 557 + {{ template "repo/pulls/fragments/pullActions" 558 + (dict 559 + "LoggedInUser" $.LoggedInUser 560 + "Pull" $.Pull 561 + "RepoInfo" $.RepoInfo 562 + "RoundNumber" .ID 563 + "MergeCheck" $.MergeCheck 564 + "ResubmitCheck" $.ResubmitCheck 565 + "BranchDeleteStatus" $.BranchDeleteStatus 566 + "Loading" (eq .ID $.Pull.LatestVersionNumber)) }} 567 + </div> 568 + </details> 569 + </div> 570 + {{ end }} 571 + </div> 572 + <div class="hidden sticky bottom-0 h-12"> 573 + <!-- TODO: bottom PR actions --> 574 + </div> 575 + </details> 576 + </div> 577 + <script> 578 + (function() { 579 + const details = document.getElementById('bottom-sheet'); 580 + const backdrop = document.getElementById('bottom-sheet-backdrop'); 581 + const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; 582 + 583 + // function to update backdrop 584 + const updateBackdrop = () => { 585 + if (backdrop) { 586 + if (details.open && !isDesktop()) { 587 + backdrop.classList.remove('opacity-0', 'pointer-events-none'); 588 + backdrop.classList.add('opacity-100', 'pointer-events-auto'); 589 + document.body.style.overflow = 'hidden'; 590 + } else { 591 + backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 592 + backdrop.classList.add('opacity-0', 'pointer-events-none'); 593 + document.body.style.overflow = ''; 594 + } 595 + } 596 + }; 597 + 598 + // close on mobile initially 599 + if (!isDesktop()) { 600 + details.open = false; 601 + } 602 + updateBackdrop(); // initialize backdrop 603 + 604 + // prevent closing on desktop 605 + details.addEventListener('toggle', function(e) { 606 + if (isDesktop() && !this.open) { 607 + this.open = true; 608 + } 609 + updateBackdrop(); 610 + }); 611 + 612 + const mediaQuery = window.matchMedia('(min-width: 768px)'); 613 + mediaQuery.addEventListener('change', function(e) { 614 + if (e.matches) { 615 + // switched to desktop - keep open 616 + details.open = true; 617 + } else { 618 + // switched to mobile - close 619 + details.open = false; 620 + } 621 + updateBackdrop(); 622 + }); 623 + 624 + // close when clicking backdrop 625 + if (backdrop) { 626 + backdrop.addEventListener('click', () => { 627 + if (!isDesktop()) { 628 + details.open = false; 629 + } 630 + }); 631 + } 632 + })(); 633 + </script> 634 + </div> 635 + {{ 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) {
+138
appview/pulls/actions.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages" 12 + "tangled.org/core/xrpc/xrpcclient" 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 + ) 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 + // only the last round's buttons and banners use merge/resubmit checks 55 + var mergeCheckParams pages.MergeCheckParams 56 + var resubmitResult = pages.Unknown 57 + if versionNumber == pull.LatestVersionNumber() { 58 + mergeCheckParams = s.composeMergeCheck(r.Context(), f, pull.TargetBranch, pull.SourceRepo, pull.LatestVersion().Head) 59 + if user != nil && syntax.DID(user.Did) == pull.OwnerDid { 60 + resubmitResult = s.resubmitCheck(r.Context(), pull) 61 + } 62 + } 63 + 64 + s.pages.PullActionsFragment(w, pages.PullActionsParams{ 65 + BaseParams: pages.BaseParamsFromContext(r.Context()), 66 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 67 + Pull: pull, 68 + RoundNumber: versionNumber, 69 + MergeCheck: mergeCheckParams, 70 + ResubmitCheck: resubmitResult, 71 + BranchDeleteStatus: s.branchDeleteStatus(r, f, pull), 72 + }) 73 + return 74 + } 75 + } 76 + 77 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *pages.BranchDeleteStatus { 78 + if pull.State != models.PullMerged || pull.SourceBranch == nil { 79 + return nil 80 + } 81 + 82 + user := s.oauth.GetMultiAccountUser(r) 83 + if user == nil { 84 + return nil 85 + } 86 + 87 + branch := *pull.SourceBranch 88 + 89 + if syntax.DID(repo.RepoDid) != pull.SourceRepo { 90 + var err error 91 + repo, err = db.GetRepoByDid(s.db, pull.SourceRepo.String()) 92 + if err != nil { 93 + return nil 94 + } 95 + } 96 + 97 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 98 + if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 99 + return nil 100 + } 101 + 102 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 103 + resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 104 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 105 + s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 106 + return nil 107 + } 108 + 109 + return &pages.BranchDeleteStatus{ 110 + Repo: repo, 111 + Branch: resp.Name, 112 + } 113 + } 114 + 115 + func (s *Pulls) resubmitCheck(ctx context.Context, pull *models.Pull) pages.ResubmitResult { 116 + if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.SourceBranch == nil { 117 + return pages.Unknown 118 + } 119 + 120 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 121 + branchResp, err := tangled.GitTempGetBranch(ctx, xrpcc, *pull.SourceBranch, pull.SourceRepo.String()) 122 + if err != nil { 123 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 124 + s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", *pull.SourceBranch) 125 + return pages.Unknown 126 + } 127 + s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 128 + return pages.Unknown 129 + } 130 + 131 + targetBranch := branchResp 132 + 133 + if pull.LatestVersion().Head != targetBranch.Hash { 134 + return pages.ShouldResubmit 135 + } 136 + 137 + return pages.ShouldNotResubmit 138 + }
+2 -3
appview/pulls/comment.go
··· 26 26 } 27 27 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 28 28 29 - roundNumberStr := chi.URLParam(r, "round") 29 + roundNumberStr := chi.URLParam(r, "version") 30 30 roundNumber, err := strconv.Atoi(roundNumberStr) 31 - if err != nil || roundNumber < 0 || roundNumber >= len(pull.Submissions) { 31 + if err != nil || roundNumber < 0 || roundNumber >= len(pull.Versions) { 32 32 http.Error(w, "bad round id", http.StatusBadRequest) 33 33 l.Error("failed to parse round id", "err", err, "round_number_str", roundNumberStr) 34 34 return ··· 38 38 case http.MethodGet: 39 39 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 40 40 BaseParams: pages.BaseParamsFromContext(r.Context()), 41 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 42 41 Pull: pull, 43 42 RoundNumber: roundNumber, 44 43 })
+171 -306
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" ··· 14 15 15 16 "tangled.org/core/api/tangled" 16 17 "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/knotcompat" 17 19 "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/oauth" 19 20 "tangled.org/core/appview/pages" 20 21 "tangled.org/core/appview/pages/markup/sanitizer" 21 - "tangled.org/core/patchutil" 22 + "tangled.org/core/consts" 22 23 "tangled.org/core/types" 23 - "tangled.org/core/xrpc/xrpcclient" 24 24 25 25 "github.com/bluesky-social/indigo/atproto/syntax" 26 26 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 49 49 s.pages.Error503(w) 50 50 return 51 51 } 52 - s.pages.RepoNewPull(w, params) 52 + if err := s.pages.RepoNewPull(w, params); err != nil { 53 + l.Error("failed to render", "err", err) 54 + } 53 55 54 56 case http.MethodPost: 55 - title := r.FormValue("title") 56 - body := r.FormValue("body") 57 - targetBranch := r.FormValue("targetBranch") 58 - fromFork := r.FormValue("fork") 57 + userDid := syntax.DID(user.Did) 58 + var ( 59 + title = r.FormValue("title") 60 + body = r.FormValue("body") 61 + targetBranch = r.FormValue("targetBranch") 62 + sourceRepoRaw = cmp.Or(r.FormValue("fork"), f.RepoDid) 63 + ) 64 + sourceRepoDid, err := syntax.ParseDID(sourceRepoRaw) 65 + if err != nil { 66 + s.pages.Notice(w, "pull", fmt.Sprintf("Source repo is invalid: %q", sourceRepoRaw)) 67 + return 68 + } 59 69 sourceBranch := r.FormValue("sourceBranch") 60 70 patch := r.FormValue("patch") 61 - userDid := syntax.DID(user.Did) 62 71 63 - if targetBranch == "" { 64 - s.pages.Notice(w, "pull", "Target branch is required.") 72 + if title == "" { 73 + s.pages.Notice(w, "pull", "Title is required") 74 + return 75 + } 76 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 77 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 65 78 return 66 79 } 67 80 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 - } 81 + if targetBranch == "" { 82 + s.pages.Notice(w, "pull", "Target branch is required.") 83 + return 85 84 } 86 85 87 86 // Validate we have at least one valid PR creation method 88 - if !isBranchBased && !isPatchBased && !isForkBased { 87 + if sourceBranch == "" && patch == "" { 89 88 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 90 89 return 91 90 } 92 - 93 91 // Can't mix branch-based and patch-based approaches 94 - if isBranchBased && patch != "" { 92 + if sourceBranch != "" && patch != "" { 95 93 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 96 94 return 97 95 } 98 96 99 - if isBranchBased && sourceBranch == targetBranch { 100 - s.pages.Notice(w, "pull", "Source and target branch must be different.") 101 - return 97 + var sourceRepo *models.Repo 98 + if sourceRepoDid == syntax.DID(f.RepoDid) { 99 + sourceRepo = f 100 + } else { 101 + var err error 102 + sourceRepo, err = db.GetRepoByDid(s.db, sourceRepoDid.String()) 103 + if err != nil { 104 + s.pages.Notice(w, "pull", fmt.Sprintf("Unknown source repository: %q", sourceRepoDid)) 105 + return 106 + } 102 107 } 103 108 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 109 + if sourceBranch != "" { 110 + roles := s.acl.RolesInRepo(r.Context(), sourceRepo, userDid.String()) 111 + if !roles.IsPushAllowed() { 112 + s.pages.Notice(w, "pull", "Cannot select forbidden branch.") 113 + return 111 114 } 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 - }, 124 115 } 125 116 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.") 117 + if sourceRepoDid == syntax.DID(f.RepoDid) && sourceBranch == targetBranch { 118 + s.pages.Notice(w, "pull", "Source and target branch must be different.") 128 119 return 129 120 } 130 121 131 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 132 - stackBodies := parseBracketedForm(r.Form, "stackBody") 122 + if ok := knotcompat.KnotHasCapability(r.Context(), f.Knot, s.config.Core.Dev, consts.CapKeepCommit); !ok { 123 + s.pages.Notice(w, "pull", "Source repo's knot doesn't support ref-based pull requests. Try another way?") 124 + return 125 + } 133 126 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) 127 + if sourceBranch != "" { 128 + s.handlePull(w, r, userDid, f, targetBranch, sourceRepo, sourceBranch, title, body) 129 + return 130 + } else if patch != "" { 131 + s.pages.Notice(w, "pull", "Patch based PR is currently unsupported.") 132 + return 153 133 } 154 - return 155 134 } 156 135 } 157 136 ··· 160 139 s.pages.MarkdownPreviewFragment(w, body) 161 140 } 162 141 142 + func (s *Pulls) PullComposeDiffFragment(w http.ResponseWriter, r *http.Request) { 143 + l := s.logger.With("handler", "PullComposeDiffFragment") 144 + ctx := r.Context() 145 + 146 + var ( 147 + repoRaw = r.URL.Query().Get("repo") 148 + base = r.URL.Query().Get("base") // base commit ID 149 + head = r.URL.Query().Get("head") // head commit ID 150 + unified = r.URL.Query().Get("view") == "unified" 151 + ) 152 + repo, err := syntax.ParseDID(repoRaw) 153 + if err != nil { 154 + http.Error(w, "invalid repo DID", http.StatusBadRequest) 155 + return 156 + } 157 + l.Debug("compose diff fragment", "base", base, "head", head) 158 + 159 + var params pages.PullDiffFragmentParams 160 + params.Repo = repo 161 + params.DiffBase = base 162 + params.DiffHead = head 163 + params.DiffUrl = r.URL.Path 164 + params.Unified = unified 165 + params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, repo, base, head, unified) 166 + if err := s.pages.PullComposeDiffFragment(w, params); err != nil { 167 + l.Error("failed to render", "err", err) 168 + } 169 + } 170 + 163 171 func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 164 172 l := s.logger.With("handler", "RefreshCompose") 165 173 ··· 183 191 func composeCanonicalURL(params pages.RepoNewPullParams) string { 184 192 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 185 193 q := url.Values{} 186 - if params.IsStacked { 187 - q.Set("mode", "stack") 188 - } 189 194 if params.Source != "" && params.Source != pages.SourceBranch { 190 195 q.Set("source", string(params.Source)) 191 196 } ··· 210 215 211 216 branches, err := s.listBranches(r.Context(), repo) 212 217 if err != nil { 213 - return pages.RepoNewPullParams{}, err 218 + return pages.RepoNewPullParams{}, fmt.Errorf("failed to list branches: %w", err) 214 219 } 215 220 216 221 var forks []models.Repo ··· 223 228 forks = slices.DeleteFunc(forks, func(f models.Repo) bool { 224 229 return f.RepoDid == "" 225 230 }) 231 + 232 + f, err := s.repoResolver.Resolve(r) 233 + if err != nil { 234 + return pages.RepoNewPullParams{}, fmt.Errorf("failed to resolve repo: %w", err) 235 + } 226 236 227 237 repoInfo := s.repoResolver.GetRepoInfo(r, user) 228 238 source, ok := pages.ParseSource(r.FormValue("source")) ··· 242 252 fork = forks[0].RepoDid 243 253 } 244 254 255 + var prefillErr error 256 + 245 257 var forkBranches []types.Branch 246 - var forkBranchesErr error 247 258 if source == pages.SourceFork && fork != "" { 248 - forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 249 - if forkBranchesErr != nil { 250 - l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 259 + forkBranches, err = s.listForkBranches(r.Context(), fork) 260 + if err != nil { 261 + l.Warn("failed to list fork branches", "err", prefillErr, "fork", fork) 262 + prefillErr = errors.Join(prefillErr, err) 251 263 } 252 264 } 253 265 ··· 255 267 targetBranch = defaultTargetBranch(branches, targetBranch) 256 268 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 257 269 258 - comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 259 - var prefillErr string 260 - if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 261 - prefillErr = joined.Error() 270 + var sourceRepo syntax.DID 271 + if fork != "" { 272 + sourceRepo = syntax.DID(fork) 273 + } else { 274 + sourceRepo = syntax.DID(repoInfo.RepoDid) 275 + } 276 + 277 + if sourceRepo == "" || sourceBranch == "" || targetBranch == "" { 278 + // return params 279 + l.Error("what's wrong", "source", sourceRepo, "source.branch", sourceBranch, "target.branch", targetBranch) 280 + return pages.RepoNewPullParams{ 281 + BaseParams: pages.BaseParamsFromContext(r.Context()), 282 + RepoInfo: repoInfo, 283 + Branches: branches, 284 + SourceBranches: sourceBranchList, 285 + ForkBranches: forkBranches, 286 + Forks: forks, 287 + Source: source, 288 + SourceBranch: sourceBranch, 289 + TargetBranch: targetBranch, 290 + Fork: fork, 291 + Patch: patch, 292 + // Title: title, 293 + // Body: body, 294 + // StepReviewParams: &stepReviewParams, 295 + // MergeCheck: mergeCheckParams, 296 + // PrefillError: prefillErrorMsg, 297 + // LabelDefs: labelDefs, 298 + // LabelState: labelState, 299 + }, nil 262 300 } 263 301 264 - mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 302 + var stepReviewParams pages.RepoNewPull_StepReviewParams 303 + 304 + commits, err := s.listCommits(r.Context(), sourceRepo, targetBranch, sourceBranch) 305 + if err != nil { 306 + prefillErr = errors.Join(prefillErr, err) 307 + } 308 + stepReviewParams.Commits = commits 265 309 266 - refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 267 - var diffOpts types.DiffOpts 268 - if r.FormValue("diff") == "split" { 269 - diffOpts.Split = true 310 + var prefillErrorMsg string 311 + if prefillErr != nil { 312 + prefillErrorMsg = prefillErr.Error() 270 313 } 271 - diffOpts.RefreshUrl = refreshUrl 272 - diffOpts.Target = "#diff-area" 273 314 274 315 labelDefs, err := s.pullLabelDefs(repo) 275 316 if err != nil { 276 - l.Warn("failed to load label definitions", "err", err) 317 + l.Error("failed to load label definitions", "err", err) 277 318 } 278 319 labelState := labelStateFromForm(r.Form, labelDefs) 279 - perCidLabelForms := parseStackLabelForms(r.Form) 280 - stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 281 - for cid, perForm := range perCidLabelForms { 282 - stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 283 - } 284 - 285 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 286 - stackBodies := parseBracketedForm(r.Form, "stackBody") 287 - stackSplits := parseBracketedForm(r.Form, "stackSplit") 288 320 289 321 title := r.FormValue("title") 290 322 body := r.FormValue("body") 291 323 titleDirty := r.FormValue("titleDirty") == "1" 292 324 bodyDirty := r.FormValue("bodyDirty") == "1" 293 - if comparison != nil && len(comparison.FormatPatch) > 0 { 294 - first := comparison.FormatPatch[0] 295 - if !titleDirty && first.PatchHeader != nil { 296 - title = first.Title 325 + if len(commits) == 1 { 326 + message := strings.SplitN(strings.TrimSpace(commits[0].Message), "\n\n", 2) 327 + if !titleDirty { 328 + title = message[0] 297 329 } 298 - if !bodyDirty && first.PatchHeader != nil { 299 - body = first.Body 330 + if !bodyDirty && len(message) > 1 && message[1] != "" { 331 + // TODO: strip trailers? 332 + body = message[1] 300 333 } 301 334 } 302 335 303 - isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 304 - var stackedDiffs []pages.StackedDiff 305 - if isStacked { 306 - stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 336 + l.Debug("label defs", "defs", labelDefs) 337 + 338 + var mergeCheckParams pages.MergeCheckParams 339 + if len(commits) > 0 { 340 + mergeCheckParams = s.composeMergeCheck(r.Context(), f, targetBranch, sourceRepo, commits[0].Hash.String()) 307 341 } 308 342 309 343 return pages.RepoNewPullParams{ ··· 322 356 Body: body, 323 357 TitleDirty: titleDirty, 324 358 BodyDirty: bodyDirty, 325 - IsStacked: isStacked, 326 - Comparison: comparison, 327 - Diff: diff, 328 - DiffOpts: diffOpts, 329 - StackedDiffs: stackedDiffs, 330 - MergeCheck: mergeCheck, 331 - StackTitles: stackTitles, 332 - StackBodies: stackBodies, 333 - PrefillError: prefillErr, 359 + StepReviewParams: &stepReviewParams, 360 + MergeCheck: mergeCheckParams, 361 + PrefillError: prefillErrorMsg, 334 362 LabelDefs: labelDefs, 335 363 LabelState: labelState, 336 - StackLabelStates: stackLabelStates, 337 364 }, nil 338 365 } 339 366 ··· 415 442 return out 416 443 } 417 444 418 - func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 419 - var ( 420 - comparison *types.RepoFormatPatchResponse 421 - err error 422 - ) 423 - switch source { 424 - case pages.SourcePatch: 425 - if strings.TrimSpace(patch) == "" { 426 - return nil, nil, nil 427 - } 428 - if verr := validatePatch(&patch); verr != nil { 429 - return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 430 - } 431 - comparison = parsePastedPatch(patch) 432 - case pages.SourceBranch: 433 - if targetBranch == "" || sourceBranch == "" { 434 - return nil, nil, nil 435 - } 436 - comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 437 - case pages.SourceFork: 438 - if fork == "" || targetBranch == "" || sourceBranch == "" { 439 - return nil, nil, nil 440 - } 441 - comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 442 - default: 443 - return nil, nil, nil 444 - } 445 + func (s *Pulls) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams { 446 + l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo, "branch", targetBranch, "source", sourceCommit) 447 + xrpcc := s.knotClient(targetRepo.Knot) 448 + out, err := tangled.GitMergeCheck(ctx, xrpcc, &tangled.GitMergeCheck_Input{ 449 + Repo: targetRepo.RepoDid, 450 + Branch: targetBranch, 451 + Source: &tangled.GitMergeCheck_Input_Source{ 452 + Repo: sourceRepoDid.String(), 453 + Commit: sourceCommit, 454 + }, 455 + }) 445 456 if err != nil { 446 - s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 447 - return nil, nil, err 457 + l.Warn("failed to do merge-check", "err", err) 458 + return pages.MergeCheckParams{Error: "unimplemented"} 448 459 } 449 - 450 - return comparison, deriveDiff(comparison, targetBranch), nil 451 - } 452 - 453 - func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 454 - if comparison == nil || targetBranch == "" { 455 - return nil 460 + for _, conflict := range out.Conflicts { 461 + l.Debug("merge-check", "conflict", *conflict) 456 462 } 457 - patch := comparison.CombinedPatchRaw 458 - if patch == "" { 459 - patch = comparison.FormatPatchRaw 460 - } 461 - if patch == "" { 462 - return nil 463 - } 464 - 465 - xrpcc := s.knotClient(repo.Knot) 466 - 467 - resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 468 - Did: repo.Did, 469 - Name: repo.Name, 470 - Branch: targetBranch, 471 - Patch: patch, 472 - }) 473 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 474 - s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 475 - return &types.MergeCheckResponse{Error: xrpcerr.Error()} 463 + return pages.MergeCheckParams{ 464 + IsConflicted: out.IsConflicted, 465 + Conflicts: out.Conflicts, 476 466 } 477 - 478 - out := mergeCheckResponseFrom(resp) 479 - return &out 480 467 } 481 468 482 469 func bracketComponents(key, prefix string) ([]string, bool) { ··· 529 516 } 530 517 return out 531 518 } 532 - 533 - func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 534 - if patch == "" { 535 - return nil 536 - } 537 - response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 538 - if patchutil.IsFormatPatch(patch) { 539 - if patches, err := patchutil.ExtractPatches(patch); err == nil { 540 - response.FormatPatch = patches 541 - } 542 - } 543 - return response 544 - } 545 - 546 - func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 547 - xrpcc := s.knotClient(repo.Knot) 548 - 549 - xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 550 - if err != nil { 551 - return nil, err 552 - } 553 - 554 - var comparison types.RepoFormatPatchResponse 555 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 556 - return nil, err 557 - } 558 - return &comparison, nil 559 - } 560 - 561 - func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 562 - if forkRepoDid == "" { 563 - return nil, fmt.Errorf("fork not found") 564 - } 565 - fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 566 - if errors.Is(err, sql.ErrNoRows) { 567 - return nil, fmt.Errorf("fork not found") 568 - } 569 - if err != nil { 570 - return nil, err 571 - } 572 - 573 - client, err := s.oauth.ServiceClient( 574 - r, 575 - oauth.WithService(fork.Knot), 576 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 577 - oauth.WithDev(s.config.Core.Dev), 578 - ) 579 - if err != nil { 580 - return nil, err 581 - } 582 - 583 - resp, err := tangled.RepoHiddenRef( 584 - r.Context(), 585 - client, 586 - &tangled.RepoHiddenRef_Input{ 587 - ForkRef: sourceBranch, 588 - RemoteRef: targetBranch, 589 - Repo: fork.RepoAt().String(), 590 - }, 591 - ) 592 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 593 - return nil, xrpcerr 594 - } 595 - if !resp.Success { 596 - if resp.Error != nil { 597 - return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 598 - } 599 - return nil, fmt.Errorf("hidden ref failed") 600 - } 601 - 602 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 603 - forkXrpcc := s.knotClient(fork.Knot) 604 - 605 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 606 - if err != nil { 607 - return nil, err 608 - } 609 - 610 - var comparison types.RepoFormatPatchResponse 611 - if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 612 - return nil, err 613 - } 614 - return &comparison, nil 615 - } 616 - 617 - func stackPerCommitDiffs( 618 - comparison *types.RepoFormatPatchResponse, 619 - targetBranch, refreshUrl string, 620 - stackSplits map[string]string, 621 - ) []pages.StackedDiff { 622 - if comparison == nil { 623 - return nil 624 - } 625 - out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 626 - for i, p := range comparison.FormatPatch { 627 - nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 628 - out[i].Diff = &nd 629 - cid := p.ChangeIdOrEmpty() 630 - if cid == "" { 631 - continue 632 - } 633 - out[i].Opts = types.DiffOpts{ 634 - Split: stackSplits[cid] == "split", 635 - RefreshUrl: refreshUrl, 636 - Target: fmt.Sprintf("#stack-diff-%s", cid), 637 - Field: fmt.Sprintf("stackSplit[%s]", cid), 638 - } 639 - } 640 - return out 641 - } 642 - 643 - func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 644 - if comparison == nil { 645 - return nil 646 - } 647 - raw := comparison.CombinedPatchRaw 648 - if raw == "" { 649 - raw = comparison.FormatPatchRaw 650 - } 651 - d := patchutil.AsNiceDiff(raw, targetBranch) 652 - return &d 653 - }
-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 - }
+97 -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" 19 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 28 20 ) 29 21 30 - func (s *Pulls) handleBranchBasedPull( 22 + func (s *Pulls) handlePull( 31 23 w http.ResponseWriter, 32 24 r *http.Request, 33 - repo *models.Repo, 34 25 userDid syntax.DID, 26 + targetRepo *models.Repo, 27 + targetBranch string, 28 + sourceRepo *models.Repo, 29 + sourceBranch string, 35 30 title, 36 - body, 37 - targetBranch, 38 - sourceBranch string, 39 - isStacked bool, 40 - stackTitles, stackBodies map[string]string, 31 + body string, 41 32 ) { 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 - } 33 + l := s.logger.With("handler", "handlePull", "user", userDid) 34 + ctx := r.Context() 227 35 228 36 client, err := s.oauth.AuthorizedClient(r) 229 37 if err != nil { ··· 232 40 return 233 41 } 234 42 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.") 43 + // 1. fetch heads of source & target branches 44 + var base, head string 45 + { 46 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 47 + g, gctx := errgroup.WithContext(ctx) 48 + g.Go(func() error { 49 + // find merge-base between targetBranch & sourceBranch 50 + out, err := tangled.GitTempGetMergeBase(gctx, xrpcc, targetBranch, sourceBranch, sourceRepo.RepoDid) 51 + if err != nil { 52 + return err 53 + } 54 + base = out.Commit 55 + return nil 56 + }) 57 + g.Go(func() error { 58 + out, err := tangled.GitTempGetBranch(gctx, xrpcc, sourceBranch, sourceRepo.RepoDid) 59 + if err != nil { 60 + return err 61 + } 62 + head = out.Hash 63 + return nil 64 + }) 65 + if err := g.Wait(); err != nil { 66 + l.Error("failed to fetch branch heads", "err", err) 67 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 253 68 return 254 69 } 255 - 256 - if title == "" { 257 - title = formatPatches[0].Title 258 - } 259 - if body == "" { 260 - body = formatPatches[0].Body 261 - } 262 - } 263 - 264 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 265 - 266 - rkey := tid.TID() 267 - 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 70 } 274 71 275 - now := time.Now() 276 - 72 + created := time.Now() 277 73 pull := &models.Pull{ 74 + ID: -1, // uninitialized 75 + OwnerDid: userDid, 76 + Rkey: syntax.RecordKey(tid.TID()), 77 + Cid: "", // uninitialized 78 + RepoDid: syntax.DID(targetRepo.RepoDid), 79 + PullId: 0, // uninitialized 80 + 278 81 Title: title, 279 82 Body: body, 280 83 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{ 84 + SourceRepo: syntax.DID(sourceRepo.RepoDid), 85 + SourceBranch: &sourceBranch, 86 + Versions: []models.PullVersion{ 287 87 { 288 - Patch: patch, 289 - Combined: combined, 290 - SourceRev: sourceRev, 291 - Blob: *blob.Blob, 292 - Created: now, 88 + ID: 0, 89 + Base: base, 90 + Head: head, 91 + Created: created, 293 92 }, 294 93 }, 295 - PullSource: pullSource, 296 - State: models.PullOpen, 297 - Created: now, 298 - Repo: repo, 299 - } 94 + Created: created, 95 + State: models.PullOpen, 300 96 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 97 + Repo: targetRepo, 325 98 } 326 99 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 - } 369 - 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 375 - } 376 - 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) 100 + // 2. call git.keepCommit 101 + { 102 + client, err := s.oauth.ServiceClient( 103 + r, 104 + oauth.WithService(sourceRepo.Knot), 105 + oauth.WithLxm(tangled.GitKeepCommitNSID), 106 + oauth.WithDev(s.config.Core.Dev), 107 + ) 381 108 if err != nil { 382 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 109 + l.Error("failed to comment to knot", "err", err) 383 110 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 384 111 return 385 112 } 386 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 387 - blobs[i] = blob.Blob 388 - } 389 113 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), 114 + _, err = tangled.GitKeepCommit(ctx, client, &tangled.GitKeepCommit_Input{ 115 + Repo: sourceRepo.RepoDid, 116 + Record: pull.AtUri().String(), 117 + Source: &tangled.GitKeepCommit_Input_Source{ 118 + GitKeepCommit_Commit: &tangled.GitKeepCommit_Commit{ 119 + Repo: sourceRepo.RepoDid, 120 + Oid: head, 121 + }, 407 122 }, 408 123 }) 124 + if err != nil { 125 + l.Error("failed to keep commit", "err", err) 126 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 127 + return 128 + } 409 129 } 410 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 411 - Repo: userDid.String(), 412 - Writes: writes, 130 + 131 + // 3. create PR record 132 + record := pull.AsRecord() 133 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 134 + Collection: tangled.RepoPullNSID, 135 + Repo: userDid.String(), 136 + Rkey: pull.Rkey.String(), 137 + Record: knotcompat.Pull(&record), 413 138 }) 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 - } 139 + pull.Cid = syntax.CID(out.Cid) 419 140 420 - // create all pulls at once 421 141 tx, err := s.db.BeginTx(r.Context(), nil) 422 142 if err != nil { 423 143 l.Error("failed to start tx", "err", err) ··· 426 146 } 427 147 defer tx.Rollback() 428 148 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 - 149 + var references []syntax.ATURI 150 + if pull.Body != "" { 151 + _, references = s.mentionsResolver.Resolve(ctx, pull.Body) 437 152 } 438 153 439 - if err = tx.Commit(); err != nil { 440 - l.Error("failed to commit transaction for pull requests", "err", err) 154 + if err := db.PutPull(r.Context(), tx, pull, references); err != nil { 155 + l.Error("failed to create pull request in database", "err", err) 441 156 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 442 157 return 443 158 } 444 159 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) 160 + if err = tx.Commit(); err != nil { 161 + l.Error("failed to commit transaction for pull request", "err", err) 162 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 163 + return 450 164 } 451 165 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, 166 + s.notifier.NewPull(r.Context(), pull) 512 167 513 - DependentOn: parentAtUri, 514 - Repo: repo, 515 - } 168 + s.applyCreationLabels(r.Context(), client, userDid, pull, r.Form, targetRepo) 516 169 517 - stack = append(stack, &pull) 518 - 519 - parent := pull.AtUri() 520 - parentAtUri = &parent 521 - } 522 - 523 - return stack, nil 170 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, targetRepo) 171 + s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 524 172 }
+138
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/pages" 16 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 17 + ) 18 + 19 + // htmx fragment. render diff between commits 20 + func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) { 21 + l := s.logger.With("handler", "PullDiffFragment") 22 + ctx := r.Context() 23 + 24 + var ( 25 + repoRaw = r.URL.Query().Get("repo") 26 + base = r.URL.Query().Get("base") // base commit ID 27 + head = r.URL.Query().Get("head") // head commit ID 28 + unified = r.URL.Query().Get("view") == "unified" 29 + ) 30 + repo, err := syntax.ParseDID(repoRaw) 31 + if err != nil { 32 + http.Error(w, "invalid repo DID", http.StatusBadRequest) 33 + return 34 + } 35 + l.Debug("diff fragment", "base", base, "head", head) 36 + 37 + var params pages.PullDiffFragmentParams 38 + params.Repo = repo 39 + params.DiffBase = base 40 + params.DiffHead = head 41 + params.DiffUrl = r.URL.Path 42 + params.Unified = unified 43 + params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, repo, base, head, unified) 44 + if err := s.pages.PullDiffFragment(w, params); err != nil { 45 + l.Error("failed to render", "err", err) 46 + } 47 + } 48 + 49 + func (s *Pulls) diffFragmentParams(ctx context.Context, l *slog.Logger, repo syntax.DID, base, head string, unified bool) ([]pages.DiffFile, string) { 50 + // a. drain the diff stream into one fileDiff per changed file. 51 + var baseRevSpec []byte 52 + if base != "" { 53 + baseRevSpec = []byte(base) 54 + } 55 + stream, err := s.gitmirror.Diff(ctx, &gitmirrorv1.DiffRequest{ 56 + Repo: repo.String(), 57 + BaseRevSpec: baseRevSpec, 58 + HeadRevSpec: []byte(head), 59 + }) 60 + if err != nil { 61 + l.Error("failed to diff", "err", err) 62 + return nil, "Failed to diff. Try again later." 63 + } 64 + var files []*fileDiff 65 + for { 66 + fd, err := stream.Recv() 67 + if errors.Is(err, io.EOF) { 68 + break 69 + } 70 + if err != nil { 71 + l.Error("failed to drain diff response", "err", err) 72 + return nil, "Failed to diff. Try again later." 73 + } 74 + files = append(files, &fileDiff{diff: fd}) 75 + } 76 + 77 + // b. fetch each file's base/head blob in parallel and split into lines. 78 + g, gctx := errgroup.WithContext(ctx) 79 + for _, f := range files { 80 + g.Go(func() error { 81 + lhs, rhs := f.diff.GetLhsSrc(), f.diff.GetRhsSrc() 82 + // Binary/submodule files have no line content to fetch. 83 + if isBinaryOrSubmodule(lhs) || isBinaryOrSubmodule(rhs) { 84 + return nil 85 + } 86 + baseBlob, err := s.getBlob(gctx, repo, lhs.GetOid()) 87 + if err != nil { 88 + return err 89 + } 90 + headBlob, err := s.getBlob(gctx, repo, rhs.GetOid()) 91 + if err != nil { 92 + return err 93 + } 94 + 95 + // TODO: highlight each blobs 96 + // pass lhs_positions & rhs_positions so highlighter can apply diff highlights 97 + for line := range strings.SplitSeq(strings.TrimSuffix(string(baseBlob), "\n"), "\n") { 98 + f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 99 + } 100 + for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") { 101 + f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 102 + } 103 + 104 + // f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n") 105 + // f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n") 106 + return nil 107 + }) 108 + } 109 + if err := g.Wait(); err != nil { 110 + l.Error("failed to prepare html", "err", err) 111 + return nil, "Failed to render diff. Try again later." 112 + } 113 + 114 + // c. build the render model 115 + var outFiles []pages.DiffFile 116 + for _, f := range files { 117 + df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()} 118 + 119 + if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) { 120 + df.Note = "binary or submodule" 121 + outFiles = append(outFiles, df) 122 + continue 123 + } 124 + 125 + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { 126 + var dh pages.DiffHunk 127 + if unified { 128 + dh.Lines = buildUnifiedLines(h, f.baseLines, f.headLines) 129 + } else { 130 + dh.Rows = buildSplitRows(h, f.baseLines, f.headLines) 131 + } 132 + df.Hunks = append(df.Hunks, dh) 133 + } 134 + outFiles = append(outFiles, df) 135 + } 136 + 137 + return outFiles, "" 138 + }
+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 + }
+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))
+16 -76
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" ··· 135 134 136 135 repoInfo := s.repoResolver.GetRepoInfo(r, user) 137 136 137 + ctx := r.Context() 138 + 138 139 var pulls []*models.Pull 139 140 140 141 if searchOpts.HasSearchFilters() { 141 - res, err := s.indexer.Search(r.Context(), searchOpts) 142 + res, err := s.indexer.Search(ctx, searchOpts) 142 143 if err != nil { 143 144 l.Error("failed to search for pulls", "err", err) 144 145 return ··· 151 152 countOpts.Page = pagination.Page{Limit: 1} 152 153 for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 153 154 countOpts.State = &ps 154 - countRes, err := s.indexer.Search(r.Context(), countOpts) 155 + countRes, err := s.indexer.Search(ctx, countOpts) 155 156 if err != nil { 156 157 continue 157 158 } ··· 166 167 } 167 168 168 169 if len(res.Hits) > 0 { 169 - pulls, err = db.GetPulls( 170 + pulls, err = db.GetPullsPaginated( 171 + ctx, 170 172 s.db, 173 + pagination.Page{Limit: searchOpts.Page.Limit}, 171 174 orm.FilterIn("id", res.Hits), 172 175 ) 173 176 if err != nil { ··· 184 187 filters = append(filters, orm.FilterEq("state", *state)) 185 188 } 186 189 pulls, err = db.GetPullsPaginated( 190 + ctx, 187 191 s.db, 188 192 page, 189 193 filters..., ··· 195 199 } 196 200 } 197 201 198 - for _, p := range pulls { 199 - var pullSourceRepo *models.Repo 200 - if p.PullSource != nil { 201 - if p.PullSource.RepoDid != nil { 202 - pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid)) 203 - if err != nil { 204 - l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String()) 205 - continue 206 - } else { 207 - p.PullSource.Repo = pullSourceRepo 208 - } 209 - } 210 - } 211 - } 212 - 213 - var stacks []models.Stack 214 202 var shas []string 215 - 216 - pullMap := make(map[string]*models.Pull) 217 203 for _, p := range pulls { 218 - shas = append(shas, p.LatestSha()) 219 - pullMap[p.AtUri().String()] = p 220 - } 221 - 222 - // track which PRs have been added to stacks 223 - visited := make(map[string]bool) 224 - 225 - // group stacked PRs together using dependent_on relationships 226 - for _, p := range pulls { 227 - if visited[p.AtUri().String()] { 228 - continue 229 - } 230 - 231 - root := p 232 - for root.DependentOn != nil { 233 - if parent, ok := pullMap[root.DependentOn.String()]; ok { 234 - root = parent 235 - } else { 236 - break // parent not in current page 237 - } 238 - } 239 - 240 - var stack models.Stack 241 - current := root 242 - for { 243 - if visited[current.AtUri().String()] { 244 - break 245 - } 246 - stack = append(stack, current) 247 - visited[current.AtUri().String()] = true 248 - 249 - found := false 250 - for _, candidate := range pulls { 251 - if candidate.DependentOn != nil && 252 - candidate.DependentOn.String() == current.AtUri().String() { 253 - current = candidate 254 - found = true 255 - break 256 - } 257 - } 258 - if !found { 259 - break 260 - } 261 - } 262 - 263 - slices.Reverse(stack) 264 - stacks = append(stacks, stack) 204 + shas = append(shas, p.LatestVersion().Head) 265 205 } 266 206 267 207 // commitId -> latest pipeline ··· 325 265 } 326 266 327 267 err = s.pages.RepoPulls(w, pages.RepoPullsParams{ 328 - BaseParams: pages.BaseParamsFromContext(r.Context()), 329 - RepoInfo: repoInfo, 330 - Pulls: pulls, 331 - LabelDefs: defs, 332 - FilterState: filterState, 333 - FilterQuery: query.String(), 334 - Stacks: stacks, 268 + BaseParams: pages.BaseParamsFromContext(r.Context()), 269 + RepoInfo: repoInfo, 270 + Pulls: pulls, 271 + LabelDefs: defs, 272 + FilterState: filterState, 273 + FilterQuery: query.String(), 274 + // Stacks: stacks, 335 275 Pipelines: pipelines, 336 276 Page: page, 337 277 PullCount: totalPulls,
+41 -77
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 - } 52 - 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)) 58 - 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 66 - } 67 - 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 - Branch: pull.TargetBranch, 78 - Patch: patch, 79 - CommitMessage: &pull.Title, 80 - AuthorName: &authorName, 81 - } 82 - 83 - if pull.Body != "" { 84 - mergeInput.CommitBody = &pull.Body 85 - } 44 + // merge target branch 45 + { 46 + client, err := s.oauth.ServiceClient( 47 + r, 48 + oauth.WithService(f.Knot), 49 + oauth.WithLxm(tangled.RepoMergePullRequestNSID), 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 + } 86 58 87 - if email.Address != "" { 88 - mergeInput.AuthorEmail = &email.Address 89 - } 59 + var commit *tangled.RepoMergePullRequest_Input_Commit 60 + // TODO: pass custom merge commit body 90 61 91 - client, err := s.oauth.ServiceClient( 92 - r, 93 - oauth.WithService(f.Knot), 94 - oauth.WithLxm(tangled.RepoMergeNSID), 95 - oauth.WithDev(s.config.Core.Dev), 96 - oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 97 - ) 98 - if err != nil { 99 - l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 100 - s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 101 - return 62 + err = tangled.RepoMergePullRequest(r.Context(), client, &tangled.RepoMergePullRequest_Input{ 63 + Repo: pull.RepoDid.String(), 64 + Branch: pull.TargetBranch, 65 + Source: &tangled.RepoMergePullRequest_Input_Source{ 66 + Repo: pull.SourceRepo.String(), 67 + Commit: pull.LatestVersion().Head, 68 + }, 69 + Commit: commit, 70 + Style: "rebase", 71 + }) 72 + if err != nil { 73 + s.logger.Error("failed to merge", "err", err) 74 + s.pages.Notice(w, "pull-action-error", err.Error()) 75 + return 76 + } 102 77 } 103 78 104 - err = tangled.RepoMerge(r.Context(), client, mergeInput) 105 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 106 - s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 107 - s.pages.Notice(w, "pull-action-error", xrpcerr.Error()) 79 + if err := s.writePullStatusRecord(r, user.Did, pull.AtUri(), models.StateMerged); err != nil { 80 + l.Error("failed to write issue state record", "err", err) 81 + s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 108 82 return 109 83 } 110 84 111 - var atUris []syntax.ATURI 112 - for _, p := range pullsToMerge { 113 - atUris = append(atUris, p.AtUri()) 114 - p.State = models.PullMerged 115 - } 116 - 117 - if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateMerged); err != nil { 118 - l.Error("failed to write pull status records after merge", "err", err) 119 - } 120 - 121 85 tx, err := s.db.Begin() 122 86 if err != nil { 123 87 l.Error("failed to start transaction", "err", err) ··· 126 90 } 127 91 defer tx.Rollback() 128 92 129 - err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris)) 93 + err = db.MergePulls( 94 + tx, 95 + orm.FilterEq("at_uri", pull.AtUri()), 96 + ) 130 97 if err != nil { 131 98 l.Error("failed to update pull request status in database", "err", err) 132 99 s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") ··· 141 108 return 142 109 } 143 110 144 - // notify about the pull merge 145 - for _, p := range pullsToMerge { 146 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 147 - } 111 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), pull) 148 112 149 113 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 150 114 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
-30
appview/pulls/pulls.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "bytes" 5 - "compress/gzip" 6 4 "fmt" 7 - "io" 8 5 "log/slog" 9 - "strings" 10 6 "time" 11 7 12 8 "tangled.org/core/appview/config" ··· 22 18 knotmirror "tangled.org/core/gitmirror/proto/gen" 23 19 "tangled.org/core/idresolver" 24 20 "tangled.org/core/ogre" 25 - "tangled.org/core/patchutil" 26 21 "tangled.org/core/types" 27 22 28 23 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 93 88 return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)} 94 89 } 95 90 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 - } 103 - 104 91 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 -518
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" 24 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 19 ) 26 20 27 21 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { ··· 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, "pull-error", "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 - } 43 + noticeId := "resubmit-error" 118 44 119 - f, err := s.repoResolver.Resolve(r) 120 - if err != nil { 121 - l.Error("failed to get repo and knot", "err", err) 45 + if pull.OwnerDid != syntax.DID(user.Did) { 46 + s.pages.Notice(w, noticeId, "Unauthorized user. Try again later.") 122 47 return 123 48 } 124 49 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) 50 + if pull.SourceBranch == nil { 51 + // can't resubmit if source is unknown. fail earlier 52 + s.pages.Notice(w, noticeId, "PR source branch is unknown.") 129 53 return 130 54 } 55 + sourceBranch := *pull.SourceBranch 131 56 132 - xrpcc := s.knotClient(f.Knot) 133 - 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.") 57 + var sourceRepo *models.Repo 58 + if pull.SourceRepo == syntax.DID(repo.RepoDid) { 59 + sourceRepo = repo 60 + } else { 61 + var err error 62 + sourceRepo, err = db.GetRepoByDid(s.db, pull.SourceRepo.String()) 63 + if err != nil { 64 + s.pages.Notice(w, noticeId, fmt.Sprintf("Unknown source repository: %q", pull.SourceRepo)) 139 65 return 140 66 } 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 - } 145 - 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 } 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 - } 69 + ctx := r.Context() 194 70 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.") 71 + var base, head string 72 + { 73 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 74 + branch, err := tangled.GitTempGetBranch(ctx, xrpcc, sourceBranch, pull.RepoDid.String()) 75 + if err != nil { 76 + s.pages.Notice(w, noticeId, "Failed to get source branch") 234 77 return 235 78 } 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.") 79 + out, err := tangled.GitTempGetMergeBase(ctx, xrpcc, pull.TargetBranch, branch.Hash, pull.RepoDid.String()) 80 + if err != nil { 81 + s.pages.Notice(w, noticeId, "Failed to compute merge-base.") 291 82 return 292 83 } 293 - } 294 84 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 85 + head = branch.Hash 86 + base = out.Commit 306 87 } 307 88 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 89 + newVersion := models.PullVersion{ 90 + ID: pull.LatestVersionNumber() + 1, 91 + Base: base, 92 + Head: head, 93 + Created: time.Now(), 314 94 } 95 + pull.Versions = append(pull.Versions, newVersion) 315 96 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 321 - } 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 - }) 327 - 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 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 349 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 350 - } 351 - 352 - func (s *Pulls) resubmitStackedPullHelper( 353 - w http.ResponseWriter, 354 - r *http.Request, 355 - repo *models.Repo, 356 - userDid syntax.DID, 357 - pull *models.Pull, 358 - patch string, 359 - ) { 360 - l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 361 - 362 - targetBranch := pull.TargetBranch 363 - 364 - origStack, _ := r.Context().Value("stack").(models.Stack) 365 - 366 - formatPatches, err := patchutil.ExtractPatches(patch) 367 - if err != nil { 368 - l.Error("failed to extract patches", "err", err) 369 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 370 - return 371 - } 372 - 373 - // must have atleast 1 patch to begin with 374 - if len(formatPatches) == 0 { 375 - l.Error("no patches found in the generated format-patch") 376 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 377 - return 378 - } 379 - 380 - client, err := s.oauth.AuthorizedClient(r) 381 - if err != nil { 382 - l.Error("failed to get authorized client", "err", err) 383 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 384 - return 385 - } 386 - 387 - // first upload all blobs 388 - blobs := make([]*lexutil.LexBlob, len(formatPatches)) 389 - for i, p := range formatPatches { 390 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 97 + // keep new version head in Knot 98 + { 99 + client, err := s.oauth.ServiceClient( 100 + r, 101 + oauth.WithService(sourceRepo.Knot), 102 + oauth.WithLxm(tangled.GitKeepCommitNSID), 103 + oauth.WithDev(s.config.Core.Dev), 104 + ) 391 105 if err != nil { 392 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 393 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 106 + l.Error("failed to create service auth", "err", err) 107 + s.pages.Notice(w, noticeId, "Failed to create service auth. Try again later.") 394 108 return 395 109 } 396 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 397 - blobs[i] = blob.Blob 398 - } 399 110 400 - newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 401 - if err != nil { 402 - l.Error("failed to create resubmitted stack", "err", err) 403 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 404 - return 405 - } 406 - 407 - // find the diff between the stacks, first, map them by changeId 408 - origById := make(map[string]*models.Pull) 409 - newById := make(map[string]*models.Pull) 410 - for _, p := range origStack { 411 - origById[p.LatestSubmission().ChangeId()] = p 412 - } 413 - for _, p := range newStack { 414 - newById[p.LatestSubmission().ChangeId()] = p 415 - } 416 - 417 - // commits that got deleted: corresponding pull is closed 418 - // commits that got added: new pull is created 419 - // commits that got updated: corresponding pull is resubmitted & new round begins 420 - additions := make(map[string]*models.Pull) 421 - deletions := make(map[string]*models.Pull) 422 - updated := make(map[string]struct{}) 423 - 424 - // pulls in original stack but not in new one 425 - for _, op := range origStack { 426 - if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 427 - deletions[op.LatestSubmission().ChangeId()] = op 428 - } 429 - } 430 - 431 - // pulls in new stack but not in original one 432 - for _, np := range newStack { 433 - if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 434 - additions[np.LatestSubmission().ChangeId()] = np 435 - } 436 - } 437 - 438 - // NOTE: this loop can be written in any of above blocks, 439 - // but is written separately in the interest of simpler code 440 - for _, np := range newStack { 441 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 442 - // pull exists in both stacks 443 - updated[op.LatestSubmission().ChangeId()] = struct{}{} 444 - } 445 - } 446 - 447 - // NOTE: we can go through the newStack and update dependent relations and 448 - // rkeys now that we know which ones have been updated 449 - // update dependentOn relations for the entire stack 450 - var parentAt *syntax.ATURI 451 - for _, np := range newStack { 452 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 453 - // pull exists in both stacks 454 - np.Rkey = op.Rkey 455 - } 456 - np.DependentOn = parentAt 457 - x := np.AtUri() 458 - parentAt = &x 459 - } 460 - 461 - l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 462 - 463 - tx, err := s.db.Begin() 464 - if err != nil { 465 - l.Error("failed to start transaction", "err", err) 466 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 467 - return 468 - } 469 - defer tx.Rollback() 470 - 471 - // pds updates to make 472 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 473 - 474 - // deleted pulls are marked as deleted in the DB 475 - for _, p := range deletions { 476 - // do not do delete already merged PRs 477 - if p.State == models.PullMerged { 478 - continue 479 - } 480 - 481 - err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri())) 111 + _, err = tangled.GitKeepCommit(ctx, client, &tangled.GitKeepCommit_Input{ 112 + Repo: sourceRepo.RepoDid, 113 + Record: pull.AtUri().String(), 114 + Source: &tangled.GitKeepCommit_Input_Source{ 115 + GitKeepCommit_Commit: &tangled.GitKeepCommit_Commit{ 116 + Repo: sourceRepo.RepoDid, 117 + Oid: head, 118 + }, 119 + }, 120 + }) 482 121 if err != nil { 483 - l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 484 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 122 + l.Error("failed to keep commit", "err", err) 123 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 485 124 return 486 125 } 487 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 488 - RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 489 - Collection: tangled.RepoPullNSID, 490 - Rkey: p.Rkey, 491 - }, 492 - }) 493 126 } 494 127 495 - // new pulls are created 496 - for _, p := range additions { 497 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 128 + // update PDS record 129 + { 130 + client, err := s.oauth.AuthorizedClient(r) 498 131 if err != nil { 499 - l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 500 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 132 + s.pages.Notice(w, noticeId, "Unauthorized user. Try again later.") 501 133 return 502 134 } 503 - p.Submissions[0].Blob = *blob.Blob 504 135 505 - if err = db.PutPull(tx, p); err != nil { 506 - l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 507 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 136 + // NOTE: some old PR records are missing CID 137 + if err := s.ensurePullCid(ctx, client, pull); err != nil { 138 + s.pages.Notice(w, noticeId, "Failed to get existing PR record. Is PR deleted from PDS?") 508 139 return 509 140 } 510 141 511 - record := p.AsRecord() 512 - record.Rounds = []*tangled.RepoPull_Round{ 513 - { 514 - CreatedAt: time.Now().Format(time.RFC3339), 515 - PatchBlob: blob.Blob, 516 - }, 517 - } 518 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 519 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 520 - Collection: tangled.RepoPullNSID, 521 - Rkey: &p.Rkey, 522 - Value: knotcompat.Pull(&record), 523 - }, 142 + record := pull.AsRecord() 143 + exCid := pull.Cid.String() 144 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 145 + Collection: tangled.RepoPullNSID, 146 + Repo: pull.OwnerDid.String(), 147 + Rkey: pull.Rkey.String(), 148 + SwapRecord: &exCid, 149 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 524 150 }) 525 - } 526 - 527 - // updated pulls are, well, updated; to start a new round 528 - for id := range updated { 529 - op, _ := origById[id] 530 - np, _ := newById[id] 531 - 532 - // do not update already merged PRs 533 - if op.State == models.PullMerged { 534 - continue 535 - } 536 - 537 - // resubmit the new pull 538 - np.Rkey = op.Rkey 539 - pullAt := op.AtUri() 540 - newRoundNumber := len(op.Submissions) 541 - newPatch := np.LatestPatch() 542 - combinedPatch := np.LatestSubmission().Combined 543 - newSourceRev := np.LatestSha() 544 - 545 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 546 151 if err != nil { 547 - l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 548 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 549 - return 550 - } 551 - 552 - // create new round 553 - err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 554 - if err != nil { 555 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 556 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 152 + l.Error("failed to create PDS record", "err", err) 153 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 557 154 return 558 155 } 559 - 560 - // update dependent-on relation 561 - if np.DependentOn != nil { 562 - err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 563 - if err != nil { 564 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 565 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 566 - return 567 - } 568 - } 569 - 570 - record := np.AsRecord() 571 - record.Rounds = op.AsRecord().Rounds 572 - record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 573 - CreatedAt: time.Now().Format(time.RFC3339), 574 - PatchBlob: blob.Blob, 575 - }) 576 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 577 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 578 - Collection: tangled.RepoPullNSID, 579 - Rkey: op.Rkey, 580 - Value: knotcompat.Pull(&record), 581 - }, 582 - }) 583 - } 584 - 585 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 586 - Repo: userDid.String(), 587 - Writes: writes, 588 - }) 589 - if err != nil { 590 - l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 591 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 592 - return 156 + pull.Cid = syntax.CID(out.Cid) 593 157 } 594 158 595 - err = tx.Commit() 596 - if err != nil { 597 - l.Error("failed to commit resubmit transaction", "err", err) 598 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 159 + if err := db.SubmitPullVersion(ctx, s.db, pull.AtUri(), newVersion); err != nil { 160 + l.Error("failed to update PR in DB", "err", err) 161 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 599 162 return 600 163 } 601 164 602 165 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 603 166 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 604 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, repo := 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, repo := 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, repo := 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, repo := 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, repo := 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 - }
+18 -20
appview/pulls/router.go
··· 12 12 r.With(middleware.Paginate).Get("/", s.RepoPulls) 13 13 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 14 14 r.Get("/", s.NewPull) 15 - r.Get("/refresh", s.RefreshCompose) 16 - r.Post("/refresh", s.RefreshCompose) 17 - r.Post("/preview", s.MarkdownPreview) 18 15 r.Post("/", s.NewPull) 16 + r.Post("/refresh", s.RefreshCompose) // TODO: remove this. we just have to refresh source & review steps 19 17 }) 18 + r.Get("/_/composediff", s.PullComposeDiffFragment) 20 19 21 20 r.Route("/{pull}", func(r chi.Router) { 22 - r.Use(mw.ResolvePull()) 23 - r.Get("/", s.RepoSinglePull) 21 + r.Use(s.ResolvePullMiddleware) 24 22 r.Get("/opengraph", s.PullOpenGraphSummary) 25 23 26 - r.Route("/round/{round}", func(r chi.Router) { 27 - r.Get("/", s.RepoPullPatch) 28 - r.Get("/interdiff", s.RepoPullInterdiff) 29 - r.Get("/actions", s.PullActions) 30 - r.Get("/comment", s.PullComment) 31 - }) 24 + // PR routes 25 + r.Get("/", s.RedirectLatestVersion) 26 + r.Get("/{version}", s.PullSingle) 27 + r.Get("/{version}/{revspec}", s.PullSingle) 28 + r.Get("/{version}.patch", s.PullPatchRaw) 32 29 33 - r.Route("/round/{round}.patch", func(r chi.Router) { 34 - r.Get("/", s.RepoPullPatchRaw) 35 - }) 30 + // htmx fragments 31 + // TODO: remove this later. HTMX should not be used for static elements 32 + r.Get("/{version}/_/actions", s.PullActions) 33 + r.Get("/{version}/_/comment", s.PullComment) 34 + 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 - r.Route("/resubmit", func(r chi.Router) { 40 - r.Get("/", s.ResubmitPull) 41 - r.Post("/", s.ResubmitPull) 42 - }) 43 - // permissions here require us to know pull author 44 - // it is handled within the route 40 + r.Post("/resubmit", s.ResubmitPull) 45 41 r.Post("/close", s.ClosePull) 46 42 r.Post("/reopen", s.ReopenPull) 43 + 47 44 // collaborators only 48 45 r.Group(func(r chi.Router) { 49 46 r.Use(mw.RepoPermissionMiddleware("repo:push")) ··· 57 54 }) 58 55 }) 59 56 }) 57 + 60 58 return r 61 59 62 60 }
+388 -345
appview/pulls/single.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io" 8 + "log/slog" 6 9 "net/http" 7 10 "strconv" 11 + "strings" 8 12 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 + "github.com/go-chi/chi/v5" 16 + "github.com/go-git/go-git/v5/plumbing" 17 + "github.com/go-git/go-git/v5/plumbing/object" 18 + "golang.org/x/sync/errgroup" 9 19 "tangled.org/core/api/tangled" 10 20 "tangled.org/core/appview/db" 11 21 "tangled.org/core/appview/models" 22 + "tangled.org/core/appview/oauth" 12 23 "tangled.org/core/appview/pages" 24 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 25 + "tangled.org/core/hostutil" 13 26 "tangled.org/core/orm" 14 - "tangled.org/core/patchutil" 15 27 "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 28 ) 23 29 24 - // htmx fragment 25 - func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 26 - l := s.logger.With("handler", "PullActions") 30 + func (s *Pulls) RedirectLatestVersion(w http.ResponseWriter, r *http.Request) { 31 + pull, ok := r.Context().Value("pull").(*models.Pull) 32 + if !ok { 33 + s.logger.Error("failed to get pull") 34 + s.pages.Error500(w) 35 + return 36 + } 37 + u := r.URL.JoinPath(strconv.Itoa(pull.LatestVersionNumber())) 38 + http.Redirect(w, r, u.String(), http.StatusFound) 39 + } 27 40 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 - } 41 + func (s *Pulls) PullSingle(w http.ResponseWriter, r *http.Request) { 42 + if strings.Contains(chi.URLParam(r, "version"), "..") { 43 + s.PullInterDiff(w, r) 44 + } else { 45 + s.PullDiff(w, r) 46 + } 47 + } 34 48 35 - f, err := s.repoResolver.Resolve(r) 49 + // PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit} 50 + // 51 + // Examples: 52 + // - /pulls/123/latest 53 + // - /pulls/123/2/head 54 + // - /pulls/123/2/base..head 55 + // - /pulls/123/2/a53ab251e..d8add468c 56 + // - /pulls/123/2/d8add468c 57 + func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) { 58 + l := s.logger.With("handler", "PullDiff") 59 + ctx := r.Context() 60 + 61 + user := s.oauth.GetMultiAccountUser(r) 62 + if user != nil { 63 + l = l.With("user", user.Did) 64 + } 65 + 66 + f, err := s.repoResolver.Resolve(r) 67 + if err != nil { 68 + l.Error("failed to get repo and knot", "err", err) 69 + return 70 + } 71 + 72 + pull, ok := r.Context().Value("pull").(*models.Pull) 73 + if !ok { 74 + l.Error("failed to get pull") 75 + s.pages.Error500(w) 76 + return 77 + } 78 + 79 + var version models.PullVersion 80 + var versionIdRaw = chi.URLParam(r, "version") 81 + if versionIdRaw == "latest" { 82 + version = pull.LatestVersion() 83 + } else { 84 + versionId, err := strconv.Atoi(versionIdRaw) 36 85 if err != nil { 37 - l.Error("failed to get repo and knot", "err", err) 86 + // invalid version number. redirect 87 + http.Redirect(w, r, 88 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 89 + http.StatusSeeOther, 90 + ) 38 91 return 39 92 } 40 - 41 - pull, ok := r.Context().Value("pull").(*models.Pull) 93 + var ok bool 94 + version, ok = pull.GetVersion(versionId) 42 95 if !ok { 43 - l.Error("failed to get pull") 44 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 96 + // invalid version number. redirect 97 + http.Redirect(w, r, 98 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 99 + http.StatusSeeOther, 100 + ) 45 101 return 46 102 } 47 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 103 + } 48 104 49 - // can be nil if this pull is not stacked 50 - stack, _ := r.Context().Value("stack").(models.Stack) 105 + diffBase, diffHead, err := parseRange(chi.URLParam(r, "revspec")) 106 + if err != nil { 107 + http.Redirect(w, r, 108 + fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.PullId, versionIdRaw), 109 + http.StatusSeeOther, 110 + ) 111 + return 112 + } 51 113 52 - roundNumberStr := chi.URLParam(r, "round") 53 - roundNumber, err := strconv.Atoi(roundNumberStr) 54 - if err != nil { 55 - roundNumber = pull.LastRoundNumber() 114 + // defer render 115 + var params pages.PullDiffParams 116 + params.PullPageBaseParams = s.makePullPageBaseParams(r, user, f, pull) 117 + params.VersionId = version.ID 118 + defer func() { 119 + if err := s.pages.PullDiff(w, params); err != nil { 120 + l.Error("Failed to render", "err", err) 56 121 } 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) 60 - return 61 - } 122 + }() 62 123 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) 66 - mergeCheckResponse := types.MergeCheckResponse{} 67 - resubmitResult := pages.Unknown 68 - if isLastRound { 69 - mergeCheckResponse = s.mergeCheck(r, f, pull, stack) 70 - if user != nil && user.Did == pull.OwnerDid { 71 - resubmitResult = s.resubmitCheck(r, f, pull, stack) 72 - } 73 - } 124 + // special cases 125 + // default to {target}..{current.head} 126 + if diffBase == "" || diffBase == "base" { 127 + diffBase = version.Base 128 + } 129 + if diffHead == "" || diffHead == "head" { 130 + diffHead = version.Head 131 + } 132 + params.DiffParams.Base = diffBase 133 + params.DiffParams.Head = diffHead 74 134 75 - s.pages.PullActionsFragment(w, pages.PullActionsParams{ 76 - BaseParams: pages.BaseParamsFromContext(r.Context()), 77 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 78 - Pull: pull, 79 - RoundNumber: roundNumber, 80 - MergeCheck: mergeCheckResponse, 81 - ResubmitCheck: resubmitResult, 82 - BranchDeleteStatus: branchDeleteStatus, 83 - Stack: stack, 84 - }) 135 + // TODO: Ideally we should show diff between <target-branch>..<pr-head>, 136 + // - but we can't find target branch's commit from forked repo. 137 + // - and we will get 0 diff when PR is merged. 138 + // so for now, we show diff between <pr-base>..<pr-head> 139 + // // resolve target branch -> (branch, commit) 140 + // xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 141 + // branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String()) 142 + // if err != nil { 143 + // l.Warn("Failed to resolve target branch", "branch", pull.TargetBranch, "err", err) 144 + // params.ErrorMsg = fmt.Sprintf("Failed to resolve target branch %q", pull.TargetBranch) 145 + // return 146 + // } 147 + commits, err := s.listCommits(ctx, pull.SourceRepo, version.Base, version.Head) 148 + if err != nil { 149 + l.Error("failed to list commits", "err", err) 150 + params.ErrorMsg = "Failed to list commits. Try again later." 85 151 return 86 152 } 153 + params.Commits = commits 154 + 155 + // commitId -> latest pipeline 156 + shas := make([]string, len(params.Commits)) 157 + for i, commit := range params.Commits { 158 + shas[i] = commit.Hash.String() 159 + } 160 + params.Pipelines = fetchPipelines(ctx, l, f, shas) 87 161 } 88 162 89 - func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 90 - l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 163 + // PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change} 164 + // 165 + // Examples: 166 + // - /pulls/123/0..2/all 167 + // - /pulls/123/0..2/nrpytyzw 168 + func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) { 169 + l := s.logger.With("handler", "PullInterDiff") 170 + ctx := r.Context() 91 171 92 172 user := s.oauth.GetMultiAccountUser(r) 93 173 if user != nil { ··· 102 182 103 183 pull, ok := r.Context().Value("pull").(*models.Pull) 104 184 if !ok { 105 - l.Error("failed to get pull") 106 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 185 + s.logger.Error("failed to get pull") 186 + s.pages.Error500(w) 107 187 return 108 188 } 109 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 110 189 111 - if user != nil { 112 - userDid := user.Did 113 - repoDid := f.RepoDid 114 - pullId := pull.PullId 115 - atUri := pull.AtUri().String() 116 - focusing := pages.BaseParamsFromContext(r.Context()).FocusParams.Focusing 117 - go func() { 118 - if !focusing { 119 - if err := db.MarkNotificationsReadForPull(s.db, userDid, repoDid, pullId); err != nil { 120 - l.Error("failed to mark pull notifications as read", "err", err) 121 - } 122 - } 123 - if err := db.UpsertRecentLink(s.db, userDid, models.RecentLinkTypePull, atUri); err != nil { 124 - l.Error("failed to upsert recent link", "err", err) 125 - } 126 - }() 127 - } 128 - 129 - backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 190 + version1Raw, version2Raw, err := parseRange(chi.URLParam(r, "version")) 130 191 if err != nil { 131 - l.Error("failed to get pull backlinks", "err", err) 132 - s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 192 + http.Redirect(w, r, 193 + fmt.Sprintf("/%s/pulls/%d/0", pull.RepoDid, pull.PullId), 194 + http.StatusSeeOther, 195 + ) 133 196 return 134 197 } 135 - 136 - roundId := chi.URLParam(r, "round") 137 - roundIdInt := pull.LastRoundNumber() 138 - if r, err := strconv.Atoi(roundId); err == nil { 139 - roundIdInt = r 140 - } 141 - if roundIdInt < 0 || roundIdInt >= len(pull.Submissions) { 142 - http.Error(w, "bad round id", http.StatusBadRequest) 143 - l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 198 + version1, err := strconv.Atoi(version1Raw) 199 + version2, err := strconv.Atoi(version2Raw) 200 + if err != nil { 201 + http.Redirect(w, r, 202 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 203 + http.StatusSeeOther, 204 + ) 144 205 return 145 206 } 146 207 147 - var diffOpts types.DiffOpts 148 - if d := r.URL.Query().Get("diff"); d == "split" { 149 - diffOpts.Split = true 208 + changeId := chi.URLParam(r, "revspec") 209 + if changeId == "all" { 210 + changeId = "" 150 211 } 151 212 152 - // can be nil if this pull is not stacked 153 - stack, _ := r.Context().Value("stack").(models.Stack) 213 + // defer render 214 + var params pages.PullInterdiffParams 215 + params.PullPageBaseParams = s.makePullPageBaseParams(r, user, f, pull) 216 + params.Version1 = version1 217 + params.Version2 = version2 218 + params.ChangeId = changeId 219 + defer func() { 220 + if err := s.pages.PullInterdiff(w, params); err != nil { 221 + l.Error("Failed to render", "err", err) 222 + } 223 + }() 154 224 155 - var shas []string 156 - for _, s := range pull.Submissions { 157 - shas = append(shas, s.SourceRev) 225 + // TODO: Ideally we should show diff between <target-branch>..<pr-head>, 226 + // - but we can't find target branch's commit from forked repo. 227 + // - and we will get 0 diff when PR is merged. 228 + // so for now, we show diff between <pr-base>..<pr-head> 229 + // // resolve target branch -> (branch, commit) 230 + // xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 231 + // branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String()) 232 + // if err != nil { 233 + // l.Warn("Failed to resolve target branch", "branch", pull.TargetBranch, "err", err) 234 + // params.ErrorMsg = fmt.Sprintf("Failed to resolve target branch %q", pull.TargetBranch) 235 + // return 236 + // } 237 + var commits1, commits2 []types.Commit 238 + g, gctx := errgroup.WithContext(ctx) 239 + if changeId != "" { 240 + g.Go(func() error { 241 + commits1, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version1].Base, pull.Versions[version1].Head) 242 + return err 243 + }) 158 244 } 159 - for _, p := range stack { 160 - shas = append(shas, p.LatestSha()) 245 + g.Go(func() error { 246 + commits2, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version2].Base, pull.Versions[version2].Head) 247 + return err 248 + }) 249 + if err := g.Wait(); err != nil { 250 + l.Error("failed to list commits", "err", err) 251 + params.ErrorMsg = "Failed to list commits. Try again later." 252 + return 161 253 } 254 + params.Commits = commits2 162 255 163 256 // commitId -> latest pipeline 164 - pipelines := func(ctx context.Context) map[string]types.Pipeline { 165 - m := make(map[string]types.Pipeline) 166 - if f.Spindle == "" { 167 - return m 257 + shas := make([]string, len(params.Commits)) 258 + for i, commit := range params.Commits { 259 + shas[i] = commit.Hash.String() 260 + } 261 + params.Pipelines = fetchPipelines(ctx, l, f, shas) 262 + 263 + if changeId != "" { 264 + // interdiff by change-id 265 + var from, to *types.Commit 266 + for _, commit := range commits1 { 267 + if commit.ChangeId == changeId { 268 + from = &commit 269 + break 270 + } 168 271 } 169 - spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) 170 - if err != nil { 171 - l.Error("invalid spindle host", "host", f.Spindle, "err", err) 172 - return m 272 + for _, commit := range commits2 { 273 + if commit.ChangeId == changeId { 274 + to = &commit 275 + break 276 + } 173 277 } 174 - xrpcc := &indigoxrpc.Client{Host: spindleUrl} 175 - out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", 0, f.RepoDid) 176 - if err != nil { 177 - l.Error("failed to fetch pipelines", "err", err) 178 - return m 179 - } 278 + l.Debug("commits", "old", from, "new", to) 180 279 181 - for _, pipeline := range out.Pipelines { 182 - if pipeline == nil { 183 - continue 280 + switch { 281 + case to == nil: 282 + // can't find change-id from v2 branch. 283 + // NOTE: This can't happen because user selected from v2's commits 284 + params.ErrorMsg = "Can't find commit with given change-id." 285 + case from == nil: 286 + // new commit -> diff <parent1>..<new> 287 + params.ActiveCommitId = to.Hash.String() 288 + params.DiffParams.Diff = &pages.DiffParams_Diff{ 289 + Base: to.FirstParentHash().String(), 290 + Head: to.Hash.String(), 291 + } 292 + default: 293 + // interdiff 294 + params.ActiveCommitId = to.Hash.String() 295 + // TODO: use merged tree of all parents 296 + params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ 297 + From: pages.DiffParams_Diff{ 298 + Base: from.FirstParentHash().String(), 299 + Head: from.Hash.String(), 300 + }, 301 + To: pages.DiffParams_Diff{ 302 + Base: to.FirstParentHash().String(), 303 + Head: to.Hash.String(), 304 + }, 184 305 } 185 - m[pipeline.Commit] = types.Pipeline{CiPipeline: pipeline} 306 + } 307 + } else { 308 + // interdiff of two versions 309 + params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ 310 + From: pages.DiffParams_Diff{ 311 + Base: pull.Versions[version1].Base, 312 + Head: pull.Versions[version1].Head, 313 + }, 314 + To: pages.DiffParams_Diff{ 315 + Base: pull.Versions[version2].Base, 316 + Head: pull.Versions[version2].Head, 317 + }, 186 318 } 187 - return m 188 - }(r.Context()) 319 + 320 + // TODO: if any of them is "", show error message 321 + } 322 + } 323 + 324 + func (s *Pulls) PullPatchRaw(w http.ResponseWriter, r *http.Request) { 325 + l := s.logger.With("handler", "RepoPullPatchRaw") 326 + 327 + pull, ok := r.Context().Value("pull").(*models.Pull) 328 + if !ok { 329 + l.Error("failed to get pull") 330 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 331 + return 332 + } 333 + l = l.With("pull_id", pull.PullId) 189 334 190 - var workflowsChanged bool 191 - var changedWorkflows []string 192 - if _, hasPipeline := pipelines[pull.LatestSha()]; pull.IsForkBased() && !hasPipeline { 193 - changedWorkflows, err = changedWorkflowFiles(pull.LatestSubmission().CombinedPatch()) 335 + var version models.PullVersion 336 + var versionIdRaw = chi.URLParam(r, "version") 337 + if versionIdRaw == "latest" { 338 + version = pull.LatestVersion() 339 + } else { 340 + versionId, err := strconv.Atoi(versionIdRaw) 194 341 if err != nil { 195 - l.Error("failed to inspect latest round's patch for workflow changes", "err", err) 342 + http.Error(w, "bad version id", http.StatusBadRequest) 343 + return 344 + } 345 + var ok bool 346 + version, ok = pull.GetVersion(versionId) 347 + if !ok { 348 + http.Error(w, "unknown version", http.StatusNotFound) 349 + return 196 350 } 197 - workflowsChanged = len(changedWorkflows) > 0 351 + } 352 + 353 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 354 + rawOut, err := tangled.GitTempFormatPatch(r.Context(), xrpcc, version.Base, pull.RepoDid.String(), version.Head) 355 + if err != nil { 356 + http.Error(w, "Failed to compute patch", http.StatusInternalServerError) 357 + return 198 358 } 199 359 360 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 361 + w.Write(rawOut) 362 + } 363 + 364 + func (s *Pulls) makePullPageBaseParams(r *http.Request, user *oauth.MultiAccountUser, f *models.Repo, pull *models.Pull) pages.PullPageBaseParams { 365 + l := s.logger 366 + ctx := r.Context() 367 + 200 368 entities := []syntax.ATURI{pull.AtUri()} 201 - for _, s := range pull.Submissions { 202 - for _, c := range s.Comments { 369 + for _, v := range pull.Versions { 370 + for _, c := range v.Comments { 203 371 entities = append(entities, c.FeedCommentAtUri()) 204 372 } 205 373 } 206 374 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) 207 375 if err != nil { 208 - l.Error("failed to get pull reactions", "err", err) 376 + l.Error("failed to get reactions", "err", err) 209 377 } 210 378 211 379 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool ··· 223 391 ) 224 392 if err != nil { 225 393 l.Error("failed to fetch labels", "err", err) 226 - s.pages.Error503(w) 227 - return 228 394 } 229 - 230 395 defs := make(map[string]*models.LabelDefinition) 231 396 for _, l := range labelDefs { 232 397 defs[l.AtUri().String()] = &l ··· 241 406 l.Error("failed to fetch vouch relationships", "err", err) 242 407 } 243 408 ownerDid := syntax.DID(pull.OwnerDid) 244 - skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid) 409 + skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid.String()) 245 410 if err != nil { 246 411 l.Error("failed to check vouch skip", "err", err) 247 412 } 248 413 vouchSkips[ownerDid] = skipped 249 414 } 250 415 251 - var diff types.DiffRenderer 252 - if interdiff { 253 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 254 - if err != nil { 255 - l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 256 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 257 - return 258 - } 416 + params := pages.PullPageBaseParams{} 417 + params.BaseParams = pages.BaseParamsFromContext(ctx) 418 + params.RepoInfo = s.repoResolver.GetRepoInfo(r, user) 419 + params.Pull = pull 420 + params.Backlinks = nil 421 + params.LabelDefs = defs 422 + params.Reactions = reactions 423 + params.UserReacted = userReactions 424 + params.VouchRelationships = vouchRelationships 425 + params.VouchSkips = vouchSkips 426 + return params 427 + } 259 428 260 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 261 - if err != nil { 262 - l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 263 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 264 - return 265 - } 266 - 267 - diff = patchutil.Interdiff(previousPatch, currentPatch) 268 - } else { 269 - diff = s.combinedDiff(pull, roundIdInt) 270 - } 271 - 272 - err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 273 - BaseParams: pages.BaseParamsFromContext(r.Context()), 274 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 275 - Pull: pull, 276 - Stack: stack, 277 - Backlinks: backlinks, 278 - BranchDeleteStatus: nil, 279 - MergeCheck: types.MergeCheckResponse{}, 280 - ResubmitCheck: pages.Unknown, 281 - Pipelines: pipelines, 282 - Diff: diff, 283 - DiffOpts: diffOpts, 284 - ActiveRound: roundIdInt, 285 - IsInterdiff: interdiff, 286 - 287 - WorkflowsChanged: workflowsChanged, 288 - ChangedWorkflowFiles: changedWorkflows, 289 - 290 - Reactions: reactions, 291 - UserReacted: userReactions, 292 - 293 - LabelDefs: defs, 294 - VouchRelationships: vouchRelationships, 295 - VouchSkips: vouchSkips, 429 + func (s *Pulls) listCommits(ctx context.Context, repo syntax.DID, base, head string) ([]types.Commit, error) { 430 + s.logger.Debug("logging commits", "repo", repo, "base", base, "head", head) 431 + stream, err := s.gitmirror.CommitLog(ctx, &gitmirrorv1.CommitLogRequest{ 432 + Repo: repo.String(), 433 + Ranges: [][]byte{fmt.Appendf(nil, "%s..%s", base, head)}, 434 + AllRefs: false, 296 435 }) 297 436 if err != nil { 298 - l.Error("failed to render page", "err", err) 437 + return nil, err 299 438 } 300 - } 301 - 302 - func (s *Pulls) combinedDiff(pull *models.Pull, round int) types.DiffRenderer { 303 - submission := pull.Submissions[round] 304 - key := fmt.Sprintf("%s|%d|%s", pull.AtUri(), round, submission.SourceRev) 305 - if cached, ok := s.diffCache.Get(key); ok { 306 - return cached 307 - } 308 - 309 - diff := patchutil.AsNiceDiff(submission.CombinedPatch(), pull.TargetBranch) 310 - s.diffCache.Add(key, diff) 311 - return diff 312 - } 313 - 314 - func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 315 - l := s.logger.With("handler", "RepoSinglePull") 316 - 317 - pull, ok := r.Context().Value("pull").(*models.Pull) 318 - if !ok { 319 - l.Error("failed to get pull") 320 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 321 - return 322 - } 323 - 324 - http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 325 - } 326 - 327 - func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 328 - if pull.State == models.PullMerged { 329 - return types.MergeCheckResponse{} 330 - } 331 - 332 - xrpcc := s.knotClient(f.Knot) 333 - 334 - // combine patches of substack 335 - subStack := stack.Below(pull) 336 - // collect the portion of the stack that is mergeable 337 - mergeable := subStack.Mergeable() 338 - // combine each patch 339 - patch := mergeable.CombinedPatch() 340 - 341 - resp, err := tangled.RepoMergeCheck( 342 - r.Context(), 343 - xrpcc, 344 - &tangled.RepoMergeCheck_Input{ 345 - Did: f.Did, 346 - Name: f.Name, 347 - Branch: pull.TargetBranch, 348 - Patch: patch, 349 - }, 350 - ) 351 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 352 - s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 353 - return types.MergeCheckResponse{ 354 - Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 439 + var commits []types.Commit 440 + for { 441 + res, err := stream.Recv() 442 + if errors.Is(err, io.EOF) { 443 + break 444 + } 445 + if err != nil { 446 + return nil, err 447 + } 448 + for _, commit := range res.Commits { 449 + commits = append(commits, types.Commit{ 450 + Hash: plumbing.NewHash(commit.Oid), 451 + // TODO: change-id 452 + Author: object.Signature{ 453 + Name: string(commit.Author.GetName()), 454 + Email: string(commit.Author.GetEmail()), 455 + When: commit.Author.Date.AsTime(), 456 + }, 457 + Committer: object.Signature{ 458 + Name: string(commit.Committer.GetName()), 459 + Email: string(commit.Committer.GetEmail()), 460 + When: commit.Committer.Date.AsTime(), 461 + }, 462 + Message: string(commit.Message), 463 + ParentHashes: func() []plumbing.Hash { 464 + var parents []plumbing.Hash 465 + for _, hash := range commit.Parents { 466 + parents = append(parents, plumbing.NewHash(hash)) 467 + } 468 + return parents 469 + }(), 470 + ChangeId: commit.ExtraHeaders["change-id"], 471 + }) 355 472 } 356 473 } 357 - 358 - return mergeCheckResponseFrom(resp) 474 + return commits, err 359 475 } 360 476 361 - func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 362 - conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 363 - for i, c := range resp.Conflicts { 364 - conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 365 - } 366 - out := types.MergeCheckResponse{ 367 - IsConflicted: resp.Is_conflicted, 368 - Conflicts: conflicts, 369 - } 370 - if resp.Message != nil { 371 - out.Message = *resp.Message 477 + func fetchPipelines(ctx context.Context, l *slog.Logger, f *models.Repo, shas []string) map[string]types.Pipeline { 478 + m := make(map[string]types.Pipeline) 479 + if f.Spindle == "" || len(shas) == 0 { 480 + return m 372 481 } 373 - if resp.Error != nil { 374 - out.Error = *resp.Error 482 + spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) 483 + if err != nil { 484 + l.Error("invalid spindle host", "host", f.Spindle, "err", err) 485 + return m 375 486 } 376 - return out 377 - } 378 - 379 - func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 380 - if pull.State != models.PullMerged { 381 - return nil 382 - } 383 - 384 - user := s.oauth.GetMultiAccountUser(r) 385 - if user == nil { 386 - return nil 487 + xrpcc := &indigoxrpc.Client{Host: spindleUrl} 488 + out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", 0, f.RepoDid) 489 + if err != nil { 490 + l.Error("failed to fetch pipelines", "err", err) 491 + return m 387 492 } 388 493 389 - var branch string 390 - // check if the branch exists 391 - // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 392 - if pull.IsBranchBased() { 393 - branch = pull.PullSource.Branch 394 - } else if pull.IsForkBased() { 395 - branch = pull.PullSource.Branch 396 - repo = pull.PullSource.Repo 397 - } else { 398 - return nil 494 + for _, pipeline := range out.Pipelines { 495 + if pipeline == nil { 496 + continue 497 + } 498 + m[pipeline.Commit] = types.Pipeline{CiPipeline: pipeline} 399 499 } 400 - 401 - // deleted fork 402 - if repo == nil { 403 - return nil 404 - } 405 - 406 - // user can only delete branch if they are a collaborator in the repo that the branch belongs to 407 - if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 408 - return nil 409 - } 410 - 411 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 412 - resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 413 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 414 - s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 415 - return nil 416 - } 417 - 418 - return &models.BranchDeleteStatus{ 419 - Repo: repo, 420 - Branch: resp.Name, 421 - } 500 + return m 422 501 } 423 502 424 - func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 425 - if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 426 - return pages.Unknown 503 + // parseRange parses <base>..<base> string 504 + func parseRange(input string) (base string, head string, err error) { 505 + input = strings.TrimSpace(input) 506 + if input == "" { 507 + return "", "", nil 427 508 } 428 509 429 - var sourceRepoDid string 430 - if pull.PullSource.RepoDid != nil { 431 - sourceRepoDid = string(*pull.PullSource.RepoDid) 432 - } else { 433 - sourceRepoDid = repo.RepoDid 510 + if strings.Count(input, "..") > 1 || strings.Contains(input, "...") { 511 + return "", "", fmt.Errorf("invalid revspec format: %q", input) 434 512 } 435 513 436 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 437 - branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 438 - if err != nil { 439 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 440 - s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 441 - return pages.Unknown 442 - } 443 - s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 444 - return pages.Unknown 514 + if !strings.Contains(input, "..") { 515 + return "", input, nil 445 516 } 446 517 447 - targetBranch := branchResp 448 - 449 - top := stack[0] 450 - latestSourceRev := top.LatestSha() 451 - 452 - if latestSourceRev != targetBranch.Hash { 453 - return pages.ShouldResubmit 518 + parts := strings.SplitN(input, "..", 2) 519 + base = strings.TrimSpace(parts[0]) 520 + head = strings.TrimSpace(parts[1]) 521 + if base == "" && head == "" { 522 + return "", "", fmt.Errorf("invalid empty range: \"..\"") 454 523 } 455 524 456 - return pages.ShouldNotResubmit 457 - } 458 - 459 - func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 460 - s.repoPullHelper(w, r, false) 461 - } 462 - 463 - func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 464 - s.repoPullHelper(w, r, true) 465 - } 466 - 467 - func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 468 - l := s.logger.With("handler", "RepoPullPatchRaw") 469 - 470 - pull, ok := r.Context().Value("pull").(*models.Pull) 471 - if !ok { 472 - l.Error("failed to get pull") 473 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 474 - return 525 + if head == "" { 526 + head = base 527 + base = "" 475 528 } 476 - l = l.With("pull_id", pull.PullId) 477 529 478 - roundId := chi.URLParam(r, "round") 479 - roundIdInt, err := strconv.Atoi(roundId) 480 - if err != nil || roundIdInt >= len(pull.Submissions) { 481 - http.Error(w, "bad round id", http.StatusBadRequest) 482 - l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 483 - return 484 - } 485 - 486 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 487 - w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 530 + return base, head, nil 488 531 }
+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 }
+37 -30
appview/pulls/trigger_ci.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 6 + "io" 5 7 "net/http" 6 8 "strings" 7 9 8 10 "tangled.org/core/api/tangled" 9 11 "tangled.org/core/appview/db" 10 12 "tangled.org/core/appview/models" 11 - "tangled.org/core/patchutil" 13 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 12 14 "tangled.org/core/workflow" 13 15 ) 14 - 15 - func changedWorkflowFiles(patch string) ([]string, error) { 16 - files, err := patchutil.AsDiff(patch) 17 - if err != nil { 18 - return nil, err 19 - } 20 - 21 - var changed []string 22 - for _, f := range files { 23 - if f == nil { 24 - continue 25 - } 26 - for _, name := range []string{f.NewName, f.OldName} { 27 - if name != "" && strings.HasPrefix(name, workflow.WorkflowDir+"/") { 28 - changed = append(changed, name) 29 - break 30 - } 31 - } 32 - } 33 - return changed, nil 34 - } 35 16 36 17 // TriggerCi manually triggers a CI pipeline for a fork-based pull request. 37 18 // authorized against and recorded under the target repo, but checked out ··· 72 53 return 73 54 } 74 55 75 - latest := pull.LatestSubmission() 76 - if latest.SourceRev == "" { 56 + latest := pull.LatestVersion() 57 + if latest.Base == "" || latest.Head == "" { 77 58 fail("cannot trigger ci: this round has no commit to run", nil) 78 59 return 79 60 } 80 61 81 - changedFiles, err := changedWorkflowFiles(latest.CombinedPatch()) 62 + changedFiles, err := func() ([]string, error) { 63 + stream, err := s.gitmirror.Diff(r.Context(), &gitmirrorv1.DiffRequest{ 64 + Repo: pull.RepoDid.String(), 65 + BaseRevSpec: []byte(latest.Base), 66 + HeadRevSpec: []byte(latest.Head), 67 + }) 68 + if err != nil { 69 + return nil, fmt.Errorf("failed to diff: %w", err) 70 + } 71 + var changed []string 72 + for { 73 + fd, err := stream.Recv() 74 + if errors.Is(err, io.EOF) { 75 + break 76 + } 77 + if err != nil { 78 + return nil, fmt.Errorf("failed to drain diff response: %w", err) 79 + } 80 + for _, name := range []string{fd.LhsSrc.Path, fd.RhsSrc.Path} { 81 + if name != "" && strings.HasPrefix(name, workflow.WorkflowDir+"/") { 82 + changed = append(changed, name) 83 + break 84 + } 85 + } 86 + } 87 + return changed, nil 88 + }() 82 89 if err != nil { 83 90 fail("failed to inspect the latest round's patch", err) 84 91 return ··· 88 95 return 89 96 } 90 97 91 - forkRepo, err := db.GetRepoByDid(s.db, pull.PullSource.RepoDid.String()) 98 + forkRepo, err := db.GetRepoByDid(s.db, pull.SourceRepo.String()) 92 99 if err != nil { 93 100 fail("failed to resolve the fork this pull request comes from", err) 94 101 return ··· 101 108 } 102 109 103 110 pullAt := pull.AtUri().String() 104 - sourceBranch := pull.PullSource.Branch 111 + sourceBranch := pull.SourceBranch 105 112 targetBranch := pull.TargetBranch 106 113 out, err := tangled.CiTriggerPipeline( 107 114 r.Context(), ··· 111 118 Trigger: &tangled.CiTriggerPipeline_Input_Trigger{ 112 119 CiTrigger_PullRequest: &tangled.CiTrigger_PullRequest{ 113 120 Pull: &pullAt, 114 - SourceBranch: &sourceBranch, 121 + SourceBranch: sourceBranch, 115 122 SourceRepo: &forkRepo.RepoDid, 116 - SourceSha: latest.SourceRev, 123 + SourceSha: latest.Head, 117 124 TargetBranch: targetBranch, 118 125 }, 119 126 }, ··· 127 134 128 135 user := s.oauth.GetMultiAccountUser(r) 129 136 repoInfo := s.repoResolver.GetRepoInfo(r, user) 130 - dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LastRoundNumber()) 137 + dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LatestVersionNumber()) 131 138 if r.Header.Get("HX-Request") == "true" { 132 139 w.Header().Set("HX-Redirect", dest) 133 140 w.WriteHeader(http.StatusOK)
+11 -11
appview/repo/feed.go
··· 74 74 75 75 // fetch and add pull requests if requested 76 76 if opts.IncludePulls { 77 - pulls, err := db.GetPullsPaginated(rp.db, feedPagePerType, orm.FilterEq("repo_did", repo.RepoDid)) 77 + pulls, err := db.GetPullsPaginated(ctx, rp.db, feedPagePerType, orm.FilterEq("repo_did", repo.RepoDid)) 78 78 if err != nil { 79 79 return nil, err 80 80 } ··· 149 149 } 150 150 151 151 func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, ownerSlashRepo string) ([]*feeds.Item, error) { 152 - owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 152 + owner, err := rp.idResolver.Directory().LookupDID(ctx, pull.OwnerDid) 153 153 if err != nil { 154 154 return nil, err 155 155 } 156 156 157 157 var items []*feeds.Item 158 158 159 - state := rp.getPullState(pull) 160 - description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo) 159 + description := rp.buildPullDescription(owner.Handle, pull, ownerSlashRepo) 161 160 162 161 mainItem := &feeds.Item{ 163 162 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), ··· 168 167 } 169 168 items = append(items, mainItem) 170 169 171 - for _, round := range pull.Submissions { 172 - if round == nil || round.RoundNumber == 0 { 170 + for _, round := range pull.Versions { 171 + if round.ID == 0 { 173 172 continue 174 173 } 175 174 176 175 roundItem := &feeds.Item{ 177 - Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 178 - Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 179 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.RoundNumber)}, 176 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.ID), 177 + Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.ID, pull.PullId, ownerSlashRepo), 178 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.ID)}, 180 179 Created: round.Created, 181 180 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 182 181 } ··· 294 293 return pull.State.String() 295 294 } 296 295 297 - func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string { 296 + func (rp *Repo) buildPullDescription(handle syntax.Handle, pull *models.Pull, repoName string) string { 297 + state := rp.getPullState(pull) 298 298 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 299 299 300 300 if pull.State == models.PullMerged { 301 - return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 301 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LatestVersionNumber(), repoName) 302 302 } 303 303 304 304 return fmt.Sprintf("%s in %s", base, repoName)
+2 -2
appview/state/profile.go
··· 484 484 485 485 evidencePulls := make(map[syntax.ATURI]*models.Pull) 486 486 if len(pullAts) > 0 { 487 - pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts)) 487 + pulls, err := db.GetPullsPaginated(r.Context(), s.db, pagination.Page{}, orm.FilterIn("at_uri", pullAts)) 488 488 if err != nil { 489 489 l.Error("failed to get evidence pulls", "err", err) 490 490 } else { ··· 713 713 714 714 func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 715 715 for _, pull := range pulls { 716 - owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 716 + owner, err := s.idResolver.Directory().LookupDID(ctx, pull.RepoDid) 717 717 if err != nil { 718 718 return err 719 719 }
+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 } ··· 129 130 } 130 131 } 131 132 132 - func (t *Timeline) buildRecents(userDid string) ([]pages.RecentItem, error) { 133 + func (t *Timeline) buildRecents(ctx context.Context, userDid string) ([]pages.RecentItem, error) { 133 134 links, err := db.GetRecentLinks(t.db, orm.FilterEq("user_did", userDid)) 134 135 if err != nil { 135 136 return nil, err ··· 178 179 // fetch pulls by aturi 179 180 pullByAtUri := make(map[string]*models.Pull) 180 181 if len(pullAtUris) > 0 { 181 - fetched, err := db.GetPulls(t.db, orm.FilterIn("at_uri", pullAtUris)) 182 + fetched, err := db.GetPullsPaginated(ctx, t.db, pagination.Page{}, orm.FilterIn("at_uri", pullAtUris)) 182 183 if err != nil { 183 184 return nil, err 184 185 }
-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
··· 332 332 } 333 333 } 334 334 335 + .diff { 336 + @apply font-mono bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400; 337 + font-size: 12px; 338 + content-visibility: auto; 339 + content-intrinsic-size: 32rem; 340 + } 341 + .diff-line { 342 + @apply flex; 343 + } 344 + .diff-side { 345 + @apply flex flex-1 min-w-0; 346 + } 347 + .diff-side + .diff-side { 348 + @apply border-l border-gray-200 dark:border-gray-700; 349 + } 350 + .diff-num { 351 + @apply contents text-gray-400 dark:text-gray-500; 352 + } 353 + .diff-num > span { 354 + @apply flex-none pr-2 text-right select-none; 355 + @apply bg-white dark:bg-gray-800; 356 + min-width: calc(6ch + 0.5rem); 357 + } 358 + .diff-indicator { 359 + @apply flex-none w-[2ch] px-1 text-center select-none; 360 + @apply border-l border-gray-200 dark:border-gray-700; 361 + } 362 + .diff-content { 363 + @apply flex-1 min-w-0 whitespace-pre-wrap pl-2 pr-3 relative; 364 + overflow-wrap: anywhere; 365 + } 366 + .diff-content div { 367 + @apply inline; 368 + } 369 + .diff-side.add, 370 + .diff-line.add { 371 + @apply bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400; 372 + } 373 + .diff-side.del, 374 + .diff-line.del { 375 + @apply bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400; 376 + } 377 + .diff-side.empty { 378 + @apply bg-gray-200/30 dark:bg-gray-700/30; 379 + } 380 + .diff-splitter { 381 + @apply bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center; 382 + } 383 + .diff[data-restrict-select="left"] .diff-side:last-child, 384 + .diff[data-restrict-select="right"] .diff-side:first-child { 385 + @apply select-none; 386 + } 387 + 335 388 .prose { 336 389 overflow-wrap: anywhere; 337 390 }
-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 + }