pub mod github;
pub mod jira;
pub mod linear;
use crate::db::DbPool;
use crate::db::models::{CreateIssue, CreateLabel};
use crate::db::queries;
use crate::error::LificError;
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedIssue {
pub source: String,
pub title: String,
pub description: String,
pub status: String,
pub priority: String,
pub labels: Vec<NormalizedLabel>,
pub comments: Vec<NormalizedComment>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedLabel {
pub name: String,
pub color: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedComment {
pub author: String,
pub created_at: Option<String>,
pub body: String,
}
impl NormalizedComment {
pub fn body_with_attribution(&self) -> String {
let when = self
.created_at
.as_deref()
.map(|t| format!(" on {t}"))
.unwrap_or_default();
format!("_Originally by @{}{}_\n\n{}", self.author, when, self.body)
}
}
#[derive(Debug, Default, Clone)]
pub struct FetchedIssues {
pub issues: Vec<NormalizedIssue>,
pub skipped_non_issues: usize,
pub skipped_assignees: usize,
pub skipped_other: usize,
}
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct ImportSummary {
pub dry_run: bool,
pub issues_created: usize,
pub issues_skipped_existing: usize,
pub comments_created: usize,
pub labels_created: usize,
pub comments_planned: usize,
pub labels_planned: usize,
pub skipped_non_issues: usize,
pub skipped_assignees: usize,
pub skipped_other: usize,
}
pub fn ensure_import_bot(
pool: &DbPool,
owner_id: i64,
source_slug: &str,
display: &str,
) -> Result<i64, LificError> {
let owner_username = {
let conn = pool.read()?;
queries::users::get_user_by_id(&conn, owner_id)?.username
};
let bot_username = format!("import-{source_slug}-{owner_username}");
let conn = pool.write()?;
if let Some(existing) = queries::users::find_bot_by_username(&conn, &bot_username)? {
return Ok(existing.id);
}
let bot = queries::users::create_bot_user(&conn, owner_id, &bot_username, display)?;
Ok(bot.id)
}
pub fn resolve_owner(pool: &DbPool, requested: Option<&str>) -> Result<Option<i64>, LificError> {
let conn = pool.read()?;
if let Some(username) = requested {
let u = queries::users::get_user_by_username(&conn, username)?;
return Ok(Some(u.id));
}
let users = queries::users::list_users(&conn)?;
let humans: Vec<_> = users.iter().filter(|u| !u.is_bot).collect();
match humans.len() {
0 => Ok(None),
1 => Ok(Some(humans[0].id)),
_ => {
let admins: Vec<_> = humans.iter().filter(|u| u.is_admin).collect();
if admins.len() == 1 {
Ok(Some(admins[0].id))
} else {
Err(LificError::BadRequest(
"multiple users exist — pass --user <username> to choose who owns the \
import bot (imported comments are attributed to it)."
.into(),
))
}
}
}
}
pub fn source_exists(conn: &rusqlite::Connection, source: &str) -> Result<bool, LificError> {
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM issues WHERE source = ?1)",
rusqlite::params![source],
|row| row.get(0),
)?;
Ok(exists)
}
pub fn ensure_label(
conn: &rusqlite::Connection,
project_id: i64,
label: &NormalizedLabel,
) -> Result<bool, LificError> {
if queries::resolve_label_name(conn, project_id, &label.name).is_ok() {
return Ok(false);
}
let color = label
.color
.clone()
.unwrap_or_else(|| "#6B7280".to_string());
queries::create_label(
conn,
&CreateLabel {
project_id,
name: label.name.clone(),
color,
},
)?;
Ok(true)
}
pub enum ApplyOutcome {
Created {
comments_created: usize,
labels_created: usize,
},
Skipped,
}
pub fn apply_issue(
pool: &DbPool,
project_id: i64,
bot_id: Option<i64>,
issue: &NormalizedIssue,
) -> Result<ApplyOutcome, LificError> {
let conn = pool.write()?;
if source_exists(&conn, &issue.source)? {
return Ok(ApplyOutcome::Skipped);
}
let mut labels_created = 0usize;
let mut comments_created = 0usize;
queries::savepoint(&conn, "import_issue", || {
for label in &issue.labels {
if ensure_label(&conn, project_id, label)? {
labels_created += 1;
}
}
let created = queries::create_issue(
&conn,
&CreateIssue {
project_id,
title: issue.title.clone(),
description: issue.description.clone(),
status: issue.status.clone(),
priority: issue.priority.clone(),
module_id: None,
start_date: None,
target_date: None,
labels: issue.labels.iter().map(|l| l.name.clone()).collect(),
source: Some(issue.source.clone()),
},
)?;
if let Some(bot) = bot_id {
for comment in &issue.comments {
queries::comments::create_comment(
&conn,
queries::comments::CommentParent::Issue(created.id),
bot,
&comment.body_with_attribution(),
)?;
comments_created += 1;
}
}
Ok(())
})?;
Ok(ApplyOutcome::Created {
comments_created,
labels_created,
})
}
pub fn run_import(
pool: &DbPool,
project_id: i64,
bot_id: Option<i64>,
fetched: &FetchedIssues,
dry_run: bool,
) -> Result<ImportSummary, LificError> {
let mut summary = ImportSummary {
dry_run,
skipped_non_issues: fetched.skipped_non_issues,
skipped_assignees: fetched.skipped_assignees,
skipped_other: fetched.skipped_other,
..Default::default()
};
if dry_run {
let conn = pool.read()?;
let mut planned_labels: std::collections::HashSet<String> = std::collections::HashSet::new();
for issue in &fetched.issues {
if source_exists(&conn, &issue.source)? {
summary.issues_skipped_existing += 1;
continue;
}
summary.issues_created += 1;
summary.comments_planned += issue.comments.len();
for label in &issue.labels {
if queries::resolve_label_name(&conn, project_id, &label.name).is_err() {
planned_labels.insert(label.name.clone());
}
}
}
summary.labels_planned = planned_labels.len();
return Ok(summary);
}
for issue in &fetched.issues {
match apply_issue(pool, project_id, bot_id, issue)? {
ApplyOutcome::Created {
comments_created,
labels_created,
} => {
summary.issues_created += 1;
summary.comments_created += comments_created;
summary.labels_created += labels_created;
}
ApplyOutcome::Skipped => summary.issues_skipped_existing += 1,
}
}
Ok(summary)
}
#[derive(Debug, Clone)]
pub struct StatusMap {
pub open: String,
pub closed: String,
}
impl Default for StatusMap {
fn default() -> Self {
StatusMap {
open: "backlog".into(),
closed: "done".into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn seed_project(pool: &DbPool, ident: &str) -> i64 {
let conn = pool.write().unwrap();
queries::create_project(
&conn,
&crate::db::models::CreateProject {
name: format!("Project {ident}"),
identifier: ident.into(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap()
.id
}
fn seed_user(pool: &DbPool, username: &str, admin: bool) -> i64 {
let conn = pool.write().unwrap();
queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: username.into(),
email: format!("{username}@test.com"),
password: "testpassword1".into(),
display_name: None,
is_admin: admin,
is_bot: false,
},
)
.unwrap()
.id
}
fn norm(source: &str, title: &str) -> NormalizedIssue {
NormalizedIssue {
source: source.into(),
title: title.into(),
description: "body".into(),
status: "backlog".into(),
priority: "none".into(),
labels: vec![],
comments: vec![],
}
}
#[test]
fn attribution_prefix_includes_author_and_time() {
let c = NormalizedComment {
author: "octocat".into(),
created_at: Some("2024-01-02T03:04:05Z".into()),
body: "looks good".into(),
};
let out = c.body_with_attribution();
assert!(out.contains("@octocat"));
assert!(out.contains("2024-01-02T03:04:05Z"));
assert!(out.ends_with("looks good"));
}
#[test]
fn attribution_prefix_without_time() {
let c = NormalizedComment {
author: "bob".into(),
created_at: None,
body: "hi".into(),
};
let out = c.body_with_attribution();
assert!(out.contains("@bob"));
assert!(!out.contains(" on "));
}
#[test]
fn resolve_owner_sole_human() {
let pool = db::open_memory().unwrap();
let id = seed_user(&pool, "solo", false);
assert_eq!(resolve_owner(&pool, None).unwrap(), Some(id));
}
#[test]
fn resolve_owner_fresh_install_is_none() {
let pool = db::open_memory().unwrap();
assert_eq!(resolve_owner(&pool, None).unwrap(), None);
}
#[test]
fn resolve_owner_multiple_requires_user() {
let pool = db::open_memory().unwrap();
seed_user(&pool, "a", false);
seed_user(&pool, "b", false);
assert!(resolve_owner(&pool, None).is_err());
let a = {
let conn = pool.read().unwrap();
queries::users::get_user_by_username(&conn, "a").unwrap().id
};
assert_eq!(resolve_owner(&pool, Some("a")).unwrap(), Some(a));
}
#[test]
fn resolve_owner_prefers_sole_admin() {
let pool = db::open_memory().unwrap();
seed_user(&pool, "user", false);
let admin = seed_user(&pool, "boss", true);
assert_eq!(resolve_owner(&pool, None).unwrap(), Some(admin));
}
#[test]
fn import_bot_is_created_once_and_owned() {
let pool = db::open_memory().unwrap();
let owner = seed_user(&pool, "solo", true);
let bot1 = ensure_import_bot(&pool, owner, "github", "GitHub Import").unwrap();
let bot2 = ensure_import_bot(&pool, owner, "github", "GitHub Import").unwrap();
assert_eq!(bot1, bot2, "second call reuses the bot");
let conn = pool.read().unwrap();
let (is_bot, owner_id): (bool, Option<i64>) = conn
.query_row(
"SELECT is_bot, owner_id FROM users WHERE id = ?1",
rusqlite::params![bot1],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(is_bot);
assert_eq!(owner_id, Some(owner));
}
#[test]
fn apply_issue_creates_and_is_idempotent() {
let pool = db::open_memory().unwrap();
let pid = seed_project(&pool, "APP");
let owner = seed_user(&pool, "solo", true);
let bot = ensure_import_bot(&pool, owner, "github", "GitHub Import").unwrap();
let mut issue = norm("github:o/n#1", "First");
issue.labels = vec![NormalizedLabel {
name: "bug".into(),
color: Some("#EF4444".into()),
}];
issue.comments = vec![NormalizedComment {
author: "octocat".into(),
created_at: Some("2024-01-01".into()),
body: "hi".into(),
}];
match apply_issue(&pool, pid, Some(bot), &issue).unwrap() {
ApplyOutcome::Created {
comments_created,
labels_created,
} => {
assert_eq!(comments_created, 1);
assert_eq!(labels_created, 1);
}
ApplyOutcome::Skipped => panic!("first apply must create"),
}
assert!(matches!(
apply_issue(&pool, pid, Some(bot), &issue).unwrap(),
ApplyOutcome::Skipped
));
let conn = pool.read().unwrap();
let issue_count: i64 = conn
.query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0))
.unwrap();
assert_eq!(issue_count, 1);
let comment_count: i64 = conn
.query_row("SELECT COUNT(*) FROM comments", [], |r| r.get(0))
.unwrap();
assert_eq!(comment_count, 1);
}
#[test]
fn dry_run_writes_nothing_and_counts() {
let pool = db::open_memory().unwrap();
let pid = seed_project(&pool, "APP");
let mut a = norm("github:o/n#1", "A");
a.labels = vec![NormalizedLabel {
name: "bug".into(),
color: None,
}];
a.comments = vec![NormalizedComment {
author: "x".into(),
created_at: None,
body: "c".into(),
}];
let mut b = norm("github:o/n#2", "B");
b.labels = vec![NormalizedLabel {
name: "bug".into(),
color: None,
}];
let fetched = FetchedIssues {
issues: vec![a, b],
skipped_non_issues: 3,
skipped_assignees: 2,
skipped_other: 1,
};
let summary = run_import(&pool, pid, None, &fetched, true).unwrap();
assert!(summary.dry_run);
assert_eq!(summary.issues_created, 2);
assert_eq!(summary.comments_planned, 1);
assert_eq!(summary.labels_planned, 1, "shared label counted once");
assert_eq!(summary.skipped_non_issues, 3);
assert_eq!(summary.skipped_assignees, 2);
let conn = pool.read().unwrap();
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0, "dry run must not write");
}
#[test]
fn run_import_skips_already_imported() {
let pool = db::open_memory().unwrap();
let pid = seed_project(&pool, "APP");
let fetched = FetchedIssues {
issues: vec![norm("github:o/n#1", "A"), norm("github:o/n#2", "B")],
..Default::default()
};
let s1 = run_import(&pool, pid, None, &fetched, false).unwrap();
assert_eq!(s1.issues_created, 2);
let s2 = run_import(&pool, pid, None, &fetched, false).unwrap();
assert_eq!(s2.issues_created, 0);
assert_eq!(s2.issues_skipped_existing, 2);
}
}