use super::{ExternalBackend, ExternalId, ExternalTask, Mention, Status};
use crate::github::http::{status_label_color, GhHttp};
use crate::github::projects::label_status::LabelSyncConfig;
use crate::github::projects::ProjectSync;
use async_trait::async_trait;
use chrono::{Duration, Utc};
const ALLOWED_ASSOCIATIONS: &[&str] = &["OWNER", "COLLABORATOR"];
fn is_trusted_author(issue: &crate::github::types::GitHubIssue) -> bool {
issue
.author_association
.as_deref()
.map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
.unwrap_or(false) }
fn is_trusted_comment_author(c: &crate::github::types::GitHubComment) -> bool {
c.author_association
.as_deref()
.map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
.unwrap_or(false)
}
fn active_status_from_label(label: &str) -> Option<Status> {
match label {
"status:routed" => Some(Status::Routed),
"status:in_progress" => Some(Status::InProgress),
"status:needs_review" => Some(Status::NeedsReview),
"status:in_review" => Some(Status::InReview),
"status:blocked" => Some(Status::Blocked),
"status:waiting" => Some(Status::Waiting),
_ => None,
}
}
fn classify_active_open_issue(
issue: crate::github::types::GitHubIssue,
) -> Option<(ExternalTask, Option<Status>)> {
if issue.labels.iter().any(|l| l.name == "status:done") {
return None;
}
let status = issue
.labels
.iter()
.find_map(|l| active_status_from_label(&l.name));
let task = ExternalTask {
id: ExternalId(issue.number.to_string()),
title: issue.title,
body: issue.body.unwrap_or_default(),
state: issue.state,
labels: issue.labels.into_iter().map(|l| l.name).collect(),
author: issue.user.login,
created_at: issue.created_at,
updated_at: issue.updated_at,
url: issue.html_url,
};
Some((task, status))
}
pub struct GitHubBackend {
repo: String,
gh: GhHttp,
}
fn is_github_issue_id(id: &ExternalId) -> bool {
id.0.parse::<u64>().is_ok()
}
impl GitHubBackend {
pub fn new(repo: String) -> anyhow::Result<Self> {
Ok(Self {
repo,
gh: GhHttp::new()?,
})
}
async fn sync_project_status(
&self,
project: &ProjectSync,
node_id: &str,
status: &Status,
) -> anyhow::Result<()> {
if LabelSyncConfig::from_config()?.manages(&self.repo, project.project_id()) {
project.add_item(node_id).await?;
Ok(())
} else {
project.sync_item_status(node_id, status).await
}
}
}
#[async_trait]
impl ExternalBackend for GitHubBackend {
fn name(&self) -> &str {
"github"
}
async fn create_task(
&self,
title: &str,
body: &str,
labels: &[String],
) -> anyhow::Result<ExternalId> {
let issue = self
.gh
.create_issue(&self.repo, title, body, labels)
.await?;
Ok(ExternalId(issue.number.to_string()))
}
async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
let issue = self.gh.get_issue(&self.repo, &id.0).await?;
Ok(ExternalTask {
id: id.clone(),
title: issue.title,
body: issue.body.unwrap_or_default(),
state: issue.state,
labels: issue.labels.into_iter().map(|l| l.name).collect(),
author: issue.user.login,
created_at: issue.created_at,
updated_at: issue.updated_at,
url: issue.html_url,
})
}
async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
let status_name = &label["status:".len()..];
self.gh
.ensure_label(
&self.repo,
label,
status_label_color(label),
&format!("Task status: {status_name}"),
)
.await
}
async fn sync_to_project(
&self,
id: &ExternalId,
status: Status,
) -> anyhow::Result<Option<String>> {
let project = match ProjectSync::from_config() {
Some(p) => p,
None => return Ok(None),
};
let issue = self.gh.get_issue(&self.repo, &id.0).await?;
let node_id = issue
.node_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
.to_string();
self.sync_project_status(&project, &node_id, &status)
.await
.map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
Ok(Some(node_id))
}
async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
LabelSyncConfig::from_config()?
.reconcile(&self.gh, &self.repo)
.await
.map(|_| ())
}
async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
let project = match ProjectSync::from_config() {
Some(p) => p,
None => return Ok(()),
};
let project = if project.estimate_field_id().is_none() {
match ProjectSync::discover_fields(project.project_id()).await {
Ok(discovered) => {
if discovered.estimate_field_id().is_some() {
if let Err(e) =
crate::github::projects::write_project_config(&discovered).await
{
tracing::warn!(
err = %e,
"failed to persist discovered estimate field ID to config"
);
} else {
tracing::info!(
"auto-discovered and persisted project_estimate_field_id to config"
);
}
discovered
} else {
tracing::debug!(
"project has no 'Estimate' number field — skipping estimate sync"
);
return Ok(());
}
}
Err(e) => {
tracing::debug!(
err = %e,
"estimate field auto-discovery failed — skipping estimate sync"
);
return Ok(());
}
}
} else {
project
};
let issue = self.gh.get_issue(&self.repo, &id.0).await?;
let node_id = issue
.node_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
project
.sync_item_estimate(node_id, estimate)
.await
.map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
Ok(())
}
async fn get_project_item_estimates(
&self,
issue_node_ids: &[String],
) -> anyhow::Result<std::collections::HashMap<String, u8>> {
let project = match ProjectSync::from_config() {
Some(p) => p,
None => return Ok(std::collections::HashMap::new()),
};
if issue_node_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
project
.get_estimates_for_issues(issue_node_ids)
.await
.map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
}
async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
if !is_github_issue_id(id) {
return Ok(());
}
let label = status.as_label();
if let Err(e) = self.ensure_status_label(label).await {
tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
}
const MAX_RETRIES: u32 = 3;
let mut last_err =
anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
let mut issue_node_id: Option<String> = None;
for attempt in 0..MAX_RETRIES {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
.await;
tracing::debug!(attempt, label, "retrying update_status");
}
let issue = match self.gh.get_issue(&self.repo, &id.0).await {
Ok(i) => i,
Err(e) => {
last_err = e;
continue;
}
};
if issue_node_id.is_none() {
issue_node_id = issue.node_id.clone();
}
let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
let mut remove_failed = false;
for old in labels
.iter()
.filter(|l| l.starts_with("status:") && l.as_str() != label)
{
if let Err(e) = self.remove_label(id, old).await {
tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
last_err = e;
remove_failed = true;
break;
}
}
if remove_failed {
continue;
}
match self.set_labels(id, &[label.to_string()]).await {
Ok(()) => {
if let Some(ref node_id) = issue_node_id {
if let Some(project) = ProjectSync::from_config() {
if let Err(e) =
self.sync_project_status(&project, node_id, &status).await
{
tracing::warn!(
task_id = id.0,
err = %e,
"project board sync failed (non-fatal)"
);
}
}
}
if status == Status::Done {
let auto_close = crate::config::get("workflow.auto_close")
.map(|v| v == "true")
.unwrap_or(true);
if auto_close {
if let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
tracing::warn!(
task_id = id.0,
err = %e,
"auto-close issue failed (non-fatal)"
);
}
}
}
return Ok(());
}
Err(e) => {
tracing::warn!(label, err = %e, attempt, "set_labels failed");
last_err = e;
}
}
}
Err(last_err.context(format!(
"update_status({label}) failed after {MAX_RETRIES} attempts"
)))
}
async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
let state = if status == Status::Done {
"all"
} else {
"open"
};
let issues = self
.gh
.list_issues_with_state(&self.repo, status.as_label(), state)
.await?;
Ok(issues
.into_iter()
.filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
id: ExternalId(issue.number.to_string()),
title: issue.title,
body: issue.body.unwrap_or_default(),
state: issue.state,
labels: issue.labels.into_iter().map(|l| l.name).collect(),
author: issue.user.login,
created_at: issue.created_at,
updated_at: issue.updated_at,
url: issue.html_url,
})
.collect())
}
async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
let issues = self.gh.list_all_issues(&self.repo).await?;
Ok(issues
.into_iter()
.filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
id: ExternalId(issue.number.to_string()),
title: issue.title,
body: issue.body.unwrap_or_default(),
state: issue.state,
labels: issue.labels.into_iter().map(|l| l.name).collect(),
author: issue.user.login,
created_at: issue.created_at,
updated_at: issue.updated_at,
url: issue.html_url,
})
.collect())
}
async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
let since = (Utc::now() - Duration::days(30)).to_rfc3339();
let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
Ok(closed
.into_iter()
.filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
id: ExternalId(issue.number.to_string()),
title: issue.title,
body: issue.body.unwrap_or_default(),
state: issue.state,
labels: issue.labels.into_iter().map(|l| l.name).collect(),
author: issue.user.login,
created_at: issue.created_at,
updated_at: issue.updated_at,
url: issue.html_url,
})
.collect())
}
async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
let issues = self.gh.list_all_open_issues(&self.repo, None).await?;
Ok(issues
.into_iter()
.filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter(|issue| {
let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
let has_status = labels.iter().any(|l| l.starts_with("status:"));
!has_status || labels.contains(&"status:new")
})
.map(|issue| ExternalTask {
id: ExternalId(issue.number.to_string()),
title: issue.title,
body: issue.body.unwrap_or_default(),
state: issue.state,
labels: issue.labels.into_iter().map(|l| l.name).collect(),
author: issue.user.login,
created_at: issue.created_at,
updated_at: issue.updated_at,
url: issue.html_url,
})
.collect())
}
async fn list_active_open_issues(
&self,
since: Option<&str>,
) -> anyhow::Result<Vec<(ExternalTask, Option<crate::backends::Status>)>> {
let issues = self.gh.list_all_open_issues(&self.repo, since).await?;
Ok(issues
.into_iter()
.filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter_map(classify_active_open_issue)
.collect())
}
async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
if !is_github_issue_id(id) {
return Ok(());
}
self.gh.add_comment(&self.repo, &id.0, body).await
}
async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
if !is_github_issue_id(id) {
return Ok(());
}
self.gh.add_labels(&self.repo, &id.0, labels).await
}
async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
if !is_github_issue_id(id) {
return Ok(());
}
self.gh.remove_label(&self.repo, &id.0, label).await
}
async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
let sub_issue_numbers = self.gh.get_sub_issues(&self.repo, &id.0).await?;
Ok(sub_issue_numbers
.into_iter()
.map(|n| ExternalId(n.to_string()))
.collect())
}
async fn create_sub_task(
&self,
parent_id: &ExternalId,
title: &str,
body: &str,
labels: &[String],
) -> anyhow::Result<ExternalId> {
let mut all_labels = labels.to_vec();
all_labels.push(format!("parent:{}", parent_id.0));
let child_issue = self
.gh
.create_issue(&self.repo, title, body, &all_labels)
.await?;
let child_id = ExternalId(child_issue.number.to_string());
if let (Some(child_node_id), Ok(parent_issue)) = (
child_issue.node_id,
self.gh.get_issue(&self.repo, &parent_id.0).await,
) {
if let Some(parent_node_id) = parent_issue.node_id {
if let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await {
tracing::warn!(
parent = parent_id.0,
child = child_id.0,
err = %e,
"failed to add native sub-issue link (parent label still set)"
);
}
}
}
Ok(child_id)
}
async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
let open_issues = if label.is_empty() {
self.gh.list_all_open_issues(&self.repo, None).await?
} else {
self.gh.list_issues(&self.repo, label).await?
};
if open_issues.iter().any(|i| i.title == title) {
return Ok(true);
}
let since = (chrono::Utc::now() - chrono::Duration::hours(24))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string();
let closed = if label.is_empty() {
self.gh.list_closed_issues_since(&self.repo, &since).await?
} else {
self.gh
.list_issues_closed_since(&self.repo, label, &since)
.await?
};
Ok(closed.iter().any(|i| i.title == title))
}
async fn health_check(&self) -> anyhow::Result<()> {
self.gh.auth_status().await
}
async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
self.gh.is_pr_merged(&self.repo, branch).await
}
async fn batch_is_pr_merged(
&self,
branches: &[String],
) -> anyhow::Result<std::collections::HashMap<String, bool>> {
self.gh
.batch_is_pr_merged_by_branch(&self.repo, branches)
.await
}
async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
self.gh.get_whoami().await.map(Some)
}
async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
let comments = self.gh.get_mentions(&self.repo, since).await?;
let mut out = Vec::new();
for c in comments.into_iter() {
let trusted = if is_trusted_comment_author(&c) {
true
} else if let Some(ref issue_url) = c.issue_url {
if let Some(n) = issue_url.rsplit('/').next() {
if let Ok(issue) = self.gh.get_issue(&self.repo, n).await {
is_trusted_author(&issue)
} else {
tracing::warn!(
repo = %self.repo,
issue_url = %issue_url,
comment_id = c.id,
author = %c.user.login,
"get_mentions: failed to fetch parent issue to check author trust, treating as untrusted — mention may be silently dropped"
);
false
}
} else {
false
}
} else {
false
};
if !trusted {
tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring mention from untrusted author");
continue;
}
out.push(Mention {
id: c.id.to_string(),
body: c.body,
author: c.user.login,
created_at: c.created_at,
issue_url: c.issue_url,
});
}
Ok(out)
}
async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
let issue_number: u64 =
id.0.parse()
.map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
let mut out = Vec::new();
for c in comments.into_iter() {
if is_trusted_comment_author(&c) {
out.push(Mention {
id: c.id.to_string(),
body: c.body,
author: c.user.login,
created_at: c.created_at,
issue_url: c.issue_url,
});
continue;
}
if let Ok(issue) = self.gh.get_issue(&self.repo, &id.0).await {
if is_trusted_author(&issue) {
out.push(Mention {
id: c.id.to_string(),
body: c.body,
author: c.user.login,
created_at: c.created_at,
issue_url: c.issue_url,
});
continue;
} else {
tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring issue comment from untrusted author");
continue;
}
} else {
tracing::debug!(
comment_id = c.id,
"failed to fetch parent issue, skipping comment"
);
continue;
}
}
Ok(out)
}
async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
self.gh.add_reaction(&self.repo, &id.0, "eyes").await
}
async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
self.gh
.add_comment_reaction(&self.repo, comment_id, "eyes")
.await
}
async fn blocked_by(
&self,
repo: &str,
issue_number: i64,
) -> anyhow::Result<crate::backends::BlockedByFetch> {
if issue_number <= 0 {
return Ok(crate::backends::BlockedByFetch::Unavailable);
}
self.gh.list_blocked_by(repo, issue_number as u64).await
}
async fn issue_close_state(
&self,
repo: &str,
issue_number: i64,
) -> anyhow::Result<Option<(String, Option<String>)>> {
if issue_number <= 0 {
return Ok(None);
}
match self.gh.get_issue(repo, &issue_number.to_string()).await {
Ok(issue) => Ok(Some((issue.state, issue.state_reason))),
Err(err) if is_missing_issue(&err) => Ok(None),
Err(err) => Err(err),
}
}
async fn post_comment_in_repo(
&self,
repo: &str,
id: &ExternalId,
body: &str,
) -> anyhow::Result<()> {
if !is_github_issue_id(id) {
return Ok(());
}
self.gh.add_comment(repo, &id.0, body).await
}
async fn pull_request_completion(
&self,
repo: &str,
pr_number: i64,
) -> anyhow::Result<Option<crate::backends::PullRequestCompletion>> {
if pr_number <= 0 {
return Ok(None);
}
let pr = match self.gh.get_pr(repo, pr_number as u64).await {
Ok(pr) => pr,
Err(err) if is_missing_issue(&err) => return Ok(None),
Err(err) => return Err(err),
};
let default_branch = self.gh.get_default_branch(repo).await.unwrap_or_default();
let reviews = match self.gh.get_pr_reviews(repo, pr_number as u64).await {
Ok(reviews) => reviews,
Err(err) => {
tracing::warn!(repo, pr_number, err = %err, "pull request reviews unreadable");
Vec::new()
}
};
let comments = match self.gh.list_comments(repo, &pr_number.to_string()).await {
Ok(comments) => comments,
Err(err) => {
tracing::warn!(repo, pr_number, err = %err, "pull request comments unreadable");
Vec::new()
}
};
let reviewer = match self.gh.get_whoami().await {
Ok(login) => Some(login),
Err(err) => {
tracing::warn!(repo, pr_number, err = %err, "reviewer login unreadable");
None
}
};
let comment_refs: Vec<AutomatedReviewComment<'_>> = comments
.iter()
.map(|comment| AutomatedReviewComment {
login: &comment.user.login,
body: &comment.body,
created_at: &comment.created_at,
})
.collect();
let comment_approved = reviewer
.as_deref()
.is_some_and(|login| latest_automated_review_approves(&comment_refs, login));
let merged = pr.merged.unwrap_or(false);
Ok(Some(crate::backends::PullRequestCompletion {
merged,
base_ref: pr.base.ref_,
default_branch,
approved: latest_review_approves(&reviews) || comment_approved,
closed: pr.state.eq_ignore_ascii_case("closed"),
}))
}
async fn add_dependency(
&self,
issue_number: i64,
blocking_repo: &str,
blocking_number: i64,
) -> anyhow::Result<()> {
let blocker = self
.gh
.get_issue(blocking_repo, &blocking_number.to_string())
.await?;
if blocker.id == 0 {
anyhow::bail!("blocking issue {blocking_repo}#{blocking_number} has no database id");
}
self.gh
.add_blocked_by(&self.repo, issue_number as u64, blocker.id as i64)
.await
}
}
fn is_missing_issue(err: &anyhow::Error) -> bool {
let text = err.to_string().to_ascii_lowercase();
text.contains("404") || text.contains("not found")
}
pub(crate) fn latest_review_approves(reviews: &[crate::github::types::GitHubReview]) -> bool {
let mut latest: Option<(&str, &str)> = None;
for review in reviews {
let state = review.state.as_str();
let substantive = state.eq_ignore_ascii_case("APPROVED")
|| state.eq_ignore_ascii_case("CHANGES_REQUESTED");
if !substantive {
continue;
}
match latest {
Some((at, _)) if review.submitted_at.as_str() < at => {}
_ => latest = Some((review.submitted_at.as_str(), state)),
}
}
latest.is_some_and(|(_, state)| state.eq_ignore_ascii_case("APPROVED"))
}
pub(crate) struct AutomatedReviewComment<'a> {
pub login: &'a str,
pub body: &'a str,
pub created_at: &'a str,
}
pub(crate) fn automated_review_verdict(body: &str) -> Option<&'static str> {
let first = body.lines().next().unwrap_or("").trim().replace('\r', "");
if !first.starts_with("## Automated Review") {
return None;
}
let normalized = first.replace(['\u{2014}', '\u{2013}'], "-");
if normalized.contains("Automated Review - Approve") {
Some("approve")
} else if normalized.contains("Automated Review - Changes Requested") {
Some("changes_requested")
} else {
None
}
}
pub(crate) fn latest_automated_review_approves(
comments: &[AutomatedReviewComment<'_>],
reviewer: &str,
) -> bool {
if reviewer.is_empty() {
return false;
}
let mut newest: Option<&AutomatedReviewComment<'_>> = None;
for comment in comments {
if automated_review_verdict(comment.body).is_none() {
continue;
}
if !comment.login.eq_ignore_ascii_case(reviewer) {
continue;
}
match newest {
Some(prev) if comment.created_at <= prev.created_at => {}
_ => newest = Some(comment),
}
}
newest.is_some_and(|comment| automated_review_verdict(comment.body) == Some("approve"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::github::types::{GitHubIssue, GitHubReview, GitHubUser};
fn review(state: &str, at: &str) -> GitHubReview {
GitHubReview {
id: 1,
user: GitHubUser {
login: "owner".into(),
},
body: None,
state: state.into(),
html_url: None,
submitted_at: at.into(),
commit_id: None,
}
}
#[test]
fn later_changes_requested_clears_an_earlier_approval() {
let reviews = vec![
review("APPROVED", "2026-01-01T00:00:00Z"),
review("COMMENTED", "2026-01-02T00:00:00Z"),
review("CHANGES_REQUESTED", "2026-01-03T00:00:00Z"),
];
assert!(!latest_review_approves(&reviews));
let approved = vec![
review("CHANGES_REQUESTED", "2026-01-01T00:00:00Z"),
review("APPROVED", "2026-01-02T00:00:00Z"),
];
assert!(latest_review_approves(&approved));
}
fn review_comment<'a>(
login: &'a str,
body: &'a str,
at: &'a str,
) -> AutomatedReviewComment<'a> {
AutomatedReviewComment {
login,
body,
created_at: at,
}
}
#[test]
fn automated_review_comment_counts_as_approval_without_formal_reviews() {
assert!(!latest_review_approves(&[]));
let comments = [review_comment(
"orch-bot",
"## Automated Review \u{2014} Approve\n\nLooks good.",
"2026-01-02T00:00:00Z",
)];
assert!(latest_automated_review_approves(&comments, "orch-bot"));
assert!(latest_automated_review_approves(&comments, "Orch-Bot"));
assert!(!latest_automated_review_approves(&comments, "mallory"));
assert!(!latest_automated_review_approves(&comments, ""));
let hyphen = [review_comment(
"orch-bot",
"## Automated Review - Approve\n\nok",
"2026-01-02T00:00:00Z",
)];
assert!(latest_automated_review_approves(&hyphen, "orch-bot"));
}
#[test]
fn later_automated_changes_requested_clears_comment_approval() {
let comments = [
review_comment(
"orch-bot",
"## Automated Review \u{2014} Approve\n\nok",
"2026-01-01T00:00:00Z",
),
review_comment(
"mallory",
"## Automated Review \u{2014} Changes Requested\n\nnope",
"2026-01-03T00:00:00Z",
),
review_comment(
"orch-bot",
"## Automated Review \u{2014} Changes Requested\n\nfix",
"2026-01-02T00:00:00Z",
),
];
assert!(!latest_automated_review_approves(&comments, "orch-bot"));
}
#[test]
fn is_github_issue_id_numeric() {
assert!(is_github_issue_id(&ExternalId("42".to_string())));
assert!(is_github_issue_id(&ExternalId("1".to_string())));
assert!(is_github_issue_id(&ExternalId("99999".to_string())));
}
#[test]
fn is_github_issue_id_rejects_internal_prefix() {
assert!(!is_github_issue_id(&ExternalId(
"internal:63857".to_string()
)));
assert!(!is_github_issue_id(&ExternalId("internal:1".to_string())));
}
#[test]
fn is_github_issue_id_rejects_empty_and_non_numeric() {
assert!(!is_github_issue_id(&ExternalId("".to_string())));
assert!(!is_github_issue_id(&ExternalId("abc".to_string())));
assert!(!is_github_issue_id(&ExternalId("1.0".to_string())));
}
#[test]
fn allowed_associations_are_owner_and_contributor() {
assert_eq!(ALLOWED_ASSOCIATIONS, &["OWNER", "COLLABORATOR"]);
}
#[test]
fn is_trusted_author_owner() {
let issue = GitHubIssue {
number: 1,
id: 0,
title: "t".to_string(),
body: None,
state: "open".to_string(),
labels: vec![],
user: GitHubUser {
login: "u".to_string(),
},
created_at: "".to_string(),
updated_at: "".to_string(),
html_url: "".to_string(),
node_id: None,
pull_request: None,
state_reason: None,
author_association: Some("OWNER".to_string()),
};
assert!(is_trusted_author(&issue));
}
#[test]
fn is_trusted_author_collaborator() {
let issue = GitHubIssue {
number: 1,
id: 0,
title: "t".to_string(),
body: None,
state: "open".to_string(),
labels: vec![],
user: GitHubUser {
login: "u".to_string(),
},
created_at: "".to_string(),
updated_at: "".to_string(),
html_url: "".to_string(),
node_id: None,
pull_request: None,
state_reason: None,
author_association: Some("COLLABORATOR".to_string()),
};
assert!(is_trusted_author(&issue));
}
#[test]
fn is_not_trusted_author_contributor() {
let issue = GitHubIssue {
number: 1,
id: 0,
title: "t".to_string(),
body: None,
state: "open".to_string(),
labels: vec![],
user: GitHubUser {
login: "u".to_string(),
},
created_at: "".to_string(),
updated_at: "".to_string(),
html_url: "".to_string(),
node_id: None,
pull_request: None,
state_reason: None,
author_association: Some("CONTRIBUTOR".to_string()),
};
assert!(!is_trusted_author(&issue));
}
#[test]
fn is_not_trusted_author_member() {
let issue = GitHubIssue {
number: 1,
id: 0,
title: "t".to_string(),
body: None,
state: "open".to_string(),
labels: vec![],
user: GitHubUser {
login: "u".to_string(),
},
created_at: "".to_string(),
updated_at: "".to_string(),
html_url: "".to_string(),
node_id: None,
pull_request: None,
state_reason: None,
author_association: Some("MEMBER".to_string()),
};
assert!(!is_trusted_author(&issue));
}
#[test]
fn is_trusted_comment_author_direct() {
use crate::github::types::GitHubComment;
let c = GitHubComment {
id: 1,
body: "hi".to_string(),
user: crate::github::types::GitHubUser {
login: "u".to_string(),
},
created_at: "".to_string(),
updated_at: None,
html_url: None,
issue_url: None,
author_association: Some("OWNER".to_string()),
};
assert!(is_trusted_comment_author(&c));
}
#[test]
fn is_not_trusted_comment_author_direct() {
use crate::github::types::GitHubComment;
let c = GitHubComment {
id: 1,
body: "hi".to_string(),
user: crate::github::types::GitHubUser {
login: "u".to_string(),
},
created_at: "".to_string(),
updated_at: None,
html_url: None,
issue_url: None,
author_association: Some("MEMBER".to_string()),
};
assert!(!is_trusted_comment_author(&c));
}
fn make_test_issue(labels: &[&str]) -> GitHubIssue {
GitHubIssue {
number: 1,
id: 0,
title: "t".to_string(),
body: None,
state: "open".to_string(),
labels: labels
.iter()
.map(|label| crate::github::types::GitHubLabel {
name: (*label).to_string(),
color: None,
})
.collect(),
user: GitHubUser {
login: "u".to_string(),
},
created_at: "".to_string(),
updated_at: "".to_string(),
html_url: "".to_string(),
node_id: None,
pull_request: None,
state_reason: None,
author_association: Some("OWNER".to_string()),
}
}
#[test]
fn classify_active_open_issue_excludes_status_done() {
assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
assert!(
classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
.is_none()
);
}
#[test]
fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
let unlabeled = classify_active_open_issue(make_test_issue(&[]))
.expect("unlabeled issue should still be ingested");
assert_eq!(unlabeled.1, None);
let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
.expect("status:new issue should still be ingested");
assert_eq!(status_new.1, None);
}
#[test]
fn classify_active_open_issue_maps_active_status() {
let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
.expect("active status issue should be ingested");
assert_eq!(routed.1, Some(Status::Routed));
}
}