diff --git a/src/gh_comments.rs b/src/gh_comments.rs new file mode 100644 index 00000000..2016c7fe --- /dev/null +++ b/src/gh_comments.rs @@ -0,0 +1,346 @@ +use std::collections::VecDeque; +use std::fmt::Write; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::Context as _; +use axum::{ + extract::{Path, State}, + http::HeaderValue, + response::{IntoResponse, Response}, +}; +use chrono::Utc; +use hyper::{ + HeaderMap, StatusCode, + header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE}, +}; + +use crate::github::{GitHubGraphQlComment, GitHubIssueWithComments}; +use crate::{ + errors::AppError, + github::GitHubSimplifiedAuthor, + handlers::Context, + utils::{immutable_headers, is_repo_autorized}, +}; + +pub const STYLE_URL: &str = "/gh-comments/style@0.0.1.css"; +pub const MARKDOWN_URL: &str = "/gh-comments/github-markdown@5.8.1.css"; + +const MAX_CACHE_CAPACITY_BYTES: u64 = 35 * 1024 * 1024; // 35 Mb + +type CacheKey = (String, String, u64); + +#[derive(Default)] +pub struct GitHubCommentsCache { + capacity: u64, + entries: VecDeque<(CacheKey, Arc)>, +} + +pub struct CachedComments { + estimated_size: usize, + duration_secs: f64, + issue_with_comments: GitHubIssueWithComments, +} + +impl GitHubCommentsCache { + pub fn get(&mut self, key: &CacheKey) -> Option> { + if let Some(pos) = self.entries.iter().position(|(k, _)| k == key) { + // Move previously cached entry to the front + let entry = self.entries.remove(pos).unwrap(); + self.entries.push_front(entry.clone()); + Some(entry.1) + } else { + None + } + } + + pub fn put(&mut self, key: CacheKey, value: Arc) -> Arc { + if value.estimated_size as u64 > MAX_CACHE_CAPACITY_BYTES { + // Entry is too large, don't cache, return as is + return value; + } + + // Remove duplicate or last entry when necessary + let removed = if let Some(pos) = self.entries.iter().position(|(k, _)| k == &key) { + self.entries.remove(pos) + } else if self.capacity + value.estimated_size as u64 >= MAX_CACHE_CAPACITY_BYTES { + self.entries.pop_back() + } else { + None + }; + if let Some(removed) = removed { + self.capacity -= removed.1.estimated_size as u64; + } + + // Add entry the front of the list and return it + self.capacity += value.estimated_size as u64; + self.entries.push_front((key, value.clone())); + value + } + + pub fn prune(&mut self, key: &CacheKey) -> bool { + if let Some(pos) = self.entries.iter().position(|(k, _)| k == key) { + self.entries.remove(pos); + true + } else { + false + } + } +} + +pub async fn gh_comments( + Path(ref key @ (ref owner, ref repo, issue_id)): Path<(String, String, u64)>, + State(ctx): State>, +) -> axum::response::Result { + if !is_repo_autorized(&ctx, &owner, &repo).await? { + return Ok(( + StatusCode::UNAUTHORIZED, + format!("repository `{owner}/{repo}` is not part of the Rust Project team repos"), + ) + .into_response()); + } + + let CachedComments { + estimated_size: _, + duration_secs, + issue_with_comments, + } = &*'comments: { + if let Some(logs) = ctx.gh_comments.write().await.get(&key) { + tracing::info!("gh_comments: cache hit for issue #{issue_id}"); + break 'comments logs; + } + + tracing::info!("gh_comments: cache miss for issue #{issue_id}"); + + let start = Instant::now(); + + let issue_with_comments = ctx + .github + .issue_with_comments(&owner, &repo, issue_id) + .await + .context("unable to fetch the issue and it's comments")?; + + let duration = start.elapsed(); + let duration_secs = duration.as_secs_f64(); + + // Rough estimation of the byte size of the issue with comments + let estimated_size: usize = std::mem::size_of::() + + issue_with_comments.url.len() + + issue_with_comments.title.len() + + issue_with_comments.body_html.len() + + issue_with_comments.title_html.len() + + issue_with_comments + .comments + .nodes + .iter() + .map(|c| { + std::mem::size_of::() + + c.url.len() + + c.body_html.len() + + c.author.login.len() + + c.author.avatar_url.len() + }) + .sum::(); + + ctx.gh_comments.write().await.put( + key.clone(), + CachedComments { + estimated_size, + duration_secs, + issue_with_comments, + } + .into(), + ) + }; + + let comment_count = issue_with_comments.comments.nodes.len(); + + let mut title = String::new(); + pulldown_cmark_escape::escape_html(&mut title, &issue_with_comments.title)?; + + let title_html = &issue_with_comments.title_html; + + let mut html = String::new(); + + writeln!( + html, + r###" + + + + + {title} - #{issue_id} + + + + + + +
+

{title_html} #{issue_id}

+

{comment_count} comments loaded in {duration_secs:.2}s

+"###, + ) + .unwrap(); + + write_comment_as_html( + &mut html, + &issue_with_comments.body_html, + &issue_with_comments.url, + &issue_with_comments.author, + &issue_with_comments.created_at, + &issue_with_comments.updated_at, + false, + None, + )?; + + for comment in &issue_with_comments.comments.nodes { + write_comment_as_html( + &mut html, + &comment.body_html, + &comment.url, + &comment.author, + &comment.created_at, + &comment.updated_at, + comment.is_minimized, + comment.minimized_reason.as_deref(), + )?; + } + + writeln!(html, r###"
"###).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=30"), + ); + headers.insert( + CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'none'; script-src 'nonce-triagebot-gh-comments'; style-src 'self'; img-src *", + ), + ); + + Ok((StatusCode::OK, headers, html).into_response()) +} + +pub async fn style_css() -> impl IntoResponse { + const STYLE_CSS: &str = include_str!("gh_comments/style.css"); + + (immutable_headers("text/css; charset=utf-8"), STYLE_CSS) +} + +pub async fn markdown_css() -> impl IntoResponse { + const MARKDOWN_CSS: &str = include_str!("gh_comments/github-markdown@5.8.1.css"); + + (immutable_headers("text/css; charset=utf-8"), MARKDOWN_CSS) +} + +fn write_comment_as_html( + buffer: &mut String, + body_html: &str, + comment_url: &str, + author: &GitHubSimplifiedAuthor, + created_at: &chrono::DateTime, + updated_at: &chrono::DateTime, + minimized: bool, + minimized_reason: Option<&str>, +) -> anyhow::Result<()> { + let author_login = &author.login; + let author_avatar_url = &author.avatar_url; + let created_at_rfc3339 = created_at.to_rfc3339(); + + if minimized && let Some(minimized_reason) = minimized_reason { + writeln!( + buffer, + r###" +
+ + {author_login} Avatar + + +
+ +
+ {author_login} + on {created_at} · hidden as {minimized_reason} +
+ +
+ + {author_login} Avatar + +
+ {author_login} + on {created_at} · hidden as {minimized_reason} +
+
+ + View on GitHub +
+ +
+ {body_html} +
+
+
+"### + )?; + } else { + let edited = if created_at != updated_at { + " · edited" + } else { + "" + }; + + writeln!( + buffer, + r###" +
+ + {author_login} Avatar + + +
+
+
+ {author_login} + on {created_at}{edited} +
+ +
+ + {author_login} Avatar + +
+ {author_login} + on {created_at}{edited} +
+
+ + View on GitHub +
+ +
+ {body_html} +
+
+
+"### + )?; + } + + Ok(()) +} diff --git a/src/gh_comments/github-markdown@5.8.1.css b/src/gh_comments/github-markdown@5.8.1.css new file mode 100644 index 00000000..bc8e12bb --- /dev/null +++ b/src/gh_comments/github-markdown@5.8.1.css @@ -0,0 +1,1228 @@ +.markdown-body { + --base-size-4: 0.25rem; + --base-size-8: 0.5rem; + --base-size-16: 1rem; + --base-size-24: 1.5rem; + --base-size-40: 2.5rem; + --base-text-weight-normal: 400; + --base-text-weight-medium: 500; + --base-text-weight-semibold: 600; + --fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + --fgColor-accent: Highlight; +} +@media (prefers-color-scheme: dark) { + .markdown-body, [data-theme="dark"] { + /* dark */ + color-scheme: dark; + --focus-outlineColor: #1f6feb; + --fgColor-default: #f0f6fc; + --fgColor-muted: #9198a1; + --fgColor-accent: #4493f8; + --fgColor-success: #3fb950; + --fgColor-attention: #d29922; + --fgColor-danger: #f85149; + --fgColor-done: #ab7df8; + --bgColor-default: #0d1117; + --bgColor-muted: #151b23; + --bgColor-neutral-muted: #656c7633; + --bgColor-attention-muted: #bb800926; + --borderColor-default: #3d444d; + --borderColor-muted: #3d444db3; + --borderColor-neutral-muted: #3d444db3; + --borderColor-accent-emphasis: #1f6feb; + --borderColor-success-emphasis: #238636; + --borderColor-attention-emphasis: #9e6a03; + --borderColor-danger-emphasis: #da3633; + --borderColor-done-emphasis: #8957e5; + --color-prettylights-syntax-comment: #9198a1; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #f0f6fc; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-brackethighlighter-angle: #9198a1; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #f0f6fc; + --color-prettylights-syntax-markup-bold: #f0f6fc; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #f0f6fc; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-sublimelinter-gutter-mark: #3d444d; + } +} +@media (prefers-color-scheme: light) { + .markdown-body, [data-theme="light"] { + /* light */ + color-scheme: light; + --focus-outlineColor: #0969da; + --fgColor-default: #1f2328; + --fgColor-muted: #59636e; + --fgColor-accent: #0969da; + --fgColor-success: #1a7f37; + --fgColor-attention: #9a6700; + --fgColor-danger: #d1242f; + --fgColor-done: #8250df; + --bgColor-default: #ffffff; + --bgColor-muted: #f6f8fa; + --bgColor-neutral-muted: #818b981f; + --bgColor-attention-muted: #fff8c5; + --borderColor-default: #d1d9e0; + --borderColor-muted: #d1d9e0b3; + --borderColor-neutral-muted: #d1d9e0b3; + --borderColor-accent-emphasis: #0969da; + --borderColor-success-emphasis: #1a7f37; + --borderColor-attention-emphasis: #9a6700; + --borderColor-danger-emphasis: #cf222e; + --borderColor-done-emphasis: #8250df; + --color-prettylights-syntax-comment: #59636e; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-prettylights-syntax-entity: #6639ba; + --color-prettylights-syntax-storage-modifier-import: #1f2328; + --color-prettylights-syntax-entity-tag: #0550ae; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-brackethighlighter-angle: #59636e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #1f2328; + --color-prettylights-syntax-markup-bold: #1f2328; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #d1d9e0; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-sublimelinter-gutter-mark: #818b98; + } +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: var(--fgColor-default); + background-color: var(--bgColor-default); + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--fgColor-accent); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body mark { + background-color: var(--bgColor-attention-muted); + color: var(--fgColor-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em var(--base-size-40); +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--borderColor-muted); + height: .25em; + padding: 0; + margin: var(--base-size-24) 0; + background-color: var(--borderColor-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--fgColor-muted); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + font-variant: tabular-nums; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: var(--base-size-4); + font: 11px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + line-height: 10px; + color: var(--fgColor-default); + vertical-align: middle; + background-color: var(--bgColor-muted); + border: solid 1px var(--borderColor-neutral-muted); + border-bottom-color: var(--borderColor-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--borderColor-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: var(--base-size-24); + margin-bottom: var(--base-size-16); + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; +} + +.markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; +} + +.markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--fgColor-muted); +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--fgColor-muted); + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: var(--base-size-8, 8px) !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--fgColor-danger); +} + +.markdown-body .anchor { + float: left; + padding-right: var(--base-size-4); + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: var(--base-size-16); +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--fgColor-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: var(--base-size-16); +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: var(--base-size-16); + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 var(--base-size-16); + margin-bottom: var(--base-size-16); +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--borderColor-default); +} + +.markdown-body table td>:last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: var(--bgColor-default); + border-top: 1px solid var(--borderColor-muted); +} + +.markdown-body table tr:nth-child(2n) { + background-color: var(--bgColor-muted); +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--borderColor-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--fgColor-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--bgColor-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: var(--base-size-16); +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: var(--base-size-16); + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: var(--fgColor-default); + background-color: var(--bgColor-muted); + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px var(--base-size-8) 9px; + text-align: right; + background: var(--bgColor-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--bgColor-muted); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--fgColor-muted); + border-top: 1px solid var(--borderColor-default); +} + +.markdown-body .footnotes ol { + padding-left: var(--base-size-16); +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: var(--base-size-16); + margin-top: var(--base-size-16); +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: calc(var(--base-size-8)*-1); + right: calc(var(--base-size-8)*-1); + bottom: calc(var(--base-size-8)*-1); + left: calc(var(--base-size-24)*-1); + pointer-events: none; + content: ""; + border: 2px solid var(--borderColor-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--fgColor-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body body:has(:modal) { + padding-right: var(--dialog-scrollgutter) !important; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), +.markdown-body button:focus:not(:focus-visible), +.markdown-body summary:focus:not(:focus-visible), +.markdown-body a:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.markdown-body [tabindex="0"]:focus:not(:focus-visible), +.markdown-body details-dialog:focus:not(:focus-visible) { + outline: none; +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: var(--base-size-4); +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body ul:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body ol:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: var(--base-size-8) var(--base-size-16); + margin-bottom: var(--base-size-16); + color: inherit; + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body .markdown-alert>:first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert>:last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: var(--borderColor-accent-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--fgColor-accent); +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: var(--borderColor-done-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--fgColor-done); +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: var(--borderColor-attention-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--fgColor-attention); +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: var(--borderColor-success-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--fgColor-success); +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: var(--borderColor-danger-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--fgColor-danger); +} + +.markdown-body>*:first-child>.heading-element:first-child { + margin-top: 0 !important; +} + +.markdown-body .highlight pre:has(+.zeroclipboard-container) { + min-height: 52px; +} + diff --git a/src/gh_comments/style.css b/src/gh_comments/style.css new file mode 100644 index 00000000..1626564e --- /dev/null +++ b/src/gh_comments/style.css @@ -0,0 +1,149 @@ +/* === Theme variables === */ +:root { + --bg-default: #ffffff; + --bg-muted: #f6f8fa; + --fg-default: #24292f; + --fg-muted: #57606a; + --fg-accent: #0969da; + --border-default: #d0d7de; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg-default: #0d1117; + --bg-muted: #161b22; + --fg-default: #e6edf3; + --fg-muted: #8b949e; + --fg-accent: #58a6ff; + --border-default: #30363d; + } +} + +/* === Base layout === */ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--bg-default); + color: var(--fg-default); + margin: 20px; + display: flex; + justify-content: center; +} + +.comments-container { + width: 100%; + max-width: 980px; +} + +.title { + font-size: 28px; + margin-bottom: 15px; +} + +/* === Comment box === */ +.comment-wrapper { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 24px; +} + +.comment { + background: var(--bg-default); + border: 1px solid var(--border-default); + border-radius: 6px; + flex: 1; + overflow: hidden; +} + +.comment-header { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-muted); + border-bottom: 1px solid var(--border-default); + padding: 8px 12px; + font-size: 0.9em; + color: var(--fg-muted); +} + +details:not([open]) > .comment-header { + border-bottom: 0px; +} + +.comment-body { + padding: 16px; +} + +/* === Avatars and author info === */ +.avatar-link { + display: block; + flex-shrink: 0; + margin-top: 6px; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + display: block; +} + +.author-info { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.author-info a { + color: var(--fg-default); + font-weight: 600; + text-decoration: none; +} + +.author-info a:hover { + text-decoration: underline; +} + +.github-link { + font-size: 0.85em; + color: var(--fg-muted); + text-decoration: none; +} + +.github-link:hover { + text-decoration: underline; +} + +/* === Markdown overrides === */ +.markdown-body .user-mention { + color: var(--fg-default); + font-weight: 600; + text-decoration: underline; +} + +/* === Responsive === */ +@media (max-width: 640px) { + /* mobile layout */ + .author-info { + display: flex; + flex-direction: column; + line-height: 1.3; + } + + .author-mobile { + display: flex; + align-items: flex-start; + gap: 8px; + } + + .desktop { + display: none; + } +} + +@media (min-width: 641px) { + .author-mobile { + display: none !important; + } +} diff --git a/src/gha_logs.rs b/src/gha_logs.rs index b5f20e6d..0051ad8e 100644 --- a/src/gha_logs.rs +++ b/src/gha_logs.rs @@ -2,12 +2,12 @@ use crate::errors::AppError; use crate::github::{self, WorkflowRunJob}; use crate::handlers::Context; use crate::interactions::REPORT_TO; -use crate::utils::is_repo_autorized; +use crate::utils::{immutable_headers, is_repo_autorized}; use anyhow::Context as _; use axum::extract::{Path, State}; use axum::http::HeaderValue; use axum::response::IntoResponse; -use hyper::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE}; +use hyper::header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE}; use hyper::{HeaderMap, StatusCode}; use std::collections::VecDeque; use std::sync::Arc; @@ -356,15 +356,3 @@ pub async fn failure_svg() -> impl IntoResponse { FAILURE_SVG, ) } - -fn immutable_headers(content_type: &'static str) -> HeaderMap { - let mut headers = HeaderMap::new(); - - headers.insert( - CACHE_CONTROL, - HeaderValue::from_static("public, max-age=15552000, immutable"), - ); - headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); - - headers -} diff --git a/src/github.rs b/src/github.rs index 67cc641f..d0f7f94d 100644 --- a/src/github.rs +++ b/src/github.rs @@ -2914,6 +2914,144 @@ impl GithubClient { anyhow::bail!("expected issue count, got {data}"); } } + + pub async fn issue_with_comments( + &self, + owner: &str, + repo: &str, + issue: u64, + ) -> anyhow::Result { + let mut cursor: Option = None; + let mut all_comments = Vec::new(); + let mut issue_json; + + loop { + let mut data = self + .graphql_query( + " +query ($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + issue(number: $issueNumber) { + url + title + titleHTML + bodyHTML + createdAt + updatedAt + author { + login + avatarUrl + } + comments(first: 100, after: $cursor) { + nodes { + author { + login + avatarUrl + } + createdAt + updatedAt + isMinimized + minimizedReason + bodyHTML + url + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} + ", + serde_json::json!({ + "owner": owner, + "repo": repo, + "issueNumber": issue, + "cursor": cursor.as_deref(), + }), + ) + .await + .context("failed to fetch the issue with comments")?; + + issue_json = data["data"]["repository"]["issue"].take(); + + let has_next_page = issue_json["comments"]["pageInfo"]["hasNextPage"] + .as_bool() + .unwrap_or(false); + let end_cursor = issue_json["comments"]["pageInfo"]["endCursor"] + .as_str() + .map(|s| s.to_string()); + + // Early return if first page has no more pages (1 API call) + if all_comments.is_empty() && !has_next_page { + return serde_json::from_value(issue_json) + .context("fail to deserialize the GraphQl json response"); + } + + // Store cursor for next iteration + cursor = end_cursor; + + // Extract and accumulate comments (avoid full deserialization) + if let Some(comments_array) = issue_json["comments"]["nodes"].as_array_mut() { + all_comments.append(comments_array); + } + + if !has_next_page { + break; + } + } + + // Reconstruct final result with all comments + let mut final_issue = issue_json; + final_issue["comments"]["nodes"] = serde_json::Value::Array(all_comments); + + serde_json::from_value(final_issue).context("fail to deserialize final response") + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct GitHubIssueWithComments { + pub title: String, + #[serde(rename = "titleHTML")] + pub title_html: String, + #[serde(rename = "bodyHTML")] + pub body_html: String, + pub url: String, + pub author: GitHubSimplifiedAuthor, + #[serde(rename = "createdAt")] + pub created_at: chrono::DateTime, + #[serde(rename = "updatedAt")] + pub updated_at: chrono::DateTime, + pub comments: GitHubGraphQlComments, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct GitHubSimplifiedAuthor { + pub login: String, + #[serde(rename = "avatarUrl")] + pub avatar_url: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct GitHubGraphQlComments { + pub nodes: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct GitHubGraphQlComment { + pub author: GitHubSimplifiedAuthor, + #[serde(rename = "createdAt")] + pub created_at: chrono::DateTime, + #[serde(rename = "updatedAt")] + pub updated_at: chrono::DateTime, + #[serde(rename = "isMinimized")] + pub is_minimized: bool, + #[serde(rename = "minimizedReason")] + pub minimized_reason: Option, + #[serde(rename = "bodyHTML")] + pub body_html: String, + pub url: String, } #[derive(Debug, serde::Deserialize)] diff --git a/src/handlers.rs b/src/handlers.rs index 75f27e82..f7465745 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,4 +1,5 @@ use crate::config::{self, Config, ConfigurationError}; +use crate::gh_comments::GitHubCommentsCache; use crate::gha_logs::GitHubActionLogsCache; use crate::github::{Event, GithubClient, IssueCommentAction, IssuesAction, IssuesEvent}; use crate::handlers::pr_tracking::ReviewerWorkqueue; @@ -56,6 +57,7 @@ pub struct Context { /// tokio's `RwLock` is used to avoid deadlocks, since we run on a single-threaded tokio runtime. pub workqueue: Arc>, pub gha_logs: Arc>, + pub gh_comments: Arc>, } #[expect( @@ -77,6 +79,23 @@ pub async fn handle(ctx: &Context, host: &str, event: &Event) -> Vec Vec Vec Vec anyhow::Result<()> { octocrab: oc, workqueue: Arc::new(RwLock::new(workqueue)), gha_logs: Arc::new(RwLock::new(GitHubActionLogsCache::default())), + gh_comments: Arc::new(RwLock::new(GitHubCommentsCache::default())), zulip, }); @@ -198,6 +200,10 @@ async fn run_server(addr: SocketAddr) -> anyhow::Result<()> { "/gh-changes-since/{owner}/{repo}/{pr}/{oldbasehead}", get(triagebot::gh_changes_since::gh_changes_since), ) + .route( + "/gh-comments/{owner}/{repo}/{pr}", + get(triagebot::gh_comments::gh_comments), + ) .layer(GovernorLayer::new(ratelimit_config)); let app = Router::new() @@ -220,6 +226,14 @@ async fn run_server(addr: SocketAddr) -> anyhow::Result<()> { triagebot::gha_logs::FAILURE_URL, get(triagebot::gha_logs::failure_svg), ) + .route( + triagebot::gh_comments::STYLE_URL, + get(triagebot::gh_comments::style_css), + ) + .route( + triagebot::gh_comments::MARKDOWN_URL, + get(triagebot::gh_comments::markdown_css), + ) .merge(protected) .nest("/agenda", agenda) .route("/bors-commit-list", get(triagebot::bors::bors_commit_list)) diff --git a/src/tests/mod.rs b/src/tests/mod.rs index d3525873..906bb3de 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -83,6 +83,7 @@ impl TestContext { octocrab, workqueue: Arc::new(RwLock::new(Default::default())), gha_logs: Arc::new(RwLock::new(Default::default())), + gh_comments: Arc::new(RwLock::new(Default::default())), }; Self { diff --git a/src/utils.rs b/src/utils.rs index cd78c854..45625b29 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,11 @@ use crate::handlers::Context; use anyhow::Context as _; +use axum::http::HeaderValue; +use hyper::{ + HeaderMap, + header::{CACHE_CONTROL, CONTENT_TYPE}, +}; use std::borrow::Cow; /// Pluralize (add an 's' sufix) to `text` based on `count`. @@ -56,3 +61,15 @@ pub(crate) async fn is_issue_under_rfcbot_fcp( false } + +pub(crate) fn immutable_headers(content_type: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + + headers.insert( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=15552000, immutable"), + ); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); + + headers +}