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),
_ => 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
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::github::types::{GitHubIssue, GitHubUser};
#[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,
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,
author_association: Some("OWNER".to_string()),
};
assert!(is_trusted_author(&issue));
}
#[test]
fn is_trusted_author_collaborator() {
let issue = GitHubIssue {
number: 1,
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,
author_association: Some("COLLABORATOR".to_string()),
};
assert!(is_trusted_author(&issue));
}
#[test]
fn is_not_trusted_author_contributor() {
let issue = GitHubIssue {
number: 1,
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,
author_association: Some("CONTRIBUTOR".to_string()),
};
assert!(!is_trusted_author(&issue));
}
#[test]
fn is_not_trusted_author_member() {
let issue = GitHubIssue {
number: 1,
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,
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,
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,
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));
}
}