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> ,
1ac84c62 Andy McCall 1 month ago ↶
33
q : String,
34
sort : String,
35
pager : super:: Pager,
36
labels : std:: collections:: HashMap< i64 , Vec< db:: issues:: Label> > ,
de505df5 Andy McCall 1 month ago ↶
37
}
38
1ac84c62 Andy McCall 1 month ago ↶
39
impl ProposalsPage {
40
fn labels_of ( & self , id : & i64 ) -> & [ db:: issues:: Label] {
41
self .labels.get ( id) .map ( Vec :: as_slice) .unwrap_or ( & [ ] )
42
}
43
}
44
de505df5 Andy McCall 1 month ago ↶
45
# [ derive ( Deserialize ) ]
46
pub struct ListQuery {
47
state : Option< String> ,
1ac84c62 Andy McCall 1 month ago ↶
48
q : Option< String> ,
49
sort : Option< String> ,
50
page : Option< usize > ,
de505df5 Andy McCall 1 month ago ↶
51
}
52
1ac84c62 Andy McCall 1 month ago ↶
53
54
pub const SORTS : & [ ( & str , & str ) ] = & [ ( " updated" , " recently updated" ) , ( " newest" , " newest" ) , ( " oldest" , " oldest" ) ] ;
55
de505df5 Andy McCall 1 month ago ↶
56
pub async fn list (
57
State( app ) : State< Shared> ,
58
viewer : Viewer,
59
Path( (owner , name ) ) : Path< ( String, String) > ,
60
Query( q) : Query< ListQuery> ,
61
) -> Result< Page< ProposalsPage> , Error> {
62
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals" ) , & viewer) ;
63
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
64
let all = pdb:: list( & app.db, row.id, & [ ] ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
65
let counts = (
66
all.iter ( ) .filter ( | p | p.is_open ( ) ) .count ( ) ,
67
all.iter ( ) .filter ( | p | p.state = = " landed" ) .count ( ) ,
68
all.iter ( ) .filter ( | p | p.state = = " closed" ) .count ( ) ,
69
) ;
70
let filter = q.state.unwrap_or_else ( | | " open" .into ( ) ) ;
1ac84c62 Andy McCall 1 month ago ↶
71
let mut labels: std:: collections:: HashMap< i64 , Vec< db:: issues:: Label> > = std:: collections:: HashMap:: new( ) ;
72
for ( id, l) in db:: issues:: labels_by_proposal( & app.db, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
73
labels.entry ( id) .or_default ( ) .push ( l) ;
74
}
75
76
let qtext = q.q.clone ( ) .unwrap_or_default ( ) ;
77
let ( mut authors, mut wanted, mut targets, mut words) = ( vec! [ ] , vec! [ ] , vec! [ ] , vec! [ ] ) ;
78
for tok in qtext.split_whitespace ( ) {
79
match tok.split_once ( ' :' ) {
80
Some ( ( " author" , v) ) => authors.push ( v.to_lowercase ( ) ) ,
81
Some ( ( " label" , v) ) => wanted.push ( v.to_lowercase ( ) ) ,
82
Some ( ( " target" , v) ) => targets.push ( v.to_string ( ) ) ,
83
_ => words.push ( tok.to_lowercase ( ) ) ,
84
}
85
}
86
let mut rows: Vec< pdb:: ProposalRow> = all
de505df5 Andy McCall 1 month ago ↶
87
.into_iter ( )
88
.filter ( | p | match filter.as_str ( ) {
89
" open" => p.is_open ( ) ,
90
" landed" => p.state = = " landed" ,
91
" closed" => p.state = = " closed" ,
92
_ => true ,
93
} )
1ac84c62 Andy McCall 1 month ago ↶
94
.filter ( | p | authors.is_empty ( ) | | authors.contains ( & p.author.to_lowercase ( ) ) )
95
.filter ( | p | targets.is_empty ( ) | | targets.contains ( & p.target) )
96
.filter ( | p | wanted.iter ( ) .all ( | w | labels.get ( & p.id) .is_some_and ( | ls | ls.iter ( ) .any ( | l | l.name.to_lowercase ( ) = = * w ) ) ) )
97
.filter ( | p | words.iter ( ) .all ( | w | p.title.to_lowercase ( ) .contains ( w) | | p.body.to_lowercase ( ) .contains ( w) ) )
de505df5 Andy McCall 1 month ago ↶
98
.collect ( ) ;
1ac84c62 Andy McCall 1 month ago ↶
99
let sort = q.sort.filter ( | s | SORTS .iter ( ) .any ( | ( k , _) | k = = s ) ) .unwrap_or_else ( | | " updated" .into ( ) ) ;
100
match sort.as_str ( ) {
101
" newest" => rows.sort_by_key ( | p | std:: cmp:: Reverse( p.number) ) ,
102
" oldest" => rows.sort_by_key ( | p | p.number ) ,
103
_ => rows.sort_by_key ( | p | std:: cmp:: Reverse( p.updated_ms) ) ,
104
}
105
let pager = super :: Pager:: new( rows.len ( ) , q.page, & [ ( " state" , & filter) , ( " q" , & qtext) , ( " sort" , & sort) ] ) ;
106
let rows = pager.slice ( rows) ;
de505df5 Andy McCall 1 month ago ↶
107
let bookmarks = app
108
.repos
109
.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) )
110
.await
111
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
1ac84c62 Andy McCall 1 month ago ↶
112
Ok ( Page( ProposalsPage {
113
shell,
114
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
115
rows,
116
filter,
117
counts,
118
bookmarks,
119
q: qtext,
120
sort,
121
pager,
122
labels,
123
} ) )
de505df5 Andy McCall 1 month ago ↶
124
}
125
126
127
128
# [ derive ( Template ) ]
129
# [ template ( path = " proposal_new.html" ) ]
130
pub struct NewProposalPage {
131
shell : Shell,
132
repo : RepoShell,
133
bookmarks : Vec< String> ,
134
source : String,
135
target : String,
136
error : Option< String> ,
137
138
preview : Vec< ChangeView> ,
139
conflicted : bool ,
140
}
141
142
# [ derive ( Deserialize ) ]
143
pub struct NewQuery {
144
source : Option< String> ,
145
target : Option< String> ,
146
}
147
148
149
fn parse_source ( app : & Shared, repo : & RepoName, source : & str ) -> Option< ( Option< RepoName> , String) > {
150
if let Some ( ( slug, bookmark) ) = source.split_once ( ' :' ) {
151
let fork = RepoName:: parse( slug) ? ;
152
if ! app.repos.exists ( & fork) | | fork = = * repo {
153
return None ;
154
}
155
return Some ( ( Some ( fork) , bookmark.to_string ( ) ) ) ;
156
}
157
crate :: repos:: names:: valid_segment( source) .then ( | | ( None , source.to_string ( ) ) )
158
}
159
160
async fn preview_for (
161
app : & Shared,
162
repo : & RepoName,
163
target : & str ,
164
source : & str ,
165
) -> Result< ( Vec< ChangeView> , bool ) , crate:: repos:: RepoError> {
166
let Some ( ( fork, bookmark) ) = parse_source ( app, repo, source) else { return Ok ( ( vec! [ ] , false ) ) } ;
167
let target = target.to_string ( ) ;
168
let fork_dir = fork.as_ref ( ) .map ( | f | app.repos.dir ( f) ) ;
169
app.repos
170
.read ( repo, move | r, cache| {
171
let projection = match & fork_dir {
172
Some ( dir) => {
173
let fork_repo = oxy_core:: repo:: Repo:: open( dir) ? ;
174
let heads = fork_repo.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
175
pl:: project( r, cache, & target, Source:: Fork { fork: & fork_repo, heads } ) ?
176
}
177
None => {
178
let heads = r.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
179
pl:: project( r, cache, & target, Source:: Bookmark( heads) ) ?
180
}
181
} ;
182
Ok ( ( projection.changes, projection.conflicted) )
183
} )
184
.await
185
}
186
187
pub async fn new_form (
188
State( app ) : State< Shared> ,
189
viewer : Viewer,
190
Path( (owner , name ) ) : Path< ( String, String) > ,
191
Query( q) : Query< NewQuery> ,
192
) -> Result< Page< NewProposalPage> , Error> {
193
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals/new" ) , & viewer) ;
194
require ( & viewer, & shell) ? ;
195
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
196
let bookmarks = app
197
.repos
198
.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) )
199
.await
200
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
201
let target = q.target.unwrap_or_else ( | | row.default_bookmark.clone ( ) ) ;
202
let source = q.source.unwrap_or_default ( ) ;
203
let ( preview, conflicted) =
204
if source.is_empty ( ) { ( vec! [ ] , false ) } else { preview_for ( & app, & repo, & target, & source) .await.unwrap_or ( ( vec! [ ] , false ) ) } ;
205
Ok ( Page( NewProposalPage {
206
shell,
207
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
208
bookmarks,
209
source,
210
target,
211
error: None ,
212
preview,
213
conflicted,
214
} ) )
215
}
216
217
# [ derive ( Deserialize ) ]
218
pub struct NewForm {
219
#[serde(rename = "_csrf")]
220
csrf_token : String,
221
title : String,
222
#[serde(default)]
223
body : String,
224
target : String,
225
source : String,
226
#[serde(default)]
227
draft : Option< String> ,
228
}
229
b64e2537 Andy McCall 1 month ago ↶
230
231
# [ allow ( clippy::too_many_arguments ) ]
232
pub async fn open_proposal (
233
app : & Shared,
234
repo : & RepoName,
235
row : & db:: repos:: RepoRow,
236
me : & accounts:: Account,
237
title : & str ,
238
body : & str ,
239
target : & str ,
240
source : & str ,
241
draft : bool ,
242
) -> Result< pdb:: ProposalRow, String> {
243
if title.trim ( ) .is_empty ( ) {
244
return Err ( " Give the proposal a title." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
245
}
b64e2537 Andy McCall 1 month ago ↶
246
let bookmarks =
247
app.repos.read ( repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) ) .await.map_err ( | e | e.to_string ( ) ) ? ;
248
if ! bookmarks.iter ( ) .any ( | b | b = = target ) {
249
return Err ( " Pick a target bookmark of this repository." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
250
}
b64e2537 Andy McCall 1 month ago ↶
251
let Some ( ( fork, bookmark) ) = parse_source ( app, repo, source.trim ( ) ) else {
252
return Err ( " The source is a bookmark of this repository, or owner/name:bookmark of a fork." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
253
} ;
b64e2537 Andy McCall 1 month ago ↶
254
if fork.is_none ( ) & & bookmark = = target {
255
return Err ( " The source and the target are the same bookmark." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
256
}
b64e2537 Andy McCall 1 month ago ↶
257
let ( preview, _ ) = preview_for ( app, repo, target, source.trim ( ) ) .await.map_err ( | e | e.to_string ( ) ) ? ;
de505df5 Andy McCall 1 month ago ↶
258
if preview.is_empty ( ) {
b64e2537 Andy McCall 1 month ago ↶
259
return Err ( " The source adds nothing to the target." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
260
}
261
let source_repo_id = match & fork {
b64e2537 Andy McCall 1 month ago ↶
262
Some ( fk) => db:: repos:: get( & app.db, & fk.owner, & fk.name) .await.map_err ( | e | e.to_string ( ) ) ? .map ( | r | r.id ) ,
de505df5 Andy McCall 1 month ago ↶
263
None => None ,
264
} ;
265
let created = pdb:: create(
266
& app.db,
267
pdb:: NewProposal {
268
repo_id: row.id,
b64e2537 Andy McCall 1 month ago ↶
269
title: title.trim ( ) ,
270
body: body.trim ( ) ,
271
author_id: Some ( me.id) ,
272
author: & me.username,
273
draft,
274
target,
de505df5 Andy McCall 1 month ago ↶
275
source_kind: if fork.is_some ( ) { " fork" } else { " bookmark" } ,
276
source_repo_id,
277
source_bookmark: & bookmark,
278
source_changes: " " ,
279
} ,
280
)
281
.await
b64e2537 Andy McCall 1 month ago ↶
282
.map_err ( | e | e.to_string ( ) ) ? ;
283
pdb:: add_event( & app.db, created.id, & me.username, " opened" , " " ) .await.ok ( ) ;
284
accounts:: audit( & app.db, Some ( me) , " proposal.opened" , & format! ( " {} #{} " , row.slug ( ) , created.number) , title) .await.ok ( ) ;
18e381ea Andy McCall 1 month ago ↶
285
db:: notify:: index( & app.db, " proposal" , row.id, created.id, created.number, & created.title, & created.body) .await.ok ( ) ;
b64e2537 Andy McCall 1 month ago ↶
286
let url = format! ( " /{} /proposal/{} " , row.slug ( ) , created.number) ;
18e381ea Andy McCall 1 month ago ↶
287
crate :: notify:: deliver(
b64e2537 Andy McCall 1 month ago ↶
288
app,
18e381ea Andy McCall 1 month ago ↶
289
crate :: notify:: Event {
290
kind: " proposal.opened" ,
b64e2537 Andy McCall 1 month ago ↶
291
title: format! ( " {} #{} : {} " , row.slug ( ) , created.number, created.title) ,
292
url,
293
actor: me,
18e381ea Andy McCall 1 month ago ↶
294
repo_id: row.id,
295
thread: Some ( ( " proposal" , created.id) ) ,
b64e2537 Andy McCall 1 month ago ↶
296
mention_text: body,
18e381ea Andy McCall 1 month ago ↶
297
direct: vec! [ ] ,
298
} ,
299
)
300
.await;
b64e2537 Andy McCall 1 month ago ↶
301
crate :: hooks:: emit(
302
app,
303
row,
304
" proposal.opened" ,
305
& me.username,
306
serde_json:: json! ( { " number" : created.number, " title" : created.title, " target" : target, " source" : source } ) ,
307
)
308
.await;
309
Ok ( created)
310
}
311
312
pub async fn create (
313
State( app ) : State< Shared> ,
314
viewer : Viewer,
315
Path( (owner , name ) ) : Path< ( String, String) > ,
316
Form( f) : Form< NewForm> ,
317
) -> Result< Response, Error> {
318
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals/new" ) , & viewer) ;
319
let me = require ( & viewer, & shell) ? ;
320
csrf ( & viewer, & f.csrf_token, & shell) ? ;
321
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
322
match open_proposal ( & app, & repo, & row, & me.account, & f.title, & f.body, & f.target, & f.source, f.draft.is_some ( ) ) .await {
323
Ok ( created) => Ok ( Redirect:: to( & format! ( " /{owner} /{name} /proposal/{} " , created.number) ) .into_response ( ) ) ,
324
Err ( msg) => {
325
let bookmarks =
326
app.repos.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) ) .await.unwrap_or_default ( ) ;
327
Ok ( Page( NewProposalPage {
328
shell,
329
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
330
bookmarks,
331
source: f.source.clone ( ) ,
332
target: f.target.clone ( ) ,
333
error: Some ( msg) ,
334
preview: vec! [ ] ,
335
conflicted: false ,
336
} )
337
.into_response ( ) )
338
}
339
}
de505df5 Andy McCall 1 month ago ↶
340
}
341
342
343
344
345
pub struct CommentView {
346
pub row : pdb:: CommentRow,
347
pub body_html : String,
348
pub line : Option< usize > ,
349
}
350
351
pub struct ReviewView {
352
pub row : pdb:: ReviewRow,
353
pub body_html : String,
354
pub sealed : bool ,
355
}
356
357
358
pub struct Loaded {
359
pub row : pdb:: ProposalRow,
360
pub changes : Vec< ChangeView> ,
361
pub hashes : Vec< ChangeHash> ,
362
pub heads : Vec< ChangeHash> ,
3f02e5ea Andy McCall 1 month ago ↶
363
pub target_heads : Vec< ChangeHash> ,
de505df5 Andy McCall 1 month ago ↶
364
pub state : String,
365
pub target_state : String,
366
pub conflicted : bool ,
367
pub conflicts : Vec< ConflictView> ,
368
pub files : Vec< FileRows> ,
9df855f2 Andy McCall 1 month ago ↶
369
370
pub diffs : Vec< oxy_core:: repo:: FileDiff> ,
371
pub bases : std:: collections:: BTreeMap< String, Vec< u8 > > ,
de505df5 Andy McCall 1 month ago ↶
372
373
pub atoms : HashMap< ( String, usize ) , ( String, u32 ) > ,
374
pub sealing_keys : Vec< String> ,
375
pub touched : Vec< String> ,
376
pub owners : Vec< String> ,
377
pub landed_already : bool ,
378
}
379
380
381
pub async fn load ( app : & Shared, repo : & RepoName, row : pdb:: ProposalRow) -> Result< Loaded, crate:: repos:: RepoError> {
382
let fork_dir = match ( row.source_kind.as_str ( ) , row.source_repo_id) {
383
( " fork" , Some ( id) ) => {
384
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) )
385
}
386
_ => None ,
387
} ;
388
let r2 = row.clone ( ) ;
389
let loaded = app
390
.repos
391
.read ( repo, move | r, cache| {
392
let source = match ( r2.source_kind.as_str ( ) , & fork_dir) {
393
( " changes" , _ ) => Source:: Changes( r2.source_changes ( ) .iter ( ) .filter_map ( | h | ChangeHash:: from_hex( h) ) .collect ( ) ) ,
394
( " fork" , Some ( dir) ) => {
395
let fork_repo = oxy_core:: repo:: Repo:: open( dir) ? ;
396
let heads =
397
fork_repo.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = r2.source_bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
398
399
let fork_state = fork_repo.load_state ( & heads) ? ;
400
let missing: Vec< ChangeHash> = fork_state.changes ( ) .map ( | ( h , _) | * h) .filter ( | h | ! r.store ( ) .has ( h) ) .collect ( ) ;
401
if ! missing.is_empty ( ) {
402
r.import_bundle ( & fork_repo.bundle_for ( & missing) ? , r.current_policy ( ) ? .is_some ( ) ) ? ;
403
}
404
Source:: Changes( heads)
405
}
406
_ => {
407
let heads = r.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = r2.source_bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
408
Source:: Bookmark( heads)
409
}
410
} ;
411
let p: Projection = pl:: project( r, cache, & r2.target, source) ? ;
412
let target_loaded = cache.load ( r, & p.target_heads) ? ;
413
let files = render_files ( & p.files, & p.bases) ;
414
let mut atoms = HashMap:: new( ) ;
415
for f in & files {
416
for l in & f.lines {
417
if let Some ( n) = l.new_no {
418
if let Some ( ( h, i) ) = pl:: atom_at( & p.loaded, & f.path, n as usize ) {
419
atoms.insert ( ( f.path.clone ( ) , n as usize ) , ( h.to_hex ( ) , i) ) ;
420
}
421
}
422
}
423
}
424
let touched: Vec< String> = p.files.iter ( ) .map ( | f | f.path.clone ( ) ) .collect ( ) ;
425
let owners = pl:: owners_for( & target_loaded, & touched) ;
426
let sealing = pl:: sealing_keys( r, & p.hashes) ? .into_iter ( ) .map ( | k | k.to_hex ( ) ) .collect ( ) ;
427
let landed_already = ! p.hashes.is_empty ( ) & & p.hashes.iter ( ) .all ( | h | target_loaded.state.contains ( h) ) ;
428
Ok ( Loaded {
429
row: r2,
430
changes: p.changes,
431
hashes: p.hashes,
432
heads: p.heads,
3f02e5ea Andy McCall 1 month ago ↶
433
target_heads: p.target_heads.clone ( ) ,
de505df5 Andy McCall 1 month ago ↶
434
state: p.state.to_hex ( ) ,
435
target_state: p.target_state.to_hex ( ) ,
436
conflicted: p.conflicted,
437
conflicts: p.conflicts,
438
files,
9df855f2 Andy McCall 1 month ago ↶
439
diffs: p.files.clone ( ) ,
440
bases: p.bases.clone ( ) ,
de505df5 Andy McCall 1 month ago ↶
441
atoms,
442
sealing_keys: sealing,
443
touched,
444
owners,
445
landed_already,
446
} )
447
} )
448
.await? ;
449
450
let heads_csv = loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
451
let changes_csv = loaded.hashes.iter ( ) .map ( | h | h.to_hex ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
452
pdb:: set_projection( & app.db, loaded.row.id, & loaded.state, & heads_csv, & loaded.target_state, & changes_csv, loaded.conflicted)
453
.await
454
.ok ( ) ;
455
Ok ( loaded)
456
}
457
458
459
pub struct Verdict {
460
pub reviews : Vec< ReviewView> ,
461
pub approvals : Vec< Approval> ,
462
pub blockers : Vec< String> ,
463
pub depends_on : Vec< i64 > ,
464
pub requirements : pdb:: Requirements,
465
}
466
467
pub async fn verdict ( app : & Shared, loaded : & Loaded, repo_id : i64 ) -> Result< Verdict, sqlx:: Error> {
468
let rows = pdb:: reviews( & app.db, loaded.row.id) .await? ;
469
let links = accounts:: identity_accounts( & app.db) .await? ;
470
let sealed_accounts: Vec< String> = links.iter ( ) .filter ( | ( k , _) | loaded.sealing_keys.contains ( k) ) .map ( | ( _, u ) | u.clone ( ) ) .collect ( ) ;
471
472
let mut latest: BTreeMap< String, & pdb:: ReviewRow> = BTreeMap:: new( ) ;
473
for r in & rows {
474
latest.insert ( r.author.clone ( ) , r) ;
475
}
476
let mut approvals = Vec :: new( ) ;
477
for ( who, r) in & latest {
478
if r.verdict = = " approve" {
479
let sealed = sealed_accounts.contains ( who) ;
480
let key = links.iter ( ) .find ( | ( k , u ) | u = = who & & loaded.sealing_keys.contains ( k) ) .map ( | ( k , _) | k.clone ( ) ) ;
481
approvals.push ( Approval { account: who.clone ( ) , sealed, key } ) ;
482
}
483
}
484
let reviews = rows
485
.iter ( )
486
.map ( | r | ReviewView {
487
row: r.clone ( ) ,
488
body_html: render:: markdown:: render( & r.body, " /" , " /" ) ,
489
sealed: r.verdict = = " approve" & & sealed_accounts.contains ( & r.author) ,
490
} )
491
.collect ( ) ;
492
let requirements = pdb:: requirements( & app.db, repo_id) .await? ;
493
let mut blockers = Vec :: new( ) ;
494
if loaded.row.state = = " draft" {
495
blockers.push ( " the proposal is a draft" .into ( ) ) ;
496
}
497
if loaded.hashes.is_empty ( ) {
498
blockers.push ( " the proposal adds no changes" .into ( ) ) ;
499
}
500
let sealed_count = approvals.iter ( ) .filter ( | a | a.sealed ) .count ( ) as i64 ;
501
if sealed_count < requirements.required_approvals {
502
blockers.push ( format! ( " {sealed_count} of {} required sealed approvals" , requirements.required_approvals) ) ;
503
}
504
if requirements.require_owners {
505
let missing: Vec< & String> = loaded.owners.iter ( ) .filter ( | o | ! approvals.iter ( ) .any ( | a | a.sealed & & & a.account = = * o ) ) .collect ( ) ;
506
if ! missing.is_empty ( ) {
507
blockers.push ( format! ( " code owners still to approve: {} " , missing.iter ( ) .map ( | s | s.as_str ( ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
508
}
509
}
510
if loaded.conflicted & & ! requirements.allow_conflicts {
511
blockers.push ( " landing would carry conflicts, which this repository refuses" .into ( ) ) ;
512
}
b64e2537 Andy McCall 1 month ago ↶
513
let required = db:: hooks:: required_checks( & app.db, repo_id) .await? ;
514
if ! required.is_empty ( ) {
515
let checks = db:: hooks:: checks_for_state( & app.db, repo_id, & loaded.state) .await? ;
516
let missing: Vec< String> =
517
required.iter ( ) .filter ( | n | ! checks.iter ( ) .any ( | c | & c.name = = * n & & c.status = = " passed" ) ) .cloned ( ) .collect ( ) ;
518
if ! missing.is_empty ( ) {
519
blockers.push ( format! ( " required checks not passed on the projected state: {} " , missing.join ( " , " ) ) ) ;
520
}
521
}
de505df5 Andy McCall 1 month ago ↶
522
let requesting: Vec< & String> = latest.iter ( ) .filter ( | ( _, r ) | r.verdict = = " changes" ) .map ( | ( who , _) | who ) .collect ( ) ;
523
if ! requesting.is_empty ( ) {
524
blockers.push ( format! ( " changes requested by {} " , requesting.iter ( ) .map ( | s | s.as_str ( ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
525
}
526
527
let mut depends_on = Vec :: new( ) ;
528
for other in pdb:: list( & app.db, repo_id, & [ " open" , " draft" ] ) .await? {
529
if other.id = = loaded.row.id | | other.number > loaded.row.number {
530
continue ;
531
}
532
let theirs = other.projected_changes ( ) ;
533
if ! theirs.is_empty ( ) & & theirs.iter ( ) .all ( | h | loaded.hashes.iter ( ) .any ( | m | m.to_hex ( ) = = * h ) ) {
534
depends_on.push ( other.number) ;
535
}
536
}
537
if ! depends_on.is_empty ( ) {
538
blockers.push ( format! ( " depends on {} " , depends_on.iter ( ) .map ( | n | format! ( " #{n} " ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
539
}
540
Ok ( Verdict { reviews, approvals, blockers, depends_on, requirements } )
541
}
542
543
# [ derive ( Template ) ]
544
# [ template ( path = " proposal.html" ) ]
545
pub struct ProposalPage {
546
shell : Shell,
547
repo : RepoShell,
548
p : pdb:: ProposalRow,
38833ed7 Andy McCall 1 month ago ↶
549
550
closes : Vec< super:: chain:: Link> ,
de505df5 Andy McCall 1 month ago ↶
551
body_html : String,
552
tab : String,
553
changes : Vec< ChangeView> ,
554
files : Vec< FileRows> ,
9df855f2 Andy McCall 1 month ago ↶
555
diffs : Vec< oxy_core:: repo:: FileDiff> ,
556
bases : std:: collections:: BTreeMap< String, Vec< u8 > > ,
557
558
ignore_ws : bool ,
de505df5 Andy McCall 1 month ago ↶
559
conflicts : Vec< ConflictView> ,
560
conflicted : bool ,
561
comments : Vec< CommentView> ,
562
563
placed : HashMap< ( String, usize ) , Vec< usize > > ,
564
outdated : Vec< usize > ,
565
reviews : Vec< ReviewView> ,
566
approvals : Vec< Approval> ,
567
blockers : Vec< String> ,
568
depends_on : Vec< i64 > ,
569
events : Vec< pdb:: EventRow> ,
570
owners : Vec< String> ,
571
requirements : pdb:: Requirements,
572
landed_already : bool ,
573
can_edit : bool ,
574
can_land : bool ,
575
hub_can_land : bool ,
576
notice : Option< String> ,
577
error : Option< String> ,
578
heads : Vec< String> ,
579
state_hash : String,
580
source_label : String,
581
api_url : String,
18e381ea Andy McCall 1 month ago ↶
582
labels : Vec< db:: issues:: Label> ,
583
all_labels : Vec< db:: issues:: Label> ,
b64e2537 Andy McCall 1 month ago ↶
584
checks : Vec< db:: hooks:: Check> ,
585
required_checks : Vec< String> ,
e1ec9fbf Andy McCall 1 month ago ↶
586
587
projects : Vec< ( db:: projects:: Project, String) > ,
de505df5 Andy McCall 1 month ago ↶
588
}
589
590
# [ derive ( Deserialize ) ]
591
pub struct DetailQuery {
592
tab : Option< String> ,
9df855f2 Andy McCall 1 month ago ↶
593
#[serde(default)]
594
w : Option< String> ,
de505df5 Andy McCall 1 month ago ↶
595
}
596
597
598
599
# [ allow ( clippy::too_many_arguments ) ]
600
async fn detail (
601
app : & Shared,
602
shell : Shell,
603
viewer : & Viewer,
604
owner : & str ,
605
name : & str ,
606
number : i64 ,
607
tab : String,
608
notice : Option< String> ,
609
error : Option< String> ,
610
) -> Result< Page< ProposalPage> , Error> {
611
let ( repo, row, role) = lookup ( app, & shell, viewer, owner, name) .await? ;
612
let p = pdb:: get( & app.db, row.id, number)
613
.await
614
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
615
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
616
let loaded = load ( app, & repo, p) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
617
618
if loaded.landed_already & & loaded.row.is_open ( ) {
619
pdb:: set_state( & app.db, loaded.row.id, " landed" , & loaded.target_state) .await.ok ( ) ;
620
pdb:: add_event( & app.db, loaded.row.id, " " , " landed" , " every change reached the target" ) .await.ok ( ) ;
621
}
622
let p = pdb:: get( & app.db, row.id, number) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( loaded.row.clone ( ) ) ;
623
let v = verdict ( app, & loaded, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
624
let comment_rows = pdb:: comments( & app.db, loaded.row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
625
let events = pdb:: events( & app.db, loaded.row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
626
627
let mut by_atom: HashMap< ( String, String, u32 ) , usize > = HashMap:: new( ) ;
628
for ( ( path, line) , ( h, i) ) in & loaded.atoms {
629
by_atom.insert ( ( path.clone ( ) , h.clone ( ) , * i) , * line) ;
630
}
631
let mut comments = Vec :: new( ) ;
632
let mut placed: HashMap< ( String, usize ) , Vec< usize > > = HashMap:: new( ) ;
633
let mut outdated = Vec :: new( ) ;
634
for ( idx, c) in comment_rows.into_iter ( ) .enumerate ( ) {
635
let line = if c.anchor_change.is_empty ( ) {
636
None
637
} else {
638
by_atom.get ( & ( c.path.clone ( ) , c.anchor_change.clone ( ) , c.anchor_index as u32 ) ) .copied ( )
639
} ;
640
if ! c.anchor_change.is_empty ( ) {
641
match line {
642
Some ( l) => placed.entry ( ( c.path.clone ( ) , l) ) .or_default ( ) .push ( idx) ,
643
None => outdated.push ( idx) ,
644
}
645
}
646
comments.push ( CommentView { body_html: render:: markdown:: render( & c.body, " /" , " /" ) , row: c, line } ) ;
647
}
648
let mut changes = loaded.changes.clone ( ) ;
649
super :: attribute( app, & mut changes.iter_mut ( ) .collect:: < Vec< _ > > ( ) ) .await;
650
let me = viewer.account ( ) ;
651
let can_edit = me.is_some_and ( | a | Some ( a.id) = = p.author_id | | role > = Role:: Maintain) ;
652
let can_land = role > = Role:: Write & & p.is_open ( ) & & v.blockers.is_empty ( ) ;
14530604 Andy McCall 1 month ago ↶
653
let delegated = match viewer.account ( ) {
654
Some ( a) => super :: edit:: delegated_identity( app, a.id) .await.is_some ( ) ,
655
None => false ,
656
} ;
657
let hub_can_land = delegated | | app.repos.read ( & repo, | r , _| Ok ( r.current_policy ( ) ? .is_none ( ) ) ) .await.unwrap_or ( false ) ;
de505df5 Andy McCall 1 month ago ↶
658
let source_label = match p.source_kind.as_str ( ) {
659
" fork" => format! (
660
" fork {} " ,
661
db:: repos:: get_by_id( & app.db, p.source_repo_id.unwrap_or ( 0 ) )
662
.await
663
.ok ( )
664
.flatten ( )
665
.map ( | r | format! ( " {} :{} " , r.slug ( ) , p.source_bookmark) )
666
.unwrap_or_default ( )
667
) ,
668
" changes" => format! ( " {} changes" , p.source_changes ( ) .len ( ) ) ,
669
_ => p.source_bookmark.clone ( ) ,
670
} ;
e1ec9fbf Andy McCall 1 month ago ↶
671
let projects = super :: projects:: placements_for( app, viewer, & row, p.number) .await;
38833ed7 Andy McCall 1 month ago ↶
672
let texts: Vec< String> = std:: iter:: once( p.body.clone ( ) ) .chain ( changes.iter ( ) .map ( | c | format! ( " {} \n {} " , c.title, c.body) ) ) .collect ( ) ;
673
let closes = super :: chain:: issues_closed_by( app, & row, & texts.iter ( ) .map ( String :: as_str) .collect:: < Vec< _ > > ( ) ) .await;
de505df5 Andy McCall 1 month ago ↶
674
Ok ( Page( ProposalPage {
38833ed7 Andy McCall 1 month ago ↶
675
closes,
e1ec9fbf Andy McCall 1 month ago ↶
676
projects,
de505df5 Andy McCall 1 month ago ↶
677
shell,
678
repo: RepoShell:: new( app, & row, " proposals" ) .with_role ( role) ,
c79a1111 Andy McCall 1 month ago ↶
679
body_html: super :: issues:: markdown( app, & row, & row.default_bookmark, & p.body) .await,
de505df5 Andy McCall 1 month ago ↶
680
p,
681
tab,
682
changes,
683
files: loaded.files,
9df855f2 Andy McCall 1 month ago ↶
684
diffs: loaded.diffs,
685
bases: loaded.bases,
686
ignore_ws: false ,
de505df5 Andy McCall 1 month ago ↶
687
conflicts: loaded.conflicts,
688
conflicted: loaded.conflicted,
689
comments,
690
placed,
691
outdated,
692
reviews: v.reviews,
693
approvals: v.approvals,
694
blockers: v.blockers,
695
depends_on: v.depends_on,
696
events,
697
owners: loaded.owners,
698
requirements: v.requirements,
699
landed_already: loaded.landed_already,
700
can_edit,
701
can_land,
702
hub_can_land,
703
notice,
704
error,
705
heads: loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect ( ) ,
b64e2537 Andy McCall 1 month ago ↶
706
state_hash: loaded.state.clone ( ) ,
de505df5 Andy McCall 1 month ago ↶
707
source_label,
708
api_url: format! ( " {} /api/v1/repos/{owner} /{name} /proposals/{number} " , app.config.base_url ( ) ) ,
18e381ea Andy McCall 1 month ago ↶
709
labels: db:: issues:: proposal_labels( & app.db, loaded.row.id) .await.unwrap_or_default ( ) ,
710
all_labels: db:: issues:: labels( & app.db, row.id) .await.unwrap_or_default ( ) ,
b64e2537 Andy McCall 1 month ago ↶
711
checks: db:: hooks:: checks_for_state( & app.db, row.id, & loaded.state) .await.unwrap_or_default ( ) ,
712
required_checks: db:: hooks:: required_checks( & app.db, row.id) .await.unwrap_or_default ( ) ,
de505df5 Andy McCall 1 month ago ↶
713
} ) )
714
}
715
716
pub async fn show (
717
State( app ) : State< Shared> ,
718
viewer : Viewer,
719
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
720
Query( q) : Query< DetailQuery> ,
721
) -> Result< Page< ProposalPage> , Error> {
722
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
9df855f2 Andy McCall 1 month ago ↶
723
let mut page = detail ( & app, shell, & viewer, & owner, & name, number, q.tab.unwrap_or_else ( | | " conversation" .into ( ) ) , None , None ) .await? ;
724
if q.w.as_deref ( ) = = Some ( " 1" ) {
725
726
page.0. files =
727
super :: history:: render_files_with( & page.0. diffs, & page.0. bases, crate :: render:: diff:: Options { ignore_whitespace: true } ) ;
728
page.0. ignore_ws = true ;
729
}
730
Ok ( page)
de505df5 Andy McCall 1 month ago ↶
731
}
732
733
734
735
# [ derive ( Deserialize ) ]
736
pub struct CommentForm {
737
#[serde(rename = "_csrf")]
738
csrf_token : String,
739
body : String,
740
#[serde(default)]
741
path : String,
742
#[serde(default)]
743
line : Option< usize > ,
744
}
745
746
pub async fn comment (
747
State( app ) : State< Shared> ,
748
viewer : Viewer,
749
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
750
Form( f) : Form< CommentForm> ,
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 ( repo, row, _ ) = 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 f.body.trim ( ) .is_empty ( ) {
761
return detail (
762
& app,
763
shell,
764
& viewer,
765
& owner,
766
& name,
767
number,
768
" conversation" .into ( ) ,
769
None ,
770
Some ( " An empty comment says nothing." .into ( ) ) ,
771
)
772
.await;
773
}
774
let anchor = match ( f.path.trim ( ) , f.line) {
775
( " " , _ ) | ( _ , None ) => None ,
776
( path, Some ( line) ) => {
777
let loaded = load ( & app, & repo, p.clone ( ) ) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
778
loaded.atoms.get ( & ( path.to_string ( ) , line) ) .cloned ( )
779
}
780
} ;
781
let tab = if anchor.is_some ( ) { " files" } else { " conversation" } ;
782
pdb:: add_comment(
783
& app.db,
784
p.id,
785
Some ( me.account.id) ,
786
& me.account.username,
787
f.body.trim ( ) ,
788
f.path.trim ( ) ,
789
anchor.as_ref ( ) .map ( | ( h , i ) | ( h.as_str ( ) , * i as i64 ) ) ,
790
)
791
.await
792
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
793
detail ( & app, shell, & viewer, & owner, & name, number, tab.into ( ) , None , None ) .await
794
}
795
796
# [ derive ( Deserialize ) ]
797
pub struct ReviewForm {
798
#[serde(rename = "_csrf")]
799
csrf_token : String,
800
verdict : String,
801
#[serde(default)]
802
body : String,
803
}
804
805
pub async fn review (
806
State( app ) : State< Shared> ,
807
viewer : Viewer,
808
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
809
Form( f) : Form< ReviewForm> ,
810
) -> Result< Page< ProposalPage> , Error> {
811
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
812
let me = require ( & viewer, & shell) ? ;
813
csrf ( & viewer, & f.csrf_token, & shell) ? ;
814
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
815
let p = pdb:: get( & app.db, row.id, number)
816
.await
817
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
818
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
819
if ! matches! ( f.verdict.as_str ( ) , " comment" | " changes" | " approve" ) {
820
return Err ( Error:: bad( shell, " a review is comment, changes or approve" ) ) ;
821
}
822
if f.verdict = = " approve" & & Some ( me.account.id) = = p.author_id {
823
return detail (
824
& app,
825
shell,
826
& viewer,
827
& owner,
828
& name,
829
number,
830
" conversation" .into ( ) ,
831
None ,
832
Some ( " You cannot approve your own proposal." .into ( ) ) ,
833
)
834
.await;
835
}
836
pdb:: add_review( & app.db, p.id, me.account.id, & me.account.username, & f.verdict, f.body.trim ( ) )
837
.await
838
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
839
pdb:: add_event( & app.db, p.id, & me.account.username, & format! ( " review.{} " , f.verdict) , " " ) .await.ok ( ) ;
b64e2537 Andy McCall 1 month ago ↶
840
crate :: hooks:: emit( & app, & row, " review" , & me.account.username, serde_json:: json! ( { " number" : number, " verdict" : f.verdict } ) ) .await;
de505df5 Andy McCall 1 month ago ↶
841
let notice = if f.verdict = = " approve" {
842
Some ( format! (
843
" Approval recorded. It counts once you seal the changes: oxy seal --proposal {} /{owner} /{name} /proposal/{number} " ,
844
app.config.base_url ( )
845
) )
846
} else {
847
None
848
} ;
849
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , notice, None ) .await
850
}
851
852
# [ derive ( Deserialize ) ]
853
pub struct EditForm {
854
#[serde(rename = "_csrf")]
855
csrf_token : String,
856
title : String,
857
#[serde(default)]
858
body : String,
859
target : String,
860
}
861
862
pub async fn edit (
863
State( app ) : State< Shared> ,
864
viewer : Viewer,
865
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
866
Form( f) : Form< EditForm> ,
867
) -> Result< Page< ProposalPage> , Error> {
868
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
869
let me = require ( & viewer, & shell) ? ;
870
csrf ( & viewer, & f.csrf_token, & shell) ? ;
871
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
872
let p = pdb:: get( & app.db, row.id, number)
873
.await
874
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
875
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
876
if Some ( me.account.id) ! = p.author_id & & role < Role:: Maintain {
877
return Err ( Error:: not_found( shell) ) ;
878
}
879
let target = if crate :: repos:: names:: valid_segment( f.target.trim ( ) ) { f.target.trim ( ) } else { & p.target } ;
880
pdb:: edit( & app.db, p.id, f.title.trim ( ) , f.body.trim ( ) , target) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
881
pdb:: add_event( & app.db, p.id, & me.account.username, " edited" , " " ) .await.ok ( ) ;
882
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , Some ( " Saved." .into ( ) ) , None ) .await
883
}
884
885
# [ derive ( Deserialize ) ]
886
pub struct StateForm {
887
#[serde(rename = "_csrf")]
888
csrf_token : String,
889
action : String,
890
}
891
892
pub async fn state (
893
State( app ) : State< Shared> ,
894
viewer : Viewer,
895
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
896
Form( f) : Form< StateForm> ,
897
) -> Result< Page< ProposalPage> , Error> {
898
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
899
let me = require ( & viewer, & shell) ? ;
900
csrf ( & viewer, & f.csrf_token, & shell) ? ;
901
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
902
let p = pdb:: get( & app.db, row.id, number)
903
.await
904
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
905
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
906
if Some ( me.account.id) ! = p.author_id & & role < Role:: Triage {
907
return Err ( Error:: not_found( shell) ) ;
908
}
909
let new_state = match ( f.action.as_str ( ) , p.state.as_str ( ) ) {
910
( " close" , " open" | " draft" ) => " closed" ,
911
( " reopen" , " closed" ) => " open" ,
912
( " ready" , " draft" ) => " open" ,
913
( " draft" , " open" ) => " draft" ,
914
_ => return Err ( Error:: bad( shell, " that transition is not possible" ) ) ,
915
} ;
916
pdb:: set_state( & app.db, p.id, new_state, " " ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
917
pdb:: add_event( & app.db, p.id, & me.account.username, new_state, " " ) .await.ok ( ) ;
b64e2537 Andy McCall 1 month ago ↶
918
crate :: hooks:: emit( & app, & row, & format! ( " proposal.{new_state} " ) , & me.account.username, serde_json:: json! ( { " number" : number } ) ) .await;
de505df5 Andy McCall 1 month ago ↶
919
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , None , None ) .await
920
}
921
922
pub async fn resolve_comment (
923
State( app ) : State< Shared> ,
924
viewer : Viewer,
925
Path( (owner , name , number , id ) ) : Path< ( String, String, i64 , i64 ) > ,
926
Form( f) : Form< super:: session:: CsrfForm> ,
927
) -> Result< Page< ProposalPage> , Error> {
928
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
929
require ( & viewer, & shell) ? ;
930
csrf ( & viewer, & f.csrf, & shell) ? ;
931
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
932
let p = pdb:: get( & app.db, row.id, number)
933
.await
934
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
935
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
936
let current: Option< bool > = sqlx:: query_scalar( " SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?" )
937
.bind ( id)
938
.bind ( p.id)
939
.fetch_optional ( & app.db)
940
.await
941
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
942
if let Some ( r) = current {
943
pdb:: set_comment_resolved( & app.db, id, ! r) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
944
}
945
detail ( & app, shell, & viewer, & owner, & name, number, " files" .into ( ) , None , None ) .await
946
}
947
948
949
950
pub async fn land (
951
State( app ) : State< Shared> ,
952
viewer : Viewer,
953
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
954
Form( f) : Form< super:: session:: CsrfForm> ,
955
) -> Result< Page< ProposalPage> , Error> {
956
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
957
let me = require ( & viewer, & shell) ? ;
958
csrf ( & viewer, & f.csrf, & shell) ? ;
959
let ( repo, row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
960
let p = pdb:: get( & app.db, row.id, number)
961
.await
962
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
963
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
964
let loaded = load ( & app, & repo, p.clone ( ) ) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
965
let v = verdict ( & app, & loaded, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
966
if ! p.is_open ( ) | | ! v.blockers.is_empty ( ) {
967
return detail (
968
& app,
969
shell,
970
& viewer,
971
& owner,
972
& name,
973
number,
974
" conversation" .into ( ) ,
975
None ,
976
Some ( format! ( " Cannot land: {} " , v.blockers.join ( " ; " ) ) ) ,
977
)
978
.await;
979
}
980
let target = p.target.clone ( ) ;
981
let heads = loaded.heads.clone ( ) ;
14530604 Andy McCall 1 month ago ↶
982
983
984
985
986
let result = match super :: edit:: signer_for( & app, me.account.id) .await {
987
Ok ( signer) => crate :: repos:: edits:: move_bookmark( & app.repos, & repo, target.clone ( ) , heads.clone ( ) , signer) .await.map ( | _| pl:: state_ref( & heads) .to_hex ( ) ) ,
988
Err ( why) => {
989
app.repos
990
.write ( & repo, move | r, _ | {
991
if r.current_policy ( ) ? .is_some ( ) {
992
return Err ( crate :: repos:: RepoError:: Oxy( oxy_core:: repo:: RepoError:: Msg( format! (
993
" this repository has a trust policy, so the landing must be signed by a key it authorises. Land from a client with oxy proposal land, or delegate to the hub. {why} "
994
) ) ) ) ;
995
}
996
r.bookmark_set ( & target, & heads) ? ;
997
Ok ( pl:: state_ref( & heads) .to_hex ( ) )
998
} )
999
.await
1000
}
1001
} ;
de505df5 Andy McCall 1 month ago ↶
1002
match result {
1003
Ok ( state) => {
1004
pdb:: set_state( & app.db, p.id, " landed" , & state) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
1005
pdb:: add_event( & app.db, p.id, & me.account.username, " landed" , " from the hub" ) .await.ok ( ) ;
b64e2537 Andy McCall 1 month ago ↶
1006
crate :: hooks:: emit(
1007
& app,
1008
& row,
1009
" proposal.landed" ,
1010
& me.account.username,
1011
serde_json:: json! ( { " number" : number, " target" : p.target, " state" : state } ) ,
1012
)
1013
.await;
18e381ea Andy McCall 1 month ago ↶
1014
crate :: notify:: deliver(
1015
& app,
1016
crate :: notify:: Event {
1017
kind: " proposal.landed" ,
1018
title: format! ( " {owner} /{name} #{number} : {} landed" , p.title) ,
1019
url: format! ( " /{owner} /{name} /proposal/{number} " ) ,
1020
actor: & me.account,
1021
repo_id: row.id,
1022
thread: Some ( ( " proposal" , p.id) ) ,
1023
mention_text: " " ,
1024
direct: p.author_id.into_iter ( ) .collect ( ) ,
1025
} ,
1026
)
1027
.await;
1028
if p.target = = row.default_bookmark {
1029
let messages: Vec< ( String, String) > =
1030
loaded.changes.iter ( ) .map ( | c | ( c.hash.clone ( ) , format! ( " {} \n {} " , c.title, c.body) ) ) .collect ( ) ;
1031
super :: issues:: close_from_messages( & app, & row, & messages) .await;
1032
}
de505df5 Andy McCall 1 month ago ↶
1033
accounts:: audit( & app.db, Some ( & me.account) , " proposal.landed" , & format! ( " {owner} /{name} #{number} " ) , " " ) .await.ok ( ) ;
1034
db:: repos:: record_push(
1035
& app.db,
1036
row.id,
1037
Some ( ( me.account.id, me.account.username.as_str ( ) ) ) ,
1038
& [ db:: repos:: PushOutcome {
1039
reference: p.target.clone ( ) ,
1040
ok: true ,
1041
reason: format! ( " landed #{number} " ) ,
1042
old_state: loaded.target_state.clone ( ) ,
1043
new_state: state,
1044
heads: loaded.heads.len ( ) as i64 ,
1045
changes: loaded.hashes.len ( ) as i64 ,
1046
} ] ,
1047
)
1048
.await
1049
.ok ( ) ;
1050
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , Some ( " Landed." .into ( ) ) , None ) .await
1051
}
1052
Err ( e) => detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , None , Some ( e.to_string ( ) ) ) .await,
1053
}
1054
}
1055
18e381ea Andy McCall 1 month ago ↶
1056
# [ derive ( Deserialize ) ]
1057
pub struct LabelsForm {
1058
#[serde(rename = "_csrf")]
1059
csrf_token : String,
1060
#[serde(default)]
1061
label : Vec< i64 > ,
1062
}
1063
1064
pub async fn set_labels (
1065
State( app ) : State< Shared> ,
1066
viewer : Viewer,
1067
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1068
axum_extra:: extract:: Form( f) : axum_extra:: extract:: Form< LabelsForm> ,
1069
) -> Result< Page< ProposalPage> , Error> {
1070
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1071
require ( & viewer, & shell) ? ;
1072
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1073
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1074
if role < Role:: Triage {
1075
return Err ( Error:: not_found( shell) ) ;
1076
}
1077
let p = pdb:: get( & app.db, row.id, number)
1078
.await
1079
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1080
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1081
for l in db:: issues:: labels( & app.db, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
1082
db:: issues:: set_proposal_label( & app.db, p.id, l.id, f.label.contains ( & l.id) ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
1083
}
1084
detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , None , None ) .await
1085
}
1086
b64e2537 Andy McCall 1 month ago ↶
1087
1088
1089
pub async fn events (
1090
State( app ) : State< Shared> ,
1091
viewer : Viewer,
1092
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1093
) -> Result< axum:: response:: Sse< impl futures:: Stream< Item = Result< axum:: response:: sse:: Event, std:: convert:: Infallible> > > , Error> {
1094
use futures:: StreamExt;
1095
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1096
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1097
let rx = app.events.proposals.subscribe ( ) ;
1098
let repo_id = row.id;
1099
let stream = tokio_stream:: wrappers:: BroadcastStream:: new( rx) .filter_map ( move | item| async move {
1100
match item {
1101
Ok ( ( id, ev) ) if id = = repo_id => {
1102
Some ( Ok ( axum:: response:: sse:: Event:: default( ) .event ( ev.kind.clone ( ) ) .data ( serde_json:: to_string( & ev) .unwrap_or_default ( ) ) ) )
1103
}
1104
_ => None ,
1105
}
1106
} ) ;
1107
Ok ( axum:: response:: Sse:: new( stream) .keep_alive ( axum:: response:: sse:: KeepAlive:: default( ) ) )
1108
}
1109
de505df5 Andy McCall 1 month ago ↶
1110
1111
1112
pub async fn to_api ( app : & Shared, owner : & str , name : & str , loaded : & Loaded, v : & Verdict, source_url : Option< String> ) -> hub_api:: Proposal {
1113
let p = & loaded.row;
1114
hub_api:: Proposal {
1115
number: p.number,
1116
title: p.title.clone ( ) ,
1117
body: p.body.clone ( ) ,
1118
author: p.author.clone ( ) ,
1119
state: ProposalState:: parse( & p.state) .unwrap_or ( ProposalState:: Open) ,
1120
target: p.target.clone ( ) ,
1121
source: match p.source_kind.as_str ( ) {
1122
" fork" => {
1123
let ( o, n) = source_url
1124
.as_deref ( )
1125
.and_then ( | u | u.rsplit_once ( ' /' ) )
1126
.and_then ( | ( b , n ) | b.rsplit_once ( ' /' ) .map ( | ( _, o ) | ( o.to_string ( ) , n.to_string ( ) ) ) )
1127
.unwrap_or_default ( ) ;
1128
ProposalSource:: Fork { owner: o, name: n, bookmark: p.source_bookmark.clone ( ) }
1129
}
1130
" changes" => ProposalSource:: Changes { hashes: p.source_changes ( ) } ,
1131
_ => ProposalSource:: Bookmark { bookmark: p.source_bookmark.clone ( ) } ,
1132
} ,
1133
changes: loaded
1134
.changes
1135
.iter ( )
1136
.map ( | c | hub_api:: ChangeSummary {
1137
hash: c.hash.clone ( ) ,
1138
message: if c.body.is_empty ( ) { c.title.clone ( ) } else { format! ( " {} \n \n {} " , c.title, c.body) } ,
1139
author: hub_api:: Author {
1140
name: c.author.name.clone ( ) ,
1141
email: c.author.email.clone ( ) ,
1142
key: c.author.key.clone ( ) ,
1143
account: c.author.account.clone ( ) ,
760a7dd3 Andy McCall 1 month ago ↶
1144
note: hub_api:: Author:: note_for( & c.author.key, & c.author.account) ,
de505df5 Andy McCall 1 month ago ↶
1145
} ,
1146
when_ms: c.when_ms,
1147
deps: c.deps.clone ( ) ,
1148
head: c.head,
1149
signature: match & c.signature {
1150
view:: SigView:: Unsigned => hub_api:: Signature:: Unsigned,
1151
view:: SigView:: Valid { key, sealed, revoked, .. } => {
1152
hub_api:: Signature:: Valid { key: key.clone ( ) , sealed: * sealed, revoked: * revoked }
1153
}
1154
view:: SigView:: Invalid( why) => hub_api:: Signature:: Invalid { reason: why.clone ( ) } ,
1155
} ,
1156
} )
1157
.collect ( ) ,
1158
projected_heads: loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect ( ) ,
1159
projected_state: Some ( loaded.state.clone ( ) ) ,
1160
conflicted: loaded.conflicted,
1161
approvals: v.approvals.clone ( ) ,
1162
landable: p.is_open ( ) & & v.blockers.is_empty ( ) ,
1163
blockers: v.blockers.clone ( ) ,
1164
created_ms: p.created_ms,
1165
updated_ms: p.updated_ms,
1166
url: format! ( " {} /{owner} /{name} /proposal/{} " , app.config.base_url ( ) , p.number) ,
1167
repository_url: format! ( " {} /{owner} /{name} " , app.config.base_url ( ) ) ,
1168
source_url,
1169
}
1170
}
1171
1172
1173
pub fn key ( path : & str , line : impl filters:: Num) -> ( String, usize ) {
1174
( path.to_string ( ) , line.as_u64 ( ) as usize )
1175
}
1176
1177
# [ allow ( dead_code ) ]
1178
fn _unused ( _ : & diff:: Line, _ : & ConflictView, _ : & Role) { }