1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
#[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)
}