Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Dates are UTC calendar days. Links at the bottom compare tags on GitHub.
- Go ecosystem support: `hallucinated_imports` and `phantom_deps` now verify imports and `go.mod` declarations against proxy.golang.org.
- New Tier-1 detector `lockfile_drift`: flags dependencies declared in `package.json`, `Cargo.toml`, or `go.mod` that are missing from their lockfile (`package-lock.json`, `Cargo.lock`, `go.sum`).
- New Tier-1 detector `license_drift`: flags declared npm / PyPI / crates.io dependencies that carry copyleft-style licenses.
- Resolving a review thread on a Codasaurus finding comment now dismisses that fingerprint repo-wide (same ACL and learning path as a 👎 reaction); unresolving the thread re-enables the finding.
- `@codasaurus retry` command re-runs the latest review for a PR.
- ARM64 Linux release binary (`aarch64-unknown-linux-gnu`) in the release workflow.
- CLI `codasaurus reset-password --email … --password …` for emergency local dashboard recovery (no email flow).
Expand Down
1 change: 1 addition & 0 deletions src/api/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ fn build_manifest(public_url: &str) -> serde_json::Value {
"pull_request",
"issue_comment",
"reaction",
"pull_request_review_thread",
"installation",
"installation_repositories"
]
Expand Down
1 change: 1 addition & 0 deletions src/bot/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,7 @@ async fn spawn_review(ctx: WebhookContext, pr_number: i64, timeout_secs: u64) {
comment: None,
issue: None,
reaction: None,
thread: None,
sender: None,
repositories: None,
repositories_added: None,
Expand Down
159 changes: 139 additions & 20 deletions src/bot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod reactions;
pub(crate) mod related_prs;
pub(crate) mod repo_context;
mod review;
mod threads;
pub(crate) use review::{github_api_headers, next_github_link, GITHUB_CLIENT};
pub(crate) mod strictness;
pub(crate) mod title_fix;
Expand Down Expand Up @@ -204,6 +205,8 @@ pub(crate) struct WebhookPayload {
issue: Option<serde_json::Value>,
/// `reaction` webhook event payload
reaction: Option<serde_json::Value>,
/// `pull_request_review_thread` webhook event payload
thread: Option<serde_json::Value>,
/// Actor who triggered the event (reactions, etc.)
sender: Option<serde_json::Value>,
/// Sent in `installation.created` event
Expand Down Expand Up @@ -310,20 +313,13 @@ fn author_can_command(payload: &WebhookPayload) -> bool {

/// Who may dismiss findings via emoji reactions (same trust bar as slash commands).
fn reactor_can_dismiss(payload: &WebhookPayload) -> bool {
let reaction_assoc = payload
let assoc = payload
.reaction
.as_ref()
.and_then(|r| r.get("author_association"))
.and_then(|a| a.as_str())
.unwrap_or("");
if matches!(
reaction_assoc,
"OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR"
) {
return true;
}

let reactor = payload
let login = payload
.reaction
.as_ref()
.and_then(|r| r.pointer("/user/login"))
Expand All @@ -334,23 +330,64 @@ fn reactor_can_dismiss(payload: &WebhookPayload) -> bool {
.as_ref()
.and_then(|s| s.get("login"))
.and_then(|v| v.as_str())
})
});
actor_can_dismiss(
assoc,
login,
repo_owner_login(payload),
pr_author_login(payload),
)
}

/// Who may dismiss findings by resolving a review thread (same trust bar as reactions).
fn thread_resolver_can_dismiss(payload: &WebhookPayload) -> bool {
let assoc = payload
.thread
.as_ref()
.and_then(|t| t.pointer("/comments/0/author_association"))
.and_then(|a| a.as_str())
.unwrap_or("");
if reactor.is_empty() {
return false;
let login = payload
.thread
.as_ref()
.and_then(|t| t.pointer("/comments/0/user/login"))
.and_then(|v| v.as_str())
.or_else(|| {
payload
.sender
.as_ref()
.and_then(|s| s.get("login"))
.and_then(|v| v.as_str())
});
actor_can_dismiss(
assoc,
login,
repo_owner_login(payload),
pr_author_login(payload),
)
}

fn actor_can_dismiss(assoc: &str, login: Option<&str>, repo_owner: &str, pr_author: &str) -> bool {
if matches!(assoc, "OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR") {
return true;
}
let Some(login) = login.filter(|l| !l.is_empty()) else {
return false;
};
login.eq_ignore_ascii_case(repo_owner) || login.eq_ignore_ascii_case(pr_author)
}

let repo_owner = payload
fn repo_owner_login(payload: &WebhookPayload) -> &str {
payload
.repo
.as_ref()
.and_then(|r| r.pointer("/owner/login"))
.and_then(|v| v.as_str())
.unwrap_or("");
if reactor.eq_ignore_ascii_case(repo_owner) {
return true;
}
.unwrap_or("")
}

let pr_author = payload
fn pr_author_login(payload: &WebhookPayload) -> &str {
payload
.issue
.as_ref()
.and_then(|i| i.pointer("/user/login"))
Expand All @@ -362,8 +399,7 @@ fn reactor_can_dismiss(payload: &WebhookPayload) -> bool {
.and_then(|pr| pr.pointer("/user/login"))
.and_then(|v| v.as_str())
})
.unwrap_or("");
reactor.eq_ignore_ascii_case(pr_author)
.unwrap_or("")
}

pub(crate) async fn handle_webhook(
Expand Down Expand Up @@ -613,6 +649,36 @@ pub(crate) async fn handle_webhook(
}
});
}
} else if event == "pull_request_review_thread"
&& matches!(payload.action.as_str(), "resolved" | "unresolved")
{
let repo_full_name = payload
.repo
.as_ref()
.and_then(|r| r["full_name"].as_str())
.unwrap_or("")
.to_string();
if !repo_full_name.is_empty() {
if let Some(thread) = payload.thread.clone() {
let action = payload.action.clone();
let allowed = thread_resolver_can_dismiss(&payload);
tokio::spawn(async move {
if let Some(pool) = bot_db_pool() {
if let Err(e) = threads::handle_thread_event(
pool,
&action,
&thread,
&repo_full_name,
allowed,
)
.await
{
tracing::warn!(error = %e, "review thread learning failed");
}
}
});
}
}
} else if event == "installation" && payload.action == "created" {
tokio::spawn(handle_installation_created(
payload.installation,
Expand Down Expand Up @@ -853,6 +919,7 @@ mod author_acl_tests {
pull_request: None,
installation: None,
reaction: None,
thread: None,
sender: None,
repositories: None,
repositories_added: None,
Expand Down Expand Up @@ -905,4 +972,56 @@ mod author_acl_tests {
fn rejects_unrelated_none() {
assert!(!author_can_command(&payload("NONE", "eve", "alice", "bob")));
}

fn thread_payload(
assoc: &str,
resolver: &str,
repo_owner: &str,
pr_author: &str,
) -> WebhookPayload {
let mut p = payload(assoc, resolver, repo_owner, pr_author);
p.thread = Some(serde_json::json!({
"node_id": "PRRT_x",
"comments": [{
"author_association": assoc,
"user": { "login": resolver },
"body": "**Secrets** · `blocking`\n<sub>`fingerprint: abcdef012345`</sub>"
}]
}));
p.pull_request = Some(serde_json::json!({
"user": { "login": pr_author }
}));
p
}

#[test]
fn thread_owner_association_allows_dismiss() {
assert!(thread_resolver_can_dismiss(&thread_payload(
"OWNER", "alice", "alice", "bob"
)));
}

#[test]
fn thread_repo_owner_login_allows_even_if_none() {
assert!(thread_resolver_can_dismiss(&thread_payload(
"NONE", "alice", "alice", "carol"
)));
}

#[test]
fn thread_pr_author_allows() {
assert!(thread_resolver_can_dismiss(&thread_payload(
"FIRST_TIME_CONTRIBUTOR",
"bob",
"org",
"bob"
)));
}

#[test]
fn thread_unrelated_none_rejected() {
assert!(!thread_resolver_can_dismiss(&thread_payload(
"NONE", "eve", "alice", "bob"
)));
}
}
102 changes: 102 additions & 0 deletions src/bot/threads.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use crate::bot::reactions::fingerprint_from_comment_body;
use crate::learning::store::LearningStore;

pub fn fingerprint_from_thread(thread: &serde_json::Value) -> Option<String> {
let comments = thread.get("comments")?.as_array()?;
for comment in comments {
if let Some(body) = comment.get("body").and_then(|b| b.as_str()) {
if let Some(fp) = fingerprint_from_comment_body(body) {
return Some(fp);
}
}
}
None
}

pub async fn handle_thread_event(
pool: &crate::db::DbPool,
action: &str,
thread: &serde_json::Value,
repo_full_name: &str,
resolver_allowed: bool,
) -> anyhow::Result<bool> {
let Some(fp) = fingerprint_from_thread(thread) else {
tracing::debug!("review thread ignored: no fingerprint in thread comments");
return Ok(false);
};
let store = LearningStore::from_pool(pool);
match action {
"resolved" => {
if !resolver_allowed {
tracing::info!(
repo = %repo_full_name,
fingerprint = %fp,
"resolve ignored: resolver lacks command ACL"
);
return Ok(false);
}
store
.dismiss_fingerprint_for_repo(
&fp,
"resolve",
repo_full_name,
"dismissed via resolved review thread",
Some(repo_full_name),
None,
None,
true,
)
.await?;
tracing::info!(
repo = %repo_full_name,
fingerprint = %fp,
"learned dismissal from resolved thread"
);
Ok(true)
}
"unresolved" => {
let removed = store.un_dismiss_fingerprint(&fp).await?;
tracing::info!(
repo = %repo_full_name,
fingerprint = %fp,
removed,
"un-dismissed finding from unresolved thread"
);
Ok(removed)
}
_ => Ok(false),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extracts_fp_from_thread_comment() {
let thread = serde_json::json!({
"node_id": "PRRT_kwDONx",
"comments": [{
"body": "**Secrets** · `blocking`\n\n---\n<sub>`fingerprint: abcdef012345` · `@codasaurus ignore abcdef012345`</sub>",
"author_association": "OWNER"
}]
});
assert_eq!(
fingerprint_from_thread(&thread).as_deref(),
Some("abcdef012345")
);
}

#[test]
fn no_fingerprint_returns_none() {
let thread = serde_json::json!({
"comments": [{"body": "just a question", "author_association": "MEMBER"}]
});
assert!(fingerprint_from_thread(&thread).is_none());
}

#[test]
fn missing_comments_returns_none() {
assert!(fingerprint_from_thread(&serde_json::json!({"node_id": "x"})).is_none());
}
}
2 changes: 2 additions & 0 deletions src/bot/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ async fn process_queued_review(
comment: None,
issue: None,
reaction: None,
thread: None,
sender: None,
repositories: None,
repositories_added: None,
Expand Down Expand Up @@ -223,6 +224,7 @@ pub(crate) async fn run_webhook_review_inline(
comment: None,
issue: None,
reaction: None,
thread: None,
sender: None,
repositories: None,
repositories_added: None,
Expand Down
8 changes: 8 additions & 0 deletions src/learning/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ impl LearningStore {
Ok(())
}

pub async fn un_dismiss_fingerprint(&self, fingerprint: &str) -> Result<bool> {
Ok(db_execute!(
&self.pool,
"DELETE FROM dismissed_findings WHERE fingerprint = ?",
fingerprint
)? > 0)
}

pub async fn count_dismissals_for_detector(&self, detector: &str) -> Result<i64> {
Ok(db_scalar!(
&self.pool,
Expand Down
Loading