use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncScope {
pub team_key: String,
pub since: Option<DateTime<Utc>>,
}
pub fn resolve_scope(
team_key: &str,
explicit_since: Option<DateTime<Utc>>,
backfill: bool,
stored_cursor: Option<DateTime<Utc>>,
) -> SyncScope {
let since = if let Some(since) = explicit_since {
Some(since)
} else if backfill {
None
} else {
stored_cursor
};
SyncScope {
team_key: team_key.to_string(),
since,
}
}
pub fn next_cursor(observed_updated: &[DateTime<Utc>]) -> Option<DateTime<Utc>> {
observed_updated.iter().max().copied()
}
pub fn validate_team_key(key: &str) -> Result<(), String> {
let mut chars = key.chars();
let ok = match chars.next() {
Some(c) if c.is_ascii_alphabetic() => chars.all(|c| c.is_ascii_alphanumeric() || c == '_'),
_ => false,
};
if ok {
Ok(())
} else {
Err(format!(
"invalid Linear team key '{key}': expected a leading ASCII letter followed by \
letters, digits or underscores (e.g. ENG)"
))
}
}
pub fn build_issues_filter(team_key: &str, since: Option<DateTime<Utc>>) -> serde_json::Value {
match since {
Some(since) => serde_json::json!({
"team": { "key": { "eq": team_key } },
"updatedAt": { "gte": since.to_rfc3339() },
}),
None => serde_json::json!({
"team": { "key": { "eq": team_key } },
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn dt(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s)
.expect("valid rfc3339 fixture")
.with_timezone(&Utc)
}
#[test]
fn resolve_scope_prefers_explicit_since() {
let scope = resolve_scope(
"ENG",
Some(dt("2026-02-01T00:00:00Z")),
true,
Some(dt("2026-01-01T00:00:00Z")),
);
assert_eq!(scope.since, Some(dt("2026-02-01T00:00:00Z")));
}
#[test]
fn resolve_scope_backfill_with_no_since_is_full_history() {
let scope = resolve_scope("ENG", None, true, Some(dt("2026-01-01T00:00:00Z")));
assert_eq!(scope.since, None);
}
#[test]
fn resolve_scope_falls_back_to_the_stored_cursor() {
let scope = resolve_scope("ENG", None, false, Some(dt("2026-01-01T00:00:00Z")));
assert_eq!(scope.since, Some(dt("2026-01-01T00:00:00Z")));
}
#[test]
fn resolve_scope_first_ever_sync_is_full_history() {
let scope = resolve_scope("ENG", None, false, None);
assert_eq!(scope.since, None);
}
#[test]
fn next_cursor_returns_the_maximum_observed() {
let times = vec![
dt("2026-01-01T00:00:00Z"),
dt("2026-03-01T00:00:00Z"),
dt("2026-02-01T00:00:00Z"),
];
assert_eq!(next_cursor(×), Some(dt("2026-03-01T00:00:00Z")));
}
#[test]
fn next_cursor_is_none_on_an_empty_run() {
assert_eq!(next_cursor(&[]), None);
}
#[test]
fn validate_team_key_accepts_conventional_keys() {
for key in ["ENG", "FE", "Ops1", "team_a"] {
assert!(validate_team_key(key).is_ok(), "key: {key}");
}
}
#[test]
fn validate_team_key_rejects_empty_and_malformed_keys() {
for key in ["", "1ENG", "ENG OR 1=1", "ENG-1", "ENG\""] {
assert!(validate_team_key(key).is_err(), "key: {key}");
}
}
#[test]
fn build_issues_filter_scopes_to_the_team() {
let filter = build_issues_filter("ENG", None);
assert_eq!(filter["team"]["key"]["eq"], "ENG");
}
#[test]
fn build_issues_filter_adds_the_updated_at_bound_when_since_is_some() {
let since = Utc.with_ymd_and_hms(2026, 3, 4, 5, 6, 7).unwrap();
let filter = build_issues_filter("ENG", Some(since));
assert_eq!(filter["updatedAt"]["gte"], since.to_rfc3339());
}
#[test]
fn build_issues_filter_omits_the_bound_when_since_is_none() {
let filter = build_issues_filter("ENG", None);
assert!(filter.get("updatedAt").is_none());
}
}