@metaphorli/pingcode-cli 0.3.2
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/LICENSE +21 -0
- package/README.md +294 -0
- package/bin/install.js +546 -0
- package/package.json +42 -0
- package/scripts/commands/auth.js +389 -0
- package/scripts/commands/context.js +575 -0
- package/scripts/commands/shared.js +41 -0
- package/scripts/commands/workitem.js +1039 -0
- package/scripts/core.js +1372 -0
- package/scripts/pingcode.js +60 -0
- package/skills/pingcode/SKILL.md +96 -0
- package/skills/pingcode/references/auth.md +33 -0
- package/skills/pingcode/references/ctx.md +51 -0
- package/skills/pingcode/references/workitem.md +64 -0
package/scripts/core.js
ADDED
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_BASE_URL = 'https://open.pingcode.com';
|
|
10
|
+
const DEFAULT_TOKEN_CACHE = '~/.cache/pingcode/token.json';
|
|
11
|
+
const DEFAULT_WORKSPACE_CACHE = '.pingcode/cache.json';
|
|
12
|
+
const MAX_TOKEN_TTL_SECONDS = 29 * 24 * 60 * 60;
|
|
13
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
|
|
14
|
+
const USER_LOOKUP_RE = /@user:([^,]+)/g;
|
|
15
|
+
const MAX_SELECTION_OPTIONS = 20;
|
|
16
|
+
|
|
17
|
+
const CLI_COMMAND = 'pingcode';
|
|
18
|
+
const CTX_COMMAND = 'pingcode context init';
|
|
19
|
+
|
|
20
|
+
const AUTH_ENV_GUIDANCE = (
|
|
21
|
+
'Configure PingCode OAuth client credentials first:\n' +
|
|
22
|
+
' export PINGCODE_CLIENT_ID="..."\n' +
|
|
23
|
+
' export PINGCODE_CLIENT_SECRET="..."\n' +
|
|
24
|
+
'Optional for private deployments:\n' +
|
|
25
|
+
' export PINGCODE_BASE_URL="https://open.pingcode.com"'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const USER_ENV_GUIDANCE = (
|
|
29
|
+
'This request needs a human PingCode identity, but client_credentials is an enterprise token.\n' +
|
|
30
|
+
'Ask the user for their PingCode user ID/name, pass --user-id/--user-name, cache a workspace user, ' +
|
|
31
|
+
'or configure one of:\n' +
|
|
32
|
+
' export PINGCODE_USER_ID="..."\n' +
|
|
33
|
+
' export PINGCODE_USER_NAME="..."\n' +
|
|
34
|
+
'Use @me for current-user-id fields; use @me_name when a name lookup is needed.\n' +
|
|
35
|
+
'To discover IDs, run:\n' +
|
|
36
|
+
` ${CLI_COMMAND} context list\n` +
|
|
37
|
+
'Then save your current user with:\n' +
|
|
38
|
+
` ${CLI_COMMAND} context set-current-user USER_ID\n` +
|
|
39
|
+
'For guided workspace setup, run:\n' +
|
|
40
|
+
` ${CTX_COMMAND}`
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const WORKSPACE_DEFAULT_GUIDANCE = (
|
|
44
|
+
'PingCode workspace context is incomplete. Run the interactive setup command first:\n' +
|
|
45
|
+
` Run: ${CTX_COMMAND}\n` +
|
|
46
|
+
'It caches the current user, project, and sprint/iteration in .pingcode/cache.json.\n' +
|
|
47
|
+
'Use --all-projects or --all-sprints when the user explicitly asks for all projects or all iterations.'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
class PingCodeError extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'PingCodeError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function emptyWorkspaceCache() {
|
|
58
|
+
return {
|
|
59
|
+
version: 1,
|
|
60
|
+
preferences: {},
|
|
61
|
+
users: null,
|
|
62
|
+
projects: null,
|
|
63
|
+
sprints: {},
|
|
64
|
+
work_item_types: {},
|
|
65
|
+
work_item_states: {},
|
|
66
|
+
work_item_priorities: {},
|
|
67
|
+
work_item_properties: {},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseJsonObject(raw, label) {
|
|
72
|
+
if (!raw) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
let data;
|
|
76
|
+
try {
|
|
77
|
+
data = JSON.parse(raw);
|
|
78
|
+
} catch (exc) {
|
|
79
|
+
throw new PingCodeError(`${label} must be valid JSON: ${exc.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
82
|
+
throw new PingCodeError(`${label} must be a JSON object`);
|
|
83
|
+
}
|
|
84
|
+
return data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseKeyValues(items) {
|
|
88
|
+
const result = {};
|
|
89
|
+
for (const item of items || []) {
|
|
90
|
+
const index = item.indexOf('=');
|
|
91
|
+
if (index === -1) {
|
|
92
|
+
throw new PingCodeError(`Expected key=value, got: ${item}`);
|
|
93
|
+
}
|
|
94
|
+
const key = item.slice(0, index).trim();
|
|
95
|
+
if (!key) {
|
|
96
|
+
throw new PingCodeError(`Empty key in parameter: ${item}`);
|
|
97
|
+
}
|
|
98
|
+
result[key] = item.slice(index + 1);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function expandUserPath(value) {
|
|
104
|
+
if (!value) return value;
|
|
105
|
+
if (value.startsWith('~/')) {
|
|
106
|
+
return path.join(os.homedir(), value.slice(2));
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadWorkspaceCache(cachePath) {
|
|
112
|
+
if (cachePath === null) {
|
|
113
|
+
return emptyWorkspaceCache();
|
|
114
|
+
}
|
|
115
|
+
let payload;
|
|
116
|
+
try {
|
|
117
|
+
payload = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
118
|
+
} catch (exc) {
|
|
119
|
+
return emptyWorkspaceCache();
|
|
120
|
+
}
|
|
121
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
122
|
+
return emptyWorkspaceCache();
|
|
123
|
+
}
|
|
124
|
+
const cache = emptyWorkspaceCache();
|
|
125
|
+
Object.assign(cache, payload);
|
|
126
|
+
if (!cache.preferences || typeof cache.preferences !== 'object') {
|
|
127
|
+
cache.preferences = {};
|
|
128
|
+
}
|
|
129
|
+
if (!cache.sprints || typeof cache.sprints !== 'object') {
|
|
130
|
+
cache.sprints = {};
|
|
131
|
+
}
|
|
132
|
+
if (!cache.work_item_types || typeof cache.work_item_types !== 'object') {
|
|
133
|
+
cache.work_item_types = {};
|
|
134
|
+
}
|
|
135
|
+
if (!cache.work_item_states || typeof cache.work_item_states !== 'object') {
|
|
136
|
+
cache.work_item_states = {};
|
|
137
|
+
}
|
|
138
|
+
if (!cache.work_item_priorities || typeof cache.work_item_priorities !== 'object') {
|
|
139
|
+
cache.work_item_priorities = {};
|
|
140
|
+
}
|
|
141
|
+
if (!cache.work_item_properties || typeof cache.work_item_properties !== 'object') {
|
|
142
|
+
cache.work_item_properties = {};
|
|
143
|
+
}
|
|
144
|
+
return cache;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mergeWorkspaceCache(existing, incoming) {
|
|
148
|
+
const merged = { ...existing };
|
|
149
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
150
|
+
const existingValue = merged[key];
|
|
151
|
+
if (existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue) &&
|
|
152
|
+
value && typeof value === 'object' && !Array.isArray(value)) {
|
|
153
|
+
merged[key] = mergeWorkspaceCache(existingValue, value);
|
|
154
|
+
} else if (value === null && existingValue !== null && existingValue !== undefined) {
|
|
155
|
+
continue;
|
|
156
|
+
} else {
|
|
157
|
+
merged[key] = value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return merged;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function compactWorkspaceCacheValue(value) {
|
|
164
|
+
const dropKeys = new Set([
|
|
165
|
+
'avatar', 'color', 'created_at', 'created_by', 'description', 'email',
|
|
166
|
+
'is_archived', 'is_deleted', 'members', 'scope_id', 'scope_type',
|
|
167
|
+
'updated_at', 'updated_by', 'url', 'visibility',
|
|
168
|
+
]);
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
return value.map(compactWorkspaceCacheValue);
|
|
171
|
+
}
|
|
172
|
+
if (value && typeof value === 'object') {
|
|
173
|
+
const result = {};
|
|
174
|
+
for (const [key, item] of Object.entries(value)) {
|
|
175
|
+
if (dropKeys.has(key)) continue;
|
|
176
|
+
result[key] = compactWorkspaceCacheValue(item);
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function saveWorkspaceCache(cachePath, cache) {
|
|
184
|
+
if (cachePath === null) {
|
|
185
|
+
throw new PingCodeError('Workspace cache is disabled');
|
|
186
|
+
}
|
|
187
|
+
let latest = loadWorkspaceCache(cachePath);
|
|
188
|
+
if (latest) {
|
|
189
|
+
cache = mergeWorkspaceCache(latest, cache);
|
|
190
|
+
}
|
|
191
|
+
cache = compactWorkspaceCacheValue(cache);
|
|
192
|
+
cache.updated_at = Math.floor(Date.now() / 1000);
|
|
193
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
194
|
+
const tmpName = `.${path.basename(cachePath)}.${process.pid}.${crypto.randomUUID().replace(/-/g, '')}.tmp`;
|
|
195
|
+
const tmpPath = path.join(path.dirname(cachePath), tmpName);
|
|
196
|
+
fs.writeFileSync(tmpPath, JSON.stringify(cache, null, 2) + '\n');
|
|
197
|
+
fs.renameSync(tmpPath, cachePath);
|
|
198
|
+
try {
|
|
199
|
+
fs.chmodSync(cachePath, 0o600);
|
|
200
|
+
} catch (exc) {
|
|
201
|
+
// ignore
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function currentUserId(userId, workspaceCache) {
|
|
206
|
+
const preferences = (workspaceCache || {}).preferences || {};
|
|
207
|
+
userId = userId || process.env.PINGCODE_USER_ID || preferences.current_user_id;
|
|
208
|
+
if (!userId) {
|
|
209
|
+
throw new PingCodeError(USER_ENV_GUIDANCE);
|
|
210
|
+
}
|
|
211
|
+
return userId;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function currentUserName(userName, workspaceCache) {
|
|
215
|
+
const preferences = (workspaceCache || {}).preferences || {};
|
|
216
|
+
userName = userName || process.env.PINGCODE_USER_NAME || preferences.current_user_name;
|
|
217
|
+
if (!userName) {
|
|
218
|
+
throw new PingCodeError(USER_ENV_GUIDANCE);
|
|
219
|
+
}
|
|
220
|
+
return userName;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pageValues(payload) {
|
|
224
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const values = payload.values;
|
|
228
|
+
if (!Array.isArray(values)) {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
return values.filter(item => item && typeof item === 'object' && !Array.isArray(item));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function nestedText(value, key) {
|
|
235
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
236
|
+
const nested = value[key];
|
|
237
|
+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
238
|
+
return nested.display_name || nested.name || nested.identifier || nested.id;
|
|
239
|
+
}
|
|
240
|
+
return nested;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function compactBusinessItem(item) {
|
|
246
|
+
const fields = [
|
|
247
|
+
'id', 'identifier', 'short_id', 'type', 'title', 'name', 'display_name',
|
|
248
|
+
'state', 'priority', 'project', 'sprint', 'parent', 'assignee', 'html_url',
|
|
249
|
+
];
|
|
250
|
+
const compact = {};
|
|
251
|
+
for (const key of fields) {
|
|
252
|
+
if (!(key in item)) continue;
|
|
253
|
+
const value = item[key];
|
|
254
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
255
|
+
const text = nestedText(item, key);
|
|
256
|
+
if (text !== null && text !== undefined) {
|
|
257
|
+
compact[key] = text;
|
|
258
|
+
}
|
|
259
|
+
} else if (value !== null && value !== undefined && value !== '' &&
|
|
260
|
+
!(Array.isArray(value) && value.length === 0) &&
|
|
261
|
+
!(typeof value === 'object' && Object.keys(value).length === 0)) {
|
|
262
|
+
compact[key] = value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const state = item.state;
|
|
266
|
+
if (state && typeof state === 'object' && !Array.isArray(state) && state.type) {
|
|
267
|
+
compact.state_type = state.type;
|
|
268
|
+
}
|
|
269
|
+
const parent = item.parent;
|
|
270
|
+
if (parent && typeof parent === 'object' && !Array.isArray(parent)) {
|
|
271
|
+
if (parent.identifier) compact.parent_identifier = parent.identifier;
|
|
272
|
+
if (parent.title) compact.parent_title = parent.title;
|
|
273
|
+
}
|
|
274
|
+
const parentId = item.parent_id;
|
|
275
|
+
if (parentId && !compact.parent_identifier) {
|
|
276
|
+
compact.parent_id = parentId;
|
|
277
|
+
}
|
|
278
|
+
return compact;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function compactResponse(payload) {
|
|
282
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
283
|
+
return payload;
|
|
284
|
+
}
|
|
285
|
+
const values = pageValues(payload);
|
|
286
|
+
if (values.length > 0) {
|
|
287
|
+
const result = {
|
|
288
|
+
page_size: payload.page_size,
|
|
289
|
+
page_index: payload.page_index,
|
|
290
|
+
total: payload.total,
|
|
291
|
+
count: values.length,
|
|
292
|
+
values: values.map(compactBusinessItem),
|
|
293
|
+
};
|
|
294
|
+
return Object.fromEntries(Object.entries(result).filter(([, v]) => v !== null && v !== undefined));
|
|
295
|
+
}
|
|
296
|
+
return compactBusinessItem(payload);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizedEntity(item) {
|
|
300
|
+
const nestedUser = item.user;
|
|
301
|
+
if (!nestedUser || typeof nestedUser !== 'object' || Array.isArray(nestedUser)) {
|
|
302
|
+
return item;
|
|
303
|
+
}
|
|
304
|
+
const merged = { ...nestedUser };
|
|
305
|
+
for (const [key, value] of Object.entries(item)) {
|
|
306
|
+
if (key !== 'user' && key !== 'project' && key !== 'role' && !(key in merged)) {
|
|
307
|
+
merged[key] = value;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return merged;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function itemNames(item) {
|
|
314
|
+
const entity = normalizedEntity(item);
|
|
315
|
+
const names = [];
|
|
316
|
+
for (const key of ['id', 'display_name', 'name', 'identifier']) {
|
|
317
|
+
const value = entity[key];
|
|
318
|
+
if (typeof value === 'string' && value) {
|
|
319
|
+
names.push(value);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return names;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function displayName(item) {
|
|
326
|
+
const entity = normalizedEntity(item);
|
|
327
|
+
for (const key of ['display_name', 'name', 'identifier', 'email', 'id']) {
|
|
328
|
+
const value = entity[key];
|
|
329
|
+
if (typeof value === 'string' && value) {
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return '<unnamed>';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function itemId(item, label) {
|
|
337
|
+
const value = normalizedEntity(item).id;
|
|
338
|
+
if (typeof value !== 'string' || !value) {
|
|
339
|
+
throw new PingCodeError(`Selected ${label} has no id`);
|
|
340
|
+
}
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function selectionItem(item) {
|
|
345
|
+
const entity = normalizedEntity(item);
|
|
346
|
+
const result = {};
|
|
347
|
+
for (const key of ['id', 'display_name', 'name', 'identifier']) {
|
|
348
|
+
const value = entity[key];
|
|
349
|
+
if (typeof value === 'string' && value) {
|
|
350
|
+
result[key] = value;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const project = item.project;
|
|
354
|
+
if (project && typeof project === 'object' && !Array.isArray(project)) {
|
|
355
|
+
const projectName = project.name;
|
|
356
|
+
const projectId = project.id;
|
|
357
|
+
if (typeof projectName === 'string' && projectName) {
|
|
358
|
+
result.project_name = projectName;
|
|
359
|
+
}
|
|
360
|
+
if (typeof projectId === 'string' && projectId) {
|
|
361
|
+
result.project_id = projectId;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function selectionOptions(payload) {
|
|
368
|
+
const values = pageValues(payload);
|
|
369
|
+
const options = values.slice(0, MAX_SELECTION_OPTIONS).map(selectionItem).filter(o => Object.keys(o).length > 0);
|
|
370
|
+
return [options, values.length];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function selectionGuidance(label, payload, command, cacheMessage) {
|
|
374
|
+
const [options, total] = selectionOptions(payload);
|
|
375
|
+
const suffix = total <= options.length ? '' : ` Showing first ${options.length} of ${total}.`;
|
|
376
|
+
return (
|
|
377
|
+
`Current PingCode ${label} is not cached. ${cacheMessage}\n` +
|
|
378
|
+
'Ask the user to choose one option, then run:\n' +
|
|
379
|
+
` ${command}\n` +
|
|
380
|
+
`Available ${label} options (${total} total).${suffix}\n` +
|
|
381
|
+
JSON.stringify(options, null, 2)
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function findCachedItem(items, query, label) {
|
|
386
|
+
const normalized = query.trim().toLowerCase();
|
|
387
|
+
if (!normalized) {
|
|
388
|
+
throw new PingCodeError(`Empty ${label} lookup`);
|
|
389
|
+
}
|
|
390
|
+
const exact = items.filter(item => itemNames(item).some(name => name.toLowerCase() === normalized));
|
|
391
|
+
if (exact.length === 1) {
|
|
392
|
+
return exact[0];
|
|
393
|
+
}
|
|
394
|
+
if (exact.length > 1) {
|
|
395
|
+
throw new PingCodeError(`Ambiguous ${label} lookup: ${query}`);
|
|
396
|
+
}
|
|
397
|
+
const partial = items.filter(item => itemNames(item).some(name => name.toLowerCase().includes(normalized)));
|
|
398
|
+
if (partial.length === 1) {
|
|
399
|
+
return partial[0];
|
|
400
|
+
}
|
|
401
|
+
if (partial.length > 1) {
|
|
402
|
+
const names = partial.slice(0, 8).map(item => itemNames(item)[0]).filter(Boolean).join(', ');
|
|
403
|
+
throw new PingCodeError(`Ambiguous ${label} lookup: ${query}. Matches: ${names}`);
|
|
404
|
+
}
|
|
405
|
+
throw new PingCodeError(`No cached ${label} matched ${query}. Refresh the workspace cache first.`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function cachedUserId(query, workspaceCache) {
|
|
409
|
+
const users = pageValues((workspaceCache || {}).users);
|
|
410
|
+
const item = findCachedItem(users, query, 'user');
|
|
411
|
+
const itemId = item.id;
|
|
412
|
+
if (typeof itemId !== 'string' || !itemId) {
|
|
413
|
+
throw new PingCodeError(`Cached user ${query} has no id`);
|
|
414
|
+
}
|
|
415
|
+
return itemId;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function expandIdentityPlaceholder(value, userId, userName, workspaceCache) {
|
|
419
|
+
if (typeof value === 'string') {
|
|
420
|
+
if (value === '@me') {
|
|
421
|
+
return currentUserId(userId, workspaceCache);
|
|
422
|
+
}
|
|
423
|
+
if (value === '@me_name' || value === '@me-name') {
|
|
424
|
+
return currentUserName(userName, workspaceCache);
|
|
425
|
+
}
|
|
426
|
+
if (value.startsWith('@user:')) {
|
|
427
|
+
return cachedUserId(value.slice(6), workspaceCache);
|
|
428
|
+
}
|
|
429
|
+
if (value.includes('@me')) {
|
|
430
|
+
return value.replaceAll('@me', currentUserId(userId, workspaceCache));
|
|
431
|
+
}
|
|
432
|
+
if (value.includes('@user:')) {
|
|
433
|
+
return value.replace(USER_LOOKUP_RE, (match, name) => cachedUserId(name, workspaceCache));
|
|
434
|
+
}
|
|
435
|
+
return value;
|
|
436
|
+
}
|
|
437
|
+
if (Array.isArray(value)) {
|
|
438
|
+
return value.map(item => expandIdentityPlaceholder(item, userId, userName, workspaceCache));
|
|
439
|
+
}
|
|
440
|
+
if (value && typeof value === 'object') {
|
|
441
|
+
const result = {};
|
|
442
|
+
for (const [key, item] of Object.entries(value)) {
|
|
443
|
+
result[key] = expandIdentityPlaceholder(item, userId, userName, workspaceCache);
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function expandIdentityPlaceholders(data, userId, userName, workspaceCache) {
|
|
451
|
+
if (data === null || data === undefined) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
return expandIdentityPlaceholder(data, userId, userName, workspaceCache);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function readRawTokenCache(cachePath) {
|
|
458
|
+
try {
|
|
459
|
+
const payload = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
460
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
461
|
+
return payload;
|
|
462
|
+
}
|
|
463
|
+
} catch (exc) {
|
|
464
|
+
// ignore
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function loadCachedToken(cachePath) {
|
|
470
|
+
let payload;
|
|
471
|
+
try {
|
|
472
|
+
payload = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
473
|
+
} catch (exc) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const token = payload.access_token;
|
|
480
|
+
const expiresAt = payload.expires_at;
|
|
481
|
+
if (typeof token !== 'string' || !token || typeof expiresAt !== 'number') {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
if (expiresAt <= Math.floor(Date.now() / 1000)) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
grant_type: payload.grant_type || 'client_credentials',
|
|
489
|
+
access_token: token,
|
|
490
|
+
refresh_token: typeof payload.refresh_token === 'string' ? payload.refresh_token : null,
|
|
491
|
+
expires_at: expiresAt,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function saveCachedToken(cachePath, token, expiresIn, grantType = 'client_credentials', refreshToken = null) {
|
|
496
|
+
let ttl;
|
|
497
|
+
if (typeof expiresIn === 'number' && !Number.isNaN(expiresIn)) {
|
|
498
|
+
ttl = Math.floor(expiresIn);
|
|
499
|
+
} else if (typeof expiresIn === 'string' && /^\d+$/.test(expiresIn)) {
|
|
500
|
+
ttl = parseInt(expiresIn, 10);
|
|
501
|
+
} else {
|
|
502
|
+
ttl = MAX_TOKEN_TTL_SECONDS;
|
|
503
|
+
}
|
|
504
|
+
ttl = Math.max(60, Math.min(ttl, MAX_TOKEN_TTL_SECONDS));
|
|
505
|
+
const payload = {
|
|
506
|
+
grant_type: grantType,
|
|
507
|
+
access_token: token,
|
|
508
|
+
expires_at: Math.floor(Date.now() / 1000) + ttl,
|
|
509
|
+
};
|
|
510
|
+
if (refreshToken) {
|
|
511
|
+
payload.refresh_token = refreshToken;
|
|
512
|
+
}
|
|
513
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
514
|
+
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2) + '\n');
|
|
515
|
+
try {
|
|
516
|
+
fs.chmodSync(cachePath, 0o600);
|
|
517
|
+
} catch (exc) {
|
|
518
|
+
// ignore
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function buildUrl(baseUrl, rawPath, params) {
|
|
523
|
+
let base;
|
|
524
|
+
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
|
|
525
|
+
base = rawPath;
|
|
526
|
+
} else {
|
|
527
|
+
base = `${baseUrl.replace(/\/$/, '')}/${rawPath.replace(/^\//, '')}`;
|
|
528
|
+
}
|
|
529
|
+
const url = new URL(base);
|
|
530
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
531
|
+
if (value === null || value === undefined) continue;
|
|
532
|
+
if (Array.isArray(value)) {
|
|
533
|
+
url.searchParams.set(key, value.map(String).join(','));
|
|
534
|
+
} else {
|
|
535
|
+
url.searchParams.set(key, String(value));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return url.toString();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function normalizePath(rawPath, baseUrl = DEFAULT_BASE_URL) {
|
|
542
|
+
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
|
|
543
|
+
return new URL(rawPath).pathname;
|
|
544
|
+
}
|
|
545
|
+
return new URL(buildUrl(baseUrl, rawPath)).pathname;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function stateCacheKey(projectId, workItemTypeId) {
|
|
549
|
+
return `${projectId}::${workItemTypeId}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function sprintProjectId(rawPath, baseUrl = DEFAULT_BASE_URL) {
|
|
553
|
+
const normalized = normalizePath(rawPath, baseUrl);
|
|
554
|
+
const match = normalized.match(/^\/v1\/project\/projects\/([^/]+)\/sprints$/);
|
|
555
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function memberProjectId(rawPath, baseUrl = DEFAULT_BASE_URL) {
|
|
559
|
+
const normalized = normalizePath(rawPath, baseUrl);
|
|
560
|
+
const match = normalized.match(/^\/v1\/project\/projects\/([^/]+)\/members$/);
|
|
561
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function cachedResponse(method, rawPath, params, workspaceCache, baseUrl) {
|
|
565
|
+
if (method.toUpperCase() !== 'GET') {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
const normalized = normalizePath(rawPath, baseUrl);
|
|
569
|
+
let projectId = memberProjectId(rawPath, baseUrl);
|
|
570
|
+
if (projectId && workspaceCache.users && typeof workspaceCache.users === 'object' && !Array.isArray(workspaceCache.users)) {
|
|
571
|
+
const users = workspaceCache.users;
|
|
572
|
+
if (users.project_id === null || users.project_id === undefined || users.project_id === projectId) {
|
|
573
|
+
return users;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (normalized === '/v1/directory/users' && workspaceCache.users && typeof workspaceCache.users === 'object') {
|
|
577
|
+
return workspaceCache.users;
|
|
578
|
+
}
|
|
579
|
+
if (normalized === '/v1/project/projects' && workspaceCache.projects && typeof workspaceCache.projects === 'object') {
|
|
580
|
+
return workspaceCache.projects;
|
|
581
|
+
}
|
|
582
|
+
projectId = sprintProjectId(rawPath, baseUrl);
|
|
583
|
+
if (projectId) {
|
|
584
|
+
const sprints = workspaceCache.sprints || {};
|
|
585
|
+
const cached = sprints[projectId];
|
|
586
|
+
if (cached && typeof cached === 'object') return cached;
|
|
587
|
+
}
|
|
588
|
+
if (normalized === '/v1/project/work_item/types') {
|
|
589
|
+
const projectIdValue = params.project_id;
|
|
590
|
+
if (typeof projectIdValue === 'string') {
|
|
591
|
+
const workItemTypes = workspaceCache.work_item_types || {};
|
|
592
|
+
const cached = workItemTypes[projectIdValue];
|
|
593
|
+
if (cached && typeof cached === 'object') return cached;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (normalized === '/v1/project/work_item/priorities') {
|
|
597
|
+
const projectIdValue = params.project_id;
|
|
598
|
+
if (typeof projectIdValue === 'string') {
|
|
599
|
+
const priorities = workspaceCache.work_item_priorities || {};
|
|
600
|
+
const cached = priorities[projectIdValue];
|
|
601
|
+
if (cached && typeof cached === 'object') return cached;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (normalized === '/v1/project/work_item/states') {
|
|
605
|
+
const projectIdValue = params.project_id;
|
|
606
|
+
const typeIdValue = params.work_item_type_id;
|
|
607
|
+
if (typeof projectIdValue === 'string' && typeof typeIdValue === 'string') {
|
|
608
|
+
const states = workspaceCache.work_item_states || {};
|
|
609
|
+
const cached = states[stateCacheKey(projectIdValue, typeIdValue)];
|
|
610
|
+
if (cached && typeof cached === 'object') return cached;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (normalized === '/v1/project/work_item/properties') {
|
|
614
|
+
const projectIdValue = params.project_id;
|
|
615
|
+
const typeIdValue = params.work_item_type_id;
|
|
616
|
+
if (typeof projectIdValue === 'string' && typeof typeIdValue === 'string') {
|
|
617
|
+
const properties = workspaceCache.work_item_properties || {};
|
|
618
|
+
const cached = properties[stateCacheKey(projectIdValue, typeIdValue)];
|
|
619
|
+
if (cached && typeof cached === 'object') return cached;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function updateWorkspaceCacheForResponse(method, rawPath, params, response, workspaceCache, baseUrl) {
|
|
626
|
+
if (method.toUpperCase() !== 'GET' || !response || typeof response !== 'object' || Array.isArray(response)) {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
const normalized = normalizePath(rawPath, baseUrl);
|
|
630
|
+
let projectId = memberProjectId(rawPath, baseUrl);
|
|
631
|
+
if (projectId) {
|
|
632
|
+
const cached = { ...response, project_id: projectId };
|
|
633
|
+
workspaceCache.users = cached;
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
if (normalized === '/v1/directory/users') {
|
|
637
|
+
workspaceCache.users = response;
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
if (normalized === '/v1/project/projects') {
|
|
641
|
+
workspaceCache.projects = response;
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
projectId = sprintProjectId(rawPath, baseUrl);
|
|
645
|
+
if (projectId) {
|
|
646
|
+
if (!workspaceCache.sprints) workspaceCache.sprints = {};
|
|
647
|
+
workspaceCache.sprints[projectId] = response;
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
if (normalized === '/v1/project/work_item/types') {
|
|
651
|
+
const projectIdValue = params.project_id;
|
|
652
|
+
if (typeof projectIdValue === 'string') {
|
|
653
|
+
if (!workspaceCache.work_item_types) workspaceCache.work_item_types = {};
|
|
654
|
+
workspaceCache.work_item_types[projectIdValue] = response;
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (normalized === '/v1/project/work_item/priorities') {
|
|
659
|
+
const projectIdValue = params.project_id;
|
|
660
|
+
if (typeof projectIdValue === 'string') {
|
|
661
|
+
if (!workspaceCache.work_item_priorities) workspaceCache.work_item_priorities = {};
|
|
662
|
+
workspaceCache.work_item_priorities[projectIdValue] = response;
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (normalized === '/v1/project/work_item/states') {
|
|
667
|
+
const projectIdValue = params.project_id;
|
|
668
|
+
const typeIdValue = params.work_item_type_id;
|
|
669
|
+
if (typeof projectIdValue === 'string' && typeof typeIdValue === 'string') {
|
|
670
|
+
if (!workspaceCache.work_item_states) workspaceCache.work_item_states = {};
|
|
671
|
+
workspaceCache.work_item_states[stateCacheKey(projectIdValue, typeIdValue)] = response;
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (normalized === '/v1/project/work_item/properties') {
|
|
676
|
+
const projectIdValue = params.project_id;
|
|
677
|
+
const typeIdValue = params.work_item_type_id;
|
|
678
|
+
if (typeof projectIdValue === 'string' && typeof typeIdValue === 'string') {
|
|
679
|
+
if (!workspaceCache.work_item_properties) workspaceCache.work_item_properties = {};
|
|
680
|
+
workspaceCache.work_item_properties[stateCacheKey(projectIdValue, typeIdValue)] = response;
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
class PingCodeClient {
|
|
688
|
+
constructor({
|
|
689
|
+
base_url,
|
|
690
|
+
client_id = null,
|
|
691
|
+
client_secret = null,
|
|
692
|
+
token = null,
|
|
693
|
+
token_cache = DEFAULT_TOKEN_CACHE,
|
|
694
|
+
workspace_cache = DEFAULT_WORKSPACE_CACHE,
|
|
695
|
+
grant_type = 'client_credentials',
|
|
696
|
+
} = {}) {
|
|
697
|
+
this.baseUrl = (base_url || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
698
|
+
this.clientId = client_id;
|
|
699
|
+
this.clientSecret = client_secret;
|
|
700
|
+
this.token = token;
|
|
701
|
+
this.tokenCache = token_cache ? expandUserPath(token_cache) : null;
|
|
702
|
+
this.workspaceCachePath = workspace_cache ? expandUserPath(workspace_cache) : null;
|
|
703
|
+
this.workspaceCache = loadWorkspaceCache(this.workspaceCachePath);
|
|
704
|
+
this.grantType = grant_type;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
resolveGrantType() {
|
|
708
|
+
if (this.grantType !== 'auto') {
|
|
709
|
+
return this.grantType;
|
|
710
|
+
}
|
|
711
|
+
if (this.tokenCache) {
|
|
712
|
+
const raw = readRawTokenCache(this.tokenCache);
|
|
713
|
+
if (raw && typeof raw.grant_type === 'string') {
|
|
714
|
+
return raw.grant_type;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return 'client_credentials';
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async accessToken() {
|
|
721
|
+
const grantType = this.resolveGrantType();
|
|
722
|
+
if (this.token) {
|
|
723
|
+
return this.token;
|
|
724
|
+
}
|
|
725
|
+
if (this.tokenCache) {
|
|
726
|
+
const cached = loadCachedToken(this.tokenCache);
|
|
727
|
+
if (cached) {
|
|
728
|
+
if (cached.grant_type !== grantType) {
|
|
729
|
+
throw new PingCodeError(
|
|
730
|
+
`Cached token grant_type '${cached.grant_type}' does not match ` +
|
|
731
|
+
`configured '${grantType}'. Remove the token cache and re-authenticate.`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
this.token = cached.access_token;
|
|
735
|
+
return cached.access_token;
|
|
736
|
+
}
|
|
737
|
+
if (grantType === 'authorization_code') {
|
|
738
|
+
const raw = readRawTokenCache(this.tokenCache);
|
|
739
|
+
if (raw && typeof raw.refresh_token === 'string' && raw.refresh_token) {
|
|
740
|
+
return this.refreshAccessToken(raw.refresh_token);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (grantType === 'client_credentials') {
|
|
745
|
+
if (!this.clientId || !this.clientSecret) {
|
|
746
|
+
throw new PingCodeError(
|
|
747
|
+
'Missing credentials. Set PINGCODE_CLIENT_ID and PINGCODE_CLIENT_SECRET, ' +
|
|
748
|
+
'or pass --token.\n' + AUTH_ENV_GUIDANCE
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
const response = await this.rawRequest(
|
|
752
|
+
'GET',
|
|
753
|
+
'/v1/auth/token',
|
|
754
|
+
{
|
|
755
|
+
grant_type: 'client_credentials',
|
|
756
|
+
client_id: this.clientId,
|
|
757
|
+
client_secret: this.clientSecret,
|
|
758
|
+
},
|
|
759
|
+
null,
|
|
760
|
+
false,
|
|
761
|
+
);
|
|
762
|
+
const token = response.access_token;
|
|
763
|
+
if (typeof token !== 'string' || !token) {
|
|
764
|
+
throw new PingCodeError('Token response did not include access_token');
|
|
765
|
+
}
|
|
766
|
+
this.token = token;
|
|
767
|
+
if (this.tokenCache) {
|
|
768
|
+
saveCachedToken(this.tokenCache, token, response.expires_in, 'client_credentials');
|
|
769
|
+
}
|
|
770
|
+
return token;
|
|
771
|
+
}
|
|
772
|
+
if (grantType === 'authorization_code') {
|
|
773
|
+
throw new PingCodeError(
|
|
774
|
+
'No valid user token available. Run `auth login` to authenticate with your PingCode account.'
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
throw new PingCodeError(`Unsupported grant_type: ${grantType}`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async exchangeAuthorizationCode(code, redirectUri) {
|
|
781
|
+
const params = {
|
|
782
|
+
grant_type: 'authorization_code',
|
|
783
|
+
code: code,
|
|
784
|
+
client_id: this.clientId,
|
|
785
|
+
client_secret: this.clientSecret,
|
|
786
|
+
};
|
|
787
|
+
if (redirectUri) {
|
|
788
|
+
params.redirect_uri = redirectUri;
|
|
789
|
+
}
|
|
790
|
+
const response = await this.rawRequest('GET', '/v1/auth/token', params, null, false);
|
|
791
|
+
const accessToken = response.access_token;
|
|
792
|
+
if (typeof accessToken !== 'string' || !accessToken) {
|
|
793
|
+
throw new PingCodeError('Token response did not include access_token');
|
|
794
|
+
}
|
|
795
|
+
this.token = accessToken;
|
|
796
|
+
const refreshToken = typeof response.refresh_token === 'string' ? response.refresh_token : null;
|
|
797
|
+
if (this.tokenCache) {
|
|
798
|
+
saveCachedToken(this.tokenCache, accessToken, response.expires_in, 'authorization_code', refreshToken);
|
|
799
|
+
}
|
|
800
|
+
return { access_token: accessToken, refresh_token: refreshToken };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async refreshAccessToken(refreshToken) {
|
|
804
|
+
const response = await this.rawRequest(
|
|
805
|
+
'GET',
|
|
806
|
+
'/v1/auth/token',
|
|
807
|
+
{
|
|
808
|
+
grant_type: 'refresh_token',
|
|
809
|
+
refresh_token: refreshToken,
|
|
810
|
+
client_id: this.clientId,
|
|
811
|
+
client_secret: this.clientSecret,
|
|
812
|
+
},
|
|
813
|
+
null,
|
|
814
|
+
false,
|
|
815
|
+
);
|
|
816
|
+
const accessToken = response.access_token;
|
|
817
|
+
if (typeof accessToken !== 'string' || !accessToken) {
|
|
818
|
+
throw new PingCodeError('Token refresh response did not include access_token');
|
|
819
|
+
}
|
|
820
|
+
this.token = accessToken;
|
|
821
|
+
const newRefreshToken = typeof response.refresh_token === 'string' ? response.refresh_token : refreshToken;
|
|
822
|
+
if (this.tokenCache) {
|
|
823
|
+
saveCachedToken(this.tokenCache, accessToken, response.expires_in, 'authorization_code', newRefreshToken);
|
|
824
|
+
}
|
|
825
|
+
return accessToken;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
buildAuthorizationUrl(redirectUri, state) {
|
|
829
|
+
return buildUrl(this.baseUrl, '/oauth2/authorize', {
|
|
830
|
+
response_type: 'code',
|
|
831
|
+
client_id: this.clientId,
|
|
832
|
+
redirect_uri: redirectUri,
|
|
833
|
+
state: state,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async rawRequest(method, rawPath, params = null, body = null, auth = true) {
|
|
838
|
+
const url = buildUrl(this.baseUrl, rawPath, params);
|
|
839
|
+
const headers = { Accept: 'application/json' };
|
|
840
|
+
let fetchBody = undefined;
|
|
841
|
+
if (body !== null && body !== undefined) {
|
|
842
|
+
fetchBody = JSON.stringify(body);
|
|
843
|
+
headers['Content-Type'] = 'application/json';
|
|
844
|
+
}
|
|
845
|
+
if (auth) {
|
|
846
|
+
headers.Authorization = `Bearer ${await this.accessToken()}`;
|
|
847
|
+
}
|
|
848
|
+
const controller = new AbortController();
|
|
849
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
850
|
+
try {
|
|
851
|
+
const response = await fetch(url, {
|
|
852
|
+
method,
|
|
853
|
+
headers,
|
|
854
|
+
body: fetchBody,
|
|
855
|
+
signal: controller.signal,
|
|
856
|
+
});
|
|
857
|
+
const content = await response.text();
|
|
858
|
+
if (!response.ok) {
|
|
859
|
+
const retryAfter = response.headers.get('x-pc-retry-after');
|
|
860
|
+
const suffix = retryAfter ? ` retry_after=${retryAfter}` : '';
|
|
861
|
+
throw new PingCodeError(`HTTP ${response.status} ${response.statusText}.${suffix} ${content}`);
|
|
862
|
+
}
|
|
863
|
+
if (!content) return {};
|
|
864
|
+
try {
|
|
865
|
+
const parsed = JSON.parse(content);
|
|
866
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
867
|
+
return { value: parsed };
|
|
868
|
+
}
|
|
869
|
+
return parsed;
|
|
870
|
+
} catch (exc) {
|
|
871
|
+
throw new PingCodeError(`Response was not JSON: ${content.slice(0, 300)}`);
|
|
872
|
+
}
|
|
873
|
+
} catch (exc) {
|
|
874
|
+
if (exc instanceof PingCodeError) throw exc;
|
|
875
|
+
throw new PingCodeError(`Request failed: ${exc.message}`);
|
|
876
|
+
} finally {
|
|
877
|
+
clearTimeout(timeout);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async request(method, rawPath, params = null, body = null, { dry_run = false, use_workspace_cache = true } = {}) {
|
|
882
|
+
method = method.toUpperCase();
|
|
883
|
+
if (!HTTP_METHODS.includes(method)) {
|
|
884
|
+
throw new PingCodeError(`Unsupported method: ${method}`);
|
|
885
|
+
}
|
|
886
|
+
if (dry_run) {
|
|
887
|
+
return {
|
|
888
|
+
dry_run: true,
|
|
889
|
+
method,
|
|
890
|
+
url: buildUrl(this.baseUrl, rawPath, params),
|
|
891
|
+
path: rawPath,
|
|
892
|
+
params: params || {},
|
|
893
|
+
json: body,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
const requestParams = params || {};
|
|
897
|
+
if (use_workspace_cache) {
|
|
898
|
+
const cached = cachedResponse(method, rawPath, requestParams, this.workspaceCache, this.baseUrl);
|
|
899
|
+
if (cached !== null) {
|
|
900
|
+
return cached;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const response = await this.rawRequest(method, rawPath, params, body);
|
|
904
|
+
if (this.workspaceCachePath !== null && updateWorkspaceCacheForResponse(
|
|
905
|
+
method,
|
|
906
|
+
rawPath,
|
|
907
|
+
requestParams,
|
|
908
|
+
response,
|
|
909
|
+
this.workspaceCache,
|
|
910
|
+
this.baseUrl,
|
|
911
|
+
)) {
|
|
912
|
+
saveWorkspaceCache(this.workspaceCachePath, this.workspaceCache);
|
|
913
|
+
}
|
|
914
|
+
return response;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
saveWorkspaceCache() {
|
|
918
|
+
saveWorkspaceCache(this.workspaceCachePath, this.workspaceCache);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async fetchProjectUsers(projectId) {
|
|
922
|
+
const encoded = encodeURIComponent(projectId);
|
|
923
|
+
const rawPath = `/v1/project/projects/${encoded}/members`;
|
|
924
|
+
const response = await fetchAllPages(this, rawPath, {});
|
|
925
|
+
if (this.workspaceCachePath !== null && updateWorkspaceCacheForResponse(
|
|
926
|
+
'GET',
|
|
927
|
+
rawPath,
|
|
928
|
+
{},
|
|
929
|
+
response,
|
|
930
|
+
this.workspaceCache,
|
|
931
|
+
this.baseUrl,
|
|
932
|
+
)) {
|
|
933
|
+
saveWorkspaceCache(this.workspaceCachePath, this.workspaceCache);
|
|
934
|
+
}
|
|
935
|
+
return response;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
940
|
+
|
|
941
|
+
async function fetchAllPages(client, rawPath, params = {}) {
|
|
942
|
+
const allValues = [];
|
|
943
|
+
let pageIndex = 0;
|
|
944
|
+
let lastResponse = null;
|
|
945
|
+
while (true) {
|
|
946
|
+
const pageParams = { ...params, page_size: DEFAULT_PAGE_SIZE, page_index: pageIndex };
|
|
947
|
+
const response = await client.rawRequest('GET', rawPath, pageParams);
|
|
948
|
+
if (!response || typeof response !== 'object' || Array.isArray(response)) break;
|
|
949
|
+
const values = pageValues(response);
|
|
950
|
+
if (values.length === 0) break;
|
|
951
|
+
allValues.push(...values);
|
|
952
|
+
lastResponse = response;
|
|
953
|
+
const total = response.total;
|
|
954
|
+
if (typeof total === 'number') {
|
|
955
|
+
if (total <= allValues.length) break;
|
|
956
|
+
} else if (values.length < DEFAULT_PAGE_SIZE) {
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
pageIndex += 1;
|
|
960
|
+
}
|
|
961
|
+
if (lastResponse) {
|
|
962
|
+
return {
|
|
963
|
+
...lastResponse,
|
|
964
|
+
page_size: DEFAULT_PAGE_SIZE,
|
|
965
|
+
page_index: pageIndex,
|
|
966
|
+
total: lastResponse.total || allValues.length,
|
|
967
|
+
count: allValues.length,
|
|
968
|
+
values: allValues,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
return { values: allValues };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function refreshCommand(client, rawPath, params = null) {
|
|
975
|
+
const response = await fetchAllPages(client, rawPath, params || {});
|
|
976
|
+
if (client.workspaceCachePath !== null && updateWorkspaceCacheForResponse(
|
|
977
|
+
'GET',
|
|
978
|
+
rawPath,
|
|
979
|
+
params || {},
|
|
980
|
+
response,
|
|
981
|
+
client.workspaceCache,
|
|
982
|
+
client.baseUrl,
|
|
983
|
+
)) {
|
|
984
|
+
saveWorkspaceCache(client.workspaceCachePath, client.workspaceCache);
|
|
985
|
+
}
|
|
986
|
+
return response;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function cacheProjects(client) {
|
|
990
|
+
return await refreshCommand(client, '/v1/project/projects', {});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function cacheSprints(client, projectId) {
|
|
994
|
+
const encoded = encodeURIComponent(projectId);
|
|
995
|
+
return await refreshCommand(client, `/v1/project/projects/${encoded}/sprints`, {});
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function cacheWorkItemTypes(client, projectId) {
|
|
999
|
+
return await refreshCommand(client, '/v1/project/work_item/types', { project_id: projectId });
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async function cacheWorkItemPriorities(client, projectId) {
|
|
1003
|
+
return await refreshCommand(client, '/v1/project/work_item/priorities', { project_id: projectId });
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function cacheWorkItemStates(client, projectId, workItemTypeId) {
|
|
1007
|
+
return await refreshCommand(client, '/v1/project/work_item/states', { project_id: projectId, work_item_type_id: workItemTypeId });
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function cacheWorkItemProperties(client, projectId, workItemTypeId) {
|
|
1011
|
+
return await refreshCommand(client, '/v1/project/work_item/properties', { project_id: projectId, work_item_type_id: workItemTypeId });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function cacheUsers(client, projectId = null) {
|
|
1015
|
+
const selectedProjectId = projectId || (client.workspaceCache.preferences || {}).current_project_id;
|
|
1016
|
+
if (typeof selectedProjectId === 'string' && selectedProjectId) {
|
|
1017
|
+
return await client.fetchProjectUsers(selectedProjectId);
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
return await refreshCommand(client, '/v1/directory/users', {});
|
|
1021
|
+
} catch (exc) {
|
|
1022
|
+
throw new PingCodeError(
|
|
1023
|
+
`${exc.message}\n` +
|
|
1024
|
+
'If this tenant does not expose a global user-list endpoint, set a current project first or pass ' +
|
|
1025
|
+
'--project-id so the CLI can cache project members.'
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function setCurrentUser(client, userId) {
|
|
1031
|
+
const usersPayload = client.workspaceCache.users;
|
|
1032
|
+
const users = pageValues(usersPayload);
|
|
1033
|
+
let found = null;
|
|
1034
|
+
if (users.length > 0) {
|
|
1035
|
+
try {
|
|
1036
|
+
found = findCachedItem(users, userId, 'user');
|
|
1037
|
+
} catch (exc) {
|
|
1038
|
+
found = users.find(item => item.id === userId) || null;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (!client.workspaceCache.preferences) client.workspaceCache.preferences = {};
|
|
1042
|
+
const preferences = client.workspaceCache.preferences;
|
|
1043
|
+
const entity = found ? normalizedEntity(found) : null;
|
|
1044
|
+
preferences.current_user_id = (entity && entity.id) ? entity.id : userId;
|
|
1045
|
+
if (found) {
|
|
1046
|
+
for (const key of ['display_name', 'name']) {
|
|
1047
|
+
const value = entity ? entity[key] : null;
|
|
1048
|
+
if (typeof value === 'string' && value) {
|
|
1049
|
+
preferences.current_user_name = value;
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
client.saveWorkspaceCache();
|
|
1055
|
+
return { preferences, message: 'current user cached' };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function setCurrentProject(client, projectId) {
|
|
1059
|
+
const projects = pageValues(client.workspaceCache.projects);
|
|
1060
|
+
let found = null;
|
|
1061
|
+
if (projects.length > 0) {
|
|
1062
|
+
try {
|
|
1063
|
+
found = findCachedItem(projects, projectId, 'project');
|
|
1064
|
+
} catch (exc) {
|
|
1065
|
+
found = projects.find(item => item.id === projectId) || null;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (!client.workspaceCache.preferences) client.workspaceCache.preferences = {};
|
|
1069
|
+
const preferences = client.workspaceCache.preferences;
|
|
1070
|
+
preferences.current_project_id = (found && found.id) ? found.id : projectId;
|
|
1071
|
+
if (found && typeof found.name === 'string') {
|
|
1072
|
+
preferences.current_project_name = found.name;
|
|
1073
|
+
}
|
|
1074
|
+
client.saveWorkspaceCache();
|
|
1075
|
+
return { preferences, message: 'current project cached' };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function setCurrentSprint(client, sprintId) {
|
|
1079
|
+
if (!client.workspaceCache.preferences) client.workspaceCache.preferences = {};
|
|
1080
|
+
const preferences = client.workspaceCache.preferences;
|
|
1081
|
+
const allSprints = [];
|
|
1082
|
+
for (const payload of Object.values(client.workspaceCache.sprints || {})) {
|
|
1083
|
+
allSprints.push(...pageValues(payload));
|
|
1084
|
+
}
|
|
1085
|
+
let found = null;
|
|
1086
|
+
if (allSprints.length > 0) {
|
|
1087
|
+
try {
|
|
1088
|
+
found = findCachedItem(allSprints, sprintId, 'sprint');
|
|
1089
|
+
} catch (exc) {
|
|
1090
|
+
found = allSprints.find(item => item.id === sprintId) || null;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
preferences.current_sprint_id = (found && found.id) ? found.id : sprintId;
|
|
1094
|
+
if (found && typeof found.name === 'string') {
|
|
1095
|
+
preferences.current_sprint_name = found.name;
|
|
1096
|
+
}
|
|
1097
|
+
client.saveWorkspaceCache();
|
|
1098
|
+
return { preferences, message: 'current sprint cached' };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function applyDefaultWorkItemFilters(
|
|
1102
|
+
rawPath,
|
|
1103
|
+
params,
|
|
1104
|
+
client,
|
|
1105
|
+
userId,
|
|
1106
|
+
userName,
|
|
1107
|
+
currentUser = true,
|
|
1108
|
+
allProjects = false,
|
|
1109
|
+
allSprints = false,
|
|
1110
|
+
) {
|
|
1111
|
+
if (
|
|
1112
|
+
normalizePath(rawPath, client.baseUrl) !== '/v1/project/work_items' ||
|
|
1113
|
+
!pathIsListWorkItems(rawPath, client.baseUrl)
|
|
1114
|
+
) {
|
|
1115
|
+
return params;
|
|
1116
|
+
}
|
|
1117
|
+
const result = { ...params };
|
|
1118
|
+
const preferences = client.workspaceCache.preferences || {};
|
|
1119
|
+
if (currentUser && !('assignee_ids' in result)) {
|
|
1120
|
+
result.assignee_ids = currentUserId(userId, client.workspaceCache);
|
|
1121
|
+
}
|
|
1122
|
+
if (!allProjects && !('project_ids' in result)) {
|
|
1123
|
+
const projectId = preferences.current_project_id;
|
|
1124
|
+
if (typeof projectId !== 'string' || !projectId) {
|
|
1125
|
+
throw new PingCodeError(WORKSPACE_DEFAULT_GUIDANCE);
|
|
1126
|
+
}
|
|
1127
|
+
result.project_ids = projectId;
|
|
1128
|
+
}
|
|
1129
|
+
if (!allSprints && !('sprint_ids' in result)) {
|
|
1130
|
+
const sprintId = preferences.current_sprint_id;
|
|
1131
|
+
if (typeof sprintId !== 'string' || !sprintId) {
|
|
1132
|
+
throw new PingCodeError(WORKSPACE_DEFAULT_GUIDANCE);
|
|
1133
|
+
}
|
|
1134
|
+
result.sprint_ids = sprintId;
|
|
1135
|
+
}
|
|
1136
|
+
return expandIdentityPlaceholders(
|
|
1137
|
+
result,
|
|
1138
|
+
userId,
|
|
1139
|
+
userName,
|
|
1140
|
+
client.workspaceCache,
|
|
1141
|
+
) || {};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function ensureWorkItemWorkspaceContext(
|
|
1145
|
+
rawPath,
|
|
1146
|
+
client,
|
|
1147
|
+
method = 'GET',
|
|
1148
|
+
currentUser = true,
|
|
1149
|
+
allProjects = false,
|
|
1150
|
+
allSprints = false,
|
|
1151
|
+
) {
|
|
1152
|
+
if (normalizePath(rawPath, client.baseUrl) !== '/v1/project/work_items') {
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'POST') {
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const preferences = client.workspaceCache.preferences || {};
|
|
1159
|
+
const required = [];
|
|
1160
|
+
if (currentUser) required.push('current_user_id');
|
|
1161
|
+
if (!allProjects) required.push('current_project_id');
|
|
1162
|
+
if (!allSprints) required.push('current_sprint_id');
|
|
1163
|
+
const missing = required.filter(key => typeof preferences[key] !== 'string' || !preferences[key]);
|
|
1164
|
+
if (missing.length > 0) {
|
|
1165
|
+
throw new PingCodeError(`${WORKSPACE_DEFAULT_GUIDANCE}\nMissing preferences: ${missing.join(', ')}`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function applyDefaultWorkItemCreateBody(method, rawPath, body, client, userId, currentUser = true) {
|
|
1170
|
+
if (
|
|
1171
|
+
method.toUpperCase() !== 'POST' ||
|
|
1172
|
+
normalizePath(rawPath, client.baseUrl) !== '/v1/project/work_items' ||
|
|
1173
|
+
body === null || body === undefined
|
|
1174
|
+
) {
|
|
1175
|
+
return body;
|
|
1176
|
+
}
|
|
1177
|
+
const result = { ...body };
|
|
1178
|
+
if (currentUser && !('assignee_id' in result)) {
|
|
1179
|
+
result.assignee_id = currentUserId(userId, client.workspaceCache);
|
|
1180
|
+
}
|
|
1181
|
+
return result;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function pathIsListWorkItems(rawPath, baseUrl = DEFAULT_BASE_URL) {
|
|
1185
|
+
return normalizePath(rawPath, baseUrl) === '/v1/project/work_items';
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function printJson(data) {
|
|
1189
|
+
const sorted = sortKeys(data);
|
|
1190
|
+
console.log(JSON.stringify(sorted, null, 2));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function sortKeys(value) {
|
|
1194
|
+
if (Array.isArray(value)) {
|
|
1195
|
+
return value.map(sortKeys);
|
|
1196
|
+
}
|
|
1197
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1198
|
+
const sorted = {};
|
|
1199
|
+
const keys = Object.keys(value).sort();
|
|
1200
|
+
for (const key of keys) {
|
|
1201
|
+
sorted[key] = sortKeys(value[key]);
|
|
1202
|
+
}
|
|
1203
|
+
return sorted;
|
|
1204
|
+
}
|
|
1205
|
+
return value;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function startAuthCallbackServer({port, path: callbackPath, state, timeoutMs = 120000}) {
|
|
1209
|
+
return new Promise((resolve, reject) => {
|
|
1210
|
+
let settled = false;
|
|
1211
|
+
let pendingResult = null;
|
|
1212
|
+
|
|
1213
|
+
function finish(action, value) {
|
|
1214
|
+
if (settled) return;
|
|
1215
|
+
settled = true;
|
|
1216
|
+
clearTimeout(timer);
|
|
1217
|
+
pendingResult = { action, value };
|
|
1218
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
1219
|
+
server.closeAllConnections();
|
|
1220
|
+
}
|
|
1221
|
+
server.close(() => {
|
|
1222
|
+
if (action === 'resolve') resolve(value);
|
|
1223
|
+
else reject(value);
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const server = http.createServer((req, res) => {
|
|
1228
|
+
res.setHeader('Connection', 'close');
|
|
1229
|
+
const callbackUrl = new URL(req.url, 'http://127.0.0.1');
|
|
1230
|
+
|
|
1231
|
+
if (callbackUrl.pathname !== callbackPath) {
|
|
1232
|
+
res.writeHead(404);
|
|
1233
|
+
res.end('Not found');
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const code = callbackUrl.searchParams.get('code');
|
|
1238
|
+
const oauthError = callbackUrl.searchParams.get('error');
|
|
1239
|
+
|
|
1240
|
+
if (oauthError) {
|
|
1241
|
+
const errorDescription = callbackUrl.searchParams.get('error_description') || '';
|
|
1242
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1243
|
+
res.end(
|
|
1244
|
+
'<html><body><h1>Authentication Error</h1>' +
|
|
1245
|
+
`<p>${escapeHtml(oauthError)}${errorDescription ? ': ' + escapeHtml(errorDescription) : ''}</p>` +
|
|
1246
|
+
'<p>You can close this window.</p></body></html>',
|
|
1247
|
+
);
|
|
1248
|
+
finish('reject', new PingCodeError(
|
|
1249
|
+
`OAuth error: ${oauthError}${errorDescription ? ' - ' + errorDescription : ''}`,
|
|
1250
|
+
));
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const returnedState = callbackUrl.searchParams.get('state');
|
|
1255
|
+
if (returnedState && returnedState !== state) {
|
|
1256
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1257
|
+
res.end(
|
|
1258
|
+
'<html><body><h1>State Mismatch</h1>' +
|
|
1259
|
+
'<p>The state parameter does not match.</p>' +
|
|
1260
|
+
'<p>You can close this window.</p></body></html>',
|
|
1261
|
+
);
|
|
1262
|
+
finish('reject', new PingCodeError(
|
|
1263
|
+
`State mismatch: expected '${state}', got '${returnedState}'`,
|
|
1264
|
+
));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (code) {
|
|
1269
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1270
|
+
res.end(
|
|
1271
|
+
'<html><body><h1>Authentication Successful</h1>' +
|
|
1272
|
+
'<p>You can close this window.</p></body></html>',
|
|
1273
|
+
);
|
|
1274
|
+
finish('resolve', { code, state: returnedState || state });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1279
|
+
res.end(
|
|
1280
|
+
'<html><body><h1>Bad Request</h1>' +
|
|
1281
|
+
'<p>Missing authorization code.</p>' +
|
|
1282
|
+
'<p>You can close this window.</p></body></html>',
|
|
1283
|
+
);
|
|
1284
|
+
finish('reject', new PingCodeError('No authorization code in callback'));
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
const timer = setTimeout(() => {
|
|
1288
|
+
finish('reject', new PingCodeError(`OAuth callback timed out after ${timeoutMs}ms`));
|
|
1289
|
+
}, timeoutMs);
|
|
1290
|
+
|
|
1291
|
+
server.on('error', (err) => {
|
|
1292
|
+
finish('reject', new PingCodeError(`Callback server error: ${err.message}`));
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
server.listen(port, '127.0.0.1');
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function escapeHtml(text) {
|
|
1300
|
+
return String(text)
|
|
1301
|
+
.replace(/&/g, '&')
|
|
1302
|
+
.replace(/</g, '<')
|
|
1303
|
+
.replace(/>/g, '>')
|
|
1304
|
+
.replace(/"/g, '"')
|
|
1305
|
+
.replace(/'/g, ''');
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
module.exports = {
|
|
1309
|
+
PingCodeError,
|
|
1310
|
+
PingCodeClient,
|
|
1311
|
+
DEFAULT_BASE_URL,
|
|
1312
|
+
DEFAULT_TOKEN_CACHE,
|
|
1313
|
+
DEFAULT_WORKSPACE_CACHE,
|
|
1314
|
+
HTTP_METHODS,
|
|
1315
|
+
MAX_SELECTION_OPTIONS,
|
|
1316
|
+
CLI_COMMAND,
|
|
1317
|
+
CTX_COMMAND,
|
|
1318
|
+
AUTH_ENV_GUIDANCE,
|
|
1319
|
+
USER_ENV_GUIDANCE,
|
|
1320
|
+
WORKSPACE_DEFAULT_GUIDANCE,
|
|
1321
|
+
emptyWorkspaceCache,
|
|
1322
|
+
parseJsonObject,
|
|
1323
|
+
parseKeyValues,
|
|
1324
|
+
loadWorkspaceCache,
|
|
1325
|
+
mergeWorkspaceCache,
|
|
1326
|
+
compactWorkspaceCacheValue,
|
|
1327
|
+
saveWorkspaceCache,
|
|
1328
|
+
currentUserId,
|
|
1329
|
+
currentUserName,
|
|
1330
|
+
pageValues,
|
|
1331
|
+
nestedText,
|
|
1332
|
+
compactBusinessItem,
|
|
1333
|
+
compactResponse,
|
|
1334
|
+
normalizedEntity,
|
|
1335
|
+
itemNames,
|
|
1336
|
+
displayName,
|
|
1337
|
+
itemId,
|
|
1338
|
+
selectionItem,
|
|
1339
|
+
selectionOptions,
|
|
1340
|
+
selectionGuidance,
|
|
1341
|
+
findCachedItem,
|
|
1342
|
+
cachedUserId,
|
|
1343
|
+
expandIdentityPlaceholder,
|
|
1344
|
+
expandIdentityPlaceholders,
|
|
1345
|
+
loadCachedToken,
|
|
1346
|
+
saveCachedToken,
|
|
1347
|
+
readRawTokenCache,
|
|
1348
|
+
buildUrl,
|
|
1349
|
+
normalizePath,
|
|
1350
|
+
stateCacheKey,
|
|
1351
|
+
sprintProjectId,
|
|
1352
|
+
memberProjectId,
|
|
1353
|
+
cachedResponse,
|
|
1354
|
+
updateWorkspaceCacheForResponse,
|
|
1355
|
+
pathIsListWorkItems,
|
|
1356
|
+
startAuthCallbackServer,
|
|
1357
|
+
refreshCommand,
|
|
1358
|
+
cacheProjects,
|
|
1359
|
+
cacheSprints,
|
|
1360
|
+
cacheWorkItemTypes,
|
|
1361
|
+
cacheWorkItemPriorities,
|
|
1362
|
+
cacheWorkItemStates,
|
|
1363
|
+
cacheWorkItemProperties,
|
|
1364
|
+
cacheUsers,
|
|
1365
|
+
setCurrentUser,
|
|
1366
|
+
setCurrentProject,
|
|
1367
|
+
setCurrentSprint,
|
|
1368
|
+
applyDefaultWorkItemFilters,
|
|
1369
|
+
ensureWorkItemWorkspaceContext,
|
|
1370
|
+
applyDefaultWorkItemCreateBody,
|
|
1371
|
+
printJson,
|
|
1372
|
+
};
|