| 1 | //! Landing page and dashboard. | |
| 2 | ||
| 3 | use axum::extract::State; | |
| 4 | use axum::response::{IntoResponse, Response}; | |
| 5 | use chrono::{DateTime, Utc}; | |
| 6 | ||
| 7 | use crate::error::AppResult; | |
| 8 | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 9 | use crate::views::pages::{ | |
| 10 | ActivityItem, BookmarkItem, Dashboard, DashChange, FeedItem, RepoSummary, StackItem, | |
| 11 | }; | |
| 12 | use crate::views::{self, Chrome}; | |
| 13 | ||
| 14 | /// Repos the viewer can reach: their own, plus any they collaborate on, plus | |
| 15 | /// any belonging to an org they are a member of, plus public repos. | |
| 16 | /// | |
| 17 | /// Visibility is enforced *in* the query rather than by filtering afterwards — | |
| 18 | /// a post-filter is how private repos leak into listings. `$1` is the viewer's | |
| 19 | /// id and `$2` is whether they are an admin. | |
| 20 | const VISIBLE_TO_VIEWER: &str = r#" | |
| 21 | r.archived = false | |
| 22 | AND ( | |
| 23 | r.visibility = 'public' | |
| 24 | OR r.owner_user_id = $1 | |
| 25 | OR EXISTS (SELECT 1 FROM repo_collaborators c | |
| 26 | WHERE c.repo_id = r.id AND c.user_id = $1) | |
| 27 | OR EXISTS (SELECT 1 FROM org_members m | |
| 28 | WHERE m.org_id = r.owner_org_id AND m.user_id = $1) | |
| 29 | OR $2 | |
| 30 | ) | |
| 31 | "#; | |
| 32 | ||
| 33 | /// `/` — landing page when signed out, dashboard when signed in. | |
| 34 | /// | |
| 35 | /// Kept dual-mode rather than redirecting: every existing link and bookmark to | |
| 36 | /// `/` keeps working, and `/dashboard` below serves the same body for the | |
| 37 | /// design's explicit dashboard links. | |
| 38 | pub async fn index( | |
| 39 | State(state): State<AppState>, | |
| 40 | CurrentUser(user): CurrentUser, | |
| 41 | CsrfToken(csrf): CsrfToken, | |
| 42 | Nonce(nonce): Nonce, | |
| 43 | ) -> AppResult<Response> { | |
| 44 | let Some(user) = user else { | |
| 45 | let feed = public_feed(&state).await?; | |
| 46 | let repos = public_repos(&state).await?; | |
| 47 | let in_flight = public_in_flight(&state).await?; | |
| 48 | let bookmarks = public_bookmarks(&state).await?; | |
| 49 | ||
| 50 | // The clone line names a repository the visitor can actually clone | |
| 51 | // when there is one, and only falls back to a placeholder on an | |
| 52 | // instance with nothing public in it. | |
| 53 | let sample = repos.first().map(|r| format!("{}/{}", r.owner, r.name)); | |
| 54 | let hint = match repos.first() { | |
| 55 | Some(r) => state.config.https_clone_url(&r.owner, &r.name), | |
| 56 | None => state.config.https_clone_url("your-org", "your-repo"), | |
| 57 | }; | |
| 58 | ||
| 59 | return Ok(views::page_full( | |
| 60 | Chrome { title: "Dogfood", user: None, csrf: &csrf, nonce: &nonce }, | |
| 61 | views::pages::landing(views::pages::Landing { | |
| 62 | clone_hint: &format!("jj git clone {hint}"), | |
| 63 | sample_repo: sample.as_deref(), | |
| 64 | feed: &feed, | |
| 65 | repos: &repos, | |
| 66 | in_flight: &in_flight, | |
| 67 | bookmarks: &bookmarks, | |
| 68 | }), | |
| 69 | ) | |
| 70 | .into_response()); | |
| 71 | }; | |
| 72 | ||
| 73 | render_dashboard(&state, &user, &csrf, &nonce).await | |
| 74 | } | |
| 75 | ||
| 76 | /// `/dashboard` — the signed-in dashboard, addressable on its own. | |
| 77 | /// | |
| 78 | /// A signed-out visitor gets the landing page rather than a login redirect: the | |
| 79 | /// dashboard is not a secret, it is just empty without an account, and bouncing | |
| 80 | /// somebody to an SSO round trip to learn that is worse. | |
| 81 | pub async fn dashboard( | |
| 82 | State(state): State<AppState>, | |
| 83 | CurrentUser(user): CurrentUser, | |
| 84 | CsrfToken(csrf): CsrfToken, | |
| 85 | Nonce(nonce): Nonce, | |
| 86 | ) -> AppResult<Response> { | |
| 87 | match user { | |
| 88 | Some(user) => render_dashboard(&state, &user, &csrf, &nonce).await, | |
| 89 | None => Ok(axum::response::Redirect::to("/").into_response()), | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | async fn render_dashboard( | |
| 94 | state: &AppState, | |
| 95 | user: &df_db::models::User, | |
| 96 | csrf: &str, | |
| 97 | nonce: &str, | |
| 98 | ) -> AppResult<Response> { | |
| 99 | let repos = visible_repos(state, user).await?; | |
| 100 | let awaiting = awaiting_review(state, user).await?; | |
| 101 | let mine = my_open_changes(state, user).await?; | |
| 102 | let activity = watched_activity(state, user).await?; | |
| 103 | ||
| 104 | Ok(views::page( | |
| 105 | Chrome { title: "Dashboard", user: Some(user), csrf, nonce }, | |
| 106 | views::pages::dashboard(Dashboard { | |
| 107 | user, | |
| 108 | awaiting: &awaiting, | |
| 109 | mine: &mine, | |
| 110 | activity: &activity, | |
| 111 | repos: &repos, | |
| 112 | }), | |
| 113 | ) | |
| 114 | .into_response()) | |
| 115 | } | |
| 116 | ||
| 117 | // ─── queries ───────────────────────────────────────────────────────────────── | |
| 118 | ||
| 119 | type RepoRow = ( | |
| 120 | String, | |
| 121 | String, | |
| 122 | Option<String>, | |
| 123 | bool, | |
| 124 | i64, | |
| 125 | i64, | |
| 126 | Option<DateTime<Utc>>, | |
| 127 | ); | |
| 128 | ||
| 129 | fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> { | |
| 130 | rows.into_iter() | |
| 131 | .map( | |
| 132 | |(owner, name, description, private, open_changes, conflicted, pushed_at)| RepoSummary { | |
| 133 | owner, | |
| 134 | name, | |
| 135 | description, | |
| 136 | private, | |
| 137 | open_changes, | |
| 138 | conflicted, | |
| 139 | pushed_at, | |
| 140 | }, | |
| 141 | ) | |
| 142 | .collect() | |
| 143 | } | |
| 144 | ||
| 145 | /// The card's counts, as correlated subqueries. | |
| 146 | /// | |
| 147 | /// A `LEFT JOIN … GROUP BY` would need two conditional aggregates over the same | |
| 148 | /// join and would still have to handle the no-changes case; two scalar | |
| 149 | /// subqueries against `(repo_id, state)` are both cheaper and easier to read. | |
| 150 | const REPO_CARD_COUNTS: &str = r#" | |
| 151 | (SELECT count(*) FROM changes c | |
| 152 | WHERE c.repo_id = r.id AND c.state = 'open') AS open_changes, | |
| 153 | (SELECT count(*) FROM changes c | |
| 154 | WHERE c.repo_id = r.id AND c.state = 'open' AND c.conflicted) AS conflicted, | |
| 155 | r.pushed_at | |
| 156 | "#; | |
| 157 | ||
| 158 | async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> { | |
| 159 | let sql = format!( | |
| 160 | r#" | |
| 161 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 162 | r.name::text, | |
| 163 | r.description, | |
| 164 | (r.visibility = 'private') AS private, | |
| 165 | {REPO_CARD_COUNTS} | |
| 166 | FROM repos r | |
| 167 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 168 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 169 | WHERE {VISIBLE_TO_VIEWER} | |
| 170 | ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC | |
| 171 | LIMIT 50 | |
| 172 | "# | |
| 173 | ); | |
| 174 | ||
| 175 | let rows: Vec<RepoRow> = sqlx::query_as(&sql) | |
| 176 | .bind(user.id) | |
| 177 | .bind(user.is_admin) | |
| 178 | .fetch_all(&state.db) | |
| 179 | .await?; | |
| 180 | ||
| 181 | Ok(to_summaries(rows)) | |
| 182 | } | |
| 183 | ||
| 184 | /// Public repositories, for the signed-out landing page. | |
| 185 | async fn public_repos(state: &AppState) -> AppResult<Vec<RepoSummary>> { | |
| 186 | let rows: Vec<RepoRow> = sqlx::query_as(&format!( | |
| 187 | r#" | |
| 188 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 189 | r.name::text, | |
| 190 | r.description, | |
| 191 | false AS private, | |
| 192 | {REPO_CARD_COUNTS} | |
| 193 | FROM repos r | |
| 194 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 195 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 196 | WHERE r.archived = false AND r.visibility = 'public' | |
| 197 | ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC | |
| 198 | LIMIT 6 | |
| 199 | "# | |
| 200 | )) | |
| 201 | .fetch_all(&state.db) | |
| 202 | .await?; | |
| 203 | ||
| 204 | Ok(to_summaries(rows)) | |
| 205 | } | |
| 206 | ||
| 207 | /// Open public changes that are part of a stack — the landing page's | |
| 208 | /// "in flight now". | |
| 209 | /// | |
| 210 | /// "Part of a stack" is exactly "has an edge in `change_edges`": a change with | |
| 211 | /// a parent or a child is one somebody is building on or building from. A lone | |
| 212 | /// open change is work, but it is not a stack, and the panel is about the thing | |
| 213 | /// branches cannot represent. | |
| 214 | async fn public_in_flight(state: &AppState) -> AppResult<Vec<StackItem>> { | |
| 215 | /// `(owner, repo, number, change_id, synthetic, title, conflicted, updated_at)` | |
| 216 | type Row = (String, String, i64, String, bool, String, bool, DateTime<Utc>); | |
| 217 | ||
| 218 | let rows: Vec<Row> = sqlx::query_as( | |
| 219 | r#" | |
| 220 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 221 | r.name::text, | |
| 222 | c.number, | |
| 223 | c.change_id, | |
| 224 | c.synthetic, | |
| 225 | c.title, | |
| 226 | c.conflicted, | |
| 227 | c.updated_at | |
| 228 | FROM changes c | |
| 229 | JOIN repos r ON r.id = c.repo_id | |
| 230 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 231 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 232 | WHERE r.archived = false | |
| 233 | AND r.visibility = 'public' | |
| 234 | AND c.state = 'open' | |
| 235 | AND EXISTS ( | |
| 236 | SELECT 1 FROM change_edges e | |
| 237 | WHERE e.child_change = c.id OR e.parent_change = c.id | |
| 238 | ) | |
| 239 | ORDER BY c.updated_at DESC | |
| 240 | LIMIT 4 | |
| 241 | "#, | |
| 242 | ) | |
| 243 | .fetch_all(&state.db) | |
| 244 | .await?; | |
| 245 | ||
| 246 | Ok(rows | |
| 247 | .into_iter() | |
| 248 | .map( | |
| 249 | |(owner, repo, number, change_id, synthetic, title, conflicted, when)| StackItem { | |
| 250 | owner, | |
| 251 | repo, | |
| 252 | number, | |
| 253 | change_id, | |
| 254 | synthetic, | |
| 255 | title, | |
| 256 | conflicted, | |
| 257 | when, | |
| 258 | }, | |
| 259 | ) | |
| 260 | .collect()) | |
| 261 | } | |
| 262 | ||
| 263 | /// Recently moved bookmarks across public repositories. | |
| 264 | async fn public_bookmarks(state: &AppState) -> AppResult<Vec<BookmarkItem>> { | |
| 265 | let rows: Vec<(String, String, String, bool, DateTime<Utc>)> = sqlx::query_as( | |
| 266 | r#" | |
| 267 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 268 | r.name::text, | |
| 269 | b.name AS bookmark, | |
| 270 | b.protected, | |
| 271 | b.updated_at | |
| 272 | FROM bookmarks b | |
| 273 | JOIN repos r ON r.id = b.repo_id | |
| 274 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 275 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 276 | WHERE r.archived = false AND r.visibility = 'public' | |
| 277 | ORDER BY b.updated_at DESC | |
| 278 | LIMIT 5 | |
| 279 | "#, | |
| 280 | ) | |
| 281 | .fetch_all(&state.db) | |
| 282 | .await?; | |
| 283 | ||
| 284 | Ok(rows | |
| 285 | .into_iter() | |
| 286 | .map(|(owner, repo, name, protected, updated_at)| BookmarkItem { | |
| 287 | owner, | |
| 288 | repo, | |
| 289 | name, | |
| 290 | protected, | |
| 291 | updated_at, | |
| 292 | }) | |
| 293 | .collect()) | |
| 294 | } | |
| 295 | ||
| 296 | /// The "shipping right now" feed. | |
| 297 | /// | |
| 298 | /// Public repositories only, and no drafts: this renders for anonymous | |
| 299 | /// visitors, so anything it can reach is world-readable by definition. | |
| 300 | /// | |
| 301 | /// Driven by the event log rather than by `changes.updated_at`, so each row can | |
| 302 | /// state what happened. The join to `changes` is an inner join on | |
| 303 | /// `subject_type = 'change'`, which also drops repository- and bookmark-scoped | |
| 304 | /// events — the feed is about work, and "somebody renamed a bookmark" is not | |
| 305 | /// what a visitor came to see. | |
| 306 | async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> { | |
| 307 | /// `(owner, repo, number, change_id, synthetic, title, actor, author_name, | |
| 308 | /// kind, created_at)` | |
| 309 | type Row = ( | |
| 310 | String, | |
| 311 | String, | |
| 312 | i64, | |
| 313 | String, | |
| 314 | bool, | |
| 315 | String, | |
| 316 | Option<String>, | |
| 317 | Option<String>, | |
| 318 | String, | |
| 319 | DateTime<Utc>, | |
| 320 | ); | |
| 321 | ||
| 322 | let rows: Vec<Row> = sqlx::query_as( | |
| 323 | // `hr.author_name` is the fallback when no account matched the commit's | |
| 324 | // email — the person is still known, just not linkable. | |
| 325 | r#" | |
| 326 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 327 | r.name::text, | |
| 328 | c.number, | |
| 329 | c.change_id, | |
| 330 | c.synthetic, | |
| 331 | c.title, | |
| 332 | ac.handle AS actor, | |
| 333 | hr.author_name, | |
| 334 | e.kind, | |
| 335 | e.created_at | |
| 336 | FROM events e | |
| 337 | JOIN changes c ON c.id = e.subject_id AND e.subject_type = 'change' | |
| 338 | JOIN repos r ON r.id = e.repo_id | |
| 339 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 340 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 341 | LEFT JOIN users ac ON ac.id = e.actor_id | |
| 342 | LEFT JOIN revisions hr ON hr.id = c.head_revision_id | |
| 343 | WHERE r.archived = false | |
| 344 | AND r.visibility = 'public' | |
| 345 | AND c.state <> 'draft' | |
| 346 | ORDER BY e.created_at DESC | |
| 347 | LIMIT 12 | |
| 348 | "#, | |
| 349 | ) | |
| 350 | .fetch_all(&state.db) | |
| 351 | .await?; | |
| 352 | ||
| 353 | Ok(rows | |
| 354 | .into_iter() | |
| 355 | .map( | |
| 356 | |(owner, repo, number, change_id, synthetic, title, actor, actor_name, kind, when)| { | |
| 357 | FeedItem { | |
| 358 | owner, | |
| 359 | repo, | |
| 360 | number, | |
| 361 | change_id, | |
| 362 | synthetic, | |
| 363 | title, | |
| 364 | actor, | |
| 365 | actor_name, | |
| 366 | kind, | |
| 367 | when, | |
| 368 | } | |
| 369 | }, | |
| 370 | ) | |
| 371 | .collect()) | |
| 372 | } | |
| 373 | ||
| 374 | type ChangeRow = ( | |
| 375 | String, | |
| 376 | String, | |
| 377 | i64, | |
| 378 | String, | |
| 379 | bool, | |
| 380 | String, | |
| 381 | String, | |
| 382 | bool, | |
| 383 | Option<String>, | |
| 384 | Option<String>, | |
| 385 | DateTime<Utc>, | |
| 386 | ); | |
| 387 | ||
| 388 | fn to_dash_changes(rows: Vec<ChangeRow>) -> Vec<DashChange> { | |
| 389 | rows.into_iter() | |
| 390 | .map( | |
| 391 | |( | |
| 392 | owner, | |
| 393 | repo, | |
| 394 | number, | |
| 395 | change_id, | |
| 396 | synthetic, | |
| 397 | title, | |
| 398 | state, | |
| 399 | conflicted, | |
| 400 | author, | |
| 401 | author_name, | |
| 402 | updated_at, | |
| 403 | )| { | |
| 404 | DashChange { | |
| 405 | owner, | |
| 406 | repo, | |
| 407 | number, | |
| 408 | change_id, | |
| 409 | synthetic, | |
| 410 | title, | |
| 411 | state, | |
| 412 | conflicted, | |
| 413 | author, | |
| 414 | author_name, | |
| 415 | updated_at, | |
| 416 | } | |
| 417 | }, | |
| 418 | ) | |
| 419 | .collect() | |
| 420 | } | |
| 421 | ||
| 422 | const CHANGE_COLUMNS: &str = r#" | |
| 423 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 424 | r.name::text, | |
| 425 | c.number, | |
| 426 | c.change_id, | |
| 427 | c.synthetic, | |
| 428 | c.title, | |
| 429 | c.state::text, | |
| 430 | c.conflicted, | |
| 431 | au.handle AS author, | |
| 432 | hr.author_name, | |
| 433 | c.updated_at | |
| 434 | FROM changes c | |
| 435 | JOIN repos r ON r.id = c.repo_id | |
| 436 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 437 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 438 | LEFT JOIN users au ON au.id = c.author_user_id | |
| 439 | LEFT JOIN revisions hr ON hr.id = c.head_revision_id | |
| 440 | "#; | |
| 441 | ||
| 442 | /// Open changes waiting on this viewer. | |
| 443 | /// | |
| 444 | /// There is no "requested reviewers" table — `reviews` records verdicts that | |
| 445 | /// were *given*, not ones that were asked for. So "awaiting your review" is | |
| 446 | /// derived: an open change in a repo you can reach, that you did not write, and | |
| 447 | /// that you have not reviewed at its current head revision. Re-reviewing is | |
| 448 | /// therefore prompted whenever the author pushes again, which is the behaviour | |
| 449 | /// a reviewer wants. | |
| 450 | async fn awaiting_review( | |
| 451 | state: &AppState, | |
| 452 | user: &df_db::models::User, | |
| 453 | ) -> AppResult<Vec<DashChange>> { | |
| 454 | let sql = format!( | |
| 455 | r#" | |
| 456 | {CHANGE_COLUMNS} | |
| 457 | WHERE {VISIBLE_TO_VIEWER} | |
| 458 | AND c.state = 'open' | |
| 459 | AND c.author_user_id IS DISTINCT FROM $1 | |
| 460 | AND NOT EXISTS ( | |
| 461 | SELECT 1 FROM reviews rv | |
| 462 | WHERE rv.change_id_fk = c.id | |
| 463 | AND rv.reviewer_id = $1 | |
| 464 | AND rv.revision_id = c.head_revision_id | |
| 465 | ) | |
| 466 | ORDER BY c.updated_at DESC | |
| 467 | LIMIT 10 | |
| 468 | "# | |
| 469 | ); | |
| 470 | ||
| 471 | let rows: Vec<ChangeRow> = sqlx::query_as(&sql) | |
| 472 | .bind(user.id) | |
| 473 | .bind(user.is_admin) | |
| 474 | .fetch_all(&state.db) | |
| 475 | .await?; | |
| 476 | ||
| 477 | Ok(to_dash_changes(rows)) | |
| 478 | } | |
| 479 | ||
| 480 | async fn my_open_changes( | |
| 481 | state: &AppState, | |
| 482 | user: &df_db::models::User, | |
| 483 | ) -> AppResult<Vec<DashChange>> { | |
| 484 | let sql = format!( | |
| 485 | r#" | |
| 486 | {CHANGE_COLUMNS} | |
| 487 | WHERE {VISIBLE_TO_VIEWER} | |
| 488 | AND c.author_user_id = $1 | |
| 489 | AND c.state IN ('open', 'draft') | |
| 490 | ORDER BY c.updated_at DESC | |
| 491 | LIMIT 10 | |
| 492 | "# | |
| 493 | ); | |
| 494 | ||
| 495 | let rows: Vec<ChangeRow> = sqlx::query_as(&sql) | |
| 496 | .bind(user.id) | |
| 497 | .bind(user.is_admin) | |
| 498 | .fetch_all(&state.db) | |
| 499 | .await?; | |
| 500 | ||
| 501 | Ok(to_dash_changes(rows)) | |
| 502 | } | |
| 503 | ||
| 504 | /// Recent events across every repository the viewer can reach. | |
| 505 | async fn watched_activity( | |
| 506 | state: &AppState, | |
| 507 | user: &df_db::models::User, | |
| 508 | ) -> AppResult<Vec<ActivityItem>> { | |
| 509 | let sql = format!( | |
| 510 | r#" | |
| 511 | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 512 | r.name::text, | |
| 513 | ac.handle AS actor, | |
| 514 | e.kind, | |
| 515 | ch.number, | |
| 516 | ch.title, | |
| 517 | e.created_at | |
| 518 | FROM events e | |
| 519 | JOIN repos r ON r.id = e.repo_id | |
| 520 | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 521 | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 522 | LEFT JOIN users ac ON ac.id = e.actor_id | |
| 523 | LEFT JOIN changes ch | |
| 524 | ON ch.id = e.subject_id AND e.subject_type = 'change' | |
| 525 | WHERE {VISIBLE_TO_VIEWER} | |
| 526 | ORDER BY e.created_at DESC | |
| 527 | LIMIT 15 | |
| 528 | "# | |
| 529 | ); | |
| 530 | ||
| 531 | let rows: Vec<( | |
| 532 | String, | |
| 533 | String, | |
| 534 | Option<String>, | |
| 535 | String, | |
| 536 | Option<i64>, | |
| 537 | Option<String>, | |
| 538 | DateTime<Utc>, | |
| 539 | )> = sqlx::query_as(&sql) | |
| 540 | .bind(user.id) | |
| 541 | .bind(user.is_admin) | |
| 542 | .fetch_all(&state.db) | |
| 543 | .await?; | |
| 544 | ||
| 545 | Ok(rows | |
| 546 | .into_iter() | |
| 547 | .map( | |
| 548 | |(owner, repo, actor, kind, change_number, change_title, when)| ActivityItem { | |
| 549 | owner, | |
| 550 | repo, | |
| 551 | actor, | |
| 552 | kind, | |
| 553 | change_number, | |
| 554 | change_title, | |
| 555 | when, | |
| 556 | }, | |
| 557 | ) | |
| 558 | .collect()) | |
| 559 | } |
559 lines · Rust