| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | use forge_core::User; |
| 9 | use forge_core::authz::{Action, authorize}; |
| 10 | use forge_core::issues::{Issue, Label}; |
| 11 | use topcoat::Result; |
| 12 | use topcoat::context::{Cx, app_context}; |
| 13 | use topcoat::router::content::RawForm; |
| 14 | use topcoat::router::error::{forbidden, not_found, see_other}; |
| 15 | use topcoat::router::{page, path_param}; |
| 16 | use topcoat::view::{View, component, view}; |
| 17 | |
| 18 | use crate::auth::require_csrf; |
| 19 | use crate::i18n::issues::{self as g, K}; |
| 20 | use crate::i18n::{self, Lang}; |
| 21 | use crate::notify; |
| 22 | use crate::pages::issue_parts::ItemKind; |
| 23 | use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; |
| 24 | use crate::state::AppState; |
| 25 | use crate::views::{alert, shell, user_facing_message}; |
| 26 | use crate::webhooks::emit::{ |
| 27 | Sender, emit_assignee_changes, emit_item_action, emit_label_changes, emit_milestone_change, |
| 28 | }; |
| 29 | |
| 30 | path_param!(owner); |
| 31 | path_param!(repo); |
| 32 | path_param!(number); |
| 33 | |
| 34 | |
| 35 | |
| 36 | pub(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 | |
| 43 | pub(crate) struct RepoFrame { |
| 44 | |
| 45 | pub title: String, |
| 46 | pub csrf: String, |
| 47 | pub archived: bool, |
| 48 | pub can_triage: bool, |
| 49 | |
| 50 | pub head: RepoHead, |
| 51 | } |
| 52 | |
| 53 | impl RepoFrame { |
| 54 | |
| 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 | |
| 73 | |
| 74 | pub(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 | |
| 85 | fn 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 | |
| 94 | #[component] |
| 95 | pub(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 | |
| 109 | pub(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 | |
| 116 | pub(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 | |
| 125 | pub(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 | |
| 134 | |
| 135 | pub(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 | |
| 153 | |
| 154 | pub(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 | |
| 178 | pub(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 | |
| 188 | #[component] |
| 189 | pub(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 | |
| 203 | #[page(POST "/{owner}/{repo}/issues/{number}/labels")] |
| 204 | async 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")] |
| 209 | async fn pull_labels_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> { |
| 210 | labels_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await |
| 211 | } |
| 212 | |
| 213 | async 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 | |
| 238 | #[page(POST "/{owner}/{repo}/issues/{number}/assignees")] |
| 239 | async 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")] |
| 244 | async fn pull_assignees_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> { |
| 245 | assignees_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await |
| 246 | } |
| 247 | |
| 248 | async 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 | |
| 286 | async 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 | |
| 297 | async 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 | |
| 311 | |
| 312 | #[page(POST "/{owner}/{repo}/issues/{number}/lock")] |
| 313 | async 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")] |
| 318 | async fn pull_lock_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> { |
| 319 | lock_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await |
| 320 | } |
| 321 | |
| 322 | async 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 | |
| 355 | #[page(POST "/{owner}/{repo}/issues/{number}/milestone")] |
| 356 | async 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")] |
| 361 | async fn pull_milestone_save(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> { |
| 362 | milestone_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await |
| 363 | } |
| 364 | |
| 365 | async 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 | |
| 413 | async 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 | } |