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}