backlog-exporter 0.2.0 → 0.2.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/README.md CHANGED
@@ -59,7 +59,7 @@ $ npm install -g backlog-exporter
59
59
  $ backlog-exporter COMMAND
60
60
  running command...
61
61
  $ backlog-exporter (--version)
62
- backlog-exporter/0.2.0 linux-x64 node-v23.9.0
62
+ backlog-exporter/0.2.2 linux-x64 node-v23.9.0
63
63
  $ backlog-exporter --help [COMMAND]
64
64
  USAGE
65
65
  $ backlog-exporter COMMAND
@@ -196,9 +196,11 @@ Backlogから課題とWikiを取得してMarkdownファイルとして保存す
196
196
 
197
197
  ```
198
198
  USAGE
199
- $ backlog-exporter all --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-o <value>]
199
+ $ backlog-exporter all --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-m <value>] [-o
200
+ <value>]
200
201
 
201
202
  FLAGS
203
+ -m, --maxCount=<value> [default: 5000] 一度に取得する課題の最大数(デフォルト: 5000)
202
204
  -o, --output=<value> 出力ディレクトリパス
203
205
  --apiKey=<value> Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)
204
206
  --domain=<value> (required) Backlog domain (e.g. example.backlog.jp)
@@ -213,9 +215,12 @@ EXAMPLES
213
215
 
214
216
  $ backlog-exporter all --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
215
217
  指定したディレクトリに課題とWikiを保存する
218
+
219
+ $ backlog-exporter all --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 1000
220
+ 最大1000件の課題を取得する(デフォルトは100件)
216
221
  ```
217
222
 
218
- _See code: [src/commands/all/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.0/src/commands/all/index.ts)_
223
+ _See code: [src/commands/all/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.2/src/commands/all/index.ts)_
219
224
 
220
225
  ## `backlog-exporter help [COMMAND]`
221
226
 
@@ -243,13 +248,16 @@ Backlogから課題を取得してMarkdownファイルとして保存する
243
248
 
244
249
  ```
245
250
  USAGE
246
- $ backlog-exporter issue --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-o <value>]
251
+ $ backlog-exporter issue --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-m <value>] [-o
252
+ <value>] [--statusId <value>]
247
253
 
248
254
  FLAGS
255
+ -m, --maxCount=<value> [default: 5000] 一度に取得する課題の最大数(デフォルト: 5000)
249
256
  -o, --output=<value> 出力ディレクトリパス
250
257
  --apiKey=<value> Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)
251
258
  --domain=<value> (required) Backlog domain (e.g. example.backlog.jp)
252
259
  --projectIdOrKey=<value> (required) Backlog project ID or key
260
+ --statusId=<value> ステータスID(カンマ区切りで複数指定可能)
253
261
 
254
262
  DESCRIPTION
255
263
  Backlogから課題を取得してMarkdownファイルとして保存する
@@ -258,11 +266,17 @@ EXAMPLES
258
266
  $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY
259
267
  課題をMarkdownファイルとして保存する
260
268
 
261
- $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
269
+ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-issues
262
270
  指定したディレクトリに課題を保存する
271
+
272
+ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --statusId 1,2,3
273
+ 指定したステータスIDの課題のみを取得する
274
+
275
+ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 10000
276
+ 最大10000件の課題を取得する(デフォルトは5000件)
263
277
  ```
264
278
 
265
- _See code: [src/commands/issue/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.0/src/commands/issue/index.ts)_
279
+ _See code: [src/commands/issue/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.2/src/commands/issue/index.ts)_
266
280
 
267
281
  ## `backlog-exporter plugins`
268
282
 
@@ -591,7 +605,7 @@ EXAMPLES
591
605
  指定したディレクトリの設定を使用して更新する
592
606
  ```
593
607
 
594
- _See code: [src/commands/update/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.0/src/commands/update/index.ts)_
608
+ _See code: [src/commands/update/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.2/src/commands/update/index.ts)_
595
609
 
596
610
  ## `backlog-exporter wiki`
597
611
 
@@ -618,7 +632,7 @@ EXAMPLES
618
632
  指定したディレクトリにWikiを保存する
619
633
  ```
620
634
 
621
- _See code: [src/commands/wiki/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.0/src/commands/wiki/index.ts)_
635
+ _See code: [src/commands/wiki/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.2/src/commands/wiki/index.ts)_
622
636
  <!-- commandsstop -->
623
637
 
624
638
  # 出力形式
@@ -5,6 +5,7 @@ export default class All extends Command {
5
5
  static flags: {
6
6
  apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ maxCount: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
9
  output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  projectIdOrKey: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
11
  };
@@ -15,6 +15,9 @@ export default class All extends Command {
15
15
  `,
16
16
  `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
17
17
  指定したディレクトリに課題とWikiを保存する
18
+ `,
19
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 1000
20
+ 最大1000件の課題を取得する(デフォルトは100件)
18
21
  `,
19
22
  ];
20
23
  static flags = {
@@ -26,6 +29,12 @@ export default class All extends Command {
26
29
  description: 'Backlog domain (e.g. example.backlog.jp)',
27
30
  required: true,
28
31
  }),
32
+ maxCount: Flags.integer({
33
+ char: 'm',
34
+ default: 5000,
35
+ description: '一度に取得する課題の最大数(デフォルト: 5000)',
36
+ required: false,
37
+ }),
29
38
  output: Flags.string({
30
39
  char: 'o',
31
40
  description: '出力ディレクトリパス',
@@ -39,7 +48,7 @@ export default class All extends Command {
39
48
  async run() {
40
49
  const { flags } = await this.parse(All);
41
50
  try {
42
- const { domain, projectIdOrKey } = flags;
51
+ const { domain, maxCount, projectIdOrKey } = flags;
43
52
  const apiKey = flags.apiKey || getApiKey(this);
44
53
  const outputRoot = flags.output || './backlog-data';
45
54
  // 出力ディレクトリの作成
@@ -53,23 +62,34 @@ export default class All extends Command {
53
62
  // Wikiの出力ディレクトリ
54
63
  const wikiOutput = path.join(outputRoot, 'wiki');
55
64
  await createOutputDirectory(wikiOutput);
65
+ // 課題フォルダに設定ファイルを保存
66
+ await updateSettings(issueOutput, {
67
+ apiKey,
68
+ domain,
69
+ folderType: FolderType.ISSUE,
70
+ outputDir: issueOutput,
71
+ projectIdOrKey,
72
+ });
73
+ // Wikiフォルダに設定ファイルを保存
74
+ await updateSettings(wikiOutput, {
75
+ apiKey,
76
+ domain,
77
+ folderType: FolderType.WIKI,
78
+ outputDir: wikiOutput,
79
+ projectIdOrKey,
80
+ });
56
81
  // 課題の取得と保存
57
82
  this.log('課題の取得を開始します...');
58
83
  await downloadIssues(this, {
59
84
  apiKey,
60
- count: 100,
85
+ count: maxCount,
61
86
  domain,
62
87
  outputDir: issueOutput,
63
88
  projectId,
64
89
  });
65
- // 課題フォルダに設定ファイルを保存
90
+ // 課題フォルダの最終更新日時を更新
66
91
  await updateSettings(issueOutput, {
67
- apiKey,
68
- domain,
69
- folderType: FolderType.ISSUE,
70
92
  lastUpdated: new Date().toISOString(),
71
- outputDir: issueOutput,
72
- projectIdOrKey,
73
93
  });
74
94
  this.log('課題の取得が完了しました');
75
95
  // Wikiの取得と保存
@@ -80,14 +100,9 @@ export default class All extends Command {
80
100
  outputDir: wikiOutput,
81
101
  projectIdOrKey,
82
102
  });
83
- // Wikiフォルダに設定ファイルを保存
103
+ // Wikiフォルダの最終更新日時を更新
84
104
  await updateSettings(wikiOutput, {
85
- apiKey,
86
- domain,
87
- folderType: FolderType.WIKI,
88
105
  lastUpdated: new Date().toISOString(),
89
- outputDir: wikiOutput,
90
- projectIdOrKey,
91
106
  });
92
107
  this.log('Wikiの取得が完了しました');
93
108
  this.log('すべてのデータの取得が完了しました!');
@@ -5,8 +5,10 @@ export default class Issue extends Command {
5
5
  static flags: {
6
6
  apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ maxCount: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
9
  output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  projectIdOrKey: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ statusId: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
12
  };
11
13
  run(): Promise<void>;
12
14
  }
@@ -12,8 +12,14 @@ export default class Issue extends Command {
12
12
  `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY
13
13
  課題をMarkdownファイルとして保存する
14
14
  `,
15
- `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
15
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-issues
16
16
  指定したディレクトリに課題を保存する
17
+ `,
18
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --statusId 1,2,3
19
+ 指定したステータスIDの課題のみを取得する
20
+ `,
21
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 10000
22
+ 最大10000件の課題を取得する(デフォルトは5000件)
17
23
  `,
18
24
  ];
19
25
  static flags = {
@@ -25,6 +31,12 @@ export default class Issue extends Command {
25
31
  description: 'Backlog domain (e.g. example.backlog.jp)',
26
32
  required: true,
27
33
  }),
34
+ maxCount: Flags.integer({
35
+ char: 'm',
36
+ default: 5000,
37
+ description: '一度に取得する課題の最大数(デフォルト: 5000)',
38
+ required: false,
39
+ }),
28
40
  output: Flags.string({
29
41
  char: 'o',
30
42
  description: '出力ディレクトリパス',
@@ -34,34 +46,42 @@ export default class Issue extends Command {
34
46
  description: 'Backlog project ID or key',
35
47
  required: true,
36
48
  }),
49
+ statusId: Flags.string({
50
+ description: 'ステータスID(カンマ区切りで複数指定可能)',
51
+ required: false,
52
+ }),
37
53
  };
38
54
  async run() {
39
55
  const { flags } = await this.parse(Issue);
40
56
  try {
41
- const { domain, projectIdOrKey } = flags;
57
+ const { domain, maxCount, projectIdOrKey, statusId } = flags;
42
58
  const apiKey = flags.apiKey || getApiKey(this);
43
- const outputDir = flags.output || './issues';
59
+ const outputDir = flags.output || './backlog-issues';
44
60
  // 出力ディレクトリの作成
45
61
  await createOutputDirectory(outputDir);
46
62
  // プロジェクトキーからプロジェクトIDを取得
47
63
  const projectId = await validateAndGetProjectId(domain, projectIdOrKey, apiKey);
48
64
  this.log(`プロジェクトID: ${projectId} を使用します`);
65
+ // 設定ファイルを保存
66
+ await updateSettings(outputDir, {
67
+ apiKey,
68
+ domain,
69
+ folderType: FolderType.ISSUE,
70
+ outputDir,
71
+ projectIdOrKey,
72
+ });
49
73
  // 課題の取得と保存
50
74
  await downloadIssues(this, {
51
75
  apiKey,
52
- count: 100,
76
+ count: maxCount,
53
77
  domain,
54
78
  outputDir,
55
79
  projectId,
80
+ statusId,
56
81
  });
57
- // 設定ファイルを保存
82
+ // 最終更新日時を更新
58
83
  await updateSettings(outputDir, {
59
- apiKey,
60
- domain,
61
- folderType: FolderType.ISSUE,
62
84
  lastUpdated: new Date().toISOString(),
63
- outputDir,
64
- projectIdOrKey,
65
85
  });
66
86
  this.log('課題の取得が完了しました!');
67
87
  }
@@ -42,22 +42,25 @@ Wikiをダウンロードする
42
42
  const outputDir = flags.output || './wiki';
43
43
  // 出力ディレクトリの作成
44
44
  await createOutputDirectory(outputDir);
45
- // Wikiの取得と保存
46
- await downloadWikis(this, {
45
+ // 設定ファイルを保存
46
+ await updateSettings(outputDir, {
47
47
  apiKey,
48
48
  domain,
49
+ folderType: FolderType.WIKI,
49
50
  outputDir,
50
51
  projectIdOrKey,
51
52
  });
52
- // 設定ファイルを保存
53
- await updateSettings(outputDir, {
53
+ // Wikiの取得と保存
54
+ await downloadWikis(this, {
54
55
  apiKey,
55
56
  domain,
56
- folderType: FolderType.WIKI,
57
- lastUpdated: new Date().toISOString(),
58
57
  outputDir,
59
58
  projectIdOrKey,
60
59
  });
60
+ // 最終更新日時を更新
61
+ await updateSettings(outputDir, {
62
+ lastUpdated: new Date().toISOString(),
63
+ });
61
64
  this.log('Wikiの取得が完了しました!');
62
65
  }
63
66
  catch (error) {
@@ -6,7 +6,7 @@ import { Command } from '@oclif/core';
6
6
  */
7
7
  export declare function downloadIssues(command: Command, options: {
8
8
  apiKey: string;
9
- count: number;
9
+ count?: number;
10
10
  domain: string;
11
11
  lastUpdated?: string;
12
12
  outputDir: string;
@@ -1,7 +1,9 @@
1
1
  import ky from 'ky';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import process from 'node:process';
4
5
  import { sanitizeFileName, sanitizeWikiFileName } from './common.js';
6
+ import { RateLimiter } from './sleep.js';
5
7
  /**
6
8
  * Backlogから課題をダウンロードする
7
9
  * @param command コマンドインスタンス
@@ -10,23 +12,56 @@ import { sanitizeFileName, sanitizeWikiFileName } from './common.js';
10
12
  export async function downloadIssues(command, options) {
11
13
  const baseUrl = `https://${options.domain}/api/v2`;
12
14
  command.log('課題の取得を開始します...');
13
- // APIパラメータの構築
14
- const params = new URLSearchParams({
15
- apiKey: options.apiKey,
16
- count: options.count.toString(),
17
- 'projectId[]': options.projectId.toString(),
18
- });
19
- // ステータスIDが指定されている場合は追加
20
- if (options.statusId) {
21
- params.append('statusId[]', options.statusId);
22
- }
23
- const issues = await ky.get(`${baseUrl}/issues?${params.toString()}`).json();
24
- command.log(`${issues.length}件の課題が見つかりました。`);
15
+ // 全ての課題を格納する配列
16
+ let allIssues = [];
17
+ // countのデフォルト値を5000に設定
18
+ const count = options.count ?? 5000;
19
+ const maxCount = Math.min(count, 100); // APIの制限は100件
20
+ // APIリクエスト数をカウントするためのRateLimiterを作成
21
+ const rateLimiter = new RateLimiter(command);
22
+ // 課題を取得する関数
23
+ const fetchIssues = async (offset) => {
24
+ // APIリクエスト数をインクリメント
25
+ await rateLimiter.increment();
26
+ // APIパラメータの構築
27
+ const params = new URLSearchParams({
28
+ apiKey: options.apiKey,
29
+ count: maxCount.toString(),
30
+ offset: offset.toString(),
31
+ 'projectId[]': options.projectId.toString(),
32
+ });
33
+ // ステータスIDが指定されている場合は追加
34
+ if (options.statusId) {
35
+ params.append('statusId[]', options.statusId);
36
+ }
37
+ // 進捗状況を一行で更新
38
+ process.stdout.write(`\r課題を取得中... (${allIssues.length}件取得済み)`);
39
+ return ky.get(`${baseUrl}/issues?${params.toString()}`).json();
40
+ };
41
+ // 再帰的に全ての課題を取得
42
+ const fetchAllIssues = async (offset) => {
43
+ try {
44
+ const issues = await fetchIssues(offset);
45
+ // 取得した課題を追加
46
+ allIssues = [...allIssues, ...issues];
47
+ // 次のページがあるかどうかを確認
48
+ if (issues.length === maxCount) {
49
+ // 次のページを取得
50
+ await fetchAllIssues(offset + maxCount);
51
+ }
52
+ }
53
+ catch (error) {
54
+ command.error(`課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ };
57
+ // 課題取得開始
58
+ await fetchAllIssues(0);
59
+ command.log(`\n合計 ${allIssues.length}件の課題が見つかりました。`);
25
60
  // 前回の更新日時より新しい課題のみをフィルタリング
26
- let filteredIssues = issues;
61
+ let filteredIssues = allIssues;
27
62
  if (options.lastUpdated) {
28
63
  const lastUpdatedDate = new Date(options.lastUpdated);
29
- filteredIssues = issues.filter((issue) => {
64
+ filteredIssues = allIssues.filter((issue) => {
30
65
  const issueUpdatedDate = new Date(issue.updated);
31
66
  return issueUpdatedDate > lastUpdatedDate;
32
67
  });
@@ -38,28 +73,29 @@ export async function downloadIssues(command, options) {
38
73
  }
39
74
  // 各課題の詳細情報を取得して保存
40
75
  command.log('課題を保存しています...');
41
- // Promise.allを使用して並列処理
42
- const issuePromises = filteredIssues.map(async (issue) => {
76
+ // 並列処理ではなく順次処理に変更
77
+ for (const issue of filteredIssues) {
43
78
  try {
44
- // 課題の詳細情報をJSONファイルとして保存
45
- const issueFileName = `${sanitizeFileName(issue.summary)}.md`;
46
- const issueFilePath = path.join(options.outputDir, issueFileName);
79
+ // 進捗状況を一行で更新
80
+ const currentIndex = filteredIssues.indexOf(issue) + 1;
81
+ process.stdout.write(`\r課題を保存中... (${currentIndex}/${filteredIssues.length}件)`);
47
82
  // BacklogのIssueへのリンクを作成
48
83
  const backlogIssueUrl = `https://${options.domain}/view/${issue.issueKey}`;
49
- // コメント一覧を取得
50
- command.log(`課題 ${issue.issueKey} のコメントを取得しています...`);
51
- const commentsParams = new URLSearchParams({
84
+ // コメントを取得する関数を呼び出し
85
+ // eslint-disable-next-line no-await-in-loop
86
+ const { comments: allComments } = await fetchAllCommentsForIssue({
52
87
  apiKey: options.apiKey,
53
- count: '100', // 最大100件のコメントを取得
54
- order: 'asc', // 古い順に取得
88
+ baseUrl,
89
+ command,
90
+ issueKey: issue.issueKey,
91
+ rateLimiter,
55
92
  });
56
- const comments = await ky.get(`${baseUrl}/issues/${issue.issueKey}/comments?${commentsParams.toString()}`).json();
57
93
  // コメントセクションを作成
58
94
  let commentsSection = '';
59
- if (comments.length > 0) {
95
+ if (allComments.length > 0) {
60
96
  commentsSection = '\n\n## コメント\n';
61
97
  let commentIndex = 1;
62
- for (const comment of comments) {
98
+ for (const comment of allComments) {
63
99
  const commentDate = new Date(comment.created).toLocaleString('ja-JP');
64
100
  commentsSection += `\n### コメント ${commentIndex}\n- **投稿者**: ${comment.createdUser.name}\n- **日時**: ${commentDate}\n\n${comment.content || '(内容なし)'}\n\n---\n`;
65
101
  commentIndex++;
@@ -67,6 +103,16 @@ export async function downloadIssues(command, options) {
67
103
  // 最後の区切り線を削除
68
104
  commentsSection = commentsSection.slice(0, -5);
69
105
  }
106
+ // 課題の作成年を取得
107
+ const createdYear = new Date(issue.created).getFullYear();
108
+ // 年ごとのフォルダパスを作成
109
+ const yearDirPath = path.join(options.outputDir, createdYear.toString());
110
+ // 年ごとのフォルダを作成
111
+ // eslint-disable-next-line no-await-in-loop
112
+ await fs.mkdir(yearDirPath, { recursive: true });
113
+ // 年ごとのフォルダ内にMarkdownファイルを保存
114
+ const issueFileName = `${sanitizeFileName(issue.summary)}.md`;
115
+ const issueFilePath = path.join(yearDirPath, issueFileName);
70
116
  // Markdownファイルに書き込む
71
117
  const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
72
118
  const markdownContent = `# ${issue.summary}
@@ -82,15 +128,43 @@ export async function downloadIssues(command, options) {
82
128
 
83
129
  ## 詳細
84
130
  ${issue.description || '詳細情報なし'}${commentsSection}`;
131
+ // eslint-disable-next-line no-await-in-loop
85
132
  await fs.writeFile(issueFilePath, markdownContent);
86
- command.log(`課題 "${issue.issueKey}: ${issue.summary}" を ${issueFilePath} に保存しました`);
87
133
  }
88
134
  catch (error) {
89
135
  command.warn(`課題 ${issue.issueKey} の保存に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
90
136
  }
91
- });
92
- await Promise.all(issuePromises);
93
- command.log('課題のダウンロードが完了しました!');
137
+ }
138
+ command.log('\n課題のダウンロードが完了しました!');
139
+ }
140
+ /**
141
+ * 課題のコメントを全て取得する
142
+ */
143
+ async function fetchAllCommentsForIssue({ apiKey, baseUrl, command, issueKey, rateLimiter, }) {
144
+ const allComments = [];
145
+ const fetchComments = async (minId) => {
146
+ // APIリクエスト数をインクリメント
147
+ await rateLimiter.increment();
148
+ let url = `${baseUrl}/issues/${issueKey}/comments?apiKey=${apiKey}&count=100`;
149
+ if (minId) {
150
+ url += `&minId=${minId}`;
151
+ }
152
+ const comments = await ky.get(url).json();
153
+ allComments.push(...comments);
154
+ if (comments.length === 100) {
155
+ // 取得したコメントの最後のIDを次のリクエストのminIdとして使用
156
+ const lastCommentId = comments.at(-1).id;
157
+ await fetchComments(lastCommentId + 1);
158
+ }
159
+ };
160
+ try {
161
+ await fetchComments();
162
+ return { comments: allComments };
163
+ }
164
+ catch (error) {
165
+ command.warn(`課題 ${issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
166
+ return { comments: allComments };
167
+ }
94
168
  }
95
169
  /**
96
170
  * BacklogからWikiをダウンロードする
@@ -100,8 +174,12 @@ ${issue.description || '詳細情報なし'}${commentsSection}`;
100
174
  export async function downloadWikis(command, options) {
101
175
  const baseUrl = `https://${options.domain}/api/v2`;
102
176
  command.log('Wikiの取得を開始します...');
177
+ // APIリクエスト数をカウントするためのRateLimiterを作成
178
+ const rateLimiter = new RateLimiter(command);
103
179
  // Wiki一覧の取得
104
180
  command.log('Wiki一覧を取得しています...');
181
+ // APIリクエスト数をインクリメント
182
+ await rateLimiter.increment();
105
183
  const wikis = await ky
106
184
  .get(`${baseUrl}/wikis?apiKey=${options.apiKey}&projectIdOrKey=${options.projectIdOrKey}`)
107
185
  .json();
@@ -122,11 +200,17 @@ export async function downloadWikis(command, options) {
122
200
  }
123
201
  // 各Wikiの詳細情報を取得
124
202
  command.log('Wiki詳細を取得しています...');
125
- // Promise.allを使用して並列処理
126
- const wikiPromises = filteredWikis.map(async (wiki) => {
203
+ // 並列処理ではなく順次処理に変更
204
+ for (const wiki of filteredWikis) {
127
205
  const wikiId = wiki.id;
128
- command.log(`Wiki: ${wiki.name} (ID: ${wikiId}) を取得しています`);
129
206
  try {
207
+ // APIリクエスト数をインクリメント
208
+ // eslint-disable-next-line no-await-in-loop
209
+ await rateLimiter.increment();
210
+ // 進捗状況を一行で更新
211
+ const currentIndex = filteredWikis.indexOf(wiki) + 1;
212
+ process.stdout.write(`\rWikiを取得中... (${currentIndex}/${filteredWikis.length}件)`);
213
+ // eslint-disable-next-line no-await-in-loop
130
214
  const wikiDetail = await ky
131
215
  .get(`${baseUrl}/wikis/${wikiId}?projectIdOrKey=${options.projectIdOrKey}&apiKey=${options.apiKey}`)
132
216
  .json();
@@ -139,6 +223,7 @@ export async function downloadWikis(command, options) {
139
223
  // ディレクトリ構造を作成(必要な場合)
140
224
  const dirPath = path.dirname(wikiFileName);
141
225
  if (dirPath !== '.') {
226
+ // eslint-disable-next-line no-await-in-loop
142
227
  await fs.mkdir(path.join(options.outputDir, dirPath), { recursive: true });
143
228
  }
144
229
  // Markdownファイルを保存
@@ -149,13 +234,15 @@ export async function downloadWikis(command, options) {
149
234
  const backlogWikiUrl = `https://${options.domain}/alias/wiki/${wikiId}`;
150
235
  // Markdownファイルに書き込む(タイトルとBacklogリンクを追加)
151
236
  const markdownContent = `# ${wiki.name}\n\n[Backlog Wiki Link](${backlogWikiUrl})\n\n${content}`;
237
+ // eslint-disable-next-line no-await-in-loop
152
238
  await fs.writeFile(wikiFilePath, markdownContent);
153
- command.log(`Wiki "${wiki.name}" を ${wikiFilePath} に保存しました`);
239
+ // 進捗状況を一行で更新
240
+ const wikiIndex = filteredWikis.indexOf(wiki) + 1;
241
+ process.stdout.write(`\rWikiを保存中... (${wikiIndex}/${filteredWikis.length}件)`);
154
242
  }
155
243
  catch (error) {
156
- command.warn(`Wiki ID ${wikiId} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
244
+ command.warn(`Wiki ${wiki.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
157
245
  }
158
- });
159
- await Promise.all(wikiPromises);
160
- command.log('Wikiのダウンロードが完了しました!');
246
+ }
247
+ command.log('\nWikiのダウンロードが完了しました!');
161
248
  }
@@ -52,9 +52,11 @@ export async function saveSettings(directory, settings) {
52
52
  export async function updateSettings(directory, newSettings) {
53
53
  // 既存の設定を読み込む
54
54
  const currentSettings = await loadSettings(directory);
55
- // 新しい設定で更新
56
- const updatedSettings = { ...currentSettings, ...newSettings };
55
+ // 新しい設定で更新(apiKeyは除外)
56
+ const { apiKey, ...settingsToSave } = newSettings;
57
+ const updatedSettings = { ...currentSettings, ...settingsToSave };
57
58
  // 更新した設定を保存
58
59
  await saveSettings(directory, updatedSettings);
59
- return updatedSettings;
60
+ // apiKeyは戻り値には含める
61
+ return { ...updatedSettings, apiKey };
60
62
  }
@@ -0,0 +1,28 @@
1
+ import { Command } from '@oclif/core';
2
+ /**
3
+ * 指定したミリ秒だけ待機する
4
+ * @param ms 待機時間(ミリ秒)
5
+ */
6
+ export declare const sleep: (ms: number) => Promise<void>;
7
+ /**
8
+ * APIリクエストのレート制限対策のためのユーティリティ
9
+ * 100リクエストごとに15秒間待機する
10
+ */
11
+ export declare class RateLimiter {
12
+ private readonly command;
13
+ private requestCount;
14
+ constructor(command: Command);
15
+ /**
16
+ * 現在のリクエスト数を取得する
17
+ */
18
+ getCount(): number;
19
+ /**
20
+ * APIリクエストをカウントし、必要に応じて待機する
21
+ * @returns 現在のリクエスト数
22
+ */
23
+ increment(): Promise<number>;
24
+ /**
25
+ * リクエスト数を設定する
26
+ */
27
+ setCount(count: number): void;
28
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 指定したミリ秒だけ待機する
3
+ * @param ms 待機時間(ミリ秒)
4
+ */
5
+ export const sleep = (ms) => new Promise((resolve) => {
6
+ setTimeout(resolve, ms);
7
+ });
8
+ /**
9
+ * APIリクエストのレート制限対策のためのユーティリティ
10
+ * 100リクエストごとに15秒間待機する
11
+ */
12
+ export class RateLimiter {
13
+ command;
14
+ requestCount = 0;
15
+ constructor(command) {
16
+ this.command = command;
17
+ }
18
+ /**
19
+ * 現在のリクエスト数を取得する
20
+ */
21
+ getCount() {
22
+ return this.requestCount;
23
+ }
24
+ /**
25
+ * APIリクエストをカウントし、必要に応じて待機する
26
+ * @returns 現在のリクエスト数
27
+ */
28
+ async increment() {
29
+ this.requestCount++;
30
+ if (this.requestCount > 1 && this.requestCount % 100 === 0) {
31
+ this.command.log('レート制限を回避するため15秒間待機します...');
32
+ await sleep(15_000);
33
+ }
34
+ return this.requestCount;
35
+ }
36
+ /**
37
+ * リクエスト数を設定する
38
+ */
39
+ setCount(count) {
40
+ this.requestCount = count;
41
+ }
42
+ }
@@ -6,7 +6,8 @@
6
6
  "description": "Backlogから課題とWikiを取得してMarkdownファイルとして保存する",
7
7
  "examples": [
8
8
  "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY\n課題とWikiをMarkdownファイルとして保存する\n",
9
- "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project\n指定したディレクトリに課題とWikiを保存する\n"
9
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project\n指定したディレクトリに課題とWikiを保存する\n",
10
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 1000\n最大1000件の課題を取得する(デフォルトは100件)\n"
10
11
  ],
11
12
  "flags": {
12
13
  "apiKey": {
@@ -25,6 +26,16 @@
25
26
  "multiple": false,
26
27
  "type": "option"
27
28
  },
29
+ "maxCount": {
30
+ "char": "m",
31
+ "description": "一度に取得する課題の最大数(デフォルト: 5000)",
32
+ "name": "maxCount",
33
+ "required": false,
34
+ "default": 5000,
35
+ "hasDynamicHelp": false,
36
+ "multiple": false,
37
+ "type": "option"
38
+ },
28
39
  "output": {
29
40
  "char": "o",
30
41
  "description": "出力ディレクトリパス",
@@ -65,7 +76,9 @@
65
76
  "description": "Backlogから課題を取得してMarkdownファイルとして保存する",
66
77
  "examples": [
67
78
  "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY\n課題をMarkdownファイルとして保存する\n",
68
- "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project\n指定したディレクトリに課題を保存する\n"
79
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-issues\n指定したディレクトリに課題を保存する\n",
80
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --statusId 1,2,3\n指定したステータスIDの課題のみを取得する\n",
81
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 10000\n最大10000件の課題を取得する(デフォルトは5000件)\n"
69
82
  ],
70
83
  "flags": {
71
84
  "apiKey": {
@@ -84,6 +97,16 @@
84
97
  "multiple": false,
85
98
  "type": "option"
86
99
  },
100
+ "maxCount": {
101
+ "char": "m",
102
+ "description": "一度に取得する課題の最大数(デフォルト: 5000)",
103
+ "name": "maxCount",
104
+ "required": false,
105
+ "default": 5000,
106
+ "hasDynamicHelp": false,
107
+ "multiple": false,
108
+ "type": "option"
109
+ },
87
110
  "output": {
88
111
  "char": "o",
89
112
  "description": "出力ディレクトリパス",
@@ -100,6 +123,14 @@
100
123
  "hasDynamicHelp": false,
101
124
  "multiple": false,
102
125
  "type": "option"
126
+ },
127
+ "statusId": {
128
+ "description": "ステータスID(カンマ区切りで複数指定可能)",
129
+ "name": "statusId",
130
+ "required": false,
131
+ "hasDynamicHelp": false,
132
+ "multiple": false,
133
+ "type": "option"
103
134
  }
104
135
  },
105
136
  "hasDynamicHelp": false,
@@ -258,5 +289,5 @@
258
289
  ]
259
290
  }
260
291
  },
261
- "version": "0.2.0"
292
+ "version": "0.2.2"
262
293
  }
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": "0.2.0",
4
+ "version": "0.2.2",
5
5
  "author": "ShuntaToda",
6
6
  "bin": {
7
7
  "backlog-exporter": "bin/run.js"