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 ,
7d2eb590 Andy McCall 1 month ago ↶
140
141
title : String,
142
body : String,
143
144
assistants : Vec< String> ,
de505df5 Andy McCall 1 month ago ↶
145
}
146
147
# [ derive ( Deserialize ) ]
148
pub struct NewQuery {
149
source : Option< String> ,
150
target : Option< String> ,
151
}
152
153
0b20f1ec Andy McCall 1 month ago ↶
154
155
156
de505df5 Andy McCall 1 month ago ↶
157
fn parse_source ( app : & Shared, repo : & RepoName, source : & str ) -> Option< ( Option< RepoName> , String) > {
0b20f1ec Andy McCall 1 month ago ↶
158
let plain = | b : & str | ! b.is_empty ( ) & & ! b.chars ( ) .any ( | c | c.is_control ( ) | | c = = ' :' ) ;
de505df5 Andy McCall 1 month ago ↶
159
if let Some ( ( slug, bookmark) ) = source.split_once ( ' :' ) {
160
let fork = RepoName:: parse( slug) ? ;
0b20f1ec Andy McCall 1 month ago ↶
161
if ! plain ( bookmark) {
de505df5 Andy McCall 1 month ago ↶
162
return None ;
0b20f1ec Andy McCall 1 month ago ↶
163
}
164
if fork = = * repo {
165
return Some ( ( None , bookmark.to_string ( ) ) ) ;
de505df5 Andy McCall 1 month ago ↶
166
}
0b20f1ec Andy McCall 1 month ago ↶
167
if ! app.repos.exists ( & fork) {
168
return None ;
169
}
de505df5 Andy McCall 1 month ago ↶
170
return Some ( ( Some ( fork) , bookmark.to_string ( ) ) ) ;
171
}
0b20f1ec Andy McCall 1 month ago ↶
172
plain ( source) .then ( | | ( None , source.to_string ( ) ) )
de505df5 Andy McCall 1 month ago ↶
173
}
174
175
async fn preview_for (
176
app : & Shared,
177
repo : & RepoName,
178
target : & str ,
179
source : & str ,
180
) -> Result< ( Vec< ChangeView> , bool ) , crate:: repos:: RepoError> {
181
let Some ( ( fork, bookmark) ) = parse_source ( app, repo, source) else { return Ok ( ( vec! [ ] , false ) ) } ;
182
let target = target.to_string ( ) ;
183
let fork_dir = fork.as_ref ( ) .map ( | f | app.repos.dir ( f) ) ;
184
app.repos
185
.read ( repo, move | r, cache| {
186
let projection = match & fork_dir {
187
Some ( dir) => {
188
let fork_repo = oxy_core:: repo:: Repo:: open( dir) ? ;
189
let heads = fork_repo.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
190
pl:: project( r, cache, & target, Source:: Fork { fork: & fork_repo, heads } ) ?
191
}
192
None => {
193
let heads = r.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
194
pl:: project( r, cache, & target, Source:: Bookmark( heads) ) ?
195
}
196
} ;
197
Ok ( ( projection.changes, projection.conflicted) )
198
} )
199
.await
200
}
201
202
pub async fn new_form (
203
State( app ) : State< Shared> ,
204
viewer : Viewer,
205
Path( (owner , name ) ) : Path< ( String, String) > ,
206
Query( q) : Query< NewQuery> ,
207
) -> Result< Page< NewProposalPage> , Error> {
208
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals/new" ) , & viewer) ;
209
require ( & viewer, & shell) ? ;
210
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
211
let bookmarks = app
212
.repos
213
.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) )
214
.await
215
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
216
let target = q.target.unwrap_or_else ( | | row.default_bookmark.clone ( ) ) ;
217
let source = q.source.unwrap_or_default ( ) ;
218
let ( preview, conflicted) =
219
if source.is_empty ( ) { ( vec! [ ] , false ) } else { preview_for ( & app, & repo, & target, & source) .await.unwrap_or ( ( vec! [ ] , false ) ) } ;
7d2eb590 Andy McCall 1 month ago ↶
220
let assistants = if preview.is_empty ( ) { vec! [ ] } else { assistant_names ( & app, & row) .await } ;
de505df5 Andy McCall 1 month ago ↶
221
Ok ( Page( NewProposalPage {
222
shell,
223
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
224
bookmarks,
225
source,
226
target,
227
error: None ,
228
preview,
229
conflicted,
7d2eb590 Andy McCall 1 month ago ↶
230
title: String :: new( ) ,
231
body: String :: new( ) ,
232
assistants,
de505df5 Andy McCall 1 month ago ↶
233
} ) )
234
}
235
236
# [ derive ( Deserialize ) ]
237
pub struct NewForm {
238
#[serde(rename = "_csrf")]
239
csrf_token : String,
240
title : String,
241
#[serde(default)]
242
body : String,
243
target : String,
244
source : String,
245
#[serde(default)]
246
draft : Option< String> ,
7d2eb590 Andy McCall 1 month ago ↶
247
248
#[serde(default)]
249
assistant : String,
de505df5 Andy McCall 1 month ago ↶
250
}
251
b64e2537 Andy McCall 1 month ago ↶
252
253
# [ allow ( clippy::too_many_arguments ) ]
254
pub async fn open_proposal (
255
app : & Shared,
256
repo : & RepoName,
257
row : & db:: repos:: RepoRow,
258
me : & accounts:: Account,
259
title : & str ,
260
body : & str ,
261
target : & str ,
262
source : & str ,
263
draft : bool ,
264
) -> Result< pdb:: ProposalRow, String> {
265
if title.trim ( ) .is_empty ( ) {
266
return Err ( " Give the proposal a title." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
267
}
b64e2537 Andy McCall 1 month ago ↶
268
let bookmarks =
269
app.repos.read ( repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) ) .await.map_err ( | e | e.to_string ( ) ) ? ;
270
if ! bookmarks.iter ( ) .any ( | b | b = = target ) {
271
return Err ( " Pick a target bookmark of this repository." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
272
}
b64e2537 Andy McCall 1 month ago ↶
273
let Some ( ( fork, bookmark) ) = parse_source ( app, repo, source.trim ( ) ) else {
274
return Err ( " The source is a bookmark of this repository, or owner/name:bookmark of a fork." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
275
} ;
0b20f1ec Andy McCall 1 month ago ↶
276
if fork.is_none ( ) & & ! bookmarks.contains ( & bookmark) {
277
return Err ( format! ( " There is no bookmark {bookmark} in this repository." ) ) ;
278
}
b64e2537 Andy McCall 1 month ago ↶
279
if fork.is_none ( ) & & bookmark = = target {
280
return Err ( " The source and the target are the same bookmark." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
281
}
b64e2537 Andy McCall 1 month ago ↶
282
let ( preview, _ ) = preview_for ( app, repo, target, source.trim ( ) ) .await.map_err ( | e | e.to_string ( ) ) ? ;
de505df5 Andy McCall 1 month ago ↶
283
if preview.is_empty ( ) {
b64e2537 Andy McCall 1 month ago ↶
284
return Err ( " The source adds nothing to the target." .into ( ) ) ;
de505df5 Andy McCall 1 month ago ↶
285
}
286
let source_repo_id = match & fork {
b64e2537 Andy McCall 1 month ago ↶
287
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 ↶
288
None => None ,
289
} ;
290
let created = pdb:: create(
291
& app.db,
292
pdb:: NewProposal {
293
repo_id: row.id,
b64e2537 Andy McCall 1 month ago ↶
294
title: title.trim ( ) ,
295
body: body.trim ( ) ,
296
author_id: Some ( me.id) ,
297
author: & me.username,
298
draft,
299
target,
de505df5 Andy McCall 1 month ago ↶
300
source_kind: if fork.is_some ( ) { " fork" } else { " bookmark" } ,
301
source_repo_id,
302
source_bookmark: & bookmark,
303
source_changes: " " ,
304
} ,
305
)
306
.await
b64e2537 Andy McCall 1 month ago ↶
307
.map_err ( | e | e.to_string ( ) ) ? ;
308
pdb:: add_event( & app.db, created.id, & me.username, " opened" , " " ) .await.ok ( ) ;
309
accounts:: audit( & app.db, Some ( me) , " proposal.opened" , & format! ( " {} #{} " , row.slug ( ) , created.number) , title) .await.ok ( ) ;
18e381ea Andy McCall 1 month ago ↶
310
db:: notify:: index( & app.db, " proposal" , row.id, created.id, created.number, & created.title, & created.body) .await.ok ( ) ;
b64e2537 Andy McCall 1 month ago ↶
311
let url = format! ( " /{} /proposal/{} " , row.slug ( ) , created.number) ;
18e381ea Andy McCall 1 month ago ↶
312
crate :: notify:: deliver(
b64e2537 Andy McCall 1 month ago ↶
313
app,
18e381ea Andy McCall 1 month ago ↶
314
crate :: notify:: Event {
315
kind: " proposal.opened" ,
b64e2537 Andy McCall 1 month ago ↶
316
title: format! ( " {} #{} : {} " , row.slug ( ) , created.number, created.title) ,
317
url,
318
actor: me,
18e381ea Andy McCall 1 month ago ↶
319
repo_id: row.id,
320
thread: Some ( ( " proposal" , created.id) ) ,
b64e2537 Andy McCall 1 month ago ↶
321
mention_text: body,
18e381ea Andy McCall 1 month ago ↶
322
direct: vec! [ ] ,
323
} ,
324
)
325
.await;
b64e2537 Andy McCall 1 month ago ↶
326
crate :: hooks:: emit(
327
app,
328
row,
329
" proposal.opened" ,
330
& me.username,
331
serde_json:: json! ( { " number" : created.number, " title" : created.title, " target" : target, " source" : source } ) ,
332
)
333
.await;
334
Ok ( created)
335
}
336
337
pub async fn create (
338
State( app ) : State< Shared> ,
339
viewer : Viewer,
340
Path( (owner , name ) ) : Path< ( String, String) > ,
341
Form( f) : Form< NewForm> ,
342
) -> Result< Response, Error> {
343
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals/new" ) , & viewer) ;
344
let me = require ( & viewer, & shell) ? ;
345
csrf ( & viewer, & f.csrf_token, & shell) ? ;
346
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
347
match open_proposal ( & app, & repo, & row, & me.account, & f.title, & f.body, & f.target, & f.source, f.draft.is_some ( ) ) .await {
348
Ok ( created) => Ok ( Redirect:: to( & format! ( " /{owner} /{name} /proposal/{} " , created.number) ) .into_response ( ) ) ,
349
Err ( msg) => {
350
let bookmarks =
351
app.repos.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) ) .await.unwrap_or_default ( ) ;
352
Ok ( Page( NewProposalPage {
353
shell,
354
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
355
bookmarks,
356
source: f.source.clone ( ) ,
357
target: f.target.clone ( ) ,
358
error: Some ( msg) ,
359
preview: vec! [ ] ,
360
conflicted: false ,
7d2eb590 Andy McCall 1 month ago ↶
361
title: f.title.clone ( ) ,
362
body: f.body.clone ( ) ,
363
assistants: vec! [ ] ,
b64e2537 Andy McCall 1 month ago ↶
364
} )
365
.into_response ( ) )
366
}
367
}
de505df5 Andy McCall 1 month ago ↶
368
}
369
370
371
372
373
pub struct CommentView {
374
pub row : pdb:: CommentRow,
375
pub body_html : String,
376
pub line : Option< usize > ,
377
}
378
a1b98695 Andy McCall 1 month ago ↶
379
380
381
pub struct Entry {
382
pub kind : & 'static str ,
383
pub index : usize ,
384
}
385
de505df5 Andy McCall 1 month ago ↶
386
pub struct ReviewView {
387
pub row : pdb:: ReviewRow,
388
pub body_html : String,
389
pub sealed : bool ,
390
}
391
392
393
pub struct Loaded {
394
pub row : pdb:: ProposalRow,
395
pub changes : Vec< ChangeView> ,
396
pub hashes : Vec< ChangeHash> ,
397
pub heads : Vec< ChangeHash> ,
3f02e5ea Andy McCall 1 month ago ↶
398
pub target_heads : Vec< ChangeHash> ,
de505df5 Andy McCall 1 month ago ↶
399
pub state : String,
400
pub target_state : String,
401
pub conflicted : bool ,
402
pub conflicts : Vec< ConflictView> ,
403
pub files : Vec< FileRows> ,
9df855f2 Andy McCall 1 month ago ↶
404
405
pub diffs : Vec< oxy_core:: repo:: FileDiff> ,
406
pub bases : std:: collections:: BTreeMap< String, Vec< u8 > > ,
de505df5 Andy McCall 1 month ago ↶
407
408
pub atoms : HashMap< ( String, usize ) , ( String, u32 ) > ,
409
pub sealing_keys : Vec< String> ,
410
pub touched : Vec< String> ,
411
pub owners : Vec< String> ,
412
pub landed_already : bool ,
ac713df7 Andy McCall 26 days ago ↶
413
414
415
416
pub source_has_changes : bool ,
de505df5 Andy McCall 1 month ago ↶
417
}
418
419
420
pub async fn load ( app : & Shared, repo : & RepoName, row : pdb:: ProposalRow) -> Result< Loaded, crate:: repos:: RepoError> {
421
let fork_dir = match ( row.source_kind.as_str ( ) , row.source_repo_id) {
422
( " fork" , Some ( id) ) => {
423
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) )
424
}
425
_ => None ,
426
} ;
427
let r2 = row.clone ( ) ;
428
let loaded = app
429
.repos
430
.read ( repo, move | r, cache| {
431
let source = match ( r2.source_kind.as_str ( ) , & fork_dir) {
432
( " changes" , _ ) => Source:: Changes( r2.source_changes ( ) .iter ( ) .filter_map ( | h | ChangeHash:: from_hex( h) ) .collect ( ) ) ,
433
( " fork" , Some ( dir) ) => {
434
let fork_repo = oxy_core:: repo:: Repo:: open( dir) ? ;
435
let heads =
436
fork_repo.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = r2.source_bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
437
438
let fork_state = fork_repo.load_state ( & heads) ? ;
439
let missing: Vec< ChangeHash> = fork_state.changes ( ) .map ( | ( h , _) | * h) .filter ( | h | ! r.store ( ) .has ( h) ) .collect ( ) ;
440
if ! missing.is_empty ( ) {
441
r.import_bundle ( & fork_repo.bundle_for ( & missing) ? , r.current_policy ( ) ? .is_some ( ) ) ? ;
442
}
443
Source:: Changes( heads)
444
}
445
_ => {
446
let heads = r.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = r2.source_bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
447
Source:: Bookmark( heads)
448
}
449
} ;
ac713df7 Andy McCall 26 days ago ↶
450
let source_has_changes = match & source {
451
Source:: Changes( h) | Source:: Bookmark( h) | Source:: Fork { heads: h, .. } => ! h.is_empty ( ) ,
452
} ;
de505df5 Andy McCall 1 month ago ↶
453
let p: Projection = pl:: project( r, cache, & r2.target, source) ? ;
454
let target_loaded = cache.load ( r, & p.target_heads) ? ;
455
let files = render_files ( & p.files, & p.bases) ;
456
let mut atoms = HashMap:: new( ) ;
457
for f in & files {
458
for l in & f.lines {
459
if let Some ( n) = l.new_no {
460
if let Some ( ( h, i) ) = pl:: atom_at( & p.loaded, & f.path, n as usize ) {
461
atoms.insert ( ( f.path.clone ( ) , n as usize ) , ( h.to_hex ( ) , i) ) ;
462
}
463
}
464
}
465
}
466
let touched: Vec< String> = p.files.iter ( ) .map ( | f | f.path.clone ( ) ) .collect ( ) ;
467
let owners = pl:: owners_for( & target_loaded, & touched) ;
468
let sealing = pl:: sealing_keys( r, & p.hashes) ? .into_iter ( ) .map ( | k | k.to_hex ( ) ) .collect ( ) ;
469
let landed_already = ! p.hashes.is_empty ( ) & & p.hashes.iter ( ) .all ( | h | target_loaded.state.contains ( h) ) ;
0b20f1ec Andy McCall 1 month ago ↶
470
471
472
let ( changes, hashes) = if r2.state = = " landed" & & p.hashes.is_empty ( ) {
473
let hashes: Vec< ChangeHash> = r2.projected_changes ( ) .iter ( ) .filter_map ( | h | ChangeHash:: from_hex( h) ) .collect ( ) ;
474
let labels = HashMap:: new( ) ;
475
let changes = hashes
476
.iter ( )
477
.filter_map ( | h | Some ( view:: change_view_pub( r, h, & r.store ( ) .get_change_meta ( h) .ok ( ) ? , false , & labels) ) )
478
.collect ( ) ;
479
( changes, hashes)
480
} else {
481
( p.changes, p.hashes)
482
} ;
de505df5 Andy McCall 1 month ago ↶
483
Ok ( Loaded {
484
row: r2,
0b20f1ec Andy McCall 1 month ago ↶
485
changes,
486
hashes,
de505df5 Andy McCall 1 month ago ↶
487
heads: p.heads,
3f02e5ea Andy McCall 1 month ago ↶
488
target_heads: p.target_heads.clone ( ) ,
de505df5 Andy McCall 1 month ago ↶
489
state: p.state.to_hex ( ) ,
490
target_state: p.target_state.to_hex ( ) ,
491
conflicted: p.conflicted,
492
conflicts: p.conflicts,
493
files,
9df855f2 Andy McCall 1 month ago ↶
494
diffs: p.files.clone ( ) ,
495
bases: p.bases.clone ( ) ,
de505df5 Andy McCall 1 month ago ↶
496
atoms,
497
sealing_keys: sealing,
498
touched,
499
owners,
500
landed_already,
ac713df7 Andy McCall 26 days ago ↶
501
source_has_changes,
de505df5 Andy McCall 1 month ago ↶
502
} )
503
} )
504
.await? ;
ac713df7 Andy McCall 26 days ago ↶
505
506
507
508
509
let mut loaded = loaded;
510
if loaded.row.is_open ( )
511
& & loaded.source_has_changes
512
& & loaded.hashes.is_empty ( )
513
& & pdb:: set_landed( & app.db, loaded.row.id, & loaded.target_state, " " , " push" ) .await.is_ok ( )
514
{
515
loaded.row.state = " landed" .into ( ) ;
516
loaded.row.landed_via = " push" .into ( ) ;
517
loaded.row.landed_state = loaded.target_state.clone ( ) ;
518
return Ok ( loaded) ;
519
}
0b20f1ec Andy McCall 1 month ago ↶
520
521
522
523
if ! loaded.row.is_open ( ) {
524
return Ok ( loaded) ;
525
}
de505df5 Andy McCall 1 month ago ↶
526
let heads_csv = loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
527
let changes_csv = loaded.hashes.iter ( ) .map ( | h | h.to_hex ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
528
pdb:: set_projection( & app.db, loaded.row.id, & loaded.state, & heads_csv, & loaded.target_state, & changes_csv, loaded.conflicted)
529
.await
530
.ok ( ) ;
531
Ok ( loaded)
ac713df7 Andy McCall 26 days ago ↶
532
}
533
534
535
536
pub async fn settle_after_push ( app : & Shared, row : & db:: repos:: RepoRow, bookmark : & str , actor : & str ) {
537
let Some ( repo) = RepoName:: new( & row.owner, & row.name) else { return } ;
538
let Ok ( open) = pdb:: list( & app.db, row.id, & [ " open" , " draft" ] ) .await else { return } ;
539
for p in open.into_iter ( ) .filter ( | p | p.target = = bookmark ) {
540
let id = p.id;
541
if let Ok ( l) = load ( app, & repo, p) .await {
542
if l.row.state = = " landed" & & l.row.landed_by.is_empty ( ) {
543
pdb:: set_landed( & app.db, id, & l.target_state, actor, " push" ) .await.ok ( ) ;
544
}
545
}
546
}
de505df5 Andy McCall 1 month ago ↶
547
}
548
549
550
pub struct Verdict {
551
pub reviews : Vec< ReviewView> ,
552
pub approvals : Vec< Approval> ,
553
pub blockers : Vec< String> ,
554
pub depends_on : Vec< i64 > ,
555
pub requirements : pdb:: Requirements,
556
}
557
558
pub async fn verdict ( app : & Shared, loaded : & Loaded, repo_id : i64 ) -> Result< Verdict, sqlx:: Error> {
559
let rows = pdb:: reviews( & app.db, loaded.row.id) .await? ;
560
let links = accounts:: identity_accounts( & app.db) .await? ;
561
let sealed_accounts: Vec< String> = links.iter ( ) .filter ( | ( k , _) | loaded.sealing_keys.contains ( k) ) .map ( | ( _, u ) | u.clone ( ) ) .collect ( ) ;
562
563
let mut latest: BTreeMap< String, & pdb:: ReviewRow> = BTreeMap:: new( ) ;
7d2eb590 Andy McCall 1 month ago ↶
564
for r in rows.iter ( ) .filter ( | r | r.via.is_empty ( ) ) {
565
de505df5 Andy McCall 1 month ago ↶
566
latest.insert ( r.author.clone ( ) , r) ;
567
}
568
let mut approvals = Vec :: new( ) ;
569
for ( who, r) in & latest {
570
if r.verdict = = " approve" {
571
let sealed = sealed_accounts.contains ( who) ;
572
let key = links.iter ( ) .find ( | ( k , u ) | u = = who & & loaded.sealing_keys.contains ( k) ) .map ( | ( k , _) | k.clone ( ) ) ;
573
approvals.push ( Approval { account: who.clone ( ) , sealed, key } ) ;
574
}
575
}
576
let reviews = rows
577
.iter ( )
578
.map ( | r | ReviewView {
579
row: r.clone ( ) ,
580
body_html: render:: markdown:: render( & r.body, " /" , " /" ) ,
581
sealed: r.verdict = = " approve" & & sealed_accounts.contains ( & r.author) ,
582
} )
583
.collect ( ) ;
584
let requirements = pdb:: requirements( & app.db, repo_id) .await? ;
585
let mut blockers = Vec :: new( ) ;
0b20f1ec Andy McCall 1 month ago ↶
586
587
if ! loaded.row.is_open ( ) {
588
return Ok ( Verdict { reviews, approvals, blockers, depends_on: vec! [ ] , requirements } ) ;
589
}
de505df5 Andy McCall 1 month ago ↶
590
if loaded.row.state = = " draft" {
591
blockers.push ( " the proposal is a draft" .into ( ) ) ;
592
}
593
if loaded.hashes.is_empty ( ) {
594
blockers.push ( " the proposal adds no changes" .into ( ) ) ;
595
}
596
let sealed_count = approvals.iter ( ) .filter ( | a | a.sealed ) .count ( ) as i64 ;
597
if sealed_count < requirements.required_approvals {
598
blockers.push ( format! ( " {sealed_count} of {} required sealed approvals" , requirements.required_approvals) ) ;
599
}
600
if requirements.require_owners {
601
let missing: Vec< & String> = loaded.owners.iter ( ) .filter ( | o | ! approvals.iter ( ) .any ( | a | a.sealed & & & a.account = = * o ) ) .collect ( ) ;
602
if ! missing.is_empty ( ) {
603
blockers.push ( format! ( " code owners still to approve: {} " , missing.iter ( ) .map ( | s | s.as_str ( ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
604
}
605
}
606
if loaded.conflicted & & ! requirements.allow_conflicts {
607
blockers.push ( " landing would carry conflicts, which this repository refuses" .into ( ) ) ;
608
}
b64e2537 Andy McCall 1 month ago ↶
609
let required = db:: hooks:: required_checks( & app.db, repo_id) .await? ;
610
if ! required.is_empty ( ) {
611
let checks = db:: hooks:: checks_for_state( & app.db, repo_id, & loaded.state) .await? ;
612
let missing: Vec< String> =
613
required.iter ( ) .filter ( | n | ! checks.iter ( ) .any ( | c | & c.name = = * n & & c.status = = " passed" ) ) .cloned ( ) .collect ( ) ;
614
if ! missing.is_empty ( ) {
615
blockers.push ( format! ( " required checks not passed on the projected state: {} " , missing.join ( " , " ) ) ) ;
616
}
617
}
de505df5 Andy McCall 1 month ago ↶
618
let requesting: Vec< & String> = latest.iter ( ) .filter ( | ( _, r ) | r.verdict = = " changes" ) .map ( | ( who , _) | who ) .collect ( ) ;
619
if ! requesting.is_empty ( ) {
620
blockers.push ( format! ( " changes requested by {} " , requesting.iter ( ) .map ( | s | s.as_str ( ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
621
}
622
623
let mut depends_on = Vec :: new( ) ;
624
for other in pdb:: list( & app.db, repo_id, & [ " open" , " draft" ] ) .await? {
625
if other.id = = loaded.row.id | | other.number > loaded.row.number {
626
continue ;
627
}
628
let theirs = other.projected_changes ( ) ;
629
if ! theirs.is_empty ( ) & & theirs.iter ( ) .all ( | h | loaded.hashes.iter ( ) .any ( | m | m.to_hex ( ) = = * h ) ) {
630
depends_on.push ( other.number) ;
631
}
632
}
633
if ! depends_on.is_empty ( ) {
634
blockers.push ( format! ( " depends on {} " , depends_on.iter ( ) .map ( | n | format! ( " #{n} " ) ) .collect:: < Vec< _ > > ( ) .join ( " , " ) ) ) ;
635
}
636
Ok ( Verdict { reviews, approvals, blockers, depends_on, requirements } )
637
}
638
639
# [ derive ( Template ) ]
640
# [ template ( path = " proposal.html" ) ]
641
pub struct ProposalPage {
642
shell : Shell,
643
repo : RepoShell,
644
p : pdb:: ProposalRow,
38833ed7 Andy McCall 1 month ago ↶
645
646
closes : Vec< super:: chain:: Link> ,
a1b98695 Andy McCall 1 month ago ↶
647
648
project_choices : Option< Vec< db:: projects:: Project> > ,
649
650
timeline : Vec< Entry> ,
651
652
can_triage : bool ,
de505df5 Andy McCall 1 month ago ↶
653
body_html : String,
654
tab : String,
655
changes : Vec< ChangeView> ,
656
files : Vec< FileRows> ,
9df855f2 Andy McCall 1 month ago ↶
657
diffs : Vec< oxy_core:: repo:: FileDiff> ,
658
bases : std:: collections:: BTreeMap< String, Vec< u8 > > ,
659
660
ignore_ws : bool ,
de505df5 Andy McCall 1 month ago ↶
661
conflicts : Vec< ConflictView> ,
662
conflicted : bool ,
663
comments : Vec< CommentView> ,
664
665
placed : HashMap< ( String, usize ) , Vec< usize > > ,
666
outdated : Vec< usize > ,
667
reviews : Vec< ReviewView> ,
668
approvals : Vec< Approval> ,
669
blockers : Vec< String> ,
670
depends_on : Vec< i64 > ,
671
events : Vec< pdb:: EventRow> ,
672
owners : Vec< String> ,
673
requirements : pdb:: Requirements,
674
landed_already : bool ,
675
can_edit : bool ,
4f37afcc Andy McCall 1 month ago ↶
676
677
678
deletable_source : Option< String> ,
de505df5 Andy McCall 1 month ago ↶
679
can_land : bool ,
680
hub_can_land : bool ,
681
notice : Option< String> ,
682
error : Option< String> ,
683
heads : Vec< String> ,
684
state_hash : String,
685
source_label : String,
686
api_url : String,
18e381ea Andy McCall 1 month ago ↶
687
labels : Vec< db:: issues:: Label> ,
688
all_labels : Vec< db:: issues:: Label> ,
b64e2537 Andy McCall 1 month ago ↶
689
checks : Vec< db:: hooks:: Check> ,
690
required_checks : Vec< String> ,
e1ec9fbf Andy McCall 1 month ago ↶
691
692
projects : Vec< ( db:: projects:: Project, String) > ,
cc1cd197 Andy McCall 1 month ago ↶
693
694
requested : Vec< String> ,
8e96499f Andy McCall 1 month ago ↶
695
696
reviewable : Vec< String> ,
cc1cd197 Andy McCall 1 month ago ↶
697
698
can_request : bool ,
7d2eb590 Andy McCall 1 month ago ↶
699
700
assistants : Vec< String> ,
701
702
draft : Option< String> ,
de505df5 Andy McCall 1 month ago ↶
703
}
704
705
# [ derive ( Deserialize ) ]
706
pub struct DetailQuery {
707
tab : Option< String> ,
a1b98695 Andy McCall 1 month ago ↶
708
9df855f2 Andy McCall 1 month ago ↶
709
#[serde(default)]
a1b98695 Andy McCall 1 month ago ↶
710
notice : Option< String> ,
711
#[serde(default)]
712
error : Option< String> ,
713
#[serde(default)]
9df855f2 Andy McCall 1 month ago ↶
714
w : Option< String> ,
de505df5 Andy McCall 1 month ago ↶
715
}
716
717
718
719
# [ allow ( clippy::too_many_arguments ) ]
720
async fn detail (
721
app : & Shared,
722
shell : Shell,
723
viewer : & Viewer,
724
owner : & str ,
725
name : & str ,
726
number : i64 ,
727
tab : String,
728
notice : Option< String> ,
729
error : Option< String> ,
730
) -> Result< Page< ProposalPage> , Error> {
731
let ( repo, row, role) = lookup ( app, & shell, viewer, owner, name) .await? ;
732
let p = pdb:: get( & app.db, row.id, number)
733
.await
734
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
735
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
736
let loaded = load ( app, & repo, p) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
737
738
if loaded.landed_already & & loaded.row.is_open ( ) {
739
pdb:: set_state( & app.db, loaded.row.id, " landed" , & loaded.target_state) .await.ok ( ) ;
740
pdb:: add_event( & app.db, loaded.row.id, " " , " landed" , " every change reached the target" ) .await.ok ( ) ;
741
}
742
let p = pdb:: get( & app.db, row.id, number) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( loaded.row.clone ( ) ) ;
743
let v = verdict ( app, & loaded, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
744
let comment_rows = pdb:: comments( & app.db, loaded.row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
745
let events = pdb:: events( & app.db, loaded.row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
746
747
let mut by_atom: HashMap< ( String, String, u32 ) , usize > = HashMap:: new( ) ;
748
for ( ( path, line) , ( h, i) ) in & loaded.atoms {
749
by_atom.insert ( ( path.clone ( ) , h.clone ( ) , * i) , * line) ;
750
}
751
let mut comments = Vec :: new( ) ;
752
let mut placed: HashMap< ( String, usize ) , Vec< usize > > = HashMap:: new( ) ;
753
let mut outdated = Vec :: new( ) ;
754
for ( idx, c) in comment_rows.into_iter ( ) .enumerate ( ) {
755
let line = if c.anchor_change.is_empty ( ) {
756
None
757
} else {
758
by_atom.get ( & ( c.path.clone ( ) , c.anchor_change.clone ( ) , c.anchor_index as u32 ) ) .copied ( )
759
} ;
760
if ! c.anchor_change.is_empty ( ) {
761
match line {
762
Some ( l) => placed.entry ( ( c.path.clone ( ) , l) ) .or_default ( ) .push ( idx) ,
763
None => outdated.push ( idx) ,
764
}
765
}
766
comments.push ( CommentView { body_html: render:: markdown:: render( & c.body, " /" , " /" ) , row: c, line } ) ;
767
}
768
let mut changes = loaded.changes.clone ( ) ;
769
super :: attribute( app, & mut changes.iter_mut ( ) .collect:: < Vec< _ > > ( ) ) .await;
770
let me = viewer.account ( ) ;
771
let can_edit = me.is_some_and ( | a | Some ( a.id) = = p.author_id | | role > = Role:: Maintain) ;
4f37afcc Andy McCall 1 month ago ↶
772
let deletable_source =
773
if p.state = = " landed" & & p.source_kind = = " bookmark" & & role > = Role:: Write & & p.source_bookmark ! = row.default_bookmark {
774
let b = p.source_bookmark.clone ( ) ;
775
app.repos
776
.read ( & repo, move | r, _ | Ok ( r.view ( ) ? .bookmarks.contains_key ( & b) ) )
777
.await
778
.unwrap_or ( false )
779
.then ( | | p.source_bookmark.clone ( ) )
780
} else {
781
None
782
} ;
de505df5 Andy McCall 1 month ago ↶
783
let can_land = role > = Role:: Write & & p.is_open ( ) & & v.blockers.is_empty ( ) ;
cc1cd197 Andy McCall 1 month ago ↶
784
let can_request = p.is_open ( ) & & me.is_some_and ( | a | Some ( a.id) = = p.author_id | | role > = Role:: Write) ;
8e96499f Andy McCall 1 month ago ↶
785
let requested = pdb:: review_requests( & app.db, p.id) .await.unwrap_or_default ( ) ;
786
let mut reviewable: Vec< String> = if can_request {
787
accounts:: assignable( & app.db, row.id, & row.owner) .await.unwrap_or_default ( ) .into_iter ( ) .filter ( | u | * u ! = p.author) .collect ( )
788
} else {
789
Vec :: new( )
790
} ;
791
for r in & requested {
792
if ! reviewable.iter ( ) .any ( | x | x.eq_ignore_ascii_case ( r) ) {
793
reviewable.push ( r.clone ( ) ) ;
794
}
795
}
7d2eb590 Andy McCall 1 month ago ↶
796
let assistants: Vec< String> = if ( can_request | | can_edit) & & p.is_open ( ) { assistant_names ( app, & row) .await } else { Vec :: new( ) } ;
14530604 Andy McCall 1 month ago ↶
797
let delegated = match viewer.account ( ) {
798
Some ( a) => super :: edit:: delegated_identity( app, a.id) .await.is_some ( ) ,
799
None => false ,
800
} ;
801
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 ↶
802
let source_label = match p.source_kind.as_str ( ) {
803
" fork" => format! (
804
" fork {} " ,
805
db:: repos:: get_by_id( & app.db, p.source_repo_id.unwrap_or ( 0 ) )
806
.await
807
.ok ( )
808
.flatten ( )
809
.map ( | r | format! ( " {} :{} " , r.slug ( ) , p.source_bookmark) )
810
.unwrap_or_default ( )
811
) ,
812
" changes" => format! ( " {} changes" , p.source_changes ( ) .len ( ) ) ,
813
_ => p.source_bookmark.clone ( ) ,
814
} ;
e1ec9fbf Andy McCall 1 month ago ↶
815
let projects = super :: projects:: placements_for( app, viewer, & row, p.number) .await;
38833ed7 Andy McCall 1 month ago ↶
816
let texts: Vec< String> = std:: iter:: once( p.body.clone ( ) ) .chain ( changes.iter ( ) .map ( | c | format! ( " {} \n {} " , c.title, c.body) ) ) .collect ( ) ;
817
let closes = super :: chain:: issues_closed_by( app, & row, & texts.iter ( ) .map ( String :: as_str) .collect:: < Vec< _ > > ( ) ) .await;
a1b98695 Andy McCall 1 month ago ↶
818
let project_choices = super :: projects:: project_choices( app, viewer, & row, & projects) .await;
819
let mut dated: Vec< ( i64 , Entry) > = Vec :: new( ) ;
820
for ( index, c) in comments.iter ( ) .enumerate ( ) {
821
if c.row.anchor_change.is_empty ( ) {
822
dated.push ( ( c.row.created_ms, Entry { kind: " comment" , index } ) ) ;
823
}
824
}
825
for ( index, r) in v.reviews.iter ( ) .enumerate ( ) {
826
dated.push ( ( r.row.created_ms, Entry { kind: " review" , index } ) ) ;
827
}
828
for ( index, e) in events.iter ( ) .enumerate ( ) {
829
dated.push ( ( e.at_ms, Entry { kind: " event" , index } ) ) ;
830
}
831
dated.sort_by_key ( | ( at , _) | * at) ;
832
let timeline: Vec< Entry> = dated.into_iter ( ) .map ( | ( _, e ) | e ) .collect ( ) ;
de505df5 Andy McCall 1 month ago ↶
833
Ok ( Page( ProposalPage {
a1b98695 Andy McCall 1 month ago ↶
834
can_triage: role > = Role:: Triage,
835
timeline,
38833ed7 Andy McCall 1 month ago ↶
836
closes,
a1b98695 Andy McCall 1 month ago ↶
837
project_choices,
e1ec9fbf Andy McCall 1 month ago ↶
838
projects,
de505df5 Andy McCall 1 month ago ↶
839
shell,
840
repo: RepoShell:: new( app, & row, " proposals" ) .with_role ( role) ,
c79a1111 Andy McCall 1 month ago ↶
841
body_html: super :: issues:: markdown( app, & row, & row.default_bookmark, & p.body) .await,
de505df5 Andy McCall 1 month ago ↶
842
p,
843
tab,
844
changes,
845
files: loaded.files,
9df855f2 Andy McCall 1 month ago ↶
846
diffs: loaded.diffs,
847
bases: loaded.bases,
848
ignore_ws: false ,
de505df5 Andy McCall 1 month ago ↶
849
conflicts: loaded.conflicts,
850
conflicted: loaded.conflicted,
851
comments,
852
placed,
853
outdated,
854
reviews: v.reviews,
855
approvals: v.approvals,
856
blockers: v.blockers,
857
depends_on: v.depends_on,
858
events,
859
owners: loaded.owners,
860
requirements: v.requirements,
861
landed_already: loaded.landed_already,
862
can_edit,
4f37afcc Andy McCall 1 month ago ↶
863
deletable_source,
de505df5 Andy McCall 1 month ago ↶
864
can_land,
865
hub_can_land,
866
notice,
867
error,
868
heads: loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect ( ) ,
b64e2537 Andy McCall 1 month ago ↶
869
state_hash: loaded.state.clone ( ) ,
de505df5 Andy McCall 1 month ago ↶
870
source_label,
871
api_url: format! ( " {} /api/v1/repos/{owner} /{name} /proposals/{number} " , app.config.base_url ( ) ) ,
18e381ea Andy McCall 1 month ago ↶
872
labels: db:: issues:: proposal_labels( & app.db, loaded.row.id) .await.unwrap_or_default ( ) ,
873
all_labels: db:: issues:: labels( & app.db, row.id) .await.unwrap_or_default ( ) ,
b64e2537 Andy McCall 1 month ago ↶
874
checks: db:: hooks:: checks_for_state( & app.db, row.id, & loaded.state) .await.unwrap_or_default ( ) ,
875
required_checks: db:: hooks:: required_checks( & app.db, row.id) .await.unwrap_or_default ( ) ,
8e96499f Andy McCall 1 month ago ↶
876
requested,
877
reviewable,
7d2eb590 Andy McCall 1 month ago ↶
878
assistants,
879
draft: None ,
cc1cd197 Andy McCall 1 month ago ↶
880
can_request,
de505df5 Andy McCall 1 month ago ↶
881
} ) )
882
}
883
a1b98695 Andy McCall 1 month ago ↶
884
885
886
fn back ( owner : & str , name : & str , number : i64 , tab : & str , notice : Option< String> , error : Option< String> ) -> Response {
887
let mut url = format! ( " /{owner} /{name} /proposal/{number} ?tab={tab} " ) ;
888
if let Some ( n) = notice {
889
url.push_str ( & format! ( " ¬ice={} " , super :: settings:: urlencode( & n) ) ) ;
890
}
891
if let Some ( e) = error {
892
url.push_str ( & format! ( " &error={} " , super :: settings:: urlencode( & e) ) ) ;
893
}
894
Redirect:: to( & url) .into_response ( )
895
}
896
de505df5 Andy McCall 1 month ago ↶
897
pub async fn show (
898
State( app ) : State< Shared> ,
899
viewer : Viewer,
900
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
901
Query( q) : Query< DetailQuery> ,
902
) -> Result< Page< ProposalPage> , Error> {
903
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
a1b98695 Andy McCall 1 month ago ↶
904
let mut page =
905
detail ( & app, shell, & viewer, & owner, & name, number, q.tab.unwrap_or_else ( | | " conversation" .into ( ) ) , q.notice, q.error) .await? ;
9df855f2 Andy McCall 1 month ago ↶
906
if q.w.as_deref ( ) = = Some ( " 1" ) {
907
908
page.0. files =
909
super :: history:: render_files_with( & page.0. diffs, & page.0. bases, crate :: render:: diff:: Options { ignore_whitespace: true } ) ;
910
page.0. ignore_ws = true ;
911
}
912
Ok ( page)
de505df5 Andy McCall 1 month ago ↶
913
}
914
915
916
917
# [ derive ( Deserialize ) ]
918
pub struct CommentForm {
919
#[serde(rename = "_csrf")]
920
csrf_token : String,
921
body : String,
922
#[serde(default)]
923
path : String,
924
#[serde(default)]
925
line : Option< usize > ,
926
}
927
928
pub async fn comment (
929
State( app ) : State< Shared> ,
930
viewer : Viewer,
931
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
932
Form( f) : Form< CommentForm> ,
a1b98695 Andy McCall 1 month ago ↶
933
) -> Result< Response, Error> {
de505df5 Andy McCall 1 month ago ↶
934
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
935
let me = require ( & viewer, & shell) ? ;
936
csrf ( & viewer, & f.csrf_token, & shell) ? ;
937
let ( repo, row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
938
let p = pdb:: get( & app.db, row.id, number)
939
.await
940
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
941
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
942
if f.body.trim ( ) .is_empty ( ) {
a1b98695 Andy McCall 1 month ago ↶
943
return Ok ( back ( & owner, & name, number, " conversation" , None , Some ( " An empty comment says nothing." .into ( ) ) ) ) ;
de505df5 Andy McCall 1 month ago ↶
944
}
945
let anchor = match ( f.path.trim ( ) , f.line) {
946
( " " , _ ) | ( _ , None ) => None ,
947
( path, Some ( line) ) => {
948
let loaded = load ( & app, & repo, p.clone ( ) ) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
949
loaded.atoms.get ( & ( path.to_string ( ) , line) ) .cloned ( )
950
}
951
} ;
952
let tab = if anchor.is_some ( ) { " files" } else { " conversation" } ;
953
pdb:: add_comment(
954
& app.db,
955
p.id,
956
Some ( me.account.id) ,
957
& me.account.username,
958
f.body.trim ( ) ,
959
f.path.trim ( ) ,
960
anchor.as_ref ( ) .map ( | ( h , i ) | ( h.as_str ( ) , * i as i64 ) ) ,
961
)
962
.await
963
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
a1b98695 Andy McCall 1 month ago ↶
964
Ok ( back ( & owner, & name, number, tab, None , None ) )
de505df5 Andy McCall 1 month ago ↶
965
}
966
967
# [ derive ( Deserialize ) ]
968
pub struct ReviewForm {
969
#[serde(rename = "_csrf")]
970
csrf_token : String,
971
verdict : String,
972
#[serde(default)]
973
body : String,
974
}
975
976
pub async fn review (
977
State( app ) : State< Shared> ,
978
viewer : Viewer,
979
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
980
Form( f) : Form< ReviewForm> ,
a1b98695 Andy McCall 1 month ago ↶
981
) -> Result< Response, Error> {
de505df5 Andy McCall 1 month ago ↶
982
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
983
let me = require ( & viewer, & shell) ? ;
984
csrf ( & viewer, & f.csrf_token, & shell) ? ;
985
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
986
let p = pdb:: get( & app.db, row.id, number)
987
.await
988
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
989
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
990
if ! matches! ( f.verdict.as_str ( ) , " comment" | " changes" | " approve" ) {
991
return Err ( Error:: bad( shell, " a review is comment, changes or approve" ) ) ;
992
}
993
if f.verdict = = " approve" & & Some ( me.account.id) = = p.author_id {
a1b98695 Andy McCall 1 month ago ↶
994
return Ok ( back ( & owner, & name, number, " conversation" , None , Some ( " You cannot approve your own proposal." .into ( ) ) ) ) ;
de505df5 Andy McCall 1 month ago ↶
995
}
996
pdb:: add_review( & app.db, p.id, me.account.id, & me.account.username, & f.verdict, f.body.trim ( ) )
997
.await
998
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
999
pdb:: add_event( & app.db, p.id, & me.account.username, & format! ( " review.{} " , f.verdict) , " " ) .await.ok ( ) ;
cc1cd197 Andy McCall 1 month ago ↶
1000
reviewed ( & app, & row, & p, & me.account, & f.verdict) .await;
b64e2537 Andy McCall 1 month ago ↶
1001
crate :: hooks:: emit( & app, & row, " review" , & me.account.username, serde_json:: json! ( { " number" : number, " verdict" : f.verdict } ) ) .await;
de505df5 Andy McCall 1 month ago ↶
1002
let notice = if f.verdict = = " approve" {
1003
Some ( format! (
1004
" Approval recorded. It counts once you seal the changes: oxy seal --proposal {} /{owner} /{name} /proposal/{number} " ,
1005
app.config.base_url ( )
1006
) )
1007
} else {
1008
None
1009
} ;
a1b98695 Andy McCall 1 month ago ↶
1010
Ok ( back ( & owner, & name, number, " conversation" , notice, None ) )
de505df5 Andy McCall 1 month ago ↶
1011
}
1012
1013
# [ derive ( Deserialize ) ]
1014
pub struct EditForm {
1015
#[serde(rename = "_csrf")]
1016
csrf_token : String,
1017
title : String,
1018
#[serde(default)]
1019
body : String,
1020
target : String,
1021
}
1022
1023
pub async fn edit (
1024
State( app ) : State< Shared> ,
1025
viewer : Viewer,
1026
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1027
Form( f) : Form< EditForm> ,
a1b98695 Andy McCall 1 month ago ↶
1028
) -> Result< Response, Error> {
de505df5 Andy McCall 1 month ago ↶
1029
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1030
let me = require ( & viewer, & shell) ? ;
1031
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1032
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1033
let p = pdb:: get( & app.db, row.id, number)
1034
.await
1035
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1036
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1037
if Some ( me.account.id) ! = p.author_id & & role < Role:: Maintain {
1038
return Err ( Error:: not_found( shell) ) ;
1039
}
1040
let target = if crate :: repos:: names:: valid_segment( f.target.trim ( ) ) { f.target.trim ( ) } else { & p.target } ;
1041
pdb:: edit( & app.db, p.id, f.title.trim ( ) , f.body.trim ( ) , target) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
1042
pdb:: add_event( & app.db, p.id, & me.account.username, " edited" , " " ) .await.ok ( ) ;
a1b98695 Andy McCall 1 month ago ↶
1043
Ok ( back ( & owner, & name, number, " conversation" , Some ( " Saved." .into ( ) ) , None ) )
de505df5 Andy McCall 1 month ago ↶
1044
}
1045
1046
# [ derive ( Deserialize ) ]
1047
pub struct StateForm {
1048
#[serde(rename = "_csrf")]
1049
csrf_token : String,
1050
action : String,
1051
}
1052
1053
pub async fn state (
1054
State( app ) : State< Shared> ,
1055
viewer : Viewer,
1056
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1057
Form( f) : Form< StateForm> ,
a1b98695 Andy McCall 1 month ago ↶
1058
) -> Result< Response, Error> {
de505df5 Andy McCall 1 month ago ↶
1059
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1060
let me = require ( & viewer, & shell) ? ;
1061
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1062
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1063
let p = pdb:: get( & app.db, row.id, number)
1064
.await
1065
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1066
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1067
if Some ( me.account.id) ! = p.author_id & & role < Role:: Triage {
1068
return Err ( Error:: not_found( shell) ) ;
1069
}
1070
let new_state = match ( f.action.as_str ( ) , p.state.as_str ( ) ) {
1071
( " close" , " open" | " draft" ) => " closed" ,
1072
( " reopen" , " closed" ) => " open" ,
1073
( " ready" , " draft" ) => " open" ,
1074
( " draft" , " open" ) => " draft" ,
1075
_ => return Err ( Error:: bad( shell, " that transition is not possible" ) ) ,
1076
} ;
1077
pdb:: set_state( & app.db, p.id, new_state, " " ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
1078
pdb:: add_event( & app.db, p.id, & me.account.username, new_state, " " ) .await.ok ( ) ;
b64e2537 Andy McCall 1 month ago ↶
1079
crate :: hooks:: emit( & app, & row, & format! ( " proposal.{new_state} " ) , & me.account.username, serde_json:: json! ( { " number" : number } ) ) .await;
a1b98695 Andy McCall 1 month ago ↶
1080
Ok ( back ( & owner, & name, number, " conversation" , None , None ) )
de505df5 Andy McCall 1 month ago ↶
1081
}
1082
1083
pub async fn resolve_comment (
1084
State( app ) : State< Shared> ,
1085
viewer : Viewer,
1086
Path( (owner , name , number , id ) ) : Path< ( String, String, i64 , i64 ) > ,
1087
Form( f) : Form< super:: session:: CsrfForm> ,
a1b98695 Andy McCall 1 month ago ↶
1088
) -> Result< Response, Error> {
de505df5 Andy McCall 1 month ago ↶
1089
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1090
require ( & viewer, & shell) ? ;
1091
csrf ( & viewer, & f.csrf, & shell) ? ;
1092
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1093
let p = pdb:: get( & app.db, row.id, number)
1094
.await
1095
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1096
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1097
let current: Option< bool > = sqlx:: query_scalar( " SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?" )
1098
.bind ( id)
1099
.bind ( p.id)
1100
.fetch_optional ( & app.db)
1101
.await
1102
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
1103
if let Some ( r) = current {
1104
pdb:: set_comment_resolved( & app.db, id, ! r) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
1105
}
a1b98695 Andy McCall 1 month ago ↶
1106
Ok ( back ( & owner, & name, number, " files" , None , None ) )
de505df5 Andy McCall 1 month ago ↶
1107
}
1108
1109
1110
1111
pub async fn land (
1112
State( app ) : State< Shared> ,
1113
viewer : Viewer,
1114
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1115
Form( f) : Form< super:: session:: CsrfForm> ,
a1b98695 Andy McCall 1 month ago ↶
1116
) -> Result< Response, Error> {
de505df5 Andy McCall 1 month ago ↶
1117
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1118
let me = require ( & viewer, & shell) ? ;
1119
csrf ( & viewer, & f.csrf, & shell) ? ;
1120
let ( repo, row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
0b20f1ec Andy McCall 1 month ago ↶
1121
match land_as ( & app, & repo, & row, & me.account, number, " web" ) .await {
a1b98695 Andy McCall 1 month ago ↶
1122
Ok ( _ ) => Ok ( back ( & owner, & name, number, " conversation" , Some ( " Landed." .into ( ) ) , None ) ) ,
1f1eb06a Andy McCall 1 month ago ↶
1123
Err ( Landing:: NotFound) => Err ( Error:: not_found( shell) ) ,
a1b98695 Andy McCall 1 month ago ↶
1124
Err ( Landing:: Refused( m) ) => Ok ( back ( & owner, & name, number, " conversation" , None , Some ( m) ) ) ,
1f1eb06a Andy McCall 1 month ago ↶
1125
}
1126
}
1127
1128
1129
pub enum Landing {
1130
NotFound,
1131
Refused( String ) ,
1132
}
1133
1134
1135
1136
0b20f1ec Andy McCall 1 month ago ↶
1137
1138
1f1eb06a Andy McCall 1 month ago ↶
1139
pub async fn land_as (
1140
app : & Shared,
1141
repo : & RepoName,
1142
row : & db:: repos:: RepoRow,
1143
me : & accounts:: Account,
1144
number : i64 ,
0b20f1ec Andy McCall 1 month ago ↶
1145
via : & str ,
1f1eb06a Andy McCall 1 month ago ↶
1146
) -> Result< String, Landing> {
1147
let ( owner, name) = ( row.owner.clone ( ) , row.name.clone ( ) ) ;
1148
let refused = | e : String| Landing:: Refused( e) ;
1149
let p = pdb:: get( & app.db, row.id, number) .await.map_err ( | e | refused ( e.to_string ( ) ) ) ? .ok_or ( Landing:: NotFound) ? ;
1150
let loaded = load ( app, repo, p.clone ( ) ) .await.map_err ( | e | refused ( e.to_string ( ) ) ) ? ;
1151
let v = verdict ( app, & loaded, row.id) .await.map_err ( | e | refused ( e.to_string ( ) ) ) ? ;
1152
if ! p.is_open ( ) {
1153
return Err ( refused ( format! ( " Cannot land: the proposal is {} " , p.state) ) ) ;
1154
}
1155
if ! v.blockers.is_empty ( ) {
1156
return Err ( refused ( format! ( " Cannot land: {} " , v.blockers.join ( " ; " ) ) ) ) ;
de505df5 Andy McCall 1 month ago ↶
1157
}
1158
let target = p.target.clone ( ) ;
1159
let heads = loaded.heads.clone ( ) ;
14530604 Andy McCall 1 month ago ↶
1160
1161
1162
1163
1f1eb06a Andy McCall 1 month ago ↶
1164
let result = match super :: edit:: signer_for( app, me.id) .await {
1165
Ok ( signer) => crate :: repos:: edits:: move_bookmark( & app.repos, repo, target.clone ( ) , heads.clone ( ) , signer) .await.map ( | _| pl:: state_ref( & heads) .to_hex ( ) ) ,
14530604 Andy McCall 1 month ago ↶
1166
Err ( why) => {
1167
app.repos
1f1eb06a Andy McCall 1 month ago ↶
1168
.write ( repo, move | r, _ | {
14530604 Andy McCall 1 month ago ↶
1169
if r.current_policy ( ) ? .is_some ( ) {
1170
return Err ( crate :: repos:: RepoError:: Oxy( oxy_core:: repo:: RepoError:: Msg( format! (
1171
" 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} "
1172
) ) ) ) ;
1173
}
1174
r.bookmark_set ( & target, & heads) ? ;
1175
Ok ( pl:: state_ref( & heads) .to_hex ( ) )
1176
} )
1177
.await
1178
}
1179
} ;
1f1eb06a Andy McCall 1 month ago ↶
1180
let state = result.map_err ( | e | refused ( e.to_string ( ) ) ) ? ;
0b20f1ec Andy McCall 1 month ago ↶
1181
pdb:: set_landed( & app.db, p.id, & state, & me.username, via) .await.map_err ( | e | refused ( e.to_string ( ) ) ) ? ;
1182
let how = if v.approvals.is_empty ( ) { format! ( " {via} , with no approval" ) } else { via.to_string ( ) } ;
1183
pdb:: add_event( & app.db, p.id, & me.username, " landed" , & how) .await.ok ( ) ;
1f1eb06a Andy McCall 1 month ago ↶
1184
crate :: hooks:: emit(
1185
app,
1186
row,
1187
" proposal.landed" ,
1188
& me.username,
1189
serde_json:: json! ( { " number" : number, " target" : p.target, " state" : state } ) ,
1190
)
1191
.await;
1192
crate :: notify:: deliver(
1193
app,
1194
crate :: notify:: Event {
1195
kind: " proposal.landed" ,
1196
title: format! ( " {owner} /{name} #{number} : {} landed" , p.title) ,
1197
url: format! ( " /{owner} /{name} /proposal/{number} " ) ,
1198
actor: me,
1199
repo_id: row.id,
1200
thread: Some ( ( " proposal" , p.id) ) ,
1201
mention_text: " " ,
1202
direct: p.author_id.into_iter ( ) .collect ( ) ,
1203
} ,
1204
)
1205
.await;
1206
if p.target = = row.default_bookmark {
1207
let messages: Vec< ( String, String) > = loaded.changes.iter ( ) .map ( | c | ( c.hash.clone ( ) , format! ( " {} \n {} " , c.title, c.body) ) ) .collect ( ) ;
1208
super :: issues:: close_from_messages( app, row, & messages) .await;
de505df5 Andy McCall 1 month ago ↶
1209
}
0b20f1ec Andy McCall 1 month ago ↶
1210
accounts:: audit( & app.db, Some ( me) , " proposal.landed" , & format! ( " {owner} /{name} #{number} " ) , & how) .await.ok ( ) ;
1f1eb06a Andy McCall 1 month ago ↶
1211
db:: repos:: record_push(
1212
& app.db,
1213
row.id,
1214
Some ( ( me.id, me.username.as_str ( ) ) ) ,
1215
& [ db:: repos:: PushOutcome {
1216
reference: p.target.clone ( ) ,
1217
ok: true ,
0b20f1ec Andy McCall 1 month ago ↶
1218
reason: format! ( " landed #{number} ({how} )" ) ,
1f1eb06a Andy McCall 1 month ago ↶
1219
old_state: loaded.target_state.clone ( ) ,
1220
new_state: state.clone ( ) ,
1221
heads: loaded.heads.len ( ) as i64 ,
1222
changes: loaded.hashes.len ( ) as i64 ,
1223
} ] ,
1224
)
1225
.await
1226
.ok ( ) ;
1227
Ok ( state)
de505df5 Andy McCall 1 month ago ↶
1228
}
1229
18e381ea Andy McCall 1 month ago ↶
1230
# [ derive ( Deserialize ) ]
1231
pub struct LabelsForm {
1232
#[serde(rename = "_csrf")]
1233
csrf_token : String,
1234
#[serde(default)]
1235
label : Vec< i64 > ,
1236
}
1237
1238
pub async fn set_labels (
1239
State( app ) : State< Shared> ,
1240
viewer : Viewer,
1241
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1242
axum_extra:: extract:: Form( f) : axum_extra:: extract:: Form< LabelsForm> ,
a1b98695 Andy McCall 1 month ago ↶
1243
) -> Result< Response, Error> {
18e381ea Andy McCall 1 month ago ↶
1244
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1245
require ( & viewer, & shell) ? ;
1246
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1247
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1248
if role < Role:: Triage {
1249
return Err ( Error:: not_found( shell) ) ;
1250
}
1251
let p = pdb:: get( & app.db, row.id, number)
1252
.await
1253
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1254
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1255
for l in db:: issues:: labels( & app.db, row.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
1256
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) ) ? ;
1257
}
a1b98695 Andy McCall 1 month ago ↶
1258
Ok ( back ( & owner, & name, number, " conversation" , None , None ) )
18e381ea Andy McCall 1 month ago ↶
1259
}
1260
b64e2537 Andy McCall 1 month ago ↶
1261
1262
1263
pub async fn events (
1264
State( app ) : State< Shared> ,
1265
viewer : Viewer,
1266
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1267
) -> Result< axum:: response:: Sse< impl futures:: Stream< Item = Result< axum:: response:: sse:: Event, std:: convert:: Infallible> > > , Error> {
1268
use futures:: StreamExt;
1269
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1270
let ( _ , row, _ ) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1271
let rx = app.events.proposals.subscribe ( ) ;
1272
let repo_id = row.id;
1273
let stream = tokio_stream:: wrappers:: BroadcastStream:: new( rx) .filter_map ( move | item| async move {
1274
match item {
1275
Ok ( ( id, ev) ) if id = = repo_id => {
1276
Some ( Ok ( axum:: response:: sse:: Event:: default( ) .event ( ev.kind.clone ( ) ) .data ( serde_json:: to_string( & ev) .unwrap_or_default ( ) ) ) )
1277
}
1278
_ => None ,
1279
}
1280
} ) ;
1281
Ok ( axum:: response:: Sse:: new( stream) .keep_alive ( axum:: response:: sse:: KeepAlive:: default( ) ) )
1282
}
1283
de505df5 Andy McCall 1 month ago ↶
1284
1285
1286
pub async fn to_api ( app : & Shared, owner : & str , name : & str , loaded : & Loaded, v : & Verdict, source_url : Option< String> ) -> hub_api:: Proposal {
1287
let p = & loaded.row;
1288
hub_api:: Proposal {
1289
number: p.number,
1290
title: p.title.clone ( ) ,
1291
body: p.body.clone ( ) ,
1292
author: p.author.clone ( ) ,
1293
state: ProposalState:: parse( & p.state) .unwrap_or ( ProposalState:: Open) ,
1294
target: p.target.clone ( ) ,
1295
source: match p.source_kind.as_str ( ) {
1296
" fork" => {
1297
let ( o, n) = source_url
1298
.as_deref ( )
1299
.and_then ( | u | u.rsplit_once ( ' /' ) )
1300
.and_then ( | ( b , n ) | b.rsplit_once ( ' /' ) .map ( | ( _, o ) | ( o.to_string ( ) , n.to_string ( ) ) ) )
1301
.unwrap_or_default ( ) ;
1302
ProposalSource:: Fork { owner: o, name: n, bookmark: p.source_bookmark.clone ( ) }
1303
}
1304
" changes" => ProposalSource:: Changes { hashes: p.source_changes ( ) } ,
1305
_ => ProposalSource:: Bookmark { bookmark: p.source_bookmark.clone ( ) } ,
1306
} ,
1307
changes: loaded
1308
.changes
1309
.iter ( )
1310
.map ( | c | hub_api:: ChangeSummary {
1311
hash: c.hash.clone ( ) ,
1312
message: if c.body.is_empty ( ) { c.title.clone ( ) } else { format! ( " {} \n \n {} " , c.title, c.body) } ,
1313
author: hub_api:: Author {
1314
name: c.author.name.clone ( ) ,
1315
email: c.author.email.clone ( ) ,
1316
key: c.author.key.clone ( ) ,
1317
account: c.author.account.clone ( ) ,
760a7dd3 Andy McCall 1 month ago ↶
1318
note: hub_api:: Author:: note_for( & c.author.key, & c.author.account) ,
de505df5 Andy McCall 1 month ago ↶
1319
} ,
1320
when_ms: c.when_ms,
1321
deps: c.deps.clone ( ) ,
1322
head: c.head,
8933faff Andy McCall 1 month ago ↶
1323
via: c.via.as_ref ( ) .map ( | v | hub_api:: Via {
1324
name: v.name.clone ( ) ,
1325
key: v.key.clone ( ) ,
1326
scope: v.scope.clone ( ) ,
1327
expires_ms: v.expires_ms,
1328
revoked: v.revoked,
1329
} ) ,
de505df5 Andy McCall 1 month ago ↶
1330
signature: match & c.signature {
1331
view:: SigView:: Unsigned => hub_api:: Signature:: Unsigned,
1332
view:: SigView:: Valid { key, sealed, revoked, .. } => {
1333
hub_api:: Signature:: Valid { key: key.clone ( ) , sealed: * sealed, revoked: * revoked }
1334
}
1335
view:: SigView:: Invalid( why) => hub_api:: Signature:: Invalid { reason: why.clone ( ) } ,
1336
} ,
1337
} )
1338
.collect ( ) ,
1339
projected_heads: loaded.heads.iter ( ) .map ( | h | h.to_hex ( ) ) .collect ( ) ,
1340
projected_state: Some ( loaded.state.clone ( ) ) ,
1341
conflicted: loaded.conflicted,
1342
approvals: v.approvals.clone ( ) ,
1343
landable: p.is_open ( ) & & v.blockers.is_empty ( ) ,
1344
blockers: v.blockers.clone ( ) ,
1345
created_ms: p.created_ms,
1346
updated_ms: p.updated_ms,
1347
url: format! ( " {} /{owner} /{name} /proposal/{} " , app.config.base_url ( ) , p.number) ,
1348
repository_url: format! ( " {} /{owner} /{name} " , app.config.base_url ( ) ) ,
1349
source_url,
0b20f1ec Andy McCall 1 month ago ↶
1350
landed: ( p.state = = " landed" & & p.landed_ms > 0 ) .then ( | | hub_api:: Landed {
1351
by: p.landed_by.clone ( ) ,
1352
via: p.landed_via.clone ( ) ,
1353
when_ms: p.landed_ms,
1354
} ) ,
cc1cd197 Andy McCall 1 month ago ↶
1355
requested_reviewers: pdb:: review_requests( & app.db, p.id) .await.unwrap_or_default ( ) ,
2291f8cc Andy McCall 1 month ago ↶
1356
labels: db:: issues:: proposal_labels( & app.db, p.id)
1357
.await
1358
.unwrap_or_default ( )
1359
.into_iter ( )
1360
.map ( | l | hub_api:: Label { name: l.name, colour: l.colour, description: l.description } )
1361
.collect ( ) ,
de505df5 Andy McCall 1 month ago ↶
1362
}
1363
}
1364
1365
1366
pub fn key ( path : & str , line : impl filters:: Num) -> ( String, usize ) {
1367
( path.to_string ( ) , line.as_u64 ( ) as usize )
1368
}
1369
1370
# [ allow ( dead_code ) ]
1371
fn _unused ( _ : & diff:: Line, _ : & ConflictView, _ : & Role) { }
cc1cd197 Andy McCall 1 month ago ↶
1372
1373
1374
1375
1376
1377
1378
1379
pub async fn request_reviewers (
1380
app : & Shared,
1381
row : & db:: repos:: RepoRow,
1382
p : & pdb:: ProposalRow,
1383
by : & accounts:: Account,
1384
names : & [String],
1385
) -> Result< Vec< String> , String> {
1386
let mut found = Vec :: new( ) ;
1387
for name in names.iter ( ) .map ( | n | n.trim ( ) .trim_start_matches ( ' @' ) ) .filter ( | n | ! n.is_empty ( ) ) {
1388
let Some ( a) = accounts:: account_by_name( & app.db, name) .await.ok ( ) .flatten ( ) else {
1389
return Err ( format! ( " There is no account {name} ." ) ) ;
1390
} ;
1391
if Some ( a.id) = = p.author_id {
1392
return Err ( format! ( " {name} wrote this proposal, and cannot review it." ) ) ;
1393
}
1394
let role = crate :: auth:: roles:: role_for( & app.db, Some ( & a) , row) .await.ok ( ) .flatten ( ) ;
1395
if ! crate :: auth:: roles:: allows( role, crate :: auth:: Access:: Read) {
1396
return Err ( format! ( " {name} cannot read this repository." ) ) ;
1397
}
1398
found.push ( a) ;
1399
}
1400
let mut asked = Vec :: new( ) ;
1401
for a in found {
1402
if ! pdb:: request_review( & app.db, p.id, a.id, & by.username) .await.map_err ( | e | e.to_string ( ) ) ? {
1403
continue ;
1404
}
1405
pdb:: add_event( & app.db, p.id, & by.username, " review requested" , & a.username) .await.ok ( ) ;
1406
crate :: notify:: deliver(
1407
app,
1408
crate :: notify:: Event {
1409
kind: " proposal.review_requested" ,
1410
title: format! ( " {} /{} #{} : {} (review requested from {} )" , row.owner, row.name, p.number, p.title, a.username) ,
1411
url: format! ( " /{} /{} /proposal/{} " , row.owner, row.name, p.number) ,
1412
actor: by,
1413
repo_id: row.id,
1414
thread: None ,
1415
mention_text: " " ,
1416
direct: vec! [ a.id] ,
1417
} ,
1418
)
1419
.await;
1420
asked.push ( a.username) ;
1421
}
1422
Ok ( asked)
1423
}
1424
1425
1426
1427
pub async fn reviewed ( app : & Shared, row : & db:: repos:: RepoRow, p : & pdb:: ProposalRow, by : & accounts:: Account, verdict : & str ) {
1428
pdb:: unrequest_review( & app.db, p.id, by.id) .await.ok ( ) ;
1429
let what = match verdict {
1430
" approve" => " approved" ,
1431
" changes" => " changes requested" ,
1432
_ => " reviewed" ,
1433
} ;
1434
crate :: notify:: deliver(
1435
app,
1436
crate :: notify:: Event {
1437
kind: " proposal.review" ,
1438
title: format! ( " {} /{} #{} : {} ({what} by {} )" , row.owner, row.name, p.number, p.title, by.username) ,
1439
url: format! ( " /{} /{} /proposal/{} " , row.owner, row.name, p.number) ,
1440
actor: by,
1441
repo_id: row.id,
1442
thread: Some ( ( " proposal" , p.id) ) ,
1443
mention_text: " " ,
1444
direct: p.author_id.into_iter ( ) .collect ( ) ,
1445
} ,
1446
)
1447
.await;
1448
}
1449
1450
# [ derive ( Deserialize ) ]
1451
pub struct ReviewersForm {
1452
#[serde(rename = "_csrf")]
1453
csrf_token : String,
8e96499f Andy McCall 1 month ago ↶
1454
cc1cd197 Andy McCall 1 month ago ↶
1455
action : String,
8e96499f Andy McCall 1 month ago ↶
1456
#[serde(default)]
1457
username : Vec< String> ,
cc1cd197 Andy McCall 1 month ago ↶
1458
}
1459
1460
1461
1462
pub async fn reviewers (
1463
State( app ) : State< Shared> ,
1464
viewer : Viewer,
1465
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
8e96499f Andy McCall 1 month ago ↶
1466
axum_extra:: extract:: Form( f) : axum_extra:: extract:: Form< ReviewersForm> ,
cc1cd197 Andy McCall 1 month ago ↶
1467
) -> Result< Response, Error> {
1468
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1469
let me = require ( & viewer, & shell) ? ;
1470
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1471
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1472
let p = pdb:: get( & app.db, row.id, number)
1473
.await
1474
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1475
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1476
if Some ( me.account.id) ! = p.author_id & & role < Role:: Write {
1477
return Err ( Error:: not_found( shell) ) ;
1478
}
8e96499f Andy McCall 1 month ago ↶
1479
let names: Vec< String> = f.username.iter ( ) .map ( | n | n.trim ( ) .trim_start_matches ( ' @' ) .to_string ( ) ) .filter ( | n | ! n.is_empty ( ) ) .collect ( ) ;
1480
let current = pdb:: review_requests( & app.db, p.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
1481
1482
let withdraw: Vec< String> = match f.action.as_str ( ) {
1483
" remove" => names.clone ( ) ,
1484
" set" => current.iter ( ) .filter ( | c | ! names.iter ( ) .any ( | n | n.eq_ignore_ascii_case ( c) ) ) .cloned ( ) .collect ( ) ,
1485
_ => Vec :: new( ) ,
1486
} ;
1487
for who in & withdraw {
1488
if let Some ( a) = accounts:: account_by_name( & app.db, who) .await.ok ( ) .flatten ( ) {
cc1cd197 Andy McCall 1 month ago ↶
1489
if pdb:: unrequest_review( & app.db, p.id, a.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
1490
pdb:: add_event( & app.db, p.id, & me.account.username, " review request withdrawn" , & a.username) .await.ok ( ) ;
1491
}
1492
}
8e96499f Andy McCall 1 month ago ↶
1493
}
1494
let ask: Vec< String> = match f.action.as_str ( ) {
1495
" add" => names,
1496
" set" => names.into_iter ( ) .filter ( | n | ! current.iter ( ) .any ( | c | c.eq_ignore_ascii_case ( n) ) ) .collect ( ) ,
1497
_ => return Ok ( back ( & owner, & name, number, " conversation" , None , None ) ) ,
1498
} ;
1499
if ask.is_empty ( ) {
cc1cd197 Andy McCall 1 month ago ↶
1500
return Ok ( back ( & owner, & name, number, " conversation" , None , None ) ) ;
1501
}
8e96499f Andy McCall 1 month ago ↶
1502
match request_reviewers ( & app, & row, & p, & me.account, & ask) .await {
cc1cd197 Andy McCall 1 month ago ↶
1503
Ok ( asked) if asked.is_empty ( ) => Ok ( back ( & owner, & name, number, " conversation" , Some ( " Already asked." .into ( ) ) , None ) ) ,
1504
Ok ( asked) => Ok ( back ( & owner, & name, number, " conversation" , Some ( format! ( " Asked {} to review." , asked.join ( " , " ) ) ) , None ) ) ,
1505
Err ( e) => Ok ( back ( & owner, & name, number, " conversation" , None , Some ( e) ) ) ,
7d2eb590 Andy McCall 1 month ago ↶
1506
}
1507
}
1508
1509
# [ derive ( Deserialize ) ]
1510
pub struct AssistantForm {
1511
#[serde(rename = "_csrf")]
1512
csrf_token : String,
1513
assistant : String,
1514
}
1515
1516
1517
1518
1519
pub async fn assistant (
1520
State( app ) : State< Shared> ,
1521
viewer : Viewer,
1522
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1523
Form( f) : Form< AssistantForm> ,
1524
) -> Result< Response, Error> {
1525
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1526
let me = require ( & viewer, & shell) ? ;
1527
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1528
let ( _ , row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1529
let p = pdb:: get( & app.db, row.id, number)
1530
.await
1531
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1532
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1533
if Some ( me.account.id) ! = p.author_id & & role < Role:: Write {
1534
return Err ( Error:: not_found( shell) ) ;
1535
}
1536
if ! p.is_open ( ) {
1537
return Ok ( back ( & owner, & name, number, " conversation" , None , Some ( " The proposal is not open." .into ( ) ) ) ) ;
1538
}
1539
let Some ( a) = crate :: assistants:: enabled_for_repo( & app.db, & row)
1540
.await
1541
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1542
.into_iter ( )
1543
.find ( | a | a.name.eq_ignore_ascii_case ( f.assistant.trim ( ) ) )
1544
else {
1545
return Ok ( back ( & owner, & name, number, " conversation" , None , Some ( format! ( " No assistant called {} here." , f.assistant.trim ( ) ) ) ) ) ;
1546
} ;
1547
let who = a.name.clone ( ) ;
1548
match crate :: assistants:: request_review( & app, & row, & p, & me.account, a) .await {
1549
Ok ( ( ) ) => Ok ( back (
1550
& owner,
1551
& name,
1552
number,
1553
" conversation" ,
1554
Some ( format! ( " Asked {who} to review; its answer appears here when it comes." ) ) ,
1555
None ,
1556
) ) ,
1557
Err ( e) => Ok ( back ( & owner, & name, number, " conversation" , None , Some ( format! ( " Not asked: {e} ." ) ) ) ) ,
1558
}
1559
}
1560
1561
1562
async fn assistant_names ( app : & Shared, row : & db:: repos:: RepoRow) -> Vec< String> {
1563
crate :: assistants:: enabled_for_repo( & app.db, row) .await.unwrap_or_default ( ) .into_iter ( ) .map ( | a | a.name ) .collect ( )
1564
}
1565
1566
1567
1568
async fn preview_diff (
1569
app : & Shared,
1570
repo : & RepoName,
1571
target : & str ,
1572
source : & str ,
1573
) -> Result< ( Vec< ChangeView> , Vec< oxy_core:: repo:: FileDiff> ) , crate:: repos:: RepoError> {
1574
let Some ( ( fork, bookmark) ) = parse_source ( app, repo, source) else { return Ok ( ( vec! [ ] , vec! [ ] ) ) } ;
1575
let target = target.to_string ( ) ;
1576
let fork_dir = fork.as_ref ( ) .map ( | f | app.repos.dir ( f) ) ;
1577
app.repos
1578
.read ( repo, move | r, cache| {
1579
let projection = match & fork_dir {
1580
Some ( dir) => {
1581
let fork_repo = oxy_core:: repo:: Repo:: open( dir) ? ;
1582
let heads = fork_repo.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
1583
pl:: project( r, cache, & target, Source:: Fork { fork: & fork_repo, heads } ) ?
1584
}
1585
None => {
1586
let heads = r.bookmarks ( ) ? .into_iter ( ) .find ( | b | b.name = = bookmark ) .map ( | b | b.heads ) .unwrap_or_default ( ) ;
1587
pl:: project( r, cache, & target, Source:: Bookmark( heads) ) ?
1588
}
1589
} ;
1590
Ok ( ( projection.changes, projection.files) )
1591
} )
1592
.await
1593
}
1594
1595
1596
1597
1598
pub async fn new_draft (
1599
State( app ) : State< Shared> ,
1600
viewer : Viewer,
1601
Path( (owner , name ) ) : Path< ( String, String) > ,
1602
Form( f) : Form< NewForm> ,
1603
) -> Result< Page< NewProposalPage> , Error> {
1604
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposals/new" ) , & viewer) ;
1605
let me = require ( & viewer, & shell) ? ;
1606
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1607
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1608
let bookmarks =
1609
app.repos.read ( & repo, | r , _| Ok ( r.bookmarks ( ) ? .into_iter ( ) .map ( | b | b.name ) .collect:: < Vec< _ > > ( ) ) ) .await.unwrap_or_default ( ) ;
1610
let ( preview, conflicted) = preview_for ( & app, & repo, & f.target, & f.source) .await.unwrap_or ( ( vec! [ ] , false ) ) ;
1611
let assistants = if preview.is_empty ( ) { vec! [ ] } else { assistant_names ( & app, & row) .await } ;
1612
let mut page = NewProposalPage {
1613
shell,
1614
repo: RepoShell:: new( & app, & row, " proposals" ) .with_role ( role) ,
1615
bookmarks,
1616
source: f.source.clone ( ) ,
1617
target: f.target.clone ( ) ,
1618
error: None ,
1619
preview,
1620
conflicted,
1621
title: f.title.clone ( ) ,
1622
body: f.body.clone ( ) ,
1623
assistants,
1624
} ;
1625
let assistant = crate :: assistants:: enabled_for_repo( & app.db, & row)
1626
.await
1627
.unwrap_or_default ( )
1628
.into_iter ( )
1629
.find ( | a | a.name.eq_ignore_ascii_case ( f.assistant.trim ( ) ) ) ;
1630
let Some ( a) = assistant else {
1631
page.error = Some ( format! ( " No assistant called {} here." , f.assistant.trim ( ) ) ) ;
1632
return Ok ( Page( page) ) ;
1633
} ;
1634
if page.preview.is_empty ( ) {
1635
page.error = Some ( " Nothing to describe: the source adds nothing to the target." .into ( ) ) ;
1636
return Ok ( Page( page) ) ;
1637
}
1638
let ( changes, diffs) = match preview_diff ( & app, & repo, & f.target, & f.source) .await {
1639
Ok ( x) => x,
1640
Err ( e) => {
1641
page.error = Some ( e.to_string ( ) ) ;
1642
return Ok ( Page( page) ) ;
1643
}
1644
} ;
1645
let d = crate :: assistants:: Draft { slot: - row.id, title: & f.title, target: & f.target, changes: & changes, diffs: & diffs } ;
1646
match crate :: assistants:: draft_description( & app, & row, & me.account, & a, d) .await {
1647
Ok ( text) => page.body = text,
1648
Err ( why) => page.error = Some ( format! ( " {} did not answer: {why} ." , a.name) ) ,
1649
}
1650
Ok ( Page( page) )
1651
}
1652
1653
# [ derive ( Deserialize ) ]
1654
pub struct DraftForm {
1655
#[serde(rename = "_csrf")]
1656
csrf_token : String,
1657
assistant : String,
1658
#[serde(default)]
1659
title : String,
1660
}
1661
1662
1663
1664
1665
pub async fn draft (
1666
State( app ) : State< Shared> ,
1667
viewer : Viewer,
1668
Path( (owner , name , number ) ) : Path< ( String, String, i64 ) > ,
1669
Form( f) : Form< DraftForm> ,
1670
) -> Result< Response, Error> {
1671
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /proposal/{number} " ) , & viewer) ;
1672
let me = require ( & viewer, & shell) ? ;
1673
csrf ( & viewer, & f.csrf_token, & shell) ? ;
1674
let ( repo, row, role) = lookup ( & app, & shell, & viewer, & owner, & name) .await? ;
1675
let p = pdb:: get( & app.db, row.id, number)
1676
.await
1677
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1678
.ok_or_else ( | | Error:: not_found( shell.clone ( ) ) ) ? ;
1679
if Some ( me.account.id) ! = p.author_id & & role < Role:: Maintain {
1680
return Err ( Error:: not_found( shell) ) ;
1681
}
1682
let Some ( a) = crate :: assistants:: enabled_for_repo( & app.db, & row)
1683
.await
1684
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ?
1685
.into_iter ( )
1686
.find ( | a | a.name.eq_ignore_ascii_case ( f.assistant.trim ( ) ) )
1687
else {
1688
return Ok ( back ( & owner, & name, number, " conversation" , None , Some ( format! ( " No assistant called {} here." , f.assistant.trim ( ) ) ) ) ) ;
1689
} ;
1690
let loaded = crate :: web:: proposals:: load( & app, & repo, p.clone ( ) ) .await.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
1691
let title = if f.title.trim ( ) .is_empty ( ) { p.title.clone ( ) } else { f.title.clone ( ) } ;
1692
let d = crate :: assistants:: Draft { slot: p.id, title: & title, target: & p.target, changes: & loaded.changes, diffs: & loaded.diffs } ;
1693
let draft = crate :: assistants:: draft_description( & app, & row, & me.account, & a, d) .await;
1694
match draft {
1695
Ok ( text) => {
1696
let mut page = detail ( & app, shell, & viewer, & owner, & name, number, " conversation" .into ( ) , None , None ) .await? ;
1697
page.0. draft = Some ( text) ;
1698
Ok ( page.into_response ( ) )
1699
}
1700
Err ( why) => Ok ( back ( & owner, & name, number, " conversation" , None , Some ( format! ( " {} did not answer: {why} ." , a.name) ) ) ) ,
cc1cd197 Andy McCall 1 month ago ↶
1701
}
1702
}