@stoneforge/quarry 1.10.2 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cli/commands/admin.d.ts.map +1 -1
- package/dist/cli/commands/admin.js +313 -3
- package/dist/cli/commands/admin.js.map +1 -1
- package/dist/cli/commands/auto-link-helper.d.ts +33 -0
- package/dist/cli/commands/auto-link-helper.d.ts.map +1 -0
- package/dist/cli/commands/auto-link-helper.js +73 -0
- package/dist/cli/commands/auto-link-helper.js.map +1 -0
- package/dist/cli/commands/crud.d.ts +1 -0
- package/dist/cli/commands/crud.d.ts.map +1 -1
- package/dist/cli/commands/crud.js +44 -5
- package/dist/cli/commands/crud.js.map +1 -1
- package/dist/cli/commands/docs.d.ts +1 -0
- package/dist/cli/commands/docs.d.ts.map +1 -1
- package/dist/cli/commands/docs.js +81 -1
- package/dist/cli/commands/docs.js.map +1 -1
- package/dist/cli/commands/external-sync.d.ts +17 -0
- package/dist/cli/commands/external-sync.d.ts.map +1 -0
- package/dist/cli/commands/external-sync.js +1647 -0
- package/dist/cli/commands/external-sync.js.map +1 -0
- package/dist/cli/commands/log.d.ts +18 -0
- package/dist/cli/commands/log.d.ts.map +1 -0
- package/dist/cli/commands/log.js +282 -0
- package/dist/cli/commands/log.js.map +1 -0
- package/dist/cli/commands/metrics.d.ts +9 -0
- package/dist/cli/commands/metrics.d.ts.map +1 -0
- package/dist/cli/commands/metrics.js +219 -0
- package/dist/cli/commands/metrics.js.map +1 -0
- package/dist/cli/runner.d.ts.map +1 -1
- package/dist/cli/runner.js +8 -0
- package/dist/cli/runner.js.map +1 -1
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +28 -0
- package/dist/config/config.js.map +1 -1
- package/dist/config/defaults.d.ts +13 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +21 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/file.d.ts.map +1 -1
- package/dist/config/file.js +61 -0
- package/dist/config/file.js.map +1 -1
- package/dist/config/index.d.ts +3 -3
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/merge.d.ts.map +1 -1
- package/dist/config/merge.js +46 -1
- package/dist/config/merge.js.map +1 -1
- package/dist/config/types.d.ts +63 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +30 -0
- package/dist/config/types.js.map +1 -1
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +51 -1
- package/dist/config/validation.js.map +1 -1
- package/dist/external-sync/adapters/task-sync-adapter.d.ts +177 -0
- package/dist/external-sync/adapters/task-sync-adapter.d.ts.map +1 -0
- package/dist/external-sync/adapters/task-sync-adapter.js +353 -0
- package/dist/external-sync/adapters/task-sync-adapter.js.map +1 -0
- package/dist/external-sync/auto-link.d.ts +66 -0
- package/dist/external-sync/auto-link.d.ts.map +1 -0
- package/dist/external-sync/auto-link.js +98 -0
- package/dist/external-sync/auto-link.js.map +1 -0
- package/dist/external-sync/conflict-resolver.d.ts +170 -0
- package/dist/external-sync/conflict-resolver.d.ts.map +1 -0
- package/dist/external-sync/conflict-resolver.js +580 -0
- package/dist/external-sync/conflict-resolver.js.map +1 -0
- package/dist/external-sync/index.d.ts +20 -0
- package/dist/external-sync/index.d.ts.map +1 -0
- package/dist/external-sync/index.js +20 -0
- package/dist/external-sync/index.js.map +1 -0
- package/dist/external-sync/provider-registry.d.ts +109 -0
- package/dist/external-sync/provider-registry.d.ts.map +1 -0
- package/dist/external-sync/provider-registry.js +188 -0
- package/dist/external-sync/provider-registry.js.map +1 -0
- package/dist/external-sync/providers/github/github-api.d.ts +271 -0
- package/dist/external-sync/providers/github/github-api.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-api.js +366 -0
- package/dist/external-sync/providers/github/github-api.js.map +1 -0
- package/dist/external-sync/providers/github/github-field-map.d.ts +76 -0
- package/dist/external-sync/providers/github/github-field-map.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-field-map.js +157 -0
- package/dist/external-sync/providers/github/github-field-map.js.map +1 -0
- package/dist/external-sync/providers/github/github-provider.d.ts +36 -0
- package/dist/external-sync/providers/github/github-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-provider.js +212 -0
- package/dist/external-sync/providers/github/github-provider.js.map +1 -0
- package/dist/external-sync/providers/github/github-task-adapter.d.ts +135 -0
- package/dist/external-sync/providers/github/github-task-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/github/github-task-adapter.js +374 -0
- package/dist/external-sync/providers/github/github-task-adapter.js.map +1 -0
- package/dist/external-sync/providers/github/index.d.ts +12 -0
- package/dist/external-sync/providers/github/index.d.ts.map +1 -0
- package/dist/external-sync/providers/github/index.js +15 -0
- package/dist/external-sync/providers/github/index.js.map +1 -0
- package/dist/external-sync/providers/index.d.ts +9 -0
- package/dist/external-sync/providers/index.d.ts.map +1 -0
- package/dist/external-sync/providers/index.js +10 -0
- package/dist/external-sync/providers/index.js.map +1 -0
- package/dist/external-sync/providers/linear/index.d.ts +19 -0
- package/dist/external-sync/providers/linear/index.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/index.js +19 -0
- package/dist/external-sync/providers/linear/index.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-api.d.ts +252 -0
- package/dist/external-sync/providers/linear/linear-api.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-api.js +522 -0
- package/dist/external-sync/providers/linear/linear-api.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-field-map.d.ts +135 -0
- package/dist/external-sync/providers/linear/linear-field-map.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-field-map.js +338 -0
- package/dist/external-sync/providers/linear/linear-field-map.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-provider.d.ts +52 -0
- package/dist/external-sync/providers/linear/linear-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-provider.js +169 -0
- package/dist/external-sync/providers/linear/linear-provider.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.d.ts +190 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.js +521 -0
- package/dist/external-sync/providers/linear/linear-task-adapter.js.map +1 -0
- package/dist/external-sync/providers/linear/linear-types.d.ts +114 -0
- package/dist/external-sync/providers/linear/linear-types.d.ts.map +1 -0
- package/dist/external-sync/providers/linear/linear-types.js +10 -0
- package/dist/external-sync/providers/linear/linear-types.js.map +1 -0
- package/dist/external-sync/sync-engine.d.ts +298 -0
- package/dist/external-sync/sync-engine.d.ts.map +1 -0
- package/dist/external-sync/sync-engine.js +785 -0
- package/dist/external-sync/sync-engine.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/services/inbox.js +1 -1
- package/dist/sync/hash.d.ts +5 -0
- package/dist/sync/hash.d.ts.map +1 -1
- package/dist/sync/hash.js +21 -2
- package/dist/sync/hash.js.map +1 -1
- package/package.json +11 -5
|
@@ -0,0 +1,1647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External Sync Commands - Manage bidirectional sync with external services
|
|
3
|
+
*
|
|
4
|
+
* Provides CLI commands for external service synchronization:
|
|
5
|
+
* - config: Show/set provider configuration (tokens, projects)
|
|
6
|
+
* - link: Link a task to an external issue
|
|
7
|
+
* - link-all: Bulk-link all unlinked tasks to external issues
|
|
8
|
+
* - unlink: Remove external link from a task
|
|
9
|
+
* - push: Push linked tasks to external service
|
|
10
|
+
* - pull: Pull changes from external for linked tasks
|
|
11
|
+
* - sync: Bidirectional sync (push + pull)
|
|
12
|
+
* - status: Show sync state overview
|
|
13
|
+
* - resolve: Resolve sync conflicts
|
|
14
|
+
*/
|
|
15
|
+
import { success, failure, ExitCode } from '../types.js';
|
|
16
|
+
import { getOutputMode } from '../formatter.js';
|
|
17
|
+
import { createAPI, resolveDatabasePath } from '../db.js';
|
|
18
|
+
import { createStorage, initializeSchema } from '@stoneforge/storage';
|
|
19
|
+
import { getValue, setValue, VALID_AUTO_LINK_PROVIDERS } from '../../config/index.js';
|
|
20
|
+
import { taskToExternalTask, getFieldMapConfigForProvider } from '../../external-sync/adapters/task-sync-adapter.js';
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Settings Service Helper
|
|
23
|
+
// ============================================================================
|
|
24
|
+
/**
|
|
25
|
+
* Dynamically imports and creates a SettingsService from a storage backend.
|
|
26
|
+
* Uses optional peer dependency @stoneforge/smithy.
|
|
27
|
+
*/
|
|
28
|
+
async function createSettingsServiceFromOptions(options) {
|
|
29
|
+
const dbPath = resolveDatabasePath(options);
|
|
30
|
+
if (!dbPath) {
|
|
31
|
+
return {
|
|
32
|
+
settingsService: null,
|
|
33
|
+
error: 'No database found. Run "sf init" to initialize a workspace, or specify --db path',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const backend = createStorage({ path: dbPath, create: true });
|
|
38
|
+
initializeSchema(backend);
|
|
39
|
+
// Dynamic import to handle optional peer dependency
|
|
40
|
+
const { createSettingsService } = await import('@stoneforge/smithy/services');
|
|
41
|
+
return { settingsService: createSettingsService(backend) };
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
45
|
+
// If the import fails, the smithy package isn't available
|
|
46
|
+
if (message.includes('Cannot find') || message.includes('MODULE_NOT_FOUND')) {
|
|
47
|
+
return {
|
|
48
|
+
settingsService: null,
|
|
49
|
+
error: 'External sync requires @stoneforge/smithy package. Ensure it is installed.',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
settingsService: null,
|
|
54
|
+
error: `Failed to initialize settings: ${message}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Token Masking
|
|
60
|
+
// ============================================================================
|
|
61
|
+
/**
|
|
62
|
+
* Masks a token for display, showing only first 4 and last 4 characters
|
|
63
|
+
*/
|
|
64
|
+
function maskToken(token) {
|
|
65
|
+
if (token.length <= 8) {
|
|
66
|
+
return '****';
|
|
67
|
+
}
|
|
68
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
69
|
+
}
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Config Command
|
|
72
|
+
// ============================================================================
|
|
73
|
+
async function configHandler(args, options) {
|
|
74
|
+
const { settingsService, error } = await createSettingsServiceFromOptions(options);
|
|
75
|
+
if (error) {
|
|
76
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
77
|
+
}
|
|
78
|
+
const settings = settingsService.getExternalSyncSettings();
|
|
79
|
+
const mode = getOutputMode(options);
|
|
80
|
+
// Also get file-based config for display
|
|
81
|
+
const enabled = getValue('externalSync.enabled');
|
|
82
|
+
const conflictStrategy = getValue('externalSync.conflictStrategy');
|
|
83
|
+
const defaultDirection = getValue('externalSync.defaultDirection');
|
|
84
|
+
const pollInterval = getValue('externalSync.pollInterval');
|
|
85
|
+
const autoLink = getValue('externalSync.autoLink');
|
|
86
|
+
const autoLinkProvider = getValue('externalSync.autoLinkProvider');
|
|
87
|
+
const configData = {
|
|
88
|
+
enabled,
|
|
89
|
+
conflictStrategy,
|
|
90
|
+
defaultDirection,
|
|
91
|
+
pollInterval,
|
|
92
|
+
autoLink,
|
|
93
|
+
autoLinkProvider,
|
|
94
|
+
providers: Object.fromEntries(Object.entries(settings.providers).map(([name, config]) => [
|
|
95
|
+
name,
|
|
96
|
+
{
|
|
97
|
+
...config,
|
|
98
|
+
token: config.token ? maskToken(config.token) : undefined,
|
|
99
|
+
},
|
|
100
|
+
])),
|
|
101
|
+
};
|
|
102
|
+
if (mode === 'json') {
|
|
103
|
+
return success(configData);
|
|
104
|
+
}
|
|
105
|
+
if (mode === 'quiet') {
|
|
106
|
+
const providerNames = Object.keys(settings.providers);
|
|
107
|
+
return success(providerNames.length > 0 ? providerNames.join(',') : 'none');
|
|
108
|
+
}
|
|
109
|
+
// Human-readable output
|
|
110
|
+
const lines = [
|
|
111
|
+
'External Sync Configuration',
|
|
112
|
+
'',
|
|
113
|
+
` Enabled: ${enabled ? 'yes' : 'no'}`,
|
|
114
|
+
` Conflict strategy: ${conflictStrategy}`,
|
|
115
|
+
` Default direction: ${defaultDirection}`,
|
|
116
|
+
` Poll interval: ${pollInterval}ms`,
|
|
117
|
+
` Auto-link: ${autoLink ? 'yes' : 'no'}`,
|
|
118
|
+
` Auto-link provider: ${autoLinkProvider ?? '(not set)'}`,
|
|
119
|
+
'',
|
|
120
|
+
];
|
|
121
|
+
const providerEntries = Object.entries(settings.providers);
|
|
122
|
+
if (providerEntries.length === 0) {
|
|
123
|
+
lines.push(' Providers: (none configured)');
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(' Run "sf external-sync config set-token <provider> <token>" to configure a provider.');
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
lines.push(' Providers:');
|
|
129
|
+
for (const [name, config] of providerEntries) {
|
|
130
|
+
lines.push(` ${name}:`);
|
|
131
|
+
lines.push(` Token: ${config.token ? maskToken(config.token) : '(not set)'}`);
|
|
132
|
+
lines.push(` API URL: ${config.apiBaseUrl ?? '(default)'}`);
|
|
133
|
+
lines.push(` Default project: ${config.defaultProject ?? '(not set)'}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return success(configData, lines.join('\n'));
|
|
137
|
+
}
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Config Set-Token Command
|
|
140
|
+
// ============================================================================
|
|
141
|
+
async function configSetTokenHandler(args, options) {
|
|
142
|
+
if (args.length < 2) {
|
|
143
|
+
return failure('Usage: sf external-sync config set-token <provider> <token>', ExitCode.INVALID_ARGUMENTS);
|
|
144
|
+
}
|
|
145
|
+
const [provider, token] = args;
|
|
146
|
+
const { settingsService, error } = await createSettingsServiceFromOptions(options);
|
|
147
|
+
if (error) {
|
|
148
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
149
|
+
}
|
|
150
|
+
const existing = settingsService.getProviderConfig(provider) ?? { provider };
|
|
151
|
+
settingsService.setProviderConfig(provider, { ...existing, token });
|
|
152
|
+
const mode = getOutputMode(options);
|
|
153
|
+
if (mode === 'json') {
|
|
154
|
+
return success({ provider, tokenSet: true });
|
|
155
|
+
}
|
|
156
|
+
if (mode === 'quiet') {
|
|
157
|
+
return success(provider);
|
|
158
|
+
}
|
|
159
|
+
return success({ provider, tokenSet: true }, `Token set for provider "${provider}" (${maskToken(token)})`);
|
|
160
|
+
}
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Config Set-Project Command
|
|
163
|
+
// ============================================================================
|
|
164
|
+
async function configSetProjectHandler(args, options) {
|
|
165
|
+
if (args.length < 2) {
|
|
166
|
+
return failure('Usage: sf external-sync config set-project <provider> <project>', ExitCode.INVALID_ARGUMENTS);
|
|
167
|
+
}
|
|
168
|
+
const [provider, project] = args;
|
|
169
|
+
const { settingsService, error } = await createSettingsServiceFromOptions(options);
|
|
170
|
+
if (error) {
|
|
171
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
172
|
+
}
|
|
173
|
+
const existing = settingsService.getProviderConfig(provider) ?? { provider };
|
|
174
|
+
settingsService.setProviderConfig(provider, { ...existing, defaultProject: project });
|
|
175
|
+
const mode = getOutputMode(options);
|
|
176
|
+
if (mode === 'json') {
|
|
177
|
+
return success({ provider, defaultProject: project });
|
|
178
|
+
}
|
|
179
|
+
if (mode === 'quiet') {
|
|
180
|
+
return success(project);
|
|
181
|
+
}
|
|
182
|
+
return success({ provider, defaultProject: project }, `Default project set for provider "${provider}": ${project}`);
|
|
183
|
+
}
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// Config Set-Auto-Link Command
|
|
186
|
+
// ============================================================================
|
|
187
|
+
async function configSetAutoLinkHandler(args, options) {
|
|
188
|
+
if (args.length < 1) {
|
|
189
|
+
return failure('Usage: sf external-sync config set-auto-link <provider>', ExitCode.INVALID_ARGUMENTS);
|
|
190
|
+
}
|
|
191
|
+
const [provider] = args;
|
|
192
|
+
// Validate provider name
|
|
193
|
+
if (!VALID_AUTO_LINK_PROVIDERS.includes(provider)) {
|
|
194
|
+
return failure(`Invalid provider "${provider}". Must be one of: ${VALID_AUTO_LINK_PROVIDERS.join(', ')}`, ExitCode.VALIDATION);
|
|
195
|
+
}
|
|
196
|
+
// Check if provider has a token configured
|
|
197
|
+
const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
|
|
198
|
+
let tokenWarning;
|
|
199
|
+
if (!settingsError) {
|
|
200
|
+
const providerConfig = settingsService.getProviderConfig(provider);
|
|
201
|
+
if (!providerConfig?.token) {
|
|
202
|
+
tokenWarning = `Warning: Provider "${provider}" has no token configured. Auto-link will not work until a token is set. Run "sf external-sync config set-token ${provider} <token>".`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Set both autoLink and autoLinkProvider
|
|
206
|
+
setValue('externalSync.autoLink', true);
|
|
207
|
+
setValue('externalSync.autoLinkProvider', provider);
|
|
208
|
+
const mode = getOutputMode(options);
|
|
209
|
+
if (mode === 'json') {
|
|
210
|
+
return success({ autoLink: true, autoLinkProvider: provider, warning: tokenWarning });
|
|
211
|
+
}
|
|
212
|
+
if (mode === 'quiet') {
|
|
213
|
+
return success(provider);
|
|
214
|
+
}
|
|
215
|
+
const lines = [`Auto-link enabled with provider "${provider}".`];
|
|
216
|
+
if (tokenWarning) {
|
|
217
|
+
lines.push('');
|
|
218
|
+
lines.push(tokenWarning);
|
|
219
|
+
}
|
|
220
|
+
return success({ autoLink: true, autoLinkProvider: provider }, lines.join('\n'));
|
|
221
|
+
}
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// Config Disable-Auto-Link Command
|
|
224
|
+
// ============================================================================
|
|
225
|
+
async function configDisableAutoLinkHandler(_args, options) {
|
|
226
|
+
// Set autoLink to false and clear autoLinkProvider
|
|
227
|
+
setValue('externalSync.autoLink', false);
|
|
228
|
+
setValue('externalSync.autoLinkProvider', undefined);
|
|
229
|
+
const mode = getOutputMode(options);
|
|
230
|
+
if (mode === 'json') {
|
|
231
|
+
return success({ autoLink: false, autoLinkProvider: null });
|
|
232
|
+
}
|
|
233
|
+
if (mode === 'quiet') {
|
|
234
|
+
return success('disabled');
|
|
235
|
+
}
|
|
236
|
+
return success({ autoLink: false }, 'Auto-link disabled.');
|
|
237
|
+
}
|
|
238
|
+
const linkOptions = [
|
|
239
|
+
{
|
|
240
|
+
name: 'provider',
|
|
241
|
+
short: 'p',
|
|
242
|
+
description: 'Provider name (default: github)',
|
|
243
|
+
hasValue: true,
|
|
244
|
+
},
|
|
245
|
+
];
|
|
246
|
+
async function linkHandler(args, options) {
|
|
247
|
+
if (args.length < 2) {
|
|
248
|
+
return failure('Usage: sf external-sync link <taskId> <url-or-issue-number>', ExitCode.INVALID_ARGUMENTS);
|
|
249
|
+
}
|
|
250
|
+
const [taskId, urlOrNumber] = args;
|
|
251
|
+
const provider = options.provider ?? 'github';
|
|
252
|
+
const { api, error } = createAPI(options);
|
|
253
|
+
if (error) {
|
|
254
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
255
|
+
}
|
|
256
|
+
// Resolve task
|
|
257
|
+
let task;
|
|
258
|
+
try {
|
|
259
|
+
task = await api.get(taskId);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
|
|
263
|
+
}
|
|
264
|
+
if (!task) {
|
|
265
|
+
return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
|
|
266
|
+
}
|
|
267
|
+
if (task.type !== 'task') {
|
|
268
|
+
return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
|
|
269
|
+
}
|
|
270
|
+
// Determine the external URL
|
|
271
|
+
let externalUrl;
|
|
272
|
+
let externalId;
|
|
273
|
+
if (/^\d+$/.test(urlOrNumber)) {
|
|
274
|
+
// Bare number — construct URL from default project
|
|
275
|
+
const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
|
|
276
|
+
if (settingsError) {
|
|
277
|
+
return failure(settingsError, ExitCode.GENERAL_ERROR);
|
|
278
|
+
}
|
|
279
|
+
const providerConfig = settingsService.getProviderConfig(provider);
|
|
280
|
+
if (!providerConfig?.defaultProject) {
|
|
281
|
+
return failure(`No default project configured for provider "${provider}". ` +
|
|
282
|
+
`Run "sf external-sync config set-project ${provider} <owner/repo>" first, ` +
|
|
283
|
+
`or provide a full URL.`, ExitCode.VALIDATION);
|
|
284
|
+
}
|
|
285
|
+
externalId = urlOrNumber;
|
|
286
|
+
if (provider === 'github') {
|
|
287
|
+
const baseUrl = providerConfig.apiBaseUrl
|
|
288
|
+
? providerConfig.apiBaseUrl.replace(/\/api\/v3\/?$/, '').replace(/\/$/, '')
|
|
289
|
+
: 'https://github.com';
|
|
290
|
+
externalUrl = `${baseUrl}/${providerConfig.defaultProject}/issues/${urlOrNumber}`;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
// Generic URL construction for other providers
|
|
294
|
+
externalUrl = `${providerConfig.defaultProject}#${urlOrNumber}`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Full URL provided
|
|
299
|
+
externalUrl = urlOrNumber;
|
|
300
|
+
// Extract issue number from URL
|
|
301
|
+
const match = urlOrNumber.match(/\/(\d+)\/?$/);
|
|
302
|
+
externalId = match ? match[1] : urlOrNumber;
|
|
303
|
+
}
|
|
304
|
+
// Extract project from URL if possible
|
|
305
|
+
let project;
|
|
306
|
+
const ghMatch = externalUrl.match(/github\.com\/([^/]+\/[^/]+)\//);
|
|
307
|
+
if (ghMatch) {
|
|
308
|
+
project = ghMatch[1];
|
|
309
|
+
}
|
|
310
|
+
// Update task with externalRef and _externalSync metadata
|
|
311
|
+
const syncMetadata = {
|
|
312
|
+
provider,
|
|
313
|
+
project: project ?? '',
|
|
314
|
+
externalId,
|
|
315
|
+
url: externalUrl,
|
|
316
|
+
direction: getValue('externalSync.defaultDirection'),
|
|
317
|
+
adapterType: 'task',
|
|
318
|
+
};
|
|
319
|
+
try {
|
|
320
|
+
const existingMetadata = (task.metadata ?? {});
|
|
321
|
+
await api.update(taskId, {
|
|
322
|
+
externalRef: externalUrl,
|
|
323
|
+
metadata: {
|
|
324
|
+
...existingMetadata,
|
|
325
|
+
_externalSync: syncMetadata,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
331
|
+
return failure(`Failed to update task: ${message}`, ExitCode.GENERAL_ERROR);
|
|
332
|
+
}
|
|
333
|
+
const mode = getOutputMode(options);
|
|
334
|
+
if (mode === 'json') {
|
|
335
|
+
return success({ taskId, externalUrl, provider, externalId, project });
|
|
336
|
+
}
|
|
337
|
+
if (mode === 'quiet') {
|
|
338
|
+
return success(externalUrl);
|
|
339
|
+
}
|
|
340
|
+
return success({ taskId, externalUrl, provider, externalId }, `Linked task ${taskId} to ${externalUrl}`);
|
|
341
|
+
}
|
|
342
|
+
// ============================================================================
|
|
343
|
+
// Unlink Command
|
|
344
|
+
// ============================================================================
|
|
345
|
+
async function unlinkHandler(args, options) {
|
|
346
|
+
if (args.length < 1) {
|
|
347
|
+
return failure('Usage: sf external-sync unlink <taskId>', ExitCode.INVALID_ARGUMENTS);
|
|
348
|
+
}
|
|
349
|
+
const [taskId] = args;
|
|
350
|
+
const { api, error } = createAPI(options);
|
|
351
|
+
if (error) {
|
|
352
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
353
|
+
}
|
|
354
|
+
// Resolve task
|
|
355
|
+
let task;
|
|
356
|
+
try {
|
|
357
|
+
task = await api.get(taskId);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
|
|
361
|
+
}
|
|
362
|
+
if (!task) {
|
|
363
|
+
return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
|
|
364
|
+
}
|
|
365
|
+
if (task.type !== 'task') {
|
|
366
|
+
return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
|
|
367
|
+
}
|
|
368
|
+
if (!task.externalRef) {
|
|
369
|
+
return failure(`Task ${taskId} is not linked to an external issue`, ExitCode.VALIDATION);
|
|
370
|
+
}
|
|
371
|
+
// Clear externalRef and _externalSync metadata
|
|
372
|
+
try {
|
|
373
|
+
const existingMetadata = (task.metadata ?? {});
|
|
374
|
+
const { _externalSync: _, ...restMetadata } = existingMetadata;
|
|
375
|
+
await api.update(taskId, {
|
|
376
|
+
externalRef: undefined,
|
|
377
|
+
metadata: restMetadata,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
382
|
+
return failure(`Failed to update task: ${message}`, ExitCode.GENERAL_ERROR);
|
|
383
|
+
}
|
|
384
|
+
const mode = getOutputMode(options);
|
|
385
|
+
if (mode === 'json') {
|
|
386
|
+
return success({ taskId, unlinked: true });
|
|
387
|
+
}
|
|
388
|
+
if (mode === 'quiet') {
|
|
389
|
+
return success(taskId);
|
|
390
|
+
}
|
|
391
|
+
return success({ taskId, unlinked: true }, `Unlinked task ${taskId} from external issue`);
|
|
392
|
+
}
|
|
393
|
+
const pushOptions = [
|
|
394
|
+
{
|
|
395
|
+
name: 'all',
|
|
396
|
+
short: 'a',
|
|
397
|
+
description: 'Push all linked tasks',
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'force',
|
|
401
|
+
short: 'f',
|
|
402
|
+
description: 'Push all linked tasks regardless of whether they have changed',
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
async function pushHandler(args, options) {
|
|
406
|
+
const { api, error } = createAPI(options);
|
|
407
|
+
if (error) {
|
|
408
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
409
|
+
}
|
|
410
|
+
// Get settings service to create a configured sync engine
|
|
411
|
+
const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
|
|
412
|
+
if (settingsError) {
|
|
413
|
+
return failure(settingsError, ExitCode.GENERAL_ERROR);
|
|
414
|
+
}
|
|
415
|
+
const syncSettings = settingsService.getExternalSyncSettings();
|
|
416
|
+
const providerConfigs = Object.values(syncSettings.providers).filter((p) => !!p.token);
|
|
417
|
+
if (providerConfigs.length === 0) {
|
|
418
|
+
return failure('No providers configured with tokens. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
|
|
419
|
+
}
|
|
420
|
+
// Build sync options
|
|
421
|
+
const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
|
|
422
|
+
const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
|
|
423
|
+
provider: p.provider,
|
|
424
|
+
token: p.token,
|
|
425
|
+
apiBaseUrl: p.apiBaseUrl,
|
|
426
|
+
defaultProject: p.defaultProject,
|
|
427
|
+
})));
|
|
428
|
+
const engine = createSyncEngine({
|
|
429
|
+
api,
|
|
430
|
+
registry,
|
|
431
|
+
settings: settingsService,
|
|
432
|
+
providerConfigs: providerConfigs.map((p) => ({
|
|
433
|
+
provider: p.provider,
|
|
434
|
+
token: p.token,
|
|
435
|
+
apiBaseUrl: p.apiBaseUrl,
|
|
436
|
+
defaultProject: p.defaultProject,
|
|
437
|
+
})),
|
|
438
|
+
});
|
|
439
|
+
// Build push options
|
|
440
|
+
const syncPushOptions = {};
|
|
441
|
+
if (options.all) {
|
|
442
|
+
syncPushOptions.all = true;
|
|
443
|
+
}
|
|
444
|
+
else if (args.length > 0) {
|
|
445
|
+
syncPushOptions.taskIds = args;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
return failure('Usage: sf external-sync push [taskId...] or sf external-sync push --all', ExitCode.INVALID_ARGUMENTS);
|
|
449
|
+
}
|
|
450
|
+
if (options.force) {
|
|
451
|
+
syncPushOptions.force = true;
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const result = await engine.push(syncPushOptions);
|
|
455
|
+
const mode = getOutputMode(options);
|
|
456
|
+
const output = {
|
|
457
|
+
success: result.success,
|
|
458
|
+
pushed: result.pushed,
|
|
459
|
+
skipped: result.skipped,
|
|
460
|
+
errors: result.errors,
|
|
461
|
+
conflicts: result.conflicts,
|
|
462
|
+
};
|
|
463
|
+
if (mode === 'json') {
|
|
464
|
+
return success(output);
|
|
465
|
+
}
|
|
466
|
+
if (mode === 'quiet') {
|
|
467
|
+
return success(String(result.pushed));
|
|
468
|
+
}
|
|
469
|
+
const lines = [
|
|
470
|
+
`Push: ${result.pushed} task(s) pushed successfully`,
|
|
471
|
+
'',
|
|
472
|
+
];
|
|
473
|
+
if (result.skipped > 0) {
|
|
474
|
+
lines.push(`Skipped: ${result.skipped}`);
|
|
475
|
+
}
|
|
476
|
+
if (result.errors.length > 0) {
|
|
477
|
+
lines.push('');
|
|
478
|
+
lines.push(`Errors (${result.errors.length}):`);
|
|
479
|
+
for (const err of result.errors) {
|
|
480
|
+
lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (result.conflicts.length > 0) {
|
|
484
|
+
lines.push('');
|
|
485
|
+
lines.push(`Conflicts (${result.conflicts.length}):`);
|
|
486
|
+
for (const conflict of result.conflicts) {
|
|
487
|
+
lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return success(output, lines.join('\n'));
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
494
|
+
return failure(`Push failed: ${message}`, ExitCode.GENERAL_ERROR);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const pullOptions = [
|
|
498
|
+
{
|
|
499
|
+
name: 'provider',
|
|
500
|
+
short: 'p',
|
|
501
|
+
description: 'Provider to pull from (default: all configured)',
|
|
502
|
+
hasValue: true,
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
name: 'discover',
|
|
506
|
+
short: 'd',
|
|
507
|
+
description: 'Discover new issues not yet linked',
|
|
508
|
+
},
|
|
509
|
+
];
|
|
510
|
+
async function pullHandler(_args, options) {
|
|
511
|
+
const { api, error } = createAPI(options);
|
|
512
|
+
if (error) {
|
|
513
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
514
|
+
}
|
|
515
|
+
const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
|
|
516
|
+
if (settingsError) {
|
|
517
|
+
return failure(settingsError, ExitCode.GENERAL_ERROR);
|
|
518
|
+
}
|
|
519
|
+
const settings = settingsService.getExternalSyncSettings();
|
|
520
|
+
const providerNames = options.provider
|
|
521
|
+
? [options.provider]
|
|
522
|
+
: Object.keys(settings.providers);
|
|
523
|
+
if (providerNames.length === 0) {
|
|
524
|
+
return failure('No providers configured. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.VALIDATION);
|
|
525
|
+
}
|
|
526
|
+
// Validate providers have tokens and build provider configs
|
|
527
|
+
const providerConfigs = [];
|
|
528
|
+
const invalidProviders = [];
|
|
529
|
+
for (const name of providerNames) {
|
|
530
|
+
const config = settings.providers[name];
|
|
531
|
+
if (config?.token) {
|
|
532
|
+
providerConfigs.push({
|
|
533
|
+
provider: config.provider,
|
|
534
|
+
token: config.token,
|
|
535
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
536
|
+
defaultProject: config.defaultProject,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
invalidProviders.push(name);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (providerConfigs.length === 0) {
|
|
544
|
+
return failure('No providers with valid tokens found. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
|
|
545
|
+
}
|
|
546
|
+
// Create sync engine (same pattern as pushHandler)
|
|
547
|
+
const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
|
|
548
|
+
const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
|
|
549
|
+
provider: p.provider,
|
|
550
|
+
token: p.token,
|
|
551
|
+
apiBaseUrl: p.apiBaseUrl,
|
|
552
|
+
defaultProject: p.defaultProject,
|
|
553
|
+
})));
|
|
554
|
+
const engine = createSyncEngine({
|
|
555
|
+
api,
|
|
556
|
+
registry,
|
|
557
|
+
settings: settingsService,
|
|
558
|
+
providerConfigs: providerConfigs.map((p) => ({
|
|
559
|
+
provider: p.provider,
|
|
560
|
+
token: p.token,
|
|
561
|
+
apiBaseUrl: p.apiBaseUrl,
|
|
562
|
+
defaultProject: p.defaultProject,
|
|
563
|
+
})),
|
|
564
|
+
});
|
|
565
|
+
// Build pull options — discover maps to 'all' to create local tasks for unlinked external issues
|
|
566
|
+
const syncPullOptions = {};
|
|
567
|
+
if (options.discover) {
|
|
568
|
+
syncPullOptions.all = true;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const result = await engine.pull(syncPullOptions);
|
|
572
|
+
const mode = getOutputMode(options);
|
|
573
|
+
const output = {
|
|
574
|
+
success: result.success,
|
|
575
|
+
pulled: result.pulled,
|
|
576
|
+
skipped: result.skipped,
|
|
577
|
+
errors: result.errors,
|
|
578
|
+
conflicts: result.conflicts,
|
|
579
|
+
invalidProviders,
|
|
580
|
+
};
|
|
581
|
+
if (mode === 'json') {
|
|
582
|
+
return success(output);
|
|
583
|
+
}
|
|
584
|
+
if (mode === 'quiet') {
|
|
585
|
+
return success(String(result.pulled));
|
|
586
|
+
}
|
|
587
|
+
const lines = [
|
|
588
|
+
`Pull: ${result.pulled} task(s) pulled successfully`,
|
|
589
|
+
'',
|
|
590
|
+
];
|
|
591
|
+
if (result.skipped > 0) {
|
|
592
|
+
lines.push(`Skipped: ${result.skipped}`);
|
|
593
|
+
}
|
|
594
|
+
if (invalidProviders.length > 0) {
|
|
595
|
+
lines.push('');
|
|
596
|
+
lines.push(`Skipped providers (no token): ${invalidProviders.join(', ')}`);
|
|
597
|
+
}
|
|
598
|
+
if (result.errors.length > 0) {
|
|
599
|
+
lines.push('');
|
|
600
|
+
lines.push(`Errors (${result.errors.length}):`);
|
|
601
|
+
for (const err of result.errors) {
|
|
602
|
+
lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (result.conflicts.length > 0) {
|
|
606
|
+
lines.push('');
|
|
607
|
+
lines.push(`Conflicts (${result.conflicts.length}):`);
|
|
608
|
+
for (const conflict of result.conflicts) {
|
|
609
|
+
lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return success(output, lines.join('\n'));
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
616
|
+
return failure(`Pull failed: ${message}`, ExitCode.GENERAL_ERROR);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const syncOptions = [
|
|
620
|
+
{
|
|
621
|
+
name: 'dry-run',
|
|
622
|
+
short: 'n',
|
|
623
|
+
description: 'Show what would change without making changes',
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
async function syncHandler(_args, options) {
|
|
627
|
+
const { api, error: apiError } = createAPI(options);
|
|
628
|
+
if (apiError) {
|
|
629
|
+
return failure(apiError, ExitCode.GENERAL_ERROR);
|
|
630
|
+
}
|
|
631
|
+
const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
|
|
632
|
+
if (settingsError) {
|
|
633
|
+
return failure(settingsError, ExitCode.GENERAL_ERROR);
|
|
634
|
+
}
|
|
635
|
+
const syncSettings = settingsService.getExternalSyncSettings();
|
|
636
|
+
const isDryRun = options['dry-run'] ?? false;
|
|
637
|
+
const providerConfigs = Object.values(syncSettings.providers).filter((p) => !!p.token);
|
|
638
|
+
if (providerConfigs.length === 0) {
|
|
639
|
+
return failure('No providers configured with tokens. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
|
|
640
|
+
}
|
|
641
|
+
// Create sync engine (same pattern as pushHandler)
|
|
642
|
+
const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
|
|
643
|
+
const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
|
|
644
|
+
provider: p.provider,
|
|
645
|
+
token: p.token,
|
|
646
|
+
apiBaseUrl: p.apiBaseUrl,
|
|
647
|
+
defaultProject: p.defaultProject,
|
|
648
|
+
})));
|
|
649
|
+
const engine = createSyncEngine({
|
|
650
|
+
api,
|
|
651
|
+
registry,
|
|
652
|
+
settings: settingsService,
|
|
653
|
+
providerConfigs: providerConfigs.map((p) => ({
|
|
654
|
+
provider: p.provider,
|
|
655
|
+
token: p.token,
|
|
656
|
+
apiBaseUrl: p.apiBaseUrl,
|
|
657
|
+
defaultProject: p.defaultProject,
|
|
658
|
+
})),
|
|
659
|
+
});
|
|
660
|
+
// Build sync options
|
|
661
|
+
const syncOpts = {};
|
|
662
|
+
if (isDryRun) {
|
|
663
|
+
syncOpts.dryRun = true;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const result = await engine.sync(syncOpts);
|
|
667
|
+
const mode = getOutputMode(options);
|
|
668
|
+
const output = {
|
|
669
|
+
success: result.success,
|
|
670
|
+
dryRun: isDryRun,
|
|
671
|
+
pushed: result.pushed,
|
|
672
|
+
pulled: result.pulled,
|
|
673
|
+
skipped: result.skipped,
|
|
674
|
+
errors: result.errors,
|
|
675
|
+
conflicts: result.conflicts,
|
|
676
|
+
};
|
|
677
|
+
if (mode === 'json') {
|
|
678
|
+
return success(output);
|
|
679
|
+
}
|
|
680
|
+
if (mode === 'quiet') {
|
|
681
|
+
return success(`${result.pushed}/${result.pulled}`);
|
|
682
|
+
}
|
|
683
|
+
const lines = [
|
|
684
|
+
isDryRun ? 'Sync (dry run) - showing what would change' : 'Bidirectional Sync Complete',
|
|
685
|
+
'',
|
|
686
|
+
` Pushed: ${result.pushed} task(s)`,
|
|
687
|
+
` Pulled: ${result.pulled} task(s)`,
|
|
688
|
+
];
|
|
689
|
+
if (result.skipped > 0) {
|
|
690
|
+
lines.push(` Skipped: ${result.skipped} (no changes)`);
|
|
691
|
+
}
|
|
692
|
+
if (result.errors.length > 0) {
|
|
693
|
+
lines.push('');
|
|
694
|
+
lines.push(`Errors (${result.errors.length}):`);
|
|
695
|
+
for (const err of result.errors) {
|
|
696
|
+
lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (result.conflicts.length > 0) {
|
|
700
|
+
lines.push('');
|
|
701
|
+
lines.push(`Conflicts (${result.conflicts.length}):`);
|
|
702
|
+
for (const conflict of result.conflicts) {
|
|
703
|
+
lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return success(output, lines.join('\n'));
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
710
|
+
return failure(`Sync failed: ${message}`, ExitCode.GENERAL_ERROR);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// ============================================================================
|
|
714
|
+
// Status Command
|
|
715
|
+
// ============================================================================
|
|
716
|
+
async function statusHandler(_args, options) {
|
|
717
|
+
const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
|
|
718
|
+
if (settingsError) {
|
|
719
|
+
return failure(settingsError, ExitCode.GENERAL_ERROR);
|
|
720
|
+
}
|
|
721
|
+
const { api, error: apiError } = createAPI(options);
|
|
722
|
+
if (apiError) {
|
|
723
|
+
return failure(apiError, ExitCode.GENERAL_ERROR);
|
|
724
|
+
}
|
|
725
|
+
const settings = settingsService.getExternalSyncSettings();
|
|
726
|
+
const enabled = getValue('externalSync.enabled');
|
|
727
|
+
// Count linked tasks and check for conflicts
|
|
728
|
+
let linkedCount = 0;
|
|
729
|
+
let conflictCount = 0;
|
|
730
|
+
const providerCounts = {};
|
|
731
|
+
try {
|
|
732
|
+
const allTasks = await api.list({ type: 'task' });
|
|
733
|
+
for (const t of allTasks) {
|
|
734
|
+
const task = t;
|
|
735
|
+
if (task.externalRef && task.metadata?._externalSync) {
|
|
736
|
+
linkedCount++;
|
|
737
|
+
const syncMeta = task.metadata._externalSync;
|
|
738
|
+
const provider = syncMeta?.provider ?? 'unknown';
|
|
739
|
+
providerCounts[provider] = (providerCounts[provider] ?? 0) + 1;
|
|
740
|
+
}
|
|
741
|
+
if (task.tags?.includes('sync-conflict')) {
|
|
742
|
+
conflictCount++;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
748
|
+
return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
|
|
749
|
+
}
|
|
750
|
+
// Build cursor info
|
|
751
|
+
const cursors = {};
|
|
752
|
+
for (const [key, value] of Object.entries(settings.syncCursors)) {
|
|
753
|
+
cursors[key] = value;
|
|
754
|
+
}
|
|
755
|
+
const mode = getOutputMode(options);
|
|
756
|
+
const statusData = {
|
|
757
|
+
enabled,
|
|
758
|
+
linkedTaskCount: linkedCount,
|
|
759
|
+
conflictCount,
|
|
760
|
+
providerCounts,
|
|
761
|
+
configuredProviders: Object.keys(settings.providers),
|
|
762
|
+
syncCursors: cursors,
|
|
763
|
+
pollIntervalMs: settings.pollIntervalMs,
|
|
764
|
+
defaultDirection: settings.defaultDirection,
|
|
765
|
+
};
|
|
766
|
+
if (mode === 'json') {
|
|
767
|
+
return success(statusData);
|
|
768
|
+
}
|
|
769
|
+
if (mode === 'quiet') {
|
|
770
|
+
return success(`${linkedCount}:${conflictCount}`);
|
|
771
|
+
}
|
|
772
|
+
const lines = [
|
|
773
|
+
'External Sync Status',
|
|
774
|
+
'',
|
|
775
|
+
` Enabled: ${enabled ? 'yes' : 'no'}`,
|
|
776
|
+
` Linked tasks: ${linkedCount}`,
|
|
777
|
+
` Pending conflicts: ${conflictCount}`,
|
|
778
|
+
` Poll interval: ${settings.pollIntervalMs}ms`,
|
|
779
|
+
` Default direction: ${settings.defaultDirection}`,
|
|
780
|
+
'',
|
|
781
|
+
];
|
|
782
|
+
// Provider breakdown
|
|
783
|
+
const providerEntries = Object.entries(settings.providers);
|
|
784
|
+
if (providerEntries.length > 0) {
|
|
785
|
+
lines.push(' Providers:');
|
|
786
|
+
for (const [name, config] of providerEntries) {
|
|
787
|
+
const count = providerCounts[name] ?? 0;
|
|
788
|
+
const hasToken = config.token ? 'yes' : 'no';
|
|
789
|
+
lines.push(` ${name}: ${count} linked task(s), token: ${hasToken}, project: ${config.defaultProject ?? '(not set)'}`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
lines.push(' Providers: (none configured)');
|
|
794
|
+
}
|
|
795
|
+
// Sync cursors
|
|
796
|
+
const cursorEntries = Object.entries(cursors);
|
|
797
|
+
if (cursorEntries.length > 0) {
|
|
798
|
+
lines.push('');
|
|
799
|
+
lines.push(' Last sync cursors:');
|
|
800
|
+
for (const [key, value] of cursorEntries) {
|
|
801
|
+
lines.push(` ${key}: ${value}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (conflictCount > 0) {
|
|
805
|
+
lines.push('');
|
|
806
|
+
lines.push(` ⚠ ${conflictCount} conflict(s) need resolution. Run "sf external-sync resolve <taskId> --keep local|remote".`);
|
|
807
|
+
}
|
|
808
|
+
return success(statusData, lines.join('\n'));
|
|
809
|
+
}
|
|
810
|
+
const resolveOptions = [
|
|
811
|
+
{
|
|
812
|
+
name: 'keep',
|
|
813
|
+
short: 'k',
|
|
814
|
+
description: 'Which version to keep: local or remote',
|
|
815
|
+
hasValue: true,
|
|
816
|
+
required: true,
|
|
817
|
+
},
|
|
818
|
+
];
|
|
819
|
+
async function resolveHandler(args, options) {
|
|
820
|
+
if (args.length < 1) {
|
|
821
|
+
return failure('Usage: sf external-sync resolve <taskId> --keep local|remote', ExitCode.INVALID_ARGUMENTS);
|
|
822
|
+
}
|
|
823
|
+
const [taskId] = args;
|
|
824
|
+
const keep = options.keep;
|
|
825
|
+
if (!keep || (keep !== 'local' && keep !== 'remote')) {
|
|
826
|
+
return failure('The --keep flag is required and must be either "local" or "remote"', ExitCode.INVALID_ARGUMENTS);
|
|
827
|
+
}
|
|
828
|
+
const { api, error } = createAPI(options);
|
|
829
|
+
if (error) {
|
|
830
|
+
return failure(error, ExitCode.GENERAL_ERROR);
|
|
831
|
+
}
|
|
832
|
+
// Resolve task
|
|
833
|
+
let task;
|
|
834
|
+
try {
|
|
835
|
+
task = await api.get(taskId);
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
|
|
839
|
+
}
|
|
840
|
+
if (!task) {
|
|
841
|
+
return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
|
|
842
|
+
}
|
|
843
|
+
if (task.type !== 'task') {
|
|
844
|
+
return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
|
|
845
|
+
}
|
|
846
|
+
if (!task.tags?.includes('sync-conflict')) {
|
|
847
|
+
return failure(`Task ${taskId} does not have a sync conflict. Only tasks tagged with "sync-conflict" can be resolved.`, ExitCode.VALIDATION);
|
|
848
|
+
}
|
|
849
|
+
// Remove sync-conflict tag and update metadata
|
|
850
|
+
try {
|
|
851
|
+
const newTags = (task.tags ?? []).filter((t) => t !== 'sync-conflict');
|
|
852
|
+
const existingMetadata = (task.metadata ?? {});
|
|
853
|
+
const syncMeta = (existingMetadata._externalSync ?? {});
|
|
854
|
+
// Record resolution in metadata
|
|
855
|
+
const updatedSyncMeta = {
|
|
856
|
+
...syncMeta,
|
|
857
|
+
lastConflictResolution: {
|
|
858
|
+
resolvedAt: new Date().toISOString(),
|
|
859
|
+
kept: keep,
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
// Clear conflict data from metadata
|
|
863
|
+
const { _syncConflict: _, ...restMetadata } = existingMetadata;
|
|
864
|
+
await api.update(taskId, {
|
|
865
|
+
tags: newTags,
|
|
866
|
+
metadata: {
|
|
867
|
+
...restMetadata,
|
|
868
|
+
_externalSync: updatedSyncMeta,
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
874
|
+
return failure(`Failed to resolve conflict: ${message}`, ExitCode.GENERAL_ERROR);
|
|
875
|
+
}
|
|
876
|
+
const mode = getOutputMode(options);
|
|
877
|
+
if (mode === 'json') {
|
|
878
|
+
return success({ taskId, resolved: true, kept: keep });
|
|
879
|
+
}
|
|
880
|
+
if (mode === 'quiet') {
|
|
881
|
+
return success(taskId);
|
|
882
|
+
}
|
|
883
|
+
return success({ taskId, resolved: true, kept: keep }, `Resolved sync conflict for task ${taskId} (kept: ${keep})`);
|
|
884
|
+
}
|
|
885
|
+
const linkAllOptions = [
|
|
886
|
+
{
|
|
887
|
+
name: 'provider',
|
|
888
|
+
short: 'p',
|
|
889
|
+
description: 'Provider to link to (required)',
|
|
890
|
+
hasValue: true,
|
|
891
|
+
required: true,
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
name: 'project',
|
|
895
|
+
description: 'Override the default project',
|
|
896
|
+
hasValue: true,
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
name: 'status',
|
|
900
|
+
short: 's',
|
|
901
|
+
description: 'Only link tasks with this status (can be repeated)',
|
|
902
|
+
hasValue: true,
|
|
903
|
+
array: true,
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
name: 'dry-run',
|
|
907
|
+
short: 'n',
|
|
908
|
+
description: 'List tasks that would be linked without creating external issues',
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
name: 'batch-size',
|
|
912
|
+
short: 'b',
|
|
913
|
+
description: 'How many tasks to process concurrently (default: 10)',
|
|
914
|
+
hasValue: true,
|
|
915
|
+
defaultValue: '10',
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
name: 'force',
|
|
919
|
+
short: 'f',
|
|
920
|
+
description: 'Re-link tasks that are already linked to a different provider',
|
|
921
|
+
},
|
|
922
|
+
];
|
|
923
|
+
/**
|
|
924
|
+
* Helper to detect rate limit errors from GitHub or Linear providers.
|
|
925
|
+
* Returns the reset timestamp (epoch seconds) if available, or undefined.
|
|
926
|
+
*/
|
|
927
|
+
function isRateLimitError(err) {
|
|
928
|
+
// Try GitHub error shape
|
|
929
|
+
if (err &&
|
|
930
|
+
typeof err === 'object' &&
|
|
931
|
+
'isRateLimited' in err &&
|
|
932
|
+
err.isRateLimited) {
|
|
933
|
+
const rateLimit = err.rateLimit;
|
|
934
|
+
return { isRateLimit: true, resetAt: rateLimit?.reset };
|
|
935
|
+
}
|
|
936
|
+
// Also check error message for rate limit keywords
|
|
937
|
+
if (err instanceof Error && /rate.limit/i.test(err.message)) {
|
|
938
|
+
return { isRateLimit: true };
|
|
939
|
+
}
|
|
940
|
+
return { isRateLimit: false };
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Extracts validation error details from a GitHub API error response.
|
|
944
|
+
* GitHub's 422 responses include an `errors` array with `resource`, `field`,
|
|
945
|
+
* `code`, and sometimes `value` or `message` entries.
|
|
946
|
+
*
|
|
947
|
+
* Example output: "invalid label: sf:priority:high"
|
|
948
|
+
*/
|
|
949
|
+
function extractValidationDetail(err) {
|
|
950
|
+
if (!err || typeof err !== 'object')
|
|
951
|
+
return null;
|
|
952
|
+
const responseBody = err.responseBody;
|
|
953
|
+
if (!responseBody || !Array.isArray(responseBody.errors))
|
|
954
|
+
return null;
|
|
955
|
+
const details = responseBody.errors
|
|
956
|
+
.map((e) => {
|
|
957
|
+
const parts = [];
|
|
958
|
+
if (e.code && typeof e.code === 'string')
|
|
959
|
+
parts.push(e.code);
|
|
960
|
+
if (e.field && typeof e.field === 'string')
|
|
961
|
+
parts.push(e.field);
|
|
962
|
+
if (e.value !== undefined)
|
|
963
|
+
parts.push(String(e.value));
|
|
964
|
+
if (e.message && typeof e.message === 'string')
|
|
965
|
+
parts.push(e.message);
|
|
966
|
+
return parts.join(': ');
|
|
967
|
+
})
|
|
968
|
+
.filter(Boolean);
|
|
969
|
+
return details.length > 0 ? details.join('; ') : null;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Creates an ExternalProvider instance from settings for the given provider name.
|
|
973
|
+
* Returns the provider, project, and direction, or an error message.
|
|
974
|
+
*/
|
|
975
|
+
async function createProviderFromSettings(providerName, projectOverride, options) {
|
|
976
|
+
const dbPath = resolveDatabasePath(options);
|
|
977
|
+
if (!dbPath) {
|
|
978
|
+
return { error: 'No database found. Run "sf init" to initialize a workspace, or specify --db path' };
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const backend = createStorage({ path: dbPath, create: true });
|
|
982
|
+
initializeSchema(backend);
|
|
983
|
+
// Dynamic import to handle optional peer dependency
|
|
984
|
+
const { createSettingsService } = await import('@stoneforge/smithy/services');
|
|
985
|
+
const settingsService = createSettingsService(backend);
|
|
986
|
+
const providerConfig = settingsService.getProviderConfig(providerName);
|
|
987
|
+
if (!providerConfig?.token) {
|
|
988
|
+
return { error: `Provider "${providerName}" has no token configured. Run "sf external-sync config set-token ${providerName} <token>" first.` };
|
|
989
|
+
}
|
|
990
|
+
const project = projectOverride ?? providerConfig.defaultProject;
|
|
991
|
+
if (!project) {
|
|
992
|
+
return { error: `No project specified and provider "${providerName}" has no default project configured. Use --project or run "sf external-sync config set-project ${providerName} <project>" first.` };
|
|
993
|
+
}
|
|
994
|
+
let provider;
|
|
995
|
+
if (providerName === 'github') {
|
|
996
|
+
const { createGitHubProvider } = await import('../../external-sync/providers/github/index.js');
|
|
997
|
+
provider = createGitHubProvider({
|
|
998
|
+
provider: 'github',
|
|
999
|
+
token: providerConfig.token,
|
|
1000
|
+
apiBaseUrl: providerConfig.apiBaseUrl,
|
|
1001
|
+
defaultProject: project,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
else if (providerName === 'linear') {
|
|
1005
|
+
const { createLinearProvider } = await import('../../external-sync/providers/linear/index.js');
|
|
1006
|
+
provider = createLinearProvider({
|
|
1007
|
+
apiKey: providerConfig.token,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
return { error: `Unsupported provider: "${providerName}". Supported providers: github, linear` };
|
|
1012
|
+
}
|
|
1013
|
+
const direction = (getValue('externalSync.defaultDirection') ?? 'bidirectional');
|
|
1014
|
+
return { provider, project, direction };
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1018
|
+
if (message.includes('Cannot find') || message.includes('MODULE_NOT_FOUND')) {
|
|
1019
|
+
return { error: 'External sync requires @stoneforge/smithy package. Ensure it is installed.' };
|
|
1020
|
+
}
|
|
1021
|
+
return { error: `Failed to initialize provider: ${message}` };
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Process a batch of tasks: create external issues and link them.
|
|
1026
|
+
* Uses the adapter's field mapping to include priority, taskType, and status
|
|
1027
|
+
* labels on the created external issues.
|
|
1028
|
+
*/
|
|
1029
|
+
async function processBatch(tasks, adapter, api, providerName, project, direction, progressLines) {
|
|
1030
|
+
let succeeded = 0;
|
|
1031
|
+
let failed = 0;
|
|
1032
|
+
let rateLimited = false;
|
|
1033
|
+
let resetAt;
|
|
1034
|
+
const fieldMapConfig = getFieldMapConfigForProvider(providerName);
|
|
1035
|
+
for (const task of tasks) {
|
|
1036
|
+
try {
|
|
1037
|
+
// Build the complete external task input using field mapping.
|
|
1038
|
+
// This maps priority → sf:priority:* labels, taskType → sf:type:* labels,
|
|
1039
|
+
// status → open/closed state, user tags → labels, and hydrates description.
|
|
1040
|
+
const externalInput = await taskToExternalTask(task, fieldMapConfig, api);
|
|
1041
|
+
// Create the external issue with fully mapped fields
|
|
1042
|
+
const externalTask = await adapter.createIssue(project, externalInput);
|
|
1043
|
+
// Build the ExternalSyncState metadata
|
|
1044
|
+
const syncState = {
|
|
1045
|
+
provider: providerName,
|
|
1046
|
+
project,
|
|
1047
|
+
externalId: externalTask.externalId,
|
|
1048
|
+
url: externalTask.url,
|
|
1049
|
+
direction,
|
|
1050
|
+
adapterType: 'task',
|
|
1051
|
+
};
|
|
1052
|
+
// Update the task with externalRef and _externalSync metadata
|
|
1053
|
+
const existingMetadata = (task.metadata ?? {});
|
|
1054
|
+
await api.update(task.id, {
|
|
1055
|
+
externalRef: externalTask.url,
|
|
1056
|
+
metadata: {
|
|
1057
|
+
...existingMetadata,
|
|
1058
|
+
_externalSync: syncState,
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
progressLines.push(`Linked ${task.id} → ${externalTask.url}`);
|
|
1062
|
+
succeeded++;
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
// Check for rate limit errors
|
|
1066
|
+
const rlCheck = isRateLimitError(err);
|
|
1067
|
+
if (rlCheck.isRateLimit) {
|
|
1068
|
+
rateLimited = true;
|
|
1069
|
+
resetAt = rlCheck.resetAt;
|
|
1070
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1071
|
+
progressLines.push(`Rate limit hit while linking ${task.id}: ${message}`);
|
|
1072
|
+
// Stop processing further tasks in this batch
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
// Log warning and continue with next task
|
|
1076
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1077
|
+
const detail = extractValidationDetail(err);
|
|
1078
|
+
progressLines.push(detail
|
|
1079
|
+
? `Failed to link ${task.id}: ${message} — ${detail}`
|
|
1080
|
+
: `Failed to link ${task.id}: ${message}`);
|
|
1081
|
+
failed++;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return { succeeded, failed, rateLimited, resetAt };
|
|
1085
|
+
}
|
|
1086
|
+
async function linkAllHandler(_args, options) {
|
|
1087
|
+
const providerName = options.provider;
|
|
1088
|
+
if (!providerName) {
|
|
1089
|
+
return failure('The --provider flag is required. Usage: sf external-sync link-all --provider <provider>', ExitCode.INVALID_ARGUMENTS);
|
|
1090
|
+
}
|
|
1091
|
+
const isDryRun = options['dry-run'] ?? false;
|
|
1092
|
+
const force = options.force ?? false;
|
|
1093
|
+
const batchSize = parseInt(options['batch-size'] ?? '10', 10);
|
|
1094
|
+
if (isNaN(batchSize) || batchSize < 1) {
|
|
1095
|
+
return failure('--batch-size must be a positive integer', ExitCode.INVALID_ARGUMENTS);
|
|
1096
|
+
}
|
|
1097
|
+
// Parse status filters
|
|
1098
|
+
const statusFilters = [];
|
|
1099
|
+
if (options.status) {
|
|
1100
|
+
if (Array.isArray(options.status)) {
|
|
1101
|
+
statusFilters.push(...options.status);
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
statusFilters.push(options.status);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// Get API for querying/updating tasks
|
|
1108
|
+
const { api, error: apiError } = createAPI(options);
|
|
1109
|
+
if (apiError) {
|
|
1110
|
+
return failure(apiError, ExitCode.GENERAL_ERROR);
|
|
1111
|
+
}
|
|
1112
|
+
// Query all tasks
|
|
1113
|
+
let allTasks;
|
|
1114
|
+
try {
|
|
1115
|
+
const results = await api.list({ type: 'task' });
|
|
1116
|
+
allTasks = results;
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1120
|
+
return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
|
|
1121
|
+
}
|
|
1122
|
+
// Filter tasks: unlinked tasks, plus (with --force) tasks linked to a DIFFERENT provider
|
|
1123
|
+
let relinkedFromProvider;
|
|
1124
|
+
let relinkCount = 0;
|
|
1125
|
+
let tasksToLink = allTasks.filter((task) => {
|
|
1126
|
+
const metadata = (task.metadata ?? {});
|
|
1127
|
+
const syncState = metadata._externalSync;
|
|
1128
|
+
if (!syncState) {
|
|
1129
|
+
// Unlinked task — always include
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
if (force && syncState.provider !== providerName) {
|
|
1133
|
+
// Force mode: include tasks linked to a DIFFERENT provider
|
|
1134
|
+
relinkedFromProvider = syncState.provider;
|
|
1135
|
+
relinkCount++;
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
// Already linked (to same provider, or force not set) — skip
|
|
1139
|
+
return false;
|
|
1140
|
+
});
|
|
1141
|
+
// Apply status filter if specified
|
|
1142
|
+
if (statusFilters.length > 0) {
|
|
1143
|
+
tasksToLink = tasksToLink.filter((task) => statusFilters.includes(task.status));
|
|
1144
|
+
}
|
|
1145
|
+
// Skip tombstone tasks by default (soft-deleted)
|
|
1146
|
+
tasksToLink = tasksToLink.filter((task) => task.status !== 'tombstone');
|
|
1147
|
+
const mode = getOutputMode(options);
|
|
1148
|
+
if (tasksToLink.length === 0) {
|
|
1149
|
+
const result = { linked: 0, failed: 0, skipped: 0, total: 0, dryRun: isDryRun };
|
|
1150
|
+
if (mode === 'json') {
|
|
1151
|
+
return success(result);
|
|
1152
|
+
}
|
|
1153
|
+
const hint = force
|
|
1154
|
+
? 'No tasks found to re-link matching the specified criteria.'
|
|
1155
|
+
: 'No unlinked tasks found. Use --force to re-link tasks from a different provider.';
|
|
1156
|
+
return success(result, hint);
|
|
1157
|
+
}
|
|
1158
|
+
// Dry run — just list tasks that would be linked
|
|
1159
|
+
if (isDryRun) {
|
|
1160
|
+
const taskList = tasksToLink.map((t) => ({
|
|
1161
|
+
id: t.id,
|
|
1162
|
+
title: t.title,
|
|
1163
|
+
status: t.status,
|
|
1164
|
+
}));
|
|
1165
|
+
const jsonResult = {
|
|
1166
|
+
dryRun: true,
|
|
1167
|
+
provider: providerName,
|
|
1168
|
+
total: tasksToLink.length,
|
|
1169
|
+
tasks: taskList,
|
|
1170
|
+
};
|
|
1171
|
+
if (force && relinkCount > 0) {
|
|
1172
|
+
jsonResult.force = true;
|
|
1173
|
+
jsonResult.relinkCount = relinkCount;
|
|
1174
|
+
jsonResult.relinkFromProvider = relinkedFromProvider;
|
|
1175
|
+
}
|
|
1176
|
+
if (mode === 'json') {
|
|
1177
|
+
return success(jsonResult);
|
|
1178
|
+
}
|
|
1179
|
+
if (mode === 'quiet') {
|
|
1180
|
+
return success(String(tasksToLink.length));
|
|
1181
|
+
}
|
|
1182
|
+
const lines = [];
|
|
1183
|
+
if (force && relinkCount > 0) {
|
|
1184
|
+
lines.push(`Dry run: Re-linking ${relinkCount} task(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
|
|
1185
|
+
const newCount = tasksToLink.length - relinkCount;
|
|
1186
|
+
if (newCount > 0) {
|
|
1187
|
+
lines.push(` Plus ${newCount} unlinked task(s) to link`);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
else {
|
|
1191
|
+
lines.push(`Dry run: ${tasksToLink.length} task(s) would be linked to ${providerName}`);
|
|
1192
|
+
}
|
|
1193
|
+
lines.push('');
|
|
1194
|
+
for (const task of tasksToLink) {
|
|
1195
|
+
lines.push(` ${task.id} ${task.status.padEnd(12)} ${task.title}`);
|
|
1196
|
+
}
|
|
1197
|
+
return success(jsonResult, lines.join('\n'));
|
|
1198
|
+
}
|
|
1199
|
+
// Create provider for actual linking (supports DI for testing)
|
|
1200
|
+
const providerFactory = options._providerFactory ?? createProviderFromSettings;
|
|
1201
|
+
const { provider: externalProvider, project, direction, error: providerError, } = await providerFactory(providerName, options.project, options);
|
|
1202
|
+
if (providerError) {
|
|
1203
|
+
return failure(providerError, ExitCode.GENERAL_ERROR);
|
|
1204
|
+
}
|
|
1205
|
+
// Get the task adapter
|
|
1206
|
+
const adapter = externalProvider.getTaskAdapter?.();
|
|
1207
|
+
if (!adapter) {
|
|
1208
|
+
return failure(`Provider "${providerName}" does not support task sync`, ExitCode.GENERAL_ERROR);
|
|
1209
|
+
}
|
|
1210
|
+
const progressLines = [];
|
|
1211
|
+
// Log re-linking info when using --force
|
|
1212
|
+
if (force && relinkCount > 0 && mode !== 'json' && mode !== 'quiet') {
|
|
1213
|
+
progressLines.push(`Re-linking ${relinkCount} task(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
|
|
1214
|
+
const newCount = tasksToLink.length - relinkCount;
|
|
1215
|
+
if (newCount > 0) {
|
|
1216
|
+
progressLines.push(`Linking ${newCount} unlinked task(s)`);
|
|
1217
|
+
}
|
|
1218
|
+
progressLines.push('');
|
|
1219
|
+
}
|
|
1220
|
+
// Process tasks in batches
|
|
1221
|
+
let totalSucceeded = 0;
|
|
1222
|
+
let totalFailed = 0;
|
|
1223
|
+
let rateLimited = false;
|
|
1224
|
+
let rateLimitResetAt;
|
|
1225
|
+
for (let i = 0; i < tasksToLink.length; i += batchSize) {
|
|
1226
|
+
const batch = tasksToLink.slice(i, i + batchSize);
|
|
1227
|
+
const batchResult = await processBatch(batch, adapter, api, providerName, project, direction, progressLines);
|
|
1228
|
+
totalSucceeded += batchResult.succeeded;
|
|
1229
|
+
totalFailed += batchResult.failed;
|
|
1230
|
+
if (batchResult.rateLimited) {
|
|
1231
|
+
rateLimited = true;
|
|
1232
|
+
rateLimitResetAt = batchResult.resetAt;
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
// Small delay between batches to be gentle on the API
|
|
1236
|
+
if (i + batchSize < tasksToLink.length) {
|
|
1237
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const skipped = tasksToLink.length - totalSucceeded - totalFailed;
|
|
1241
|
+
// Build result
|
|
1242
|
+
const result = {
|
|
1243
|
+
provider: providerName,
|
|
1244
|
+
project,
|
|
1245
|
+
linked: totalSucceeded,
|
|
1246
|
+
failed: totalFailed,
|
|
1247
|
+
skipped,
|
|
1248
|
+
total: tasksToLink.length,
|
|
1249
|
+
rateLimited,
|
|
1250
|
+
rateLimitResetAt: rateLimitResetAt ? new Date(rateLimitResetAt * 1000).toISOString() : undefined,
|
|
1251
|
+
};
|
|
1252
|
+
if (force && relinkCount > 0) {
|
|
1253
|
+
result.force = true;
|
|
1254
|
+
result.relinkCount = relinkCount;
|
|
1255
|
+
result.relinkFromProvider = relinkedFromProvider;
|
|
1256
|
+
}
|
|
1257
|
+
if (mode === 'json') {
|
|
1258
|
+
return success(result);
|
|
1259
|
+
}
|
|
1260
|
+
if (mode === 'quiet') {
|
|
1261
|
+
return success(String(totalSucceeded));
|
|
1262
|
+
}
|
|
1263
|
+
// Human-readable output
|
|
1264
|
+
const lines = [...progressLines, ''];
|
|
1265
|
+
// Summary
|
|
1266
|
+
const summaryParts = [`Linked ${totalSucceeded} tasks to ${providerName}`];
|
|
1267
|
+
if (totalFailed > 0) {
|
|
1268
|
+
summaryParts.push(`(${totalFailed} failed)`);
|
|
1269
|
+
}
|
|
1270
|
+
if (skipped > 0) {
|
|
1271
|
+
summaryParts.push(`(${skipped} skipped)`);
|
|
1272
|
+
}
|
|
1273
|
+
lines.push(summaryParts.join(' '));
|
|
1274
|
+
if (rateLimited) {
|
|
1275
|
+
lines.push('');
|
|
1276
|
+
if (rateLimitResetAt) {
|
|
1277
|
+
const resetDate = new Date(rateLimitResetAt * 1000);
|
|
1278
|
+
lines.push(`Rate limit reached. Resets at ${resetDate.toISOString()}. Re-run this command after the reset to link remaining tasks.`);
|
|
1279
|
+
}
|
|
1280
|
+
else {
|
|
1281
|
+
lines.push('Rate limit reached. Re-run this command later to link remaining tasks.');
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return success(result, lines.join('\n'));
|
|
1285
|
+
}
|
|
1286
|
+
// ============================================================================
|
|
1287
|
+
// Config Parent Command (for subcommand structure)
|
|
1288
|
+
// ============================================================================
|
|
1289
|
+
const configSetTokenCommand = {
|
|
1290
|
+
name: 'set-token',
|
|
1291
|
+
description: 'Set authentication token for a provider',
|
|
1292
|
+
usage: 'sf external-sync config set-token <provider> <token>',
|
|
1293
|
+
help: `Store an authentication token for an external sync provider.
|
|
1294
|
+
|
|
1295
|
+
The token is stored in the local SQLite database (not git-tracked).
|
|
1296
|
+
|
|
1297
|
+
Arguments:
|
|
1298
|
+
provider Provider name (e.g., github, linear)
|
|
1299
|
+
token Authentication token
|
|
1300
|
+
|
|
1301
|
+
Examples:
|
|
1302
|
+
sf external-sync config set-token github ghp_xxxxxxxxxxxx
|
|
1303
|
+
sf external-sync config set-token linear lin_api_xxxxxxxxxxxx`,
|
|
1304
|
+
options: [],
|
|
1305
|
+
handler: configSetTokenHandler,
|
|
1306
|
+
};
|
|
1307
|
+
const configSetProjectCommand = {
|
|
1308
|
+
name: 'set-project',
|
|
1309
|
+
description: 'Set default project for a provider',
|
|
1310
|
+
usage: 'sf external-sync config set-project <provider> <project>',
|
|
1311
|
+
help: `Set the default project (e.g., owner/repo) for an external sync provider.
|
|
1312
|
+
|
|
1313
|
+
This is used when linking tasks with bare issue numbers.
|
|
1314
|
+
|
|
1315
|
+
Arguments:
|
|
1316
|
+
provider Provider name (e.g., github, linear)
|
|
1317
|
+
project Project identifier (e.g., owner/repo for GitHub)
|
|
1318
|
+
|
|
1319
|
+
Examples:
|
|
1320
|
+
sf external-sync config set-project github my-org/my-repo
|
|
1321
|
+
sf external-sync config set-project linear MY-PROJECT`,
|
|
1322
|
+
options: [],
|
|
1323
|
+
handler: configSetProjectHandler,
|
|
1324
|
+
};
|
|
1325
|
+
const configSetAutoLinkCommand = {
|
|
1326
|
+
name: 'set-auto-link',
|
|
1327
|
+
description: 'Enable auto-link with a provider',
|
|
1328
|
+
usage: 'sf external-sync config set-auto-link <provider>',
|
|
1329
|
+
help: `Enable auto-link for new tasks with the specified provider.
|
|
1330
|
+
|
|
1331
|
+
When auto-link is enabled, newly created Stoneforge tasks will automatically
|
|
1332
|
+
get a corresponding external issue created and linked.
|
|
1333
|
+
|
|
1334
|
+
Arguments:
|
|
1335
|
+
provider Provider name (github or linear)
|
|
1336
|
+
|
|
1337
|
+
Examples:
|
|
1338
|
+
sf external-sync config set-auto-link github
|
|
1339
|
+
sf external-sync config set-auto-link linear`,
|
|
1340
|
+
options: [],
|
|
1341
|
+
handler: configSetAutoLinkHandler,
|
|
1342
|
+
};
|
|
1343
|
+
const configDisableAutoLinkCommand = {
|
|
1344
|
+
name: 'disable-auto-link',
|
|
1345
|
+
description: 'Disable auto-link',
|
|
1346
|
+
usage: 'sf external-sync config disable-auto-link',
|
|
1347
|
+
help: `Disable auto-link for new tasks.
|
|
1348
|
+
|
|
1349
|
+
Clears the auto-link provider and disables automatic external issue creation.
|
|
1350
|
+
|
|
1351
|
+
Examples:
|
|
1352
|
+
sf external-sync config disable-auto-link`,
|
|
1353
|
+
options: [],
|
|
1354
|
+
handler: configDisableAutoLinkHandler,
|
|
1355
|
+
};
|
|
1356
|
+
const configParentCommand = {
|
|
1357
|
+
name: 'config',
|
|
1358
|
+
description: 'Show or set provider configuration',
|
|
1359
|
+
usage: 'sf external-sync config [set-token|set-project|set-auto-link|disable-auto-link]',
|
|
1360
|
+
help: `Show current external sync provider configuration.
|
|
1361
|
+
|
|
1362
|
+
Tokens are masked in output for security.
|
|
1363
|
+
|
|
1364
|
+
Subcommands:
|
|
1365
|
+
set-token <provider> <token> Store auth token
|
|
1366
|
+
set-project <provider> <project> Set default project
|
|
1367
|
+
set-auto-link <provider> Enable auto-link with a provider
|
|
1368
|
+
disable-auto-link Disable auto-link
|
|
1369
|
+
|
|
1370
|
+
Examples:
|
|
1371
|
+
sf external-sync config
|
|
1372
|
+
sf external-sync config set-token github ghp_xxxxxxxxxxxx
|
|
1373
|
+
sf external-sync config set-project github my-org/my-repo
|
|
1374
|
+
sf external-sync config set-auto-link github
|
|
1375
|
+
sf external-sync config disable-auto-link`,
|
|
1376
|
+
subcommands: {
|
|
1377
|
+
'set-token': configSetTokenCommand,
|
|
1378
|
+
'set-project': configSetProjectCommand,
|
|
1379
|
+
'set-auto-link': configSetAutoLinkCommand,
|
|
1380
|
+
'disable-auto-link': configDisableAutoLinkCommand,
|
|
1381
|
+
},
|
|
1382
|
+
options: [],
|
|
1383
|
+
handler: configHandler,
|
|
1384
|
+
};
|
|
1385
|
+
// ============================================================================
|
|
1386
|
+
// Link Parent Command
|
|
1387
|
+
// ============================================================================
|
|
1388
|
+
const linkCommand = {
|
|
1389
|
+
name: 'link',
|
|
1390
|
+
description: 'Link a task to an external issue',
|
|
1391
|
+
usage: 'sf external-sync link <taskId> <url-or-issue-number>',
|
|
1392
|
+
help: `Link a Stoneforge task to an external issue (e.g., GitHub issue).
|
|
1393
|
+
|
|
1394
|
+
Sets the task's externalRef and _externalSync metadata. If given a bare
|
|
1395
|
+
issue number, constructs the URL from the provider's default project.
|
|
1396
|
+
|
|
1397
|
+
Arguments:
|
|
1398
|
+
taskId Stoneforge task ID
|
|
1399
|
+
url-or-number Full URL or bare issue number
|
|
1400
|
+
|
|
1401
|
+
Options:
|
|
1402
|
+
-p, --provider Provider name (default: github)
|
|
1403
|
+
|
|
1404
|
+
Examples:
|
|
1405
|
+
sf external-sync link el-abc123 https://github.com/org/repo/issues/42
|
|
1406
|
+
sf external-sync link el-abc123 42
|
|
1407
|
+
sf external-sync link el-abc123 42 --provider github`,
|
|
1408
|
+
options: linkOptions,
|
|
1409
|
+
handler: linkHandler,
|
|
1410
|
+
};
|
|
1411
|
+
// ============================================================================
|
|
1412
|
+
// Link-All Command
|
|
1413
|
+
// ============================================================================
|
|
1414
|
+
const linkAllCommand = {
|
|
1415
|
+
name: 'link-all',
|
|
1416
|
+
description: 'Bulk-link all unlinked tasks to external issues',
|
|
1417
|
+
usage: 'sf external-sync link-all --provider <provider> [--project <project>] [--status <status>] [--dry-run] [--batch-size <n>] [--force]',
|
|
1418
|
+
help: `Create external issues for all unlinked tasks and link them in bulk.
|
|
1419
|
+
|
|
1420
|
+
Finds all tasks that do NOT have external sync metadata and creates
|
|
1421
|
+
a corresponding external issue for each one, then links them.
|
|
1422
|
+
|
|
1423
|
+
Use --force to re-link tasks that are already linked to a different provider.
|
|
1424
|
+
Tasks linked to the same target provider are always skipped.
|
|
1425
|
+
|
|
1426
|
+
Options:
|
|
1427
|
+
-p, --provider <name> Provider to link to (required)
|
|
1428
|
+
--project <project> Override the default project
|
|
1429
|
+
-s, --status <status> Only link tasks with this status (can be repeated)
|
|
1430
|
+
-n, --dry-run List tasks that would be linked without creating issues
|
|
1431
|
+
-b, --batch-size <n> Tasks to process concurrently (default: 10)
|
|
1432
|
+
-f, --force Re-link tasks already linked to a different provider
|
|
1433
|
+
|
|
1434
|
+
Rate Limits:
|
|
1435
|
+
If a rate limit is hit, the command stops gracefully and reports how
|
|
1436
|
+
many tasks were linked. Re-run the command to continue linking.
|
|
1437
|
+
|
|
1438
|
+
Examples:
|
|
1439
|
+
sf external-sync link-all --provider github
|
|
1440
|
+
sf external-sync link-all --provider github --status open
|
|
1441
|
+
sf external-sync link-all --provider github --status open --status in_progress
|
|
1442
|
+
sf external-sync link-all --provider github --dry-run
|
|
1443
|
+
sf external-sync link-all --provider github --project my-org/my-repo
|
|
1444
|
+
sf external-sync link-all --provider linear --batch-size 5
|
|
1445
|
+
sf external-sync link-all --provider linear --force
|
|
1446
|
+
sf external-sync link-all --provider linear --force --dry-run`,
|
|
1447
|
+
options: linkAllOptions,
|
|
1448
|
+
handler: linkAllHandler,
|
|
1449
|
+
};
|
|
1450
|
+
// ============================================================================
|
|
1451
|
+
// Unlink Command
|
|
1452
|
+
// ============================================================================
|
|
1453
|
+
const unlinkCommand = {
|
|
1454
|
+
name: 'unlink',
|
|
1455
|
+
description: 'Remove external link from a task',
|
|
1456
|
+
usage: 'sf external-sync unlink <taskId>',
|
|
1457
|
+
help: `Remove the external link from a Stoneforge task.
|
|
1458
|
+
|
|
1459
|
+
Clears the task's externalRef field and _externalSync metadata.
|
|
1460
|
+
|
|
1461
|
+
Arguments:
|
|
1462
|
+
taskId Stoneforge task ID
|
|
1463
|
+
|
|
1464
|
+
Examples:
|
|
1465
|
+
sf external-sync unlink el-abc123`,
|
|
1466
|
+
options: [],
|
|
1467
|
+
handler: unlinkHandler,
|
|
1468
|
+
};
|
|
1469
|
+
// ============================================================================
|
|
1470
|
+
// Push Command
|
|
1471
|
+
// ============================================================================
|
|
1472
|
+
const pushCommand = {
|
|
1473
|
+
name: 'push',
|
|
1474
|
+
description: 'Push linked tasks to external service',
|
|
1475
|
+
usage: 'sf external-sync push [taskId...] [--all] [--force]',
|
|
1476
|
+
help: `Push specific linked tasks to their external service, or push all linked tasks.
|
|
1477
|
+
|
|
1478
|
+
If specific task IDs are given, pushes only those tasks. With --all,
|
|
1479
|
+
pushes every task that has an external link.
|
|
1480
|
+
|
|
1481
|
+
Use --force to push all linked tasks regardless of whether their local
|
|
1482
|
+
content has changed. This is useful when label generation logic changes
|
|
1483
|
+
and the external representation needs to be refreshed.
|
|
1484
|
+
|
|
1485
|
+
Arguments:
|
|
1486
|
+
taskId... One or more task IDs to push (optional with --all)
|
|
1487
|
+
|
|
1488
|
+
Options:
|
|
1489
|
+
-a, --all Push all linked tasks
|
|
1490
|
+
-f, --force Push all linked tasks regardless of whether they have changed
|
|
1491
|
+
|
|
1492
|
+
Examples:
|
|
1493
|
+
sf external-sync push el-abc123
|
|
1494
|
+
sf external-sync push el-abc123 el-def456
|
|
1495
|
+
sf external-sync push --all
|
|
1496
|
+
sf external-sync push --all --force
|
|
1497
|
+
sf external-sync push el-abc123 --force`,
|
|
1498
|
+
options: pushOptions,
|
|
1499
|
+
handler: pushHandler,
|
|
1500
|
+
};
|
|
1501
|
+
// ============================================================================
|
|
1502
|
+
// Pull Command
|
|
1503
|
+
// ============================================================================
|
|
1504
|
+
const pullCommand = {
|
|
1505
|
+
name: 'pull',
|
|
1506
|
+
description: 'Pull changes from external for linked tasks',
|
|
1507
|
+
usage: 'sf external-sync pull [--provider <name>] [--discover]',
|
|
1508
|
+
help: `Pull changes from external services for all linked tasks.
|
|
1509
|
+
|
|
1510
|
+
Optionally discover new issues not yet linked to Stoneforge tasks.
|
|
1511
|
+
|
|
1512
|
+
Options:
|
|
1513
|
+
-p, --provider <name> Pull from specific provider (default: all configured)
|
|
1514
|
+
-d, --discover Discover new unlinked issues
|
|
1515
|
+
|
|
1516
|
+
Examples:
|
|
1517
|
+
sf external-sync pull
|
|
1518
|
+
sf external-sync pull --provider github
|
|
1519
|
+
sf external-sync pull --discover`,
|
|
1520
|
+
options: pullOptions,
|
|
1521
|
+
handler: pullHandler,
|
|
1522
|
+
};
|
|
1523
|
+
// ============================================================================
|
|
1524
|
+
// Sync Command
|
|
1525
|
+
// ============================================================================
|
|
1526
|
+
const biSyncCommand = {
|
|
1527
|
+
name: 'sync',
|
|
1528
|
+
description: 'Bidirectional sync with external services',
|
|
1529
|
+
usage: 'sf external-sync sync [--dry-run]',
|
|
1530
|
+
help: `Run bidirectional sync between Stoneforge and external services.
|
|
1531
|
+
|
|
1532
|
+
Performs both push and pull operations. In dry-run mode, reports what
|
|
1533
|
+
would change without making any modifications.
|
|
1534
|
+
|
|
1535
|
+
Options:
|
|
1536
|
+
-n, --dry-run Show what would change without making changes
|
|
1537
|
+
|
|
1538
|
+
Examples:
|
|
1539
|
+
sf external-sync sync
|
|
1540
|
+
sf external-sync sync --dry-run`,
|
|
1541
|
+
options: syncOptions,
|
|
1542
|
+
handler: syncHandler,
|
|
1543
|
+
};
|
|
1544
|
+
// ============================================================================
|
|
1545
|
+
// Status Command
|
|
1546
|
+
// ============================================================================
|
|
1547
|
+
const extStatusCommand = {
|
|
1548
|
+
name: 'status',
|
|
1549
|
+
description: 'Show external sync state',
|
|
1550
|
+
usage: 'sf external-sync status',
|
|
1551
|
+
help: `Show the current external sync state.
|
|
1552
|
+
|
|
1553
|
+
Displays linked task count, last sync times, configured providers,
|
|
1554
|
+
and pending conflicts.
|
|
1555
|
+
|
|
1556
|
+
Examples:
|
|
1557
|
+
sf external-sync status
|
|
1558
|
+
sf external-sync status --json`,
|
|
1559
|
+
options: [],
|
|
1560
|
+
handler: statusHandler,
|
|
1561
|
+
};
|
|
1562
|
+
// ============================================================================
|
|
1563
|
+
// Resolve Command
|
|
1564
|
+
// ============================================================================
|
|
1565
|
+
const resolveCommand = {
|
|
1566
|
+
name: 'resolve',
|
|
1567
|
+
description: 'Resolve a sync conflict',
|
|
1568
|
+
usage: 'sf external-sync resolve <taskId> --keep local|remote',
|
|
1569
|
+
help: `Resolve a sync conflict by choosing which version to keep.
|
|
1570
|
+
|
|
1571
|
+
Tasks with sync conflicts are tagged with "sync-conflict". This command
|
|
1572
|
+
resolves the conflict by keeping either the local or remote version.
|
|
1573
|
+
|
|
1574
|
+
Arguments:
|
|
1575
|
+
taskId Task ID with a sync conflict
|
|
1576
|
+
|
|
1577
|
+
Options:
|
|
1578
|
+
-k, --keep <version> Which version to keep: local or remote (required)
|
|
1579
|
+
|
|
1580
|
+
Examples:
|
|
1581
|
+
sf external-sync resolve el-abc123 --keep local
|
|
1582
|
+
sf external-sync resolve el-abc123 --keep remote`,
|
|
1583
|
+
options: resolveOptions,
|
|
1584
|
+
handler: resolveHandler,
|
|
1585
|
+
};
|
|
1586
|
+
// ============================================================================
|
|
1587
|
+
// External Sync Parent Command
|
|
1588
|
+
// ============================================================================
|
|
1589
|
+
export const externalSyncCommand = {
|
|
1590
|
+
name: 'external-sync',
|
|
1591
|
+
description: 'External service sync commands',
|
|
1592
|
+
usage: 'sf external-sync <command> [options]',
|
|
1593
|
+
help: `Manage bidirectional synchronization between Stoneforge and external services
|
|
1594
|
+
(GitHub Issues, Linear, etc.).
|
|
1595
|
+
|
|
1596
|
+
Commands:
|
|
1597
|
+
config Show provider configuration
|
|
1598
|
+
config set-token <provider> <token> Store auth token
|
|
1599
|
+
config set-project <provider> <project> Set default project
|
|
1600
|
+
config set-auto-link <provider> Enable auto-link with a provider
|
|
1601
|
+
config disable-auto-link Disable auto-link
|
|
1602
|
+
link <taskId> <url-or-issue-number> Link task to external issue
|
|
1603
|
+
link-all --provider <name> Bulk-link all unlinked tasks
|
|
1604
|
+
unlink <taskId> Remove external link
|
|
1605
|
+
push [taskId...] [--force] Push linked task(s) to external
|
|
1606
|
+
pull Pull changes from external
|
|
1607
|
+
sync [--dry-run] Bidirectional sync
|
|
1608
|
+
status Show sync state
|
|
1609
|
+
resolve <taskId> --keep local|remote Resolve sync conflict
|
|
1610
|
+
|
|
1611
|
+
Examples:
|
|
1612
|
+
sf external-sync config
|
|
1613
|
+
sf external-sync config set-token github ghp_xxxxxxxxxxxx
|
|
1614
|
+
sf external-sync config set-project github my-org/my-repo
|
|
1615
|
+
sf external-sync config set-auto-link github
|
|
1616
|
+
sf external-sync config disable-auto-link
|
|
1617
|
+
sf external-sync link el-abc123 42
|
|
1618
|
+
sf external-sync link-all --provider github
|
|
1619
|
+
sf external-sync link-all --provider github --dry-run
|
|
1620
|
+
sf external-sync push --all
|
|
1621
|
+
sf external-sync push --all --force
|
|
1622
|
+
sf external-sync pull
|
|
1623
|
+
sf external-sync sync --dry-run
|
|
1624
|
+
sf external-sync status
|
|
1625
|
+
sf external-sync resolve el-abc123 --keep local`,
|
|
1626
|
+
subcommands: {
|
|
1627
|
+
config: configParentCommand,
|
|
1628
|
+
link: linkCommand,
|
|
1629
|
+
'link-all': linkAllCommand,
|
|
1630
|
+
unlink: unlinkCommand,
|
|
1631
|
+
push: pushCommand,
|
|
1632
|
+
pull: pullCommand,
|
|
1633
|
+
sync: biSyncCommand,
|
|
1634
|
+
status: extStatusCommand,
|
|
1635
|
+
resolve: resolveCommand,
|
|
1636
|
+
},
|
|
1637
|
+
handler: async (_args, options) => {
|
|
1638
|
+
const mode = getOutputMode(options);
|
|
1639
|
+
if (mode === 'json') {
|
|
1640
|
+
return success({
|
|
1641
|
+
commands: ['config', 'link', 'link-all', 'unlink', 'push', 'pull', 'sync', 'status', 'resolve'],
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
return failure('Usage: sf external-sync <command>\n\nCommands: config, link, link-all, unlink, push, pull, sync, status, resolve\n\nRun "sf external-sync --help" for more information.', ExitCode.INVALID_ARGUMENTS);
|
|
1645
|
+
},
|
|
1646
|
+
};
|
|
1647
|
+
//# sourceMappingURL=external-sync.js.map
|