backlog-exporter 1.2.0 → 1.3.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 CHANGED
@@ -42,7 +42,7 @@ $ npm install -g backlog-exporter
42
42
  $ backlog-exporter COMMAND
43
43
  running command...
44
44
  $ backlog-exporter (--version)
45
- backlog-exporter/1.2.0 linux-x64 node-v26.8.1
45
+ backlog-exporter/1.3.0 linux-x64 node-v26.10.0
46
46
  $ backlog-exporter --help [COMMAND]
47
47
  USAGE
48
48
  $ backlog-exporter COMMAND
@@ -303,13 +303,23 @@ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KE
303
303
  ## 基本情報
304
304
 
305
305
  - 課題キー: PROJ-123
306
+ - 種別: タスク
307
+ - カテゴリー: 設計, 実装
306
308
  - ステータス: 処理中
307
309
  - 優先度: 高
308
310
  - 担当者: 山田太郎
311
+ - 親課題: [PROJ-100 親課題のタイトル](https://example.backlog.jp/view/PROJ-100)
312
+ - 開始日: 2023/01/01
313
+ - 期限日: 2023/01/31
309
314
  - 作成日時: 2023/01/01 10:00:00
310
315
  - 更新日時: 2023/01/02 15:30:45
311
316
  - [Backlog Issue Link](https://example.backlog.jp/view/PROJ-123)
312
317
 
318
+ ## 子課題
319
+
320
+ - [PROJ-124 子課題のタイトル](https://example.backlog.jp/view/PROJ-124)
321
+ - [PROJ-125 もう 1 つの子課題](https://example.backlog.jp/view/PROJ-125)
322
+
313
323
  ## カスタム属性
314
324
 
315
325
  | 属性名 | 値 |
@@ -334,6 +344,15 @@ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KE
334
344
  コメントの内容がここに表示されます。
335
345
  ```
336
346
 
347
+ **親課題・子課題について**:
348
+
349
+ - 親課題がある場合のみ「親課題」の行を、子課題がある場合のみ「子課題」セクションを出力します
350
+ - 3 階層(親課題・子課題・孫課題)にも対応しています。各ファイルは 1 つ上と 1 つ下の階層を出力するため、2 階層目の課題には「親課題」の行と「子課題」セクションの両方が出ます
351
+ - 子課題は、プロジェクト全体のエクスポート(`issue` / `all`)では取得済みの課題から組み立てるため追加の API 呼び出しは発生しません。課題キーを指定した再取得(`update --issueIdOrKey`)でのみ API から取得します(対象課題を 100 件ずつまとめて引きます)
352
+ - ID を指定しない差分更新(`update`)では、更新された課題に加えてその直接の親課題・子課題も書き直すため、子課題の追加や件名の変更は親子どちらのファイルにも反映されます。ただし親課題を変更・解除した子課題の元の親と、削除された子課題の親は書き直されないため、揃えるには `issue` / `all` で再エクスポートしてください
353
+ - `--statusId` で状態を絞り込んだ場合、絞り込みから外れた子課題は「子課題」セクションに現れません
354
+ - 親課題が別プロジェクトにあるなどで課題キーを解決できなかった場合は `- 親課題: (ID: 100)` のように課題 ID のみを出力します
355
+
337
356
  ### Wiki の出力形式
338
357
 
339
358
  ```markdown
@@ -7,12 +7,16 @@ const BLOCK_TYPES = new Set([
7
7
  'orderedList',
8
8
  'paragraph',
9
9
  'table',
10
+ 'taskList',
10
11
  ]);
11
12
  // マーク適用順。codeは最内側に置き、他の記号がコードスパンの外に出るようにする
12
13
  const MARK_ORDER = ['code', 'bold', 'italic', 'strike', 'link'];
13
14
  function isNode(value) {
14
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
15
16
  }
17
+ function isListNode(node) {
18
+ return node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList';
19
+ }
16
20
  function isBlockLike(node) {
17
21
  return typeof node.type === 'string' && BLOCK_TYPES.has(node.type);
18
22
  }
@@ -206,6 +210,10 @@ function renderInline(nodes, atBlockStart = false) {
206
210
  result += renderAttachmentBadge(node);
207
211
  break;
208
212
  }
213
+ case 'documentMention': {
214
+ result += renderDocumentMention(node);
215
+ break;
216
+ }
209
217
  case 'hardBreak': {
210
218
  result += ' \n';
211
219
  atLineStart = true;
@@ -253,6 +261,13 @@ function renderAttachmentBadge(node) {
253
261
  const resolved = escapeBracketText(label.length > 0 ? label : '添付ファイル');
254
262
  return src ? `[${resolved}](${linkDestination(src)})` : resolved;
255
263
  }
264
+ // Backlog独自ノード。attrsにのみ値を持ち子ノードが無いためリンクに組み立てる
265
+ function renderDocumentMention(node) {
266
+ const label = attrString(node, 'label') ?? attrString(node, 'title') ?? attrString(node, 'text') ?? renderInline(childNodes(node));
267
+ const url = attrString(node, 'url') ?? attrString(node, 'href');
268
+ const resolved = escapeBracketText(label.length > 0 ? label : 'ドキュメント');
269
+ return url ? `[${resolved}](${linkDestination(url)})` : resolved;
270
+ }
256
271
  // Backlog独自ノード。課題キーを持つキーを順に探す
257
272
  function renderIssueMention(node) {
258
273
  const key = attrString(node, 'issueKey') ??
@@ -279,9 +294,12 @@ function renderBlockquote(node) {
279
294
  .map((line) => (line === '' ? '>' : `> ${line}`))
280
295
  .join('\n');
281
296
  }
282
- function renderListItem(node, marker) {
297
+ // チェックボックス記法の継続行はリスト記号"- "の幅で字下げする。
298
+ // マーカー全長(6桁)だとCommonMarkがコードブロックと解釈しネストが壊れる
299
+ const TASK_ITEM_INDENT = 2;
300
+ function renderListItem(node, marker, indentWidth = marker.length) {
283
301
  const children = childNodes(node);
284
- const rendered = children.map((child) => child.type === 'bulletList' || child.type === 'orderedList' ? renderList(child) : renderBlock(child));
302
+ const rendered = children.map((child) => (isListNode(child) ? renderList(child) : renderBlock(child)));
285
303
  let body = '';
286
304
  for (const [index, block] of rendered.entries()) {
287
305
  if (block === '')
@@ -291,18 +309,25 @@ function renderListItem(node, marker) {
291
309
  continue;
292
310
  }
293
311
  // 段落直後のネストリストはtight listとして1改行で繋ぐ
294
- const isNestedList = children[index].type === 'bulletList' || children[index].type === 'orderedList';
295
- body += isNestedList ? `\n${block}` : `\n\n${block}`;
312
+ body += isListNode(children[index]) ? `\n${block}` : `\n\n${block}`;
296
313
  }
297
314
  if (body === '')
298
315
  return marker.trimEnd();
299
- const indent = ' '.repeat(marker.length);
316
+ const indent = ' '.repeat(indentWidth);
300
317
  return body
301
318
  .split('\n')
302
319
  .map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
303
320
  .join('\n');
304
321
  }
322
+ function taskMarker(item) {
323
+ return item.attrs?.checked === true ? '- [x] ' : '- [ ] ';
324
+ }
305
325
  function renderList(node) {
326
+ if (node.type === 'taskList') {
327
+ return childNodes(node)
328
+ .map((item) => renderListItem(item, taskMarker(item), TASK_ITEM_INDENT))
329
+ .join('\n');
330
+ }
306
331
  const ordered = node.type === 'orderedList';
307
332
  const startAttr = node.attrs?.start;
308
333
  const start = ordered && typeof startAttr === 'number' && Number.isInteger(startAttr) ? startAttr : 1;
@@ -342,7 +367,8 @@ function renderBlock(node) {
342
367
  return renderBlockquote(node);
343
368
  }
344
369
  case 'bulletList':
345
- case 'orderedList': {
370
+ case 'orderedList':
371
+ case 'taskList': {
346
372
  return renderList(node);
347
373
  }
348
374
  case 'codeBlock': {
@@ -1,5 +1,15 @@
1
+ import { IssueParent, IssueRef, IssueRelations } from './issue-relations.js';
1
2
  import { CustomField, Issue, IssueAttachment, IssueComment } from './issue.js';
2
3
  export declare function createCustomFieldsSection(customFields?: CustomField[]): string;
3
4
  export declare function buildAttachmentsSection(attachments: IssueAttachment[] | undefined, localLinks?: Map<number, string>): string;
4
5
  export declare function buildCommentsSection(comments: IssueComment[], backlogIssueUrl: string, rewriteBody?: (text: string) => string): string;
5
- export declare function buildIssueMarkdown(issue: Issue, comments: IssueComment[], backlogIssueUrl: string, attachmentLinks?: Map<number, string>): string;
6
+ export declare function formatCategories(categories?: Issue['category']): string;
7
+ export type IssueUrlResolver = (issueKey: string) => string;
8
+ export declare function buildParentIssueLine(parent: IssueParent | null, issueUrl?: IssueUrlResolver): string;
9
+ export declare function buildChildIssuesSection(children: IssueRef[], issueUrl?: IssueUrlResolver): string;
10
+ export interface BuildIssueMarkdownOptions {
11
+ attachmentLinks?: Map<number, string>;
12
+ issueUrl?: IssueUrlResolver;
13
+ relations?: IssueRelations;
14
+ }
15
+ export declare function buildIssueMarkdown(issue: Issue, comments: IssueComment[], backlogIssueUrl: string, options?: BuildIssueMarkdownOptions): string;
@@ -99,11 +99,40 @@ export function buildCommentsSection(comments, backlogIssueUrl, rewriteBody = (t
99
99
  // 最後の区切り線を削除
100
100
  return commentsSection.slice(0, -5);
101
101
  }
102
- export function buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentLinks) {
102
+ // Backlogのカテゴリーは複数設定できるため、カンマ区切りで並べる
103
+ export function formatCategories(categories) {
104
+ if (!categories || categories.length === 0)
105
+ return '未設定';
106
+ return categories.map((category) => category.name).join(', ');
107
+ }
108
+ // URL解決関数が無い場合は壊れたリンクを書かないよう、ラベルだけを出す
109
+ function issueRefLink(ref, issueUrl) {
110
+ const label = `${ref.issueKey} ${ref.summary}`.trim();
111
+ return issueUrl ? `[${escapeLinkText(label)}](${issueUrl(ref.issueKey)})` : label;
112
+ }
113
+ // 課題キーを解決できなかった親はリンクにできないため、Backlog上のIDだけを出す
114
+ export function buildParentIssueLine(parent, issueUrl) {
115
+ if (!parent)
116
+ return '';
117
+ if (!parent.ref)
118
+ return `\n- 親課題: (ID: ${parent.parentIssueId})`;
119
+ return `\n- 親課題: ${issueRefLink(parent.ref, issueUrl)}`;
120
+ }
121
+ // 件数が可変なので基本情報の箇条書きではなく独立セクションにする
122
+ export function buildChildIssuesSection(children, issueUrl) {
123
+ if (children.length === 0)
124
+ return '';
125
+ const lines = children.map((child) => `- ${issueRefLink(child, issueUrl)}`);
126
+ return `\n\n## 子課題\n\n${lines.join('\n')}`;
127
+ }
128
+ export function buildIssueMarkdown(issue, comments, backlogIssueUrl, options = {}) {
129
+ const { attachmentLinks, issueUrl, relations } = options;
103
130
  const rewriteBody = (text) => rewriteInlineImages(text, issue.attachments, attachmentLinks);
104
131
  const commentsSection = buildCommentsSection(comments, backlogIssueUrl, rewriteBody);
105
132
  const customFieldsSection = createCustomFieldsSection(issue.customFields);
106
133
  const attachmentsSection = buildAttachmentsSection(issue.attachments, attachmentLinks);
134
+ const parentIssueLine = buildParentIssueLine(relations?.parent ?? null, issueUrl);
135
+ const childIssuesSection = buildChildIssuesSection(relations?.children ?? [], issueUrl);
107
136
  const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
108
137
  const startDate = issue.startDate ? new Date(issue.startDate).toLocaleDateString('ja-JP') : '未設定';
109
138
  const dueDate = issue.dueDate ? new Date(issue.dueDate).toLocaleDateString('ja-JP') : '未設定';
@@ -112,14 +141,15 @@ export function buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentL
112
141
  ## 基本情報
113
142
  - 課題キー: ${issue.issueKey}
114
143
  - 種別: ${issue.issueType.name}
144
+ - カテゴリー: ${formatCategories(issue.category)}
115
145
  - ステータス: ${issue.status.name}
116
146
  - 優先度: ${issue.priority.name}
117
- - 担当者: ${assigneeName}
147
+ - 担当者: ${assigneeName}${parentIssueLine}
118
148
  - 開始日: ${startDate}
119
149
  - 期限日: ${dueDate}
120
150
  - 作成日時: ${new Date(issue.created).toLocaleString('ja-JP')}
121
151
  - 更新日時: ${new Date(issue.updated).toLocaleString('ja-JP')}
122
- - [Backlog Issue Link](${backlogIssueUrl})${customFieldsSection}${attachmentsSection}
152
+ - [Backlog Issue Link](${backlogIssueUrl})${childIssuesSection}${customFieldsSection}${attachmentsSection}
123
153
 
124
154
  ## 詳細
125
155
 
@@ -0,0 +1,19 @@
1
+ import { Issue } from './issue.js';
2
+ export interface IssueRef {
3
+ issueKey: string;
4
+ summary: string;
5
+ }
6
+ export interface IssueParent {
7
+ parentIssueId: number;
8
+ ref: IssueRef | null;
9
+ }
10
+ export interface IssueRelations {
11
+ children: IssueRef[];
12
+ parent: IssueParent | null;
13
+ }
14
+ export declare function toIssueRef(issue: Pick<Issue, 'issueKey' | 'summary'>): IssueRef;
15
+ export declare function buildIssueRefIndex(issues: Issue[]): Map<number, IssueRef>;
16
+ export declare function buildChildIndex(issues: Issue[]): Map<number, IssueRef[]>;
17
+ export declare function withParentsAndChildren(allIssues: Issue[], updatedIssues: Issue[]): Issue[];
18
+ export declare function findChildren(issue: Issue, childIndex: Map<number, IssueRef[]>): IssueRef[];
19
+ export declare function findParent(issue: Issue, refIndex: Map<number, IssueRef>): IssueParent | null;
@@ -0,0 +1,42 @@
1
+ export function toIssueRef(issue) {
2
+ return { issueKey: issue.issueKey, summary: issue.summary };
3
+ }
4
+ export function buildIssueRefIndex(issues) {
5
+ return new Map(issues.map((issue) => [issue.id, toIssueRef(issue)]));
6
+ }
7
+ // parentIssueIdでグルーピングするだけなので、親子孫の3階層でもそのまま成立する
8
+ export function buildChildIndex(issues) {
9
+ const childIndex = new Map();
10
+ // 一覧APIの返却順はソート指定に依存するため、Backlogの画面に合わせて課題ID昇順に固定する
11
+ for (const issue of [...issues].sort((a, b) => a.id - b.id)) {
12
+ if (issue.parentIssueId === null || issue.parentIssueId === undefined)
13
+ continue;
14
+ const siblings = childIndex.get(issue.parentIssueId);
15
+ if (siblings) {
16
+ siblings.push(toIssueRef(issue));
17
+ }
18
+ else {
19
+ childIndex.set(issue.parentIssueId, [toIssueRef(issue)]);
20
+ }
21
+ }
22
+ return childIndex;
23
+ }
24
+ // 課題のファイルには直接の親と子しか載らないため、更新された課題の上下1段を書き直せば親子の表示が揃う
25
+ export function withParentsAndChildren(allIssues, updatedIssues) {
26
+ const updatedIds = new Set(updatedIssues.map((issue) => issue.id));
27
+ const parentIds = new Set(updatedIssues
28
+ .map((issue) => issue.parentIssueId)
29
+ .filter((parentIssueId) => parentIssueId !== null));
30
+ return allIssues.filter((issue) => updatedIds.has(issue.id) ||
31
+ parentIds.has(issue.id) ||
32
+ (issue.parentIssueId !== null && updatedIds.has(issue.parentIssueId)));
33
+ }
34
+ export function findChildren(issue, childIndex) {
35
+ return childIndex.get(issue.id) ?? [];
36
+ }
37
+ export function findParent(issue, refIndex) {
38
+ const parentIssueId = issue.parentIssueId ?? null;
39
+ if (parentIssueId === null)
40
+ return null;
41
+ return { parentIssueId, ref: refIndex.get(parentIssueId) ?? null };
42
+ }
@@ -3,6 +3,8 @@ export interface IssueRepository {
3
3
  downloadAttachment(issueIdOrKey: string, attachmentId: number): Promise<ArrayBuffer>;
4
4
  fetchAllComments(issueKey: string): Promise<IssueComment[]>;
5
5
  fetchByIdOrKey(issueIdOrKey: string): Promise<Issue>;
6
+ fetchByIds(ids: number[]): Promise<Issue[]>;
7
+ fetchChildren(parentIssueIds: number[]): Promise<Issue[]>;
6
8
  fetchPage(options: {
7
9
  count: number;
8
10
  offset: number;
@@ -4,6 +4,7 @@ export interface Issue {
4
4
  name: string;
5
5
  };
6
6
  attachments?: IssueAttachment[];
7
+ category: IssueCategory[];
7
8
  created: string;
8
9
  customFields: CustomField[];
9
10
  description: string;
@@ -14,6 +15,7 @@ export interface Issue {
14
15
  id: number;
15
16
  name: string;
16
17
  };
18
+ parentIssueId: null | number;
17
19
  priority: {
18
20
  id: number;
19
21
  name: string;
@@ -31,6 +33,10 @@ export interface IssueAttachment {
31
33
  name: string;
32
34
  size: number;
33
35
  }
36
+ export interface IssueCategory {
37
+ id: number;
38
+ name: string;
39
+ }
34
40
  export interface CustomField {
35
41
  id: number;
36
42
  name: string;
@@ -1,3 +1,11 @@
1
+ const PAGE_SIZE = 100;
2
+ function listParams(key, values, offset) {
3
+ return {
4
+ count: PAGE_SIZE.toString(),
5
+ [key]: values.map((value) => value.toString()),
6
+ offset: offset.toString(),
7
+ };
8
+ }
1
9
  export function newBacklogIssueRepository(client) {
2
10
  return {
3
11
  async downloadAttachment(issueIdOrKey, attachmentId) {
@@ -26,6 +34,22 @@ export function newBacklogIssueRepository(client) {
26
34
  async fetchByIdOrKey(issueIdOrKey) {
27
35
  return client.getJson(`/issues/${issueIdOrKey}`);
28
36
  },
37
+ async fetchByIds(ids) {
38
+ return client.getJson('/issues', listParams('id[]', ids, 0));
39
+ },
40
+ // 課題キー指定エクスポートのように取得済み集合に子が含まれない場合の補完用。
41
+ // 複数の親をまとめて指定できるため、課題ごとではなく一括で引く
42
+ async fetchChildren(parentIssueIds) {
43
+ const children = [];
44
+ for (;;) {
45
+ // eslint-disable-next-line no-await-in-loop
46
+ const page = await client.getJson('/issues', listParams('parentIssueId[]', parentIssueIds, children.length));
47
+ children.push(...page);
48
+ if (page.length < PAGE_SIZE) {
49
+ return children;
50
+ }
51
+ }
52
+ },
29
53
  async fetchPage(options) {
30
54
  const params = {
31
55
  count: options.count.toString(),
@@ -5,6 +5,9 @@ import { appendLog } from '../../../shared/storage/update-log.js';
5
5
  import { filterIssuesUpdatedSince } from '../domain/issue-filter.js';
6
6
  import { buildIssueMarkdown } from '../domain/issue-markdown.js';
7
7
  import { attachmentMarkdownLink, attachmentRelativePath, issueRelativePath, issueUrl } from '../domain/issue-path.js';
8
+ import { buildChildIndex, buildIssueRefIndex, findChildren, findParent, withParentsAndChildren, } from '../domain/issue-relations.js';
9
+ // Backlogの配列パラメータの上限に合わせ、一括取得は100件ずつに分ける
10
+ const BULK_FETCH_SIZE = 100;
8
11
  export async function exportIssues(deps, options) {
9
12
  const { logger } = deps;
10
13
  logger.log('課題の取得を開始します...');
@@ -20,12 +23,18 @@ export async function exportIssues(deps, options) {
20
23
  logger.log('更新が必要な課題はありません。');
21
24
  return;
22
25
  }
26
+ const updatedIssueIds = new Set(filteredIssues.map((issue) => issue.id));
27
+ const targetIssues = withParentsAndChildren(allIssues, filteredIssues);
28
+ if (targetIssues.length > filteredIssues.length) {
29
+ logger.log(`親子の表示を揃えるため、${targetIssues.length - filteredIssues.length}件の課題もあわせて書き直します。`);
30
+ }
23
31
  logger.log('課題を保存しています...');
24
- for (const [index, issue] of filteredIssues.entries()) {
32
+ const relationIndex = await buildRelationIndex(deps, allIssues, targetIssues, options);
33
+ for (const [index, issue] of targetIssues.entries()) {
25
34
  try {
26
- writeProgress(`課題を保存中... (${index + 1}/${filteredIssues.length}件)`);
35
+ writeProgress(`課題を保存中... (${index + 1}/${targetIssues.length}件)`);
27
36
  // eslint-disable-next-line no-await-in-loop
28
- await saveIssue(deps, issue, options);
37
+ await saveIssue(deps, issue, options, { relationIndex, relationOnly: !updatedIssueIds.has(issue.id) });
29
38
  }
30
39
  catch (error) {
31
40
  logger.warn(`課題 ${issue.issueKey} の保存に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
@@ -71,23 +80,85 @@ async function fetchAllIssues(deps, options) {
71
80
  }
72
81
  return issues;
73
82
  }
74
- async function saveIssue(deps, issue, options) {
83
+ async function saveIssue(deps, issue, options, { relationIndex, relationOnly }) {
75
84
  const backlogIssueUrl = issueUrl(options.domain, issue.issueKey);
76
- // コメント取得に失敗しても課題本体は保存する
77
85
  let comments = [];
78
86
  try {
79
87
  comments = await deps.issueRepository.fetchAllComments(issue.issueKey);
80
88
  }
81
89
  catch (error) {
82
- deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
90
+ const reason = error instanceof Error ? error.message : String(error);
91
+ // 更新された課題はコメントが欠けても本体を保存するが、親子の表示を揃えるためだけの書き直しでは既存のファイルを残す
92
+ if (relationOnly) {
93
+ deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗したため、親子の表示の更新を見送りました: ${reason}`);
94
+ return;
95
+ }
96
+ deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗しました: ${reason}`);
83
97
  }
84
- const attachmentLinks = options.downloadAttachments
85
- ? await downloadIssueAttachments(deps, issue, options)
86
- : undefined;
98
+ const attachmentLinks = options.downloadAttachments ? await downloadIssueAttachments(deps, issue, options) : undefined;
87
99
  const filePath = path.join(options.outputDir, issueRelativePath(issue, options));
88
- await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentLinks));
100
+ await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl, {
101
+ attachmentLinks,
102
+ issueUrl: (issueKey) => issueUrl(options.domain, issueKey),
103
+ relations: {
104
+ children: findChildren(issue, relationIndex.childIndex),
105
+ parent: findParent(issue, relationIndex.refIndex),
106
+ },
107
+ }));
89
108
  await appendLog(options.outputDir, `課題「${issue.summary}」を更新しました: ${backlogIssueUrl}`);
90
109
  }
110
+ // 取得済みの課題だけでは足りない親子をAPIで補い、索引を組み立てる。
111
+ // 課題ごとに引くとN+1になるため、いずれも配列パラメータでまとめて取得する。
112
+ // 補完に失敗しても課題本体の保存は続行する(関連情報が欠けるだけに留める)
113
+ async function buildRelationIndex(deps, allIssues, targets, options) {
114
+ const sources = [...allIssues];
115
+ // 課題キー指定では取得済み集合に子が含まれないため、対象課題の子をまとめて引く
116
+ if (options.issueIdOrKeys && options.issueIdOrKeys.length > 0) {
117
+ sources.push(...(await fetchChildrenOf(deps, targets)));
118
+ }
119
+ sources.push(...(await fetchMissingParents(deps, targets, buildIssueRefIndex(sources))));
120
+ return { childIndex: buildChildIndex(sources), refIndex: buildIssueRefIndex(sources) };
121
+ }
122
+ async function fetchChildrenOf(deps, targets) {
123
+ const children = [];
124
+ for (const ids of chunkIds(targets.map((target) => target.id))) {
125
+ try {
126
+ // eslint-disable-next-line no-await-in-loop
127
+ children.push(...(await deps.issueRepository.fetchChildren(ids)));
128
+ }
129
+ catch (error) {
130
+ deps.logger.warn(`子課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ }
133
+ return children;
134
+ }
135
+ // 別プロジェクトの親やstatusIdで絞り込まれた親は取得済み集合に現れないため、IDから引き直す
136
+ async function fetchMissingParents(deps, targets, refIndex) {
137
+ const missingIds = [
138
+ ...new Set(targets
139
+ .map((target) => target.parentIssueId)
140
+ .filter((parentIssueId) => typeof parentIssueId === 'number')
141
+ .filter((parentIssueId) => !refIndex.has(parentIssueId))),
142
+ ];
143
+ const parents = [];
144
+ for (const ids of chunkIds(missingIds)) {
145
+ try {
146
+ // eslint-disable-next-line no-await-in-loop
147
+ parents.push(...(await deps.issueRepository.fetchByIds(ids)));
148
+ }
149
+ catch (error) {
150
+ deps.logger.warn(`親課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
151
+ }
152
+ }
153
+ return parents;
154
+ }
155
+ function chunkIds(ids) {
156
+ const chunks = [];
157
+ for (let index = 0; index < ids.length; index += BULK_FETCH_SIZE) {
158
+ chunks.push(ids.slice(index, index + BULK_FETCH_SIZE));
159
+ }
160
+ return chunks;
161
+ }
91
162
  // 保存できた添付のみリンク化する。個々の失敗は警告に留め、課題本体の保存は続行する
92
163
  async function downloadIssueAttachments(deps, issue, options) {
93
164
  const links = new Map();
@@ -2,6 +2,7 @@ export declare class HttpError extends Error {
2
2
  readonly status: number;
3
3
  constructor(status: number, statusText: string, url: string);
4
4
  }
5
+ export type QueryParams = Record<string, string | string[]>;
5
6
  export declare function rateLimitWaitMs(resetHeader: null | string, nowMs: number): number;
6
7
  export declare class BacklogHttpClient {
7
8
  private readonly apiKey;
@@ -12,8 +13,8 @@ export declare class BacklogHttpClient {
12
13
  domain: string;
13
14
  onRateLimitExceeded?: (waitSeconds: number) => void;
14
15
  });
15
- getBinary(pathname: string, params?: Record<string, string>): Promise<ArrayBuffer>;
16
- getJson<T>(pathname: string, params?: Record<string, string>): Promise<T>;
16
+ getBinary(pathname: string, params?: QueryParams): Promise<ArrayBuffer>;
17
+ getJson<T>(pathname: string, params?: QueryParams): Promise<T>;
17
18
  private fetchWithRetry;
18
19
  private maskApiKey;
19
20
  private requestUrl;
@@ -87,7 +87,12 @@ export class BacklogHttpClient {
87
87
  return url.replace(/apiKey=[^&]*/, 'apiKey=***');
88
88
  }
89
89
  requestUrl(pathname, params) {
90
- const searchParams = new URLSearchParams({ apiKey: this.apiKey, ...params });
90
+ const searchParams = new URLSearchParams({ apiKey: this.apiKey });
91
+ for (const [key, value] of Object.entries(params)) {
92
+ for (const item of Array.isArray(value) ? value : [value]) {
93
+ searchParams.append(key, item);
94
+ }
95
+ }
91
96
  return `${this.baseUrl}${pathname}?${searchParams.toString()}`;
92
97
  }
93
98
  }
@@ -189,71 +189,6 @@
189
189
  "index.js"
190
190
  ]
191
191
  },
192
- "prune": {
193
- "aliases": [],
194
- "args": {
195
- "directory": {
196
- "description": "対象ディレクトリ(設定ファイルが保存されている場所)",
197
- "name": "directory",
198
- "required": false
199
- }
200
- },
201
- "description": "Backlog上で削除・移動された課題・ドキュメント・Wikiのローカルファイルを削除し、Backlogと同じ状態に揃える",
202
- "examples": [
203
- "<%= config.bin %> <%= command.id %>\nカレントディレクトリ配下の設定ファイルを探索し、Backlog上に存在しないドキュメントファイルを削除する\n",
204
- "<%= config.bin %> <%= command.id %> ./backlog-documents\n指定したディレクトリを対象にする\n",
205
- "<%= config.bin %> <%= command.id %> --force\n確認プロンプトをスキップして削除する\n"
206
- ],
207
- "flags": {
208
- "apiKey": {
209
- "description": "Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)",
210
- "name": "apiKey",
211
- "required": false,
212
- "hasDynamicHelp": false,
213
- "multiple": false,
214
- "type": "option"
215
- },
216
- "domain": {
217
- "description": "Backlog domain (e.g. example.backlog.jp)",
218
- "name": "domain",
219
- "required": false,
220
- "hasDynamicHelp": false,
221
- "multiple": false,
222
- "type": "option"
223
- },
224
- "force": {
225
- "char": "f",
226
- "description": "確認プロンプトをスキップする",
227
- "name": "force",
228
- "required": false,
229
- "allowNo": false,
230
- "type": "boolean"
231
- },
232
- "projectIdOrKey": {
233
- "description": "Backlog project ID or key",
234
- "name": "projectIdOrKey",
235
- "required": false,
236
- "hasDynamicHelp": false,
237
- "multiple": false,
238
- "type": "option"
239
- }
240
- },
241
- "hasDynamicHelp": false,
242
- "hiddenAliases": [],
243
- "id": "prune",
244
- "pluginAlias": "backlog-exporter",
245
- "pluginName": "backlog-exporter",
246
- "pluginType": "core",
247
- "strict": true,
248
- "enableJsonFlag": false,
249
- "isESM": true,
250
- "relativePath": [
251
- "dist",
252
- "commands",
253
- "prune",
254
- "index.js"
255
- ]
256
- },
257
192
  "issue": {
258
193
  "aliases": [],
259
194
  "args": {},
@@ -359,6 +294,71 @@
359
294
  "index.js"
360
295
  ]
361
296
  },
297
+ "prune": {
298
+ "aliases": [],
299
+ "args": {
300
+ "directory": {
301
+ "description": "対象ディレクトリ(設定ファイルが保存されている場所)",
302
+ "name": "directory",
303
+ "required": false
304
+ }
305
+ },
306
+ "description": "Backlog上で削除・移動された課題・ドキュメント・Wikiのローカルファイルを削除し、Backlogと同じ状態に揃える",
307
+ "examples": [
308
+ "<%= config.bin %> <%= command.id %>\nカレントディレクトリ配下の設定ファイルを探索し、Backlog上に存在しないドキュメントファイルを削除する\n",
309
+ "<%= config.bin %> <%= command.id %> ./backlog-documents\n指定したディレクトリを対象にする\n",
310
+ "<%= config.bin %> <%= command.id %> --force\n確認プロンプトをスキップして削除する\n"
311
+ ],
312
+ "flags": {
313
+ "apiKey": {
314
+ "description": "Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)",
315
+ "name": "apiKey",
316
+ "required": false,
317
+ "hasDynamicHelp": false,
318
+ "multiple": false,
319
+ "type": "option"
320
+ },
321
+ "domain": {
322
+ "description": "Backlog domain (e.g. example.backlog.jp)",
323
+ "name": "domain",
324
+ "required": false,
325
+ "hasDynamicHelp": false,
326
+ "multiple": false,
327
+ "type": "option"
328
+ },
329
+ "force": {
330
+ "char": "f",
331
+ "description": "確認プロンプトをスキップする",
332
+ "name": "force",
333
+ "required": false,
334
+ "allowNo": false,
335
+ "type": "boolean"
336
+ },
337
+ "projectIdOrKey": {
338
+ "description": "Backlog project ID or key",
339
+ "name": "projectIdOrKey",
340
+ "required": false,
341
+ "hasDynamicHelp": false,
342
+ "multiple": false,
343
+ "type": "option"
344
+ }
345
+ },
346
+ "hasDynamicHelp": false,
347
+ "hiddenAliases": [],
348
+ "id": "prune",
349
+ "pluginAlias": "backlog-exporter",
350
+ "pluginName": "backlog-exporter",
351
+ "pluginType": "core",
352
+ "strict": true,
353
+ "enableJsonFlag": false,
354
+ "isESM": true,
355
+ "relativePath": [
356
+ "dist",
357
+ "commands",
358
+ "prune",
359
+ "index.js"
360
+ ]
361
+ },
362
362
  "update": {
363
363
  "aliases": [],
364
364
  "args": {
@@ -566,5 +566,5 @@
566
566
  ]
567
567
  }
568
568
  },
569
- "version": "1.2.0"
569
+ "version": "1.3.0"
570
570
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backlog-exporter",
3
3
  "description": "A new CLI generated with oclif",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "author": "ShuntaToda",
6
6
  "bin": {
7
7
  "backlog-exporter": "bin/run.js"