tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// A dry-run stand-in for a remote reference that pass 1 of a real run would
/// create. Used only in the preview, so an edge to a not-yet-exported issue reads
/// as sendable without inventing an identifier.
pub(super) const DRY_RUN_NEW_REF: &str = "<new remote id>";

/// `tix export`: mirror local issues to the remote tracker, recording the
/// remote id back into the local `gitlab_id`/`linear_id` so a re-run updates
/// instead of creating duplicates (idempotency, FR-Remote-sync).
///
/// Returns the number of issues handled (created or updated — `close` on an
/// updated GitLab done issue is not counted separately).
#[allow(clippy::too_many_lines)]
pub fn export(
    store: &Store,
    tool: Tool,
    config: &Config,
    dry_run: bool,
    out: &mut dyn Write,
) -> Result<SyncReport> {
    let mut adapter = adapter_for(tool, config)?;
    let mut report = SyncReport::default();
    // Built **once** for the whole run, which is the service's rule: resolution is
    // not rebuilt per issue. See `workflow::resolution`.
    let resolution = crate::workflow::resolution(config, config.default_config(), "default");

    if dry_run {
        for issue in store.list()? {
            match remote_ref(tool, &issue) {
                Some(r) => {
                    writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
                }
                None => {
                    writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
                }
            }
        }
        writeln!(out, "dry run — {tool} CLI was not invoked")?;
        report.handled = store.list()?.len();
        // Every issue a real run would export, plus the remote references the
        // existing ones already carry. A relation endpoint that is **in the export
        // set** is resolvable: pass 1 will give it a remote reference, so the edge
        // is sendable and the preview must say so rather than report it as
        // unsendable. Only a target genuinely absent from the export set (nothing a
        // real run would create) is reported as unsendable.
        let export_set: HashSet<u32> = store.list()?.into_iter().map(|i| i.number).collect();
        let existing: HashMap<u32, String> = store
            .list()?
            .into_iter()
            .filter_map(|i| remote_ref(tool, &i).map(|r| (i.number, r.to_string())))
            .collect();
        // The dry run reports the same diagnostics a real run would, so
        // "preview then commit" is not the only way to find out that a
        // relationship cannot leave the machine.
        for issue in store.list()? {
            let own_ref = crate::id::canonical_ref(&issue);
            for rel in store.relationships_for(issue.number)? {
                // The same adjacency rule as the real pass 2: one edge, from its
                // declaring side, so a dry run previews exactly what a real run does
                // rather than reporting an edge twice.
                if rel.from != own_ref {
                    continue;
                }
                let other_ref = if rel.from == own_ref {
                    rel.to
                } else {
                    rel.from
                };
                let other_number = store
                    .get(crate::storage::IssueSelector::Ref(other_ref))
                    .ok()
                    .map(|issue| issue.number);
                let target = other_number.and_then(|other| {
                    existing
                        .get(&other)
                        .map(String::as_str)
                        .or_else(|| export_set.contains(&other).then_some(DRY_RUN_NEW_REF))
                });
                match map_relation_to_remote(tool, &rel, target) {
                    MappedRelation::Remote { field, value } => {
                        writeln!(out, "would export {tool} {field} {value} ({})", rel.id)?;
                    }
                    unsupported @ MappedRelation::Unsupported(_) => {
                        report.note(unsupported, out)?
                    }
                }
            }
            // And the parent, so a dry run surfaces exactly what a real run would.
            if let Some(parent) = issue.parent {
                let parent_number = store
                    .get(crate::storage::IssueSelector::Ref(parent))
                    .ok()
                    .map(|issue| issue.number)
                    .or_else(|| parent.as_number());
                report.note_parent(map_parent_to_remote(tool, issue.number, parent_number), out)?;
            }
            // And every project association, for the same reason: on both trackers
            // the answer is "it cannot leave the machine", and a user previewing an
            // export is exactly who needs to know that before running it.
            for project_id in issue.projects.as_deref().unwrap_or_default() {
                report.note_project(map_project_to_remote(tool, issue.number, project_id), out)?;
            }
            // And every epic reference, for the same reason (C-007): the answer is
            // the same on both trackers, and a preview that hid it would be the one
            // place a reader could not learn it.
            for epic_id in issue.epics.as_deref().unwrap_or_default() {
                report.note_epic(map_epic_to_remote(tool, issue.number, epic_id), out)?;
            }
        }
        return Ok(report);
    }

    // **Pass 1** — create or update every issue, and record `issue.number -> remote
    // reference`. Projects and epics are sent here, because they hang off the issue
    // itself and need no other endpoint to exist.
    let mut remote_ids: HashMap<u32, String> = HashMap::new();
    let mut handled = 0;
    for mut issue in store.list()? {
        let remote_status = local_to_remote_status(tool, issue.status.display(), config);
        let labels: Vec<String> = issue.labels.clone();
        let assignee = issue.assignee.as_deref();
        let priority = issue.priority.to_string();
        match remote_ref(tool, &issue) {
            Some(r) => {
                let r = r.to_string();
                adapter.update_issue(
                    &r,
                    &issue.title,
                    &issue.body,
                    &labels,
                    assignee,
                    Some(&priority),
                    &remote_status,
                )?;
                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
                {
                    adapter.mark_done(&r)?;
                }
                writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
            }
            None => {
                let id = adapter.create_issue(
                    &issue.title,
                    &issue.body,
                    &labels,
                    assignee,
                    Some(&priority),
                    &remote_status,
                )?;
                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
                {
                    adapter.mark_done(&id)?;
                }
                match tool {
                    Tool::GitLab => issue.gitlab_id = Some(id.clone()),
                    Tool::Linear => issue.linear_id = Some(id.clone()),
                }
                store.update(&issue.filename, &issue)?;
                writeln!(
                    out,
                    "created {tool} issue {id} \"{}\" (remote id recorded)",
                    issue.title
                )?;
            }
        }
        // The parent reference is offered to the same shared mapper, on the same
        // "after the issue" footing as a relationship: a parent only means
        // something once the parent itself exists remotely. Nothing is sent while
        // `PARENT_WIRE_VERIFIED` is empty, so this reports rather than acts — and
        // the local reference is untouched either way.
        let parent_number = issue.parent.and_then(|parent| {
            store
                .get(crate::storage::IssueSelector::Ref(parent))
                .ok()
                .map(|issue| issue.number)
                .or_else(|| parent.as_number())
        });
        report.note_parent(map_parent_to_remote(tool, issue.number, parent_number), out)?;

        // Project associations are offered to the same shared mapper, on the same
        // "after the issue" footing as a relationship: what it accepts is sent to the
        // adapter, what it does not becomes a diagnostic naming the field. Nothing is
        // sendable on either tracker — see `PROJECT_WIRE_VERIFIED` — so this reports
        // rather than acts, and the local list is untouched either way (C-005). A
        // diagnostic is emitted whether or not the issue has a remote id yet, unlike
        // the relationship block below: the membership cannot be sent regardless, so
        // suppressing the report while the id is still missing would hide the one
        // fact the reader needs.
        let mut sendable_projects: Vec<MappedProject> = Vec::new();
        for project_id in issue.projects.as_deref().unwrap_or_default() {
            match map_project_to_remote(tool, issue.number, project_id) {
                MappedProject::Remote { field, value } => {
                    writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
                    sendable_projects.push(MappedProject::Remote { field, value });
                }
                unsupported @ MappedProject::Unsupported(_) => {
                    report.note_project(unsupported, out)?;
                }
            }
        }

        // Epic references are offered to the same shared mapper, on the same
        // "after the issue" footing: what it accepts would be sent to the adapter,
        // what it does not becomes a diagnostic naming the field. Nothing is
        // sendable on either tracker — see `EPIC_WIRE_VERIFIED` — so this reports
        // rather than acts, and the local reference list is untouched either way
        // (C-007). A diagnostic is emitted whether or not the issue has a remote id
        // yet, for the same reason the project block gives: the reference cannot be
        // sent regardless, so suppressing the report while the id is missing would
        // hide the one fact the reader needs.
        let mut sendable_epics: Vec<MappedEpic> = Vec::new();
        for epic_id in issue.epics.as_deref().unwrap_or_default() {
            match map_epic_to_remote(tool, issue.number, epic_id) {
                MappedEpic::Remote { field, value } => {
                    writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
                    sendable_epics.push(MappedEpic::Remote { field, value });
                }
                unsupported @ MappedEpic::Unsupported(_) => {
                    report.note_epic(unsupported, out)?;
                }
            }
        }

        let remote = match remote_ref(tool, &issue) {
            Some(r) => r.to_string(),
            None => match tool {
                Tool::GitLab => issue.gitlab_id.clone(),
                Tool::Linear => issue.linear_id.clone(),
            }
            .unwrap_or_default(),
        };
        if !remote.is_empty() {
            adapter.set_projects(&remote, &sendable_projects)?;
            adapter.set_epics(&remote, &sendable_epics)?;
            remote_ids.insert(issue.number, remote);
        }
        handled += 1;
    }

    // **Pass 2** — the relationships, now that **both** endpoints exist remotely.
    // An edge is only meaningful once both issues have a remote reference, and on a
    // first export the target is frequently created *after* the issue the edge is
    // stored on, so this cannot run inside pass 1. Each is offered to the shared
    // mapper with the target's resolved remote reference: what it accepts is sent to
    // the adapter, what it does not (including a target that was never exported)
    // becomes a diagnostic naming the field. The adapter never decides
    // representability.
    for issue in store.list()? {
        let Some(remote) = remote_ids.get(&issue.number).cloned() else {
            continue;
        };
        let own_ref = crate::id::canonical_ref(&issue);
        let mut sendable = Vec::new();
        for rel in store.relationships_for(issue.number)? {
            // `relationships_for` is an **adjacency** lookup: it returns every edge
            // that *touches* this issue, in its stored form (`from` is the declaring
            // side). Send each edge exactly once, from its declaring side — Linear's
            // `relations add` is additive, so sending it from both endpoints would
            // create a duplicate remote edge (and print the line twice).
            if rel.from != own_ref {
                continue;
            }
            // `from` is now this issue, so the other endpoint is `to`; spelled out
            // rather than assumed, so the adjacency semantics are not re-learnt by
            // the next reader.
            let other_ref = if rel.from == own_ref {
                rel.to
            } else {
                rel.from
            };
            let Some(other) = store
                .get(crate::storage::IssueSelector::Ref(other_ref))
                .ok()
                .map(|issue| issue.number)
            else {
                report.note(
                    MappedRelation::Unsupported(crate::sync::adapters::RemoteMappingDiagnostic {
                        relation_id: rel.id.clone(),
                        field: "target".to_string(),
                        tool,
                        message: format!(
                            "target {other_ref} resolves to no issue in the export set"
                        ),
                    }),
                    out,
                )?;
                continue;
            };
            let target = remote_ids.get(&other).map(String::as_str);
            match map_relation_to_remote(tool, &rel, target) {
                MappedRelation::Remote { field, value } => {
                    writeln!(out, "exported {tool} {field} {value} ({})", rel.id)?;
                    sendable.push(MappedRelation::Remote { field, value });
                }
                unsupported @ MappedRelation::Unsupported(_) => report.note(unsupported, out)?,
            }
        }
        adapter.set_relations(&remote, &sendable)?;
    }
    report.handled = handled;
    writeln!(
        out,
        "exported {handled} issue(s) to {tool} ({} diagnostic(s) — local data the remote \
         cannot hold)",
        report.diagnostics.len()
    )?;
    Ok(report)
}