Skip to main content

tix/sync/
export.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// A dry-run stand-in for a remote reference that pass 1 of a real run would
5/// create. Used only in the preview, so an edge to a not-yet-exported issue reads
6/// as sendable without inventing an identifier.
7pub(super) const DRY_RUN_NEW_REF: &str = "<new remote id>";
8
9/// `tix export`: mirror local issues to the remote tracker, recording the
10/// remote id back into the local `gitlab_id`/`linear_id` so a re-run updates
11/// instead of creating duplicates (idempotency, FR-Remote-sync).
12///
13/// Returns the number of issues handled (created or updated — `close` on an
14/// updated GitLab done issue is not counted separately).
15#[allow(clippy::too_many_lines)]
16pub fn export(
17    store: &Store,
18    tool: Tool,
19    config: &Config,
20    dry_run: bool,
21    out: &mut dyn Write,
22) -> Result<SyncReport> {
23    let mut adapter = adapter_for(tool, config)?;
24    let mut report = SyncReport::default();
25    // Built **once** for the whole run, which is the service's rule: resolution is
26    // not rebuilt per issue. See `workflow::resolution`.
27    let resolution = crate::workflow::resolution(config, config.default_config(), "default");
28
29    if dry_run {
30        for issue in store.list()? {
31            match remote_ref(tool, &issue) {
32                Some(r) => {
33                    writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
34                }
35                None => {
36                    writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
37                }
38            }
39        }
40        writeln!(out, "dry run — {tool} CLI was not invoked")?;
41        report.handled = store.list()?.len();
42        // Every issue a real run would export, plus the remote references the
43        // existing ones already carry. A relation endpoint that is **in the export
44        // set** is resolvable: pass 1 will give it a remote reference, so the edge
45        // is sendable and the preview must say so rather than report it as
46        // unsendable. Only a target genuinely absent from the export set (nothing a
47        // real run would create) is reported as unsendable.
48        let export_set: HashSet<u32> = store.list()?.into_iter().map(|i| i.number).collect();
49        let existing: HashMap<u32, String> = store
50            .list()?
51            .into_iter()
52            .filter_map(|i| remote_ref(tool, &i).map(|r| (i.number, r.to_string())))
53            .collect();
54        // The dry run reports the same diagnostics a real run would, so
55        // "preview then commit" is not the only way to find out that a
56        // relationship cannot leave the machine.
57        for issue in store.list()? {
58            let own_ref = crate::id::canonical_ref(&issue);
59            for rel in store.relationships_for(issue.number)? {
60                // The same adjacency rule as the real pass 2: one edge, from its
61                // declaring side, so a dry run previews exactly what a real run does
62                // rather than reporting an edge twice.
63                if rel.from != own_ref {
64                    continue;
65                }
66                let other_ref = if rel.from == own_ref {
67                    rel.to
68                } else {
69                    rel.from
70                };
71                let other_number = store
72                    .get(crate::storage::IssueSelector::Ref(other_ref))
73                    .ok()
74                    .map(|issue| issue.number);
75                let target = other_number.and_then(|other| {
76                    existing
77                        .get(&other)
78                        .map(String::as_str)
79                        .or_else(|| export_set.contains(&other).then_some(DRY_RUN_NEW_REF))
80                });
81                match map_relation_to_remote(tool, &rel, target) {
82                    MappedRelation::Remote { field, value } => {
83                        writeln!(out, "would export {tool} {field} {value} ({})", rel.id)?;
84                    }
85                    unsupported @ MappedRelation::Unsupported(_) => {
86                        report.note(unsupported, out)?
87                    }
88                }
89            }
90            // And the parent, so a dry run surfaces exactly what a real run would.
91            if let Some(parent) = issue.parent {
92                let parent_number = store
93                    .get(crate::storage::IssueSelector::Ref(parent))
94                    .ok()
95                    .map(|issue| issue.number)
96                    .or_else(|| parent.as_number());
97                report.note_parent(map_parent_to_remote(tool, issue.number, parent_number), out)?;
98            }
99            // And every project association, for the same reason: on both trackers
100            // the answer is "it cannot leave the machine", and a user previewing an
101            // export is exactly who needs to know that before running it.
102            for project_id in issue.projects.as_deref().unwrap_or_default() {
103                report.note_project(map_project_to_remote(tool, issue.number, project_id), out)?;
104            }
105            // And every epic reference, for the same reason (C-007): the answer is
106            // the same on both trackers, and a preview that hid it would be the one
107            // place a reader could not learn it.
108            for epic_id in issue.epics.as_deref().unwrap_or_default() {
109                report.note_epic(map_epic_to_remote(tool, issue.number, epic_id), out)?;
110            }
111        }
112        return Ok(report);
113    }
114
115    // **Pass 1** — create or update every issue, and record `issue.number -> remote
116    // reference`. Projects and epics are sent here, because they hang off the issue
117    // itself and need no other endpoint to exist.
118    let mut remote_ids: HashMap<u32, String> = HashMap::new();
119    let mut handled = 0;
120    for mut issue in store.list()? {
121        let remote_status = local_to_remote_status(tool, issue.status.display(), config);
122        let labels: Vec<String> = issue.labels.clone();
123        let assignee = issue.assignee.as_deref();
124        let priority = issue.priority.to_string();
125        match remote_ref(tool, &issue) {
126            Some(r) => {
127                let r = r.to_string();
128                adapter.update_issue(
129                    &r,
130                    &issue.title,
131                    &issue.body,
132                    &labels,
133                    assignee,
134                    Some(&priority),
135                    &remote_status,
136                )?;
137                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
138                {
139                    adapter.mark_done(&r)?;
140                }
141                writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
142            }
143            None => {
144                let id = adapter.create_issue(
145                    &issue.title,
146                    &issue.body,
147                    &labels,
148                    assignee,
149                    Some(&priority),
150                    &remote_status,
151                )?;
152                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
153                {
154                    adapter.mark_done(&id)?;
155                }
156                match tool {
157                    Tool::GitLab => issue.gitlab_id = Some(id.clone()),
158                    Tool::Linear => issue.linear_id = Some(id.clone()),
159                }
160                store.update(&issue.filename, &issue)?;
161                writeln!(
162                    out,
163                    "created {tool} issue {id} \"{}\" (remote id recorded)",
164                    issue.title
165                )?;
166            }
167        }
168        // The parent reference is offered to the same shared mapper, on the same
169        // "after the issue" footing as a relationship: a parent only means
170        // something once the parent itself exists remotely. Nothing is sent while
171        // `PARENT_WIRE_VERIFIED` is empty, so this reports rather than acts — and
172        // the local reference is untouched either way.
173        let parent_number = issue.parent.and_then(|parent| {
174            store
175                .get(crate::storage::IssueSelector::Ref(parent))
176                .ok()
177                .map(|issue| issue.number)
178                .or_else(|| parent.as_number())
179        });
180        report.note_parent(map_parent_to_remote(tool, issue.number, parent_number), out)?;
181
182        // Project associations are offered to the same shared mapper, on the same
183        // "after the issue" footing as a relationship: what it accepts is sent to the
184        // adapter, what it does not becomes a diagnostic naming the field. Nothing is
185        // sendable on either tracker — see `PROJECT_WIRE_VERIFIED` — so this reports
186        // rather than acts, and the local list is untouched either way (C-005). A
187        // diagnostic is emitted whether or not the issue has a remote id yet, unlike
188        // the relationship block below: the membership cannot be sent regardless, so
189        // suppressing the report while the id is still missing would hide the one
190        // fact the reader needs.
191        let mut sendable_projects: Vec<MappedProject> = Vec::new();
192        for project_id in issue.projects.as_deref().unwrap_or_default() {
193            match map_project_to_remote(tool, issue.number, project_id) {
194                MappedProject::Remote { field, value } => {
195                    writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
196                    sendable_projects.push(MappedProject::Remote { field, value });
197                }
198                unsupported @ MappedProject::Unsupported(_) => {
199                    report.note_project(unsupported, out)?;
200                }
201            }
202        }
203
204        // Epic references are offered to the same shared mapper, on the same
205        // "after the issue" footing: what it accepts would be sent to the adapter,
206        // what it does not becomes a diagnostic naming the field. Nothing is
207        // sendable on either tracker — see `EPIC_WIRE_VERIFIED` — so this reports
208        // rather than acts, and the local reference list is untouched either way
209        // (C-007). A diagnostic is emitted whether or not the issue has a remote id
210        // yet, for the same reason the project block gives: the reference cannot be
211        // sent regardless, so suppressing the report while the id is missing would
212        // hide the one fact the reader needs.
213        let mut sendable_epics: Vec<MappedEpic> = Vec::new();
214        for epic_id in issue.epics.as_deref().unwrap_or_default() {
215            match map_epic_to_remote(tool, issue.number, epic_id) {
216                MappedEpic::Remote { field, value } => {
217                    writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
218                    sendable_epics.push(MappedEpic::Remote { field, value });
219                }
220                unsupported @ MappedEpic::Unsupported(_) => {
221                    report.note_epic(unsupported, out)?;
222                }
223            }
224        }
225
226        let remote = match remote_ref(tool, &issue) {
227            Some(r) => r.to_string(),
228            None => match tool {
229                Tool::GitLab => issue.gitlab_id.clone(),
230                Tool::Linear => issue.linear_id.clone(),
231            }
232            .unwrap_or_default(),
233        };
234        if !remote.is_empty() {
235            adapter.set_projects(&remote, &sendable_projects)?;
236            adapter.set_epics(&remote, &sendable_epics)?;
237            remote_ids.insert(issue.number, remote);
238        }
239        handled += 1;
240    }
241
242    // **Pass 2** — the relationships, now that **both** endpoints exist remotely.
243    // An edge is only meaningful once both issues have a remote reference, and on a
244    // first export the target is frequently created *after* the issue the edge is
245    // stored on, so this cannot run inside pass 1. Each is offered to the shared
246    // mapper with the target's resolved remote reference: what it accepts is sent to
247    // the adapter, what it does not (including a target that was never exported)
248    // becomes a diagnostic naming the field. The adapter never decides
249    // representability.
250    for issue in store.list()? {
251        let Some(remote) = remote_ids.get(&issue.number).cloned() else {
252            continue;
253        };
254        let own_ref = crate::id::canonical_ref(&issue);
255        let mut sendable = Vec::new();
256        for rel in store.relationships_for(issue.number)? {
257            // `relationships_for` is an **adjacency** lookup: it returns every edge
258            // that *touches* this issue, in its stored form (`from` is the declaring
259            // side). Send each edge exactly once, from its declaring side — Linear's
260            // `relations add` is additive, so sending it from both endpoints would
261            // create a duplicate remote edge (and print the line twice).
262            if rel.from != own_ref {
263                continue;
264            }
265            // `from` is now this issue, so the other endpoint is `to`; spelled out
266            // rather than assumed, so the adjacency semantics are not re-learnt by
267            // the next reader.
268            let other_ref = if rel.from == own_ref {
269                rel.to
270            } else {
271                rel.from
272            };
273            let Some(other) = store
274                .get(crate::storage::IssueSelector::Ref(other_ref))
275                .ok()
276                .map(|issue| issue.number)
277            else {
278                report.note(
279                    MappedRelation::Unsupported(crate::sync::adapters::RemoteMappingDiagnostic {
280                        relation_id: rel.id.clone(),
281                        field: "target".to_string(),
282                        tool,
283                        message: format!(
284                            "target {other_ref} resolves to no issue in the export set"
285                        ),
286                    }),
287                    out,
288                )?;
289                continue;
290            };
291            let target = remote_ids.get(&other).map(String::as_str);
292            match map_relation_to_remote(tool, &rel, target) {
293                MappedRelation::Remote { field, value } => {
294                    writeln!(out, "exported {tool} {field} {value} ({})", rel.id)?;
295                    sendable.push(MappedRelation::Remote { field, value });
296                }
297                unsupported @ MappedRelation::Unsupported(_) => report.note(unsupported, out)?,
298            }
299        }
300        adapter.set_relations(&remote, &sendable)?;
301    }
302    report.handled = handled;
303    writeln!(
304        out,
305        "exported {handled} issue(s) to {tool} ({} diagnostic(s) — local data the remote \
306         cannot hold)",
307        report.diagnostics.len()
308    )?;
309    Ok(report)
310}