forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
15.8 KiB
1//! Triage writes on an issue's labels, assignees, milestone and conversation lock
2//! (`/{owner}/{repo}/issues/{n}/...`).
3//! Every handler checks CSRF, `Action::Triage` and that the repo is not archived (403). A hidden
4//! repo or a missing issue is 404. A store validation error (unknown user, more than 10 assignees,
5//! a label or milestone from another repo) re-renders a small error page with the reason. Success
6//! redirects back to the issue.
7
8use forge_core::User;
9use forge_core::authz::{Action, authorize};
10use forge_core::issues::{Issue, Label};
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::RawForm;
14use topcoat::router::error::{forbidden, not_found, see_other};
15use topcoat::router::{page, path_param};
16use topcoat::view::{View, component, view};
17
18use crate::auth::require_csrf;
19use crate::i18n::issues::{self as g, K};
20use crate::i18n::{self, Lang};
21use crate::notify;
22use crate::pages::issue_parts::ItemKind;
23use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
24use crate::state::AppState;
25use crate::views::{alert, shell, user_facing_message};
26use crate::webhooks::emit::{
27 Sender, emit_assignee_changes, emit_item_action, emit_label_changes, emit_milestone_change,
28};
29
30path_param!(owner);
31path_param!(repo);
32path_param!(number);
33
34/// Whether the viewer may triage this repo's issues, labels and milestones: Triage or higher, or
35/// the owner or an instance admin, on a repo that is not archived.
36pub(crate) fn can_triage(ctx: &RepoCtx) -> bool {
37 let actor = ctx.viewer.as_ref().map(|(user, _)| user);
38 !ctx.repo.archived
39 && authorize(actor, &ctx.repo, ctx.role, ctx.org_role, Action::Triage).is_ok()
40}
41
42/// The repo fields and viewer flags that the label and milestone pages share.
43pub(crate) struct RepoFrame {
44 /// `owner/name`.
45 pub title: String,
46 pub csrf: String,
47 pub archived: bool,
48 pub can_triage: bool,
49 /// The repo header, which `repo_head` renders.
50 pub head: RepoHead,
51}
52
53impl RepoFrame {
54 /// The frame for a page on the Issues tab group. `active` is the header tab to mark.
55 pub(crate) async fn new(ctx: &RepoCtx, state: &AppState, active: &'static str) -> Result<Self> {
56 let csrf = ctx
57 .viewer
58 .as_ref()
59 .map(|(_, session)| session.csrf_token.clone())
60 .unwrap_or_default();
61 let head = ctx.head(state, active).await?;
62 Ok(Self {
63 title: format!("{}/{}", ctx.repo.owner_name, ctx.repo.name),
64 csrf,
65 archived: ctx.repo.archived,
66 can_triage: can_triage(ctx),
67 head,
68 })
69 }
70}
71
72/// The message for a failed store write that the form can show, or the error to propagate. A
73/// missing row is 404 and anything that is not a validation error is a server error.
74pub(crate) fn refusal_message(e: forge_core::Error) -> Result<String> {
75 if matches!(e, forge_core::Error::NotFound) {
76 return Err(not_found().into());
77 }
78 match user_facing_message(&e) {
79 Some(message) => Ok(message),
80 None => Err(e.into()),
81 }
82}
83
84/// Class for one sub-navigation link: the one named by `active` is marked current.
85fn subnav_class(active: &str, link: &str) -> &'static str {
86 if active == link {
87 "subnav-link current"
88 } else {
89 "subnav-link"
90 }
91}
92
93/// Links between the issue-adjacent pages of a repo: Issues, Labels and Milestones.
94#[component]
95pub(crate) async fn issue_subnav(lang: Lang, title: String, active: String) -> Result<impl View> {
96 let issues = format!("/{title}/issues");
97 let labels = format!("/{title}/labels");
98 let milestones = format!("/{title}/milestones");
99 Ok(view! {
100 <nav class="subnav" aria-label=(g::t(lang, K::IssuePages))>
101 <a class=(subnav_class(&active, "issues")) href=(issues.as_str())>(g::t(lang, K::Issues))</a>
102 <a class=(subnav_class(&active, "labels")) href=(labels.as_str())>(g::t(lang, K::Labels))</a>
103 <a class=(subnav_class(&active, "milestones")) href=(milestones.as_str())>(g::t(lang, K::Milestones))</a>
104 </nav>
105 })
106}
107
108/// Every `key=value` pair of a urlencoded body, in order, with repeated keys kept.
109pub(crate) fn form_pairs(raw: &[u8]) -> Vec<(String, String)> {
110 form_urlencoded::parse(raw)
111 .map(|(k, v)| (k.into_owned(), v.into_owned()))
112 .collect()
113}
114
115/// The first value for `key`, or an empty string.
116pub(crate) fn form_field(pairs: &[(String, String)], key: &str) -> String {
117 pairs
118 .iter()
119 .find(|(k, _)| k == key)
120 .map(|(_, v)| v.clone())
121 .unwrap_or_default()
122}
123
124/// Every value for `key`, in order.
125pub(crate) fn form_all(pairs: &[(String, String)], key: &str) -> Vec<String> {
126 pairs
127 .iter()
128 .filter(|(k, _)| k == key)
129 .map(|(_, v)| v.clone())
130 .collect()
131}
132
133/// The CSRF check and triage gate for a write. Returns the signed-in user. Anonymous viewers are
134/// sent to log in, a bad token is 403, and a viewer without Triage (or an archived repo) is 403.
135pub(crate) async fn triage_guard(cx: &Cx, ctx: &RepoCtx, csrf: &str) -> Result<User> {
136 let (user, _session) = require_csrf(cx, csrf).await?;
137 if ctx.repo.archived
138 || authorize(
139 Some(&user),
140 &ctx.repo,
141 ctx.role,
142 ctx.org_role,
143 Action::Triage,
144 )
145 .is_err()
146 {
147 return Err(forbidden().into());
148 }
149 Ok(user)
150}
151
152/// Loads the issue or pull request named by `{number}` in this repo, or 404. A pull request's
153/// sidebar data lives on its issue row, so the returned `Issue` serves both.
154pub(crate) async fn item_from_path(
155 cx: &Cx,
156 ctx: &RepoCtx,
157 kind: ItemKind,
158 number: &str,
159) -> Result<Issue> {
160 let Some(n) = number.parse::<i64>().ok() else {
161 return Err(not_found().into());
162 };
163 let store = &app_context::<AppState>(cx).store;
164 let found = match kind {
165 ItemKind::Issue => store.issue_by_number(&ctx.repo.id, n).await?,
166 ItemKind::Pull => store
167 .pull_by_number(&ctx.repo.id, n)
168 .await?
169 .map(|pr| pr.issue),
170 };
171 match found {
172 Some(item) => Ok(item),
173 None => Err(not_found().into()),
174 }
175}
176
177/// `/{owner}/{repo}/issues/{n}` or `/{owner}/{repo}/pulls/{n}` for this repo.
178pub(crate) fn item_href(ctx: &RepoCtx, kind: ItemKind, number: i64) -> String {
179 format!(
180 "/{}/{}/{}/{number}",
181 ctx.repo.owner_name,
182 ctx.repo.name,
183 kind.segment()
184 )
185}
186
187/// The error box with a link back to where the action came from, in the viewer's language.
188#[component]
189pub(crate) async fn action_error_at(
190 lang: Lang,
191 message: String,
192 back: String,
193) -> Result<impl View> {
194 Ok(view! {
195 <section class="card">
196 alert(message: message.as_str())
197 <p><a href=(back.as_str())>(g::t(lang, K::GoBack))</a></p>
198 </section>
199 })
200}
201
202/// Sets the labels on an item. The form repeats `label` once per label id.
203#[page(POST "/{owner}/{repo}/issues/{number}/labels")]
204async fn issue_labels_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
205 labels_save(cx, ItemKind::Issue, form_pairs(&raw[..])).await
206}
207
208#[page(POST "/{owner}/{repo}/pulls/{number}/labels")]
209async fn pull_labels_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
210 labels_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await
211}
212
213async fn labels_save(cx: &Cx, kind: ItemKind, fields: Vec<(String, String)>) -> Result<impl View> {
214 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
215 let item = item_from_path(cx, &ctx, kind, path_param::<Number>(cx)).await?;
216 let user = triage_guard(cx, &ctx, &form_field(&fields, "csrf")).await?;
217 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
218 let ids = form_all(&fields, "label");
219 let back = item_href(&ctx, kind, item.number);
220 let state = app_context::<AppState>(cx);
221 let pull = matches!(kind, ItemKind::Pull);
222 let before = item_labels(state, &item.id).await;
223 let result = state.store.set_issue_labels(&item.id, &user.id, &ids).await;
224 if let Err(e) = result {
225 let message = refusal_message(e)?;
226 return Ok(view! { cx =>
227 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
228 action_error_at(lang: lang, message: message, back: back))
229 });
230 }
231 let after = item_labels(state, &item.id).await;
232 let sender = Sender::of(&user);
233 emit_label_changes(state, &ctx.repo, &sender, pull, &item, &before, &after).await;
234 Err(see_other(back).into())
235}
236
237/// Sets the assignees. The field is a comma-separated list of usernames; empty clears them.
238#[page(POST "/{owner}/{repo}/issues/{number}/assignees")]
239async fn issue_assignees_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
240 assignees_save(cx, ItemKind::Issue, form_pairs(&raw[..])).await
241}
242
243#[page(POST "/{owner}/{repo}/pulls/{number}/assignees")]
244async fn pull_assignees_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
245 assignees_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await
246}
247
248async fn assignees_save(
249 cx: &Cx,
250 kind: ItemKind,
251 fields: Vec<(String, String)>,
252) -> Result<impl View> {
253 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
254 let item = item_from_path(cx, &ctx, kind, path_param::<Number>(cx)).await?;
255 let user = triage_guard(cx, &ctx, &form_field(&fields, "csrf")).await?;
256 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
257 let usernames: Vec<String> = form_field(&fields, "assignees")
258 .split(',')
259 .map(str::trim)
260 .filter(|s| !s.is_empty())
261 .map(str::to_owned)
262 .collect();
263 let back = item_href(&ctx, kind, item.number);
264 let state = app_context::<AppState>(cx);
265 let pull = matches!(kind, ItemKind::Pull);
266 let before = assignee_names(state, &item.id).await;
267 let result = state
268 .store
269 .set_issue_assignees(&item.id, &user.id, &usernames)
270 .await;
271 if let Err(e) = result {
272 let message = refusal_message(e)?;
273 return Ok(view! { cx =>
274 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
275 action_error_at(lang: lang, message: message, back: back))
276 });
277 }
278 let after = assignee_names(state, &item.id).await;
279 let sender = Sender::of(&user);
280 emit_assignee_changes(state, &ctx.repo, &sender, pull, &item, &before, &after).await;
281 notify::dispatch_assigned(state, &ctx.repo, &item, &user.id, &before, &after).await;
282 Err(see_other(back).into())
283}
284
285/// Labels on an issue, for diffing around a save. A lookup error reads as none.
286async fn item_labels(state: &AppState, issue_id: &str) -> Vec<Label> {
287 match state.store.issue_labels(issue_id).await {
288 Ok(labels) => labels,
289 Err(e) => {
290 tracing::error!(error = %e, issue_id, "webhooks: label lookup failed");
291 Vec::new()
292 }
293 }
294}
295
296/// Assignee usernames on an issue, for diffing around a save. A lookup error reads as none.
297async fn assignee_names(state: &AppState, issue_id: &str) -> Vec<String> {
298 match state.store.issue_assignees(issue_id).await {
299 Ok(assignees) => assignees
300 .into_iter()
301 .map(|(_, username)| username)
302 .collect(),
303 Err(e) => {
304 tracing::error!(error = %e, issue_id, "webhooks: assignee lookup failed");
305 Vec::new()
306 }
307 }
308}
309
310/// Locks (`locked=1`, with an optional `reason`) or unlocks (`locked=0`) the conversation. Only
311/// Triage or higher may do this. A repeat of the current state changes nothing and sends nothing.
312#[page(POST "/{owner}/{repo}/issues/{number}/lock")]
313async fn issue_lock_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
314 lock_save(cx, ItemKind::Issue, form_pairs(&raw[..])).await
315}
316
317#[page(POST "/{owner}/{repo}/pulls/{number}/lock")]
318async fn pull_lock_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
319 lock_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await
320}
321
322async fn lock_save(cx: &Cx, kind: ItemKind, fields: Vec<(String, String)>) -> Result<impl View> {
323 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
324 let item = item_from_path(cx, &ctx, kind, path_param::<Number>(cx)).await?;
325 let user = triage_guard(cx, &ctx, &form_field(&fields, "csrf")).await?;
326 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
327 let locked = form_field(&fields, "locked") == "1";
328 let reason = form_field(&fields, "reason");
329 let back = item_href(&ctx, kind, item.number);
330 let state = app_context::<AppState>(cx);
331 let result = state
332 .store
333 .set_issue_locked(&item.id, Some(&user.id), locked, Some(reason.as_str()))
334 .await;
335 let changed = match result {
336 Ok(changed) => changed,
337 Err(e) => {
338 let message = refusal_message(e)?;
339 return Ok(view! { cx =>
340 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
341 action_error_at(lang: lang, message: message, back: back))
342 });
343 }
344 };
345 if changed {
346 let action = if locked { "locked" } else { "unlocked" };
347 let pull = matches!(kind, ItemKind::Pull);
348 let sender = Sender::of(&user);
349 emit_item_action(state, &ctx.repo, &sender, pull, item.number, action).await;
350 }
351 Err(see_other(back).into())
352}
353
354/// Sets or clears the milestone. An empty `milestone_id` means no milestone.
355#[page(POST "/{owner}/{repo}/issues/{number}/milestone")]
356async fn issue_milestone_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
357 milestone_save(cx, ItemKind::Issue, form_pairs(&raw[..])).await
358}
359
360#[page(POST "/{owner}/{repo}/pulls/{number}/milestone")]
361async fn pull_milestone_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
362 milestone_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await
363}
364
365async fn milestone_save(
366 cx: &Cx,
367 kind: ItemKind,
368 fields: Vec<(String, String)>,
369) -> Result<impl View> {
370 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
371 let item = item_from_path(cx, &ctx, kind, path_param::<Number>(cx)).await?;
372 let user = triage_guard(cx, &ctx, &form_field(&fields, "csrf")).await?;
373 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
374 let milestone_id = form_field(&fields, "milestone_id");
375 let milestone = (!milestone_id.is_empty()).then_some(milestone_id.as_str());
376 let back = item_href(&ctx, kind, item.number);
377 let state = app_context::<AppState>(cx);
378 let before = match item.milestone_id.as_deref() {
379 Some(id) => milestone_title(state, &ctx.repo.id, id).await,
380 None => None,
381 };
382 let result = state
383 .store
384 .set_issue_milestone(&item.id, &user.id, milestone)
385 .await;
386 if let Err(e) = result {
387 let message = refusal_message(e)?;
388 return Ok(view! { cx =>
389 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
390 action_error_at(lang: lang, message: message, back: back))
391 });
392 }
393 let after = match milestone {
394 Some(id) => milestone_title(state, &ctx.repo.id, id).await,
395 None => None,
396 };
397 let sender = Sender::of(&user);
398 let pull = matches!(kind, ItemKind::Pull);
399 emit_milestone_change(
400 state,
401 &ctx.repo,
402 &sender,
403 pull,
404 &item,
405 before.as_deref(),
406 after.as_deref(),
407 )
408 .await;
409 Err(see_other(back).into())
410}
411
412/// The title of a milestone in this repo. A lookup error or a missing row reads as none.
413async fn milestone_title(state: &AppState, repo_id: &str, milestone_id: &str) -> Option<String> {
414 match state.store.milestone_by_id(repo_id, milestone_id).await {
415 Ok(milestone) => milestone.map(|m| m.title),
416 Err(e) => {
417 tracing::error!(error = %e, milestone_id, "webhooks: milestone lookup failed");
418 None
419 }
420 }
421}