de505df5 Andy McCall 1 month ago ↶
1
2
3
4
use super :: history:: { render_files, FileRows} ;
5
use super :: settings:: { csrf, require} ;
6
use super :: { filters, lookup, lookup_role, Error, Page, RepoShell, Shell} ;
7
use crate :: auth:: { Access, Role, Viewer} ;
8
use crate :: db:: { self , accounts, proposals as pdb} ;
9
use crate :: render:: { self , diff} ;
10
use crate :: repos:: proposals:: { self as pl, Projection, Source} ;
11
use crate :: repos:: view:: { self , ChangeView, ConflictView} ;
12
use crate :: repos:: RepoName;
13
use crate :: Shared;
14
use askama:: Template;
15
use axum:: extract:: { Form, Path, Query, State} ;
16
use axum:: response:: { IntoResponse, Redirect, Response} ;
17
use hub_api:: { Approval, ProposalSource, ProposalState} ;
18
use oxy_core:: hash:: ChangeHash;
19
use serde:: Deserialize;
20
use std:: collections:: { BTreeMap, HashMap} ;
21
22
23
24
# [ derive ( Template ) ]
25
# [ template ( path = " proposals.html" ) ]
26
pub struct ProposalsPage {
27
shell : Shell,
28
repo : RepoShell,
29
rows : Vec< pdb:: ProposalRow> ,
30
filter : String,
31
counts : ( usize , usize , usize ) ,
32
bookmarks : Vec< String> ,
33
}
34
35
# [ derive ( Deserialize ) ]
36
pub struct ListQuery {
37
state : Option< String> ,
38
}
39
40
pub async fn list (
41
State( app ) : State< Shared> ,
42
viewer : Viewer,
43
Path( (owner , name ) ) : Path< ( String, String) > ,
44
Query( q) : Query< ListQuery> ,
45
) -> Result< Page< ProposalsPage> , Error> {
46
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals" ) , & viewer) ;
47
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
48
let all = pdb:: list( & app.db, row.id, & [ ] ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
49
let counts = (
50
all.iter ( ) .filter ( | p | p.is_open ( ) ) .count ( ) ,
51
all.iter ( ) .filter ( | p | p.state = = " landed" ) .count ( ) ,
52
all.iter ( ) .filter ( | p | p.state = = " closed" ) .count ( ) ,
53
) ;
54
let filter = q.state.unwrap_or_else ( | | " open" .into ( ) ) ;
55
let rows = all
56
.into_iter ( )
57
.filter ( | p | match filter.as_str ( ) {
58
" open" => p.is_open ( ) ,
59
" landed" => p.state = = " landed" ,
60
" closed" => p.state = = " closed" ,
61
_ => true ,
62
} )
63
.collect ( ) ;
64
let bookmarks = app
65
.repos
66
.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) )
67
.await
68
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
69
Ok ( Page( ProposalsPage { shell, repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) , rows, filter, counts, bookmarks } ) )
70
}
71
72
73
74
# [ derive ( Template ) ]
75
# [ template ( path = " proposal_new.html" ) ]
76
pub struct NewProposalPage {
77
shell : Shell,
78
repo : RepoShell,
79
bookmarks : Vec< String> ,
80
source : String,
81
target : String,
82
error : Option< String> ,
83
84
preview : Vec< ChangeView> ,
85
conflicted : bool ,
86
}
87
88
# [ derive ( Deserialize ) ]
89
pub struct NewQuery {
90
source : Option< String> ,
91
target : Option< String> ,
92
}
93
94
95
fn parse_source ( app : & Shared, repo : & RepoName, source : & str ) -> Option< ( Option< RepoName> , String) > {
96
if let Some ( ( slug, bookmark) ) = source.split_once ( ' :' ) {
97
let fork = RepoName:: parse( slug) ? ;
98
if ! app.repos.exists ( & fork) | | fork = = * repo {
99
return None ;
100
}
101
return Some ( ( Some ( fork) , bookmark.to_string ( ) ) ) ;
102
}
103
crate :: repos:: names:: valid_segment( source) .then ( | | ( None , source.to_string ( ) ) )
104
}
105
106
async fn preview_for (
107
app : & Shared,
108
repo : & RepoName,
109
target : & str ,
110
source : & str ,
111
) -> Result< ( Vec< ChangeView> , bool ) , crate:: repos:: RepoError> {
112
let Some ( ( fork, bookmark) ) = parse_source ( app, repo, source) else { return Ok ( ( vec! [ ] , false ) ) } ;
113
let target = target.to_string ( ) ;
114
let fork_dir = fork.as_ref ( ) .map ( | f | app.repos.dir ( f) ) ;
115
app.repos
116
.read ( repo, move | r, cache| {
117
let projection = match & fork_dir {
118
Some ( dir) => {
119
let fork_repo = oxy_core:: repo:: Repo:: open( dir) ? ;
120
let heads = fork_repo.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
121
pl:: project( r, cache, & target, Source:: Fork { fork: & fork_repo, heads } ) ?
122
}
123
None => {
124
let heads = r.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
125
pl:: project( r, cache, & target, Source:: Bookmark( heads) ) ?
126
}
127
} ;
128
Ok ( ( projection.changes, projection.conflicted) )
129
} )
130
.await
131
}
132
133
pub async fn new_form (
134
State( app ) : State< Shared> ,
135
viewer : Viewer,
136
Path( (owner , name ) ) : Path< ( String, String) > ,
137
Query( q) : Query< NewQuery> ,
138
) -> Result< Page< NewProposalPage> , Error> {
139
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals/new" ) , & viewer) ;
140
require ( & viewer, & shell) ? ;
141
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
142
let bookmarks = app
143
.repos
144
.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) )
145
.await
146
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
147
let target = q.target.unwrap_or_else ( | | row.default_bookmark.clone ( ) ) ;
148
let source = q.source.unwrap_or_default ( ) ;
149
let ( preview, conflicted) =
150
if source.is_empty ( ) { ( vec! [ ] , false ) } else { preview_for ( & app, & repo, & target, & source) .await.unwrap_or ( ( vec! [ ] , false ) ) } ;
151
Ok ( Page( NewProposalPage {
152
shell,
153
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
154
bookmarks,
155
source,
156
target,
157
error: None ,
158
preview,
159
conflicted,
160
} ) )
161
}
162
163
# [ derive ( Deserialize ) ]
164
pub struct NewForm {
165
#[serde(rename = "_csrf")]
166
csrf_token : String,
167
title : String,
168
#[serde(default)]
169
body : String,
170
target : String,
171
source : String,
172
#[serde(default)]
173
draft : Option< String> ,
174
}
175
176
pub async fn create (
177
State( app ) : State< Shared> ,
178
viewer : Viewer,
179
Path( (owner , name ) ) : Path< ( String, String) > ,
180
Form( f) : Form< NewForm> ,
181
) -> Result< Response, Error> {
182
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals/new" ) , & viewer) ;
183
let me = require ( & viewer, & shell) ? ;
184
csrf ( & viewer, & f.csrf_token, & shell) ? ;
185
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
186
let bookmarks = app
187
.repos
188
.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) )
189
.await
190
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
191
let fail = | msg : & str , preview : Vec< ChangeView> | {
192
Page( NewProposalPage {
193
shell: shell.clone ( ) ,
194
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
195
bookmarks: bookmarks.clone ( ) ,
196
source: f.source.clone ( ) ,
197
target: f.target.clone ( ) ,
198
error: Some ( msg.into ( ) ) ,
199
preview,
200
conflicted: false ,
201
} )
202
.into_response ( )
203
} ;
204
if f.title.trim ( ) .is_empty ( ) {
205
return Ok ( fail ( " Give the proposal a title." , vec! [ ] ) ) ;
206
}
207
if ! bookmarks.contains ( & f.target) {
208
return Ok ( fail ( " Pick a target bookmark of this repository." , vec! [ ] ) ) ;
209
}
210
let Some ( ( fork, bookmark) ) = parse_source ( & app, & repo, f.source.trim ( ) ) else {
211
return Ok ( fail ( " The source is a bookmark of this repository, or owner/name:bookmark of a fork." , vec! [ ] ) ) ;
212
} ;
213
if fork.is_none ( ) & & bookmark = = f.target {
214
return Ok ( fail ( " The source and the target are the same bookmark." , vec! [ ] ) ) ;
215
}
216
let ( preview, _ ) = preview_for ( & app, & repo, & f.target, f.source.trim ( ) ) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
217
if preview.is_empty ( ) {
218
return Ok ( fail ( " The source adds nothing to the target." , vec! [ ] ) ) ;
219
}
220
let source_repo_id = match & fork {
221
Some ( fk) => db:: repos:: get( & app.db, & fk.owner, & fk.name) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .map ( | r | r.id ) ,
222
None => None ,
223
} ;
224
let created = pdb:: create(
225
& app.db,
226
pdb:: NewProposal {
227
repo_id: row.id,
228
title: f.title.trim ( ) ,
229
body: f.body.trim ( ) ,
230
author_id: Some ( me.account.id) ,
231
author: & me.account.username,
232
draft: f.draft.is_some ( ) ,
233
target: & f.target,
234
source_kind: if fork.is_some ( ) { " fork" } else { " bookmark" } ,
235
source_repo_id,
236
source_bookmark: & bookmark,
237
source_changes: " " ,
238
} ,
239
)
240
.await
241
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
242
pdb:: add_event( & app.db, created.id, & me.account.username, " opened" , " " ) .await.ok ( ) ;
243
accounts:: audit( & app.db, Some ( & me.account) , " proposal.opened" , & format! ( " {owner} /{name} #{} " , created.number) , & f.title) .await.ok ( ) ;
244
Ok ( Redirect:: to( & format! ( " /{owner} /{name} /proposal/{} " , created.number) ) .into_response ( ) )
245
}
246
247
248
249
250
pub struct CommentView {
251
pub row : pdb:: CommentRow,
252
pub body_html : String,
253
pub line : Option< usize > ,
254
}
255
256
pub struct ReviewView {
257
pub row : pdb:: ReviewRow,
258
pub body_html : String,
259
pub sealed : bool ,
260
}
261
262
263
pub struct Loaded {
264
pub row : pdb:: ProposalRow,
265
pub changes : Vec< ChangeView> ,
266
pub hashes : Vec< ChangeHash> ,
267
pub heads : Vec< ChangeHash> ,
268
pub state : String,
269
pub target_state : String,
270
pub conflicted : bool ,
271
pub conflicts : Vec< ConflictView> ,
272
pub files : Vec< FileRows> ,
273
274
pub atoms : HashMap< ( String, usize ) , ( String, u32 ) > ,
275
pub sealing_keys : Vec< String> ,
276
pub touched : Vec< String> ,
277
pub owners : Vec< String> ,
278
pub landed_already : bool ,
279
}
280
281
282
pub async fn load ( app : & Shared, repo : & RepoName, row : pdb:: ProposalRow) -> Result< Loaded, crate:: repos:: RepoError> {
283
let fork_dir = match ( row.source_kind.as_str ( ) , row.source_repo_id) {
284
( " fork" , Some ( id) ) => {
285
db:: repos:: get_by_id( & app.db, id) .await.ok ( ) .flatten ( ) .and_then ( | r | RepoName:: new( & r.owner, & r.name) ) .map ( | n | app.repos.dir ( & n) )
286
}
287
_ => None ,
288
} ;
289
let r2 = row.clone ( ) ;
290
let loaded = app
291
.repos
292
.read ( repo, move | r, cache| {
293
let source = match ( r2.source_kind.as_str ( ) , & fork_dir) {
294
( " changes" , _ ) => Source:: Changes( r2.source_changes ( ) .iter ( ) .filter_map ( | h | ChangeHash:: from_hex( h) ) .collect ( ) ) ,
295
( " fork" , Some ( dir) ) => {
296
let fork_repo = oxy_core:: repo:: Repo:: open( dir) ? ;
297
let heads =
298
fork_repo.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = r2.source_bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
299
300
let fork_state = fork_repo.load_state ( & heads) ? ;
301
let missing: Vec< ChangeHash> = fork_state.changes ( ) .map ( | ( h , _) | * h) .filter ( | h | ! r.store ( ) .has ( h) ) .collect ( ) ;
302
if ! missing.is_empty ( ) {
303
r.import_bundle ( & fork_repo.bundle_for ( & missing) ? , r.current_policy ( ) ? .is_some ( ) ) ? ;
304
}
305
Source:: Changes( heads)
306
}
307
_ => {
308
let heads = r.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = r2.source_bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
309
Source:: Bookmark( heads)
310
}
311
} ;
312
let p: Projection = pl:: project( r, cache, & r2.target, source) ? ;
313
let target_loaded = cache.load ( r, & p.target_heads) ? ;
314
let files = render_files ( & p.files, & p.bases) ;
315
let mut atoms = HashMap:: new( ) ;
316
for f in & files {
317
for l in & f.lines {
318
if let Some ( n) = l.new_no {
319
if let Some ( ( h, i) ) = pl:: atom_at( & p.loaded, & f.path, n as usize ) {
320
atoms.insert ( ( f.path.clone ( ) , n as usize ) , ( h.to_hex ( ) , i) ) ;
321
}
322
}
323
}
324
}
325
let touched: Vec< String> = p.files.iter ( ) .map ( | f | f.path.clone ( ) ) .collect ( ) ;
326
let owners = pl:: owners_for( & target_loaded, & touched) ;
327
let sealing = pl:: sealing_keys( r, & p.hashes) ? .into_iter ( ) .map ( | k | k.to_hex ( ) ) .collect ( ) ;
328
let landed_already = ! p.hashes.is_empty ( ) & & p.hashes.iter ( ) .all ( | h | target_loaded.state.contains ( h) ) ;
329
Ok ( Loaded {
330
row: r2,
331
changes: p.changes,
332
hashes: p.hashes,
333
heads: p.heads,
334
state: p.state.to_hex ( ) ,
335
target_state: p.target_state.to_hex ( ) ,
336
conflicted: p.conflicted,
337
conflicts: p.conflicts,
338
files,
339
atoms,
340
sealing_keys: sealing,
341
touched,
342
owners,
343
landed_already,
344
} )
345
} )
346
.await? ;
347
348
let heads_csv = loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
349
let changes_csv = loaded.hashes.iter ( ) .map ( | h | h.to_hex ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
350
pdb:: set_projection( & app.db, loaded.row.id, & loaded.state, & heads_csv, & loaded.target_state, & changes_csv, loaded.conflicted)
351
.await
352
.ok ( ) ;
353
Ok ( loaded)
354
}
355
356
357
pub struct Verdict {
358
pub reviews : Vec< ReviewView> ,
359
pub approvals : Vec< Approval> ,
360
pub blockers : Vec< String> ,
361
pub depends_on : Vec< i64 > ,
362
pub requirements : pdb:: Requirements,
363
}
364
365
pub async fn verdict ( app : & Shared, loaded : & Loaded, repo_id : i64 ) -> Result< Verdict, sqlx:: Error> {
366
let rows = pdb:: reviews( & app.db, loaded.row.id) .await? ;
367
let links = accounts:: identity_accounts( & app.db) .await? ;
368
let sealed_accounts: Vec< String> = links.iter ( ) .filter ( | ( k , _) | loaded.sealing_keys.contains ( k) ) .map ( | ( _, u ) | u.clone ( ) ) .collect ( ) ;
369
370
let mut latest: BTreeMap< String, & pdb:: ReviewRow> = BTreeMap:: new( ) ;
371
for r in & rows {
372
latest.insert ( r.author.clone ( ) , r) ;
373
}
374
let mut approvals = Vec :: new( ) ;
375
for ( who, r) in & latest {
376
if r.verdict = = " approve" {
377
let sealed = sealed_accounts.contains ( who) ;
378
let key = links.iter ( ) .find ( | ( k , u ) | u = = who & & loaded.sealing_keys.contains ( k) ) .map ( | ( k , _) | k.clone ( ) ) ;
379
approvals.push ( Approval { account: who.clone ( ) , sealed, key } ) ;
380
}
381
}
382
let reviews = rows
383
.iter ( )
384
.map ( | r | ReviewView {
385
row: r.clone ( ) ,
386
body_html: render:: markdown:: render( & r.body, " /" , " /" ) ,
387
sealed: r.verdict = = " approve" & & sealed_accounts.contains ( & r.author) ,
388
} )
389
.collect ( ) ;
390
let requirements = pdb:: requirements( & app.db, repo_id) .await? ;
391
let mut blockers = Vec :: new( ) ;
392
if loaded.row.state = = " draft" {
393
blockers.push ( " the proposal is a draft" .into ( ) ) ;
394
}
395
if loaded.hashes.is_empty ( ) {
396
blockers.push ( " the proposal adds no changes" .into ( ) ) ;
397
}
398
let sealed_count = approvals.iter ( ) .filter ( | a | a.sealed ) .count ( ) as i64 ;
399
if sealed_count < requirements.required_approvals {
400
blockers.push ( format! ( " {sealed_count} of {} required sealed approvals" , requirements.required_approvals) ) ;
401
}
402
if requirements.require_owners {
403
let missing: Vec< & String> = loaded.owners.iter ( ) .filter ( | o | ! approvals.iter ( ) .any ( | a | a.sealed & & & a.account = = * o ) ) .collect ( ) ;
404
if ! missing.is_empty ( ) {
405
blockers.push ( format! ( " code owners still to approve: {} " , missing.iter ( ) .map ( | s | s.as_str ( ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
406
}
407
}
408
if loaded.conflicted & & ! requirements.allow_conflicts {
1e23d010 Andy McCall 1 month ago ↶
409
blockers.push ( format! (
410
" landing would carry conflicts, which this repository refuses: resolve them on the source (oxy merge {} there, fix the marked files, oxy commit, push)" ,
411
loaded.row.target
412
) ) ;
de505df5 Andy McCall 1 month ago ↶
413
}
414
let requesting: Vec< & String> = latest.iter ( ) .filter ( | ( _, r ) | r.verdict = = " changes" ) .map ( | ( who , _) | who ) .collect ( ) ;
415
if ! requesting.is_empty ( ) {
416
blockers.push ( format! ( " changes requested by {} " , requesting.iter ( ) .map ( | s | s.as_str ( ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
417
}
418
419
let mut depends_on = Vec :: new( ) ;
420
for other in pdb:: list( & app.db, repo_id, & [ " open" , " draft" ] ) .await? {
421
if other.id = = loaded.row.id | | other.number > loaded.row.number {
422
continue ;
423
}
424
let theirs = other.projected_changes ( ) ;
425
if ! theirs.is_empty ( ) & & theirs.iter ( ) .all ( | h | loaded.hashes.iter ( ) .any ( | m | m.to_hex ( ) = = * h ) ) {
426
depends_on.push ( other.number) ;
427
}
428
}
429
if ! depends_on.is_empty ( ) {
430
blockers.push ( format! ( " depends on {} " , depends_on.iter ( ) .map ( | n | format! ( " #{n} " ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
431
}
432
Ok ( Verdict { reviews, approvals, blockers, depends_on, requirements } )
433
}
434
435
# [ derive ( Template ) ]
436
# [ template ( path = " proposal.html" ) ]
437
pub struct ProposalPage {
438
shell : Shell,
439
repo : RepoShell,
440
p : pdb:: ProposalRow,
441
body_html : String,
442
tab : String,
443
changes : Vec< ChangeView> ,
444
files : Vec< FileRows> ,
445
conflicts : Vec< ConflictView> ,
446
conflicted : bool ,
447
comments : Vec< CommentView> ,
448
449
placed : HashMap< ( String, usize ) , Vec< usize > > ,
450
outdated : Vec< usize > ,
451
reviews : Vec< ReviewView> ,
452
approvals : Vec< Approval> ,
453
blockers : Vec< String> ,
454
depends_on : Vec< i64 > ,
455
events : Vec< pdb:: EventRow> ,
456
owners : Vec< String> ,
457
requirements : pdb:: Requirements,
458
landed_already : bool ,
459
can_edit : bool ,
460
can_land : bool ,
461
hub_can_land : bool ,
462
notice : Option< String> ,
463
error : Option< String> ,
464
heads : Vec< String> ,
465
state_hash : String,
466
source_label : String,
467
api_url : String,
468
}
469
470
# [ derive ( Deserialize ) ]
471
pub struct DetailQuery {
472
tab : Option< String> ,
473
}
474
475
476
477
# [ allow ( clippy::too_many_arguments ) ]
478
async fn detail (
479
app : & Shared,
480
shell : Shell,
481
viewer : & Viewer,
482
owner : & str ,
483
name : & str ,
484
number : i64 ,
485
tab : String,
486
notice : Option< String> ,
487
error : Option< String> ,
488
) -> Result< Page< ProposalPage> , Error> {
489
let ( repo, row, role) = lookup ( app, & shell, viewer, owner, name) .await? ;
490
let p = pdb:: get( & app.db, row.id, number)
491
.await
492
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
493
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
494
let loaded = load ( app, & repo, p) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
495
496
if loaded.landed_already & & loaded.row.is_open ( ) {
497
pdb:: set_state( & app.db, loaded.row.id, " landed" , & loaded.target_state) .await.ok ( ) ;
498
pdb:: add_event( & app.db, loaded.row.id, " " , " landed" , " every change reached the target" ) .await.ok ( ) ;
499
}
500
let p = pdb:: get( & app.db, row.id, number) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( loaded.row.clone ( ) ) ;
501
let v = verdict ( app, & loaded, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
502
let comment_rows = pdb:: comments( & app.db, loaded.row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
503
let events = pdb:: events( & app.db, loaded.row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
504
505
let mut by_atom: HashMap< ( String, String, u32 ) , usize > = HashMap:: new( ) ;
506
for ( ( path, line) , ( h, i) ) in & loaded.atoms {
507
by_atom.insert ( ( path.clone ( ) , h.clone ( ) , * i) , * line) ;
508
}
509
let mut comments = Vec :: new( ) ;
510
let mut placed: HashMap< ( String, usize ) , Vec< usize > > = HashMap:: new( ) ;
511
let mut outdated = Vec :: new( ) ;
512
for ( idx, c) in comment_rows.into_iter ( ) .enumerate ( ) {
513
let line = if c.anchor_change.is_empty ( ) {
514
None
515
} else {
516
by_atom.get ( & ( c.path.clone ( ) , c.anchor_change.clone ( ) , c.anchor_index as u32 ) ) .copied ( )
517
} ;
518
if ! c.anchor_change.is_empty ( ) {
519
match line {
520
Some ( l) => placed.entry ( ( c.path.clone ( ) , l) ) .or_default ( ) .push ( idx) ,
521
None => outdated.push ( idx) ,
522
}
523
}
524
comments.push ( CommentView { body_html: render:: markdown:: render( & c.body, " /" , " /" ) , row: c, line } ) ;
525
}
526
let mut changes = loaded.changes.clone ( ) ;
527
super :: attribute( app, & mut changes.iter_mut ( ) .collect:: < Vec< _ > > ( ) ) .await;
528
let me = viewer.account ( ) ;
529
let can_edit = me.is_some_and ( | a | Some ( a.id) = = p.author_id | | role > = Role:: Maintain) ;
530
let can_land = role > = Role:: Write & & p.is_open ( ) & & v.blockers.is_empty ( ) ;
531
let hub_can_land = app.repos.read ( & repo, | r , _| Ok ( r.current_policy ( ) ? .is_none ( ) ) ) .await.unwrap_or ( false ) ;
532
let source_label = match p.source_kind.as_str ( ) {
533
" fork" => format! (
534
" fork {} " ,
535
db:: repos:: get_by_id( & app.db, p.source_repo_id.unwrap_or ( 0 ) )
536
.await
537
.ok ( )
538
.flatten ( )
539
.map ( | r | format! ( " {} :{} " , r.slug ( ) , p.source_bookmark) )
540
.unwrap_or_default ( )
541
) ,
542
" changes" => format! ( " {} changes" , p.source_changes ( ) .len ( ) ) ,
543
_ => p.source_bookmark.clone ( ) ,
544
} ;
545
Ok ( Page( ProposalPage {
546
shell,
547
repo: RepoShell:: new( app, & row, " proposals" ) .with_role ( role) ,
548
body_html: render:: markdown:: render( & p.body, " /" , " /" ) ,
549
p,
550
tab,
551
changes,
552
files: loaded.files,
553
conflicts: loaded.conflicts,
554
conflicted: loaded.conflicted,
555
comments,
556
placed,
557
outdated,
558
reviews: v.reviews,
559
approvals: v.approvals,
560
blockers: v.blockers,
561
depends_on: v.depends_on,
562
events,
563
owners: loaded.owners,
564
requirements: v.requirements,
565
landed_already: loaded.landed_already,
566
can_edit,
567
can_land,
568
hub_can_land,
569
notice,
570
error,
571
heads: loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect ( ) ,
572
state_hash: loaded.state,
573
source_label,
574
api_url: format! ( " {} /api/v1/repos/{owner} /{name} /proposals/{number} " , app.config.base_url ( ) ) ,
575
} ) )
576
}
577
578
pub async fn show (
579
State( app ) : State< Shared> ,
580
viewer : Viewer,
581
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
582
Query( q) : Query< DetailQuery> ,
583
) -> Result< Page< ProposalPage> , Error> {
584
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
585
detail ( & app, shell, & viewer, & owner, & name, number, q.tab.unwrap_or_else ( | | " conversation" .into ( ) ) , None , None ) .await
586
}
587
588
589
590
# [ derive ( Deserialize ) ]
591
pub struct CommentForm {
592
#[serde(rename = "_csrf")]
593
csrf_token : String,
594
body : String,
595
#[serde(default)]
596
path : String,
597
#[serde(default)]
598
line : Option< usize > ,
599
}
600
601
pub async fn comment (
602
State( app ) : State< Shared> ,
603
viewer : Viewer,
604
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
605
Form( f) : Form< CommentForm> ,
606
) -> Result< Page< ProposalPage> , Error> {
607
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
608
let me = require ( & viewer, & shell) ? ;
609
csrf ( & viewer, & f.csrf_token, & shell) ? ;
610
let ( repo, row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
611
let p = pdb:: get( & app.db, row.id, number)
612
.await
613
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
614
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
615
if f.body.trim ( ) .is_empty ( ) {
616
return detail (
617
& app,
618
shell,
619
& viewer,
620
& owner,
621
& name,
622
number,
623
" conversation" .into ( ) ,
624
None ,
625
Some ( " An empty comment says nothing." .into ( ) ) ,
626
)
627
.await;
628
}
629
let anchor = match ( f.path.trim ( ) , f.line) {
630
( " " , _ ) | ( _ , None ) => None ,
631
( path, Some ( line) ) => {
632
let loaded = load ( & app, & repo, p.clone ( ) ) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
633
loaded.atoms.get ( & ( path.to_string ( ) , line) ) .cloned ( )
634
}
635
} ;
636
let tab = if anchor.is_some ( ) { " files" } else { " conversation" } ;
637
pdb:: add_comment(
638
& app.db,
639
p.id,
640
Some ( me.account.id) ,
641
& me.account.username,
642
f.body.trim ( ) ,
643
f.path.trim ( ) ,
644
anchor.as_ref ( ) .map ( | ( h , i ) | ( h.as_str ( ) , * i as i64 ) ) ,
645
)
646
.await
647
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
648
detail ( & app, shell, & viewer, & owner, & name, number, tab.into ( ) , None , None ) .await
649
}
650
651
# [ derive ( Deserialize ) ]
652
pub struct ReviewForm {
653
#[serde(rename = "_csrf")]
654
csrf_token : String,
655
verdict : String,
656
#[serde(default)]
657
body : String,
658
}
659
660
pub async fn review (
661
State( app ) : State< Shared> ,
662
viewer : Viewer,
663
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
664
Form( f) : Form< ReviewForm> ,
665
) -> Result< Page< ProposalPage> , Error> {
666
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
667
let me = require ( & viewer, & shell) ? ;
668
csrf ( & viewer, & f.csrf_token, & shell) ? ;
669
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
670
let p = pdb:: get( & app.db, row.id, number)
671
.await
672
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
673
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
674
if ! matches! ( f.verdict.as_str ( ) , " comment" | " changes" | " approve" ) {
675
return Err ( Error:: bad( shell, " a review is comment, changes or approve" ) ) ;
676
}
677
if f.verdict = = " approve" & & Some ( me.account.id) = = p.author_id {
678
return detail (
679
& app,
680
shell,
681
& viewer,
682
& owner,
683
& name,
684
number,
685
" conversation" .into ( ) ,
686
None ,
687
Some ( " You cannot approve your own proposal." .into ( ) ) ,
688
)
689
.await;
690
}
691
pdb:: add_review( & app.db, p.id, me.account.id, & me.account.username, & f.verdict, f.body.trim ( ) )
692
.await
693
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
694
pdb:: add_event( & app.db, p.id, & me.account.username, & format! ( " review.{} " , f.verdict) , " " ) .await.ok ( ) ;
695
let notice = if f.verdict = = " approve" {
696
Some ( format! (
697
" Approval recorded. It counts once you seal the changes: oxy seal --proposal {} /{owner} /{name} /proposal/{number} " ,
698
app.config.base_url ( )
699
) )
700
} else {
701
None
702
} ;
703
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , notice, None ) .await
704
}
705
706
# [ derive ( Deserialize ) ]
707
pub struct EditForm {
708
#[serde(rename = "_csrf")]
709
csrf_token : String,
710
title : String,
711
#[serde(default)]
712
body : String,
713
target : String,
714
}
715
716
pub async fn edit (
717
State( app ) : State< Shared> ,
718
viewer : Viewer,
719
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
720
Form( f) : Form< EditForm> ,
721
) -> Result< Page< ProposalPage> , Error> {
722
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
723
let me = require ( & viewer, & shell) ? ;
724
csrf ( & viewer, & f.csrf_token, & shell) ? ;
725
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
726
let p = pdb:: get( & app.db, row.id, number)
727
.await
728
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
729
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
730
if Some ( me.account.id) ! = p.author_id & & role < Role:: Maintain {
731
return Err ( Error:: not_found( shell) ) ;
732
}
733
let target = if crate :: repos:: names:: valid_segment( f.target.trim ( ) ) { f.target.trim ( ) } else { & p.target } ;
734
pdb:: edit( & app.db, p.id, f.title.trim ( ) , f.body.trim ( ) , target) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
735
pdb:: add_event( & app.db, p.id, & me.account.username, " edited" , " " ) .await.ok ( ) ;
736
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , Some ( " Saved." .into ( ) ) , None ) .await
737
}
738
739
# [ derive ( Deserialize ) ]
740
pub struct StateForm {
741
#[serde(rename = "_csrf")]
742
csrf_token : String,
743
action : String,
744
}
745
746
pub async fn state (
747
State( app ) : State< Shared> ,
748
viewer : Viewer,
749
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
750
Form( f) : Form< StateForm> ,
751
) -> Result< Page< ProposalPage> , Error> {
752
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
753
let me = require ( & viewer, & shell) ? ;
754
csrf ( & viewer, & f.csrf_token, & shell) ? ;
755
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
756
let p = pdb:: get( & app.db, row.id, number)
757
.await
758
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
759
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
760
if Some ( me.account.id) ! = p.author_id & & role < Role:: Triage {
761
return Err ( Error:: not_found( shell) ) ;
762
}
763
let new_state = match ( f.action.as_str ( ) , p.state.as_str ( ) ) {
764
( " close" , " open" | " draft" ) => " closed" ,
765
( " reopen" , " closed" ) => " open" ,
766
( " ready" , " draft" ) => " open" ,
767
( " draft" , " open" ) => " draft" ,
768
_ => return Err ( Error:: bad( shell, " that transition is not possible" ) ) ,
769
} ;
770
pdb:: set_state( & app.db, p.id, new_state, " " ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
771
pdb:: add_event( & app.db, p.id, & me.account.username, new_state, " " ) .await.ok ( ) ;
772
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , None , None ) .await
773
}
774
775
pub async fn resolve_comment (
776
State( app ) : State< Shared> ,
777
viewer : Viewer,
778
Path( (owner , name , number , id ) ) : Path< ( String, String, i64 , i64 ) > ,
779
Form( f) : Form< super:: session:: CsrfForm> ,
780
) -> Result< Page< ProposalPage> , Error> {
781
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
782
require ( & viewer, & shell) ? ;
783
csrf ( & viewer, & f.csrf, & shell) ? ;
784
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
785
let p = pdb:: get( & app.db, row.id, number)
786
.await
787
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
788
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
789
let current: Option< bool > = sqlx:: query_scalar( " SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?" )
790
.bind ( id)
791
.bind ( p.id)
792
.fetch_optional ( & app.db)
793
.await
794
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
795
if let Some ( r) = current {
796
pdb:: set_comment_resolved( & app.db, id, ! r) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
797
}
798
detail ( & app, shell, & viewer, & owner, & name, number, " files" .into ( ) , None , None ) .await
799
}
800
801
802
803
pub async fn land (
804
State( app ) : State< Shared> ,
805
viewer : Viewer,
806
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
807
Form( f) : Form< super:: session:: CsrfForm> ,
808
) -> Result< Page< ProposalPage> , Error> {
809
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
810
let me = require ( & viewer, & shell) ? ;
811
csrf ( & viewer, & f.csrf, & shell) ? ;
812
let ( repo, row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
813
let p = pdb:: get( & app.db, row.id, number)
814
.await
815
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
816
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
817
let loaded = load ( & app, & repo, p.clone ( ) ) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
818
let v = verdict ( & app, & loaded, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
819
if ! p.is_open ( ) | | ! v.blockers.is_empty ( ) {
820
return detail (
821
& app,
822
shell,
823
& viewer,
824
& owner,
825
& name,
826
number,
827
" conversation" .into ( ) ,
828
None ,
829
Some ( format! ( " Cannot land: {} " , v.blockers.join ( " ; " ) ) ) ,
830
)
831
.await;
832
}
833
let target = p.target.clone ( ) ;
834
let heads = loaded.heads.clone ( ) ;
835
let result = app
836
.repos
837
.write ( & repo, move | r, _ | {
838
if r.current_policy ( ) ? .is_some ( ) {
839
return Err ( crate :: repos:: RepoError:: Oxy( oxy_core:: repo:: RepoError:: Msg(
840
" this repository has a trust policy, so a reference update must be signed by an authorised key: land from a client with oxy proposal land" .into ( ) ,
841
) ) ) ;
842
}
843
r.bookmark_set ( & target, & heads) ? ;
844
Ok ( pl:: state_ref( & heads) .to_hex ( ) )
845
} )
846
.await;
847
match result {
848
Ok ( state) => {
849
pdb:: set_state( & app.db, p.id, " landed" , & state) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
850
pdb:: add_event( & app.db, p.id, & me.account.username, " landed" , " from the hub" ) .await.ok ( ) ;
851
accounts:: audit( & app.db, Some ( & me.account) , " proposal.landed" , & format! ( " {owner} /{name} #{number} " ) , " " ) .await.ok ( ) ;
852
db:: repos:: record_push(
853
& app.db,
854
row.id,
855
Some ( ( me.account.id, me.account.username.as_str ( ) ) ) ,
856
& [ db:: repos:: PushOutcome {
857
reference: p.target.clone ( ) ,
858
ok: true ,
859
reason: format! ( " landed #{number} " ) ,
860
old_state: loaded.target_state.clone ( ) ,
861
new_state: state,
862
heads: loaded.heads.len ( ) as i64 ,
863
changes: loaded.hashes.len ( ) as i64 ,
864
} ] ,
865
)
866
.await
867
.ok ( ) ;
868
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , Some ( " Landed." .into ( ) ) , None ) .await
869
}
870
Err ( e) => detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , None , Some ( e.to_string ( ) ) ) .await,
871
}
872
}
873
874
875
876
pub async fn to_api ( app : & Shared, owner : & str , name : & str , loaded : & Loaded, v : & Verdict, source_url : Option< String> ) -> hub_api:: Proposal {
877
let p = & loaded.row;
878
hub_api:: Proposal {
879
number: p.number,
880
title: p.title.clone ( ) ,
881
body: p.body.clone ( ) ,
882
author: p.author.clone ( ) ,
883
state: ProposalState:: parse( & p.state) .unwrap_or ( ProposalState:: Open) ,
884
target: p.target.clone ( ) ,
885
source: match p.source_kind.as_str ( ) {
886
" fork" => {
887
let ( o, n) = source_url
888
.as_deref ( )
889
.and_then ( | u | u.rsplit_once ( ' /' ) )
890
.and_then ( | ( b , n ) | b.rsplit_once ( ' /' ) .map ( | ( _, o ) | ( o.to_string ( ) , n.to_string ( ) ) ) )
891
.unwrap_or_default ( ) ;
892
ProposalSource:: Fork { owner: o, name: n, bookmark: p.source_bookmark.clone ( ) }
893
}
894
" changes" => ProposalSource:: Changes { hashes: p.source_changes ( ) } ,
895
_ => ProposalSource:: Bookmark { bookmark: p.source_bookmark.clone ( ) } ,
896
} ,
897
changes: loaded
898
.changes
899
.iter ( )
900
.map ( | c | hub_api:: ChangeSummary {
901
hash: c.hash.clone ( ) ,
902
message: if c.body.is_empty ( ) { c.title.clone ( ) } else { format! ( " {} \n \n {} " , c.title, c.body) } ,
903
author: hub_api:: Author {
904
name: c.author.name.clone ( ) ,
905
email: c.author.email.clone ( ) ,
906
key: c.author.key.clone ( ) ,
907
account: c.author.account.clone ( ) ,
908
} ,
909
when_ms: c.when_ms,
910
deps: c.deps.clone ( ) ,
911
head: c.head,
912
signature: match & c.signature {
913
view:: SigView:: Unsigned => hub_api:: Signature:: Unsigned,
914
view:: SigView:: Valid { key, sealed, revoked, .. } => {
915
hub_api:: Signature:: Valid { key: key.clone ( ) , sealed: * sealed, revoked: * revoked }
916
}
917
view:: SigView:: Invalid( why) => hub_api:: Signature:: Invalid { reason: why.clone ( ) } ,
918
} ,
919
} )
920
.collect ( ) ,
921
projected_heads: loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect ( ) ,
922
projected_state: Some ( loaded.state.clone ( ) ) ,
923
conflicted: loaded.conflicted,
924
approvals: v.approvals.clone ( ) ,
925
landable: p.is_open ( ) & & v.blockers.is_empty ( ) ,
926
blockers: v.blockers.clone ( ) ,
927
created_ms: p.created_ms,
928
updated_ms: p.updated_ms,
929
url: format! ( " {} /{owner} /{name} /proposal/{} " , app.config.base_url ( ) , p.number) ,
930
repository_url: format! ( " {} /{owner} /{name} " , app.config.base_url ( ) ) ,
931
source_url,
932
}
933
}
934
935
936
pub fn key ( path : & str , line : impl filters:: Num) -> ( String, usize ) {
937
( path.to_string ( ) , line.as_u64 ( ) as usize )
938
}
939
940
# [ allow ( dead_code ) ]
941
fn _unused ( _ : & diff:: Line, _ : & ConflictView, _ : & Role) { }