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