backlog-exporter 0.7.2 → 1.0.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 +87 -1
- package/bin/dev.js +6 -2
- package/dist/commands/all/index.d.ts +2 -0
- package/dist/commands/all/index.js +62 -126
- package/dist/commands/document/index.js +18 -11
- package/dist/commands/issue/index.js +18 -11
- package/dist/commands/prune/index.d.ts +16 -0
- package/dist/commands/prune/index.js +86 -0
- package/dist/commands/update/index.d.ts +3 -7
- package/dist/commands/update/index.js +28 -260
- package/dist/commands/wiki/index.js +17 -9
- package/dist/composition/backlog-repositories.d.ts +16 -0
- package/dist/composition/backlog-repositories.js +15 -0
- package/dist/modules/all/domain/content-selection.d.ts +4 -0
- package/dist/modules/all/domain/content-selection.js +16 -0
- package/dist/modules/all/use-case/export-all.d.ts +24 -0
- package/dist/modules/all/use-case/export-all.js +78 -0
- package/dist/modules/document/domain/document-markdown.d.ts +2 -0
- package/dist/modules/document/domain/document-markdown.js +37 -0
- package/dist/modules/document/domain/document-path.d.ts +15 -0
- package/dist/modules/document/domain/document-path.js +48 -0
- package/dist/modules/document/domain/document-repository.d.ts +6 -0
- package/dist/modules/document/domain/document-repository.js +1 -0
- package/dist/modules/document/domain/document-save-plan.d.ts +9 -0
- package/dist/modules/document/domain/document-save-plan.js +15 -0
- package/dist/modules/document/domain/document.d.ts +50 -0
- package/dist/modules/document/domain/document.js +1 -0
- package/dist/modules/document/repository/backlog-document-repository.d.ts +3 -0
- package/dist/modules/document/repository/backlog-document-repository.js +31 -0
- package/dist/modules/document/use-case/export-documents.d.ts +16 -0
- package/dist/modules/document/use-case/export-documents.js +82 -0
- package/dist/modules/issue/domain/issue-filter.d.ts +2 -0
- package/dist/modules/issue/domain/issue-filter.js +7 -0
- package/dist/modules/issue/domain/issue-markdown.d.ts +4 -0
- package/dist/modules/issue/domain/issue-markdown.js +112 -0
- package/dist/modules/issue/domain/issue-path.d.ts +23 -0
- package/dist/modules/issue/domain/issue-path.js +30 -0
- package/dist/modules/issue/domain/issue-repository.d.ts +11 -0
- package/dist/modules/issue/domain/issue-repository.js +1 -0
- package/dist/modules/issue/domain/issue.d.ts +47 -0
- package/dist/modules/issue/domain/issue.js +1 -0
- package/dist/modules/issue/repository/backlog-issue-repository.d.ts +3 -0
- package/dist/modules/issue/repository/backlog-issue-repository.js +38 -0
- package/dist/modules/issue/use-case/export-issues.d.ts +18 -0
- package/dist/modules/issue/use-case/export-issues.js +87 -0
- package/dist/modules/project/domain/project-repository.d.ts +3 -0
- package/dist/modules/project/domain/project-repository.js +1 -0
- package/dist/modules/project/repository/backlog-project-repository.d.ts +3 -0
- package/dist/modules/project/repository/backlog-project-repository.js +17 -0
- package/dist/modules/prune/domain/expected-paths.d.ts +4 -0
- package/dist/modules/prune/domain/expected-paths.js +1 -0
- package/dist/modules/prune/domain/prune-target.d.ts +3 -0
- package/dist/modules/prune/domain/prune-target.js +21 -0
- package/dist/modules/prune/repository/prune-walker.d.ts +7 -0
- package/dist/modules/prune/repository/prune-walker.js +39 -0
- package/dist/modules/prune/use-case/prune-directories.d.ts +28 -0
- package/dist/modules/prune/use-case/prune-directories.js +74 -0
- package/dist/modules/prune/use-case/prune-exports.d.ts +27 -0
- package/dist/modules/prune/use-case/prune-exports.js +74 -0
- package/dist/modules/settings/domain/settings.d.ts +15 -0
- package/dist/modules/settings/domain/settings.js +6 -0
- package/dist/modules/settings/repository/settings-store.d.ts +6 -0
- package/dist/modules/settings/repository/settings-store.js +56 -0
- package/dist/modules/update/domain/update-plan.d.ts +36 -0
- package/dist/modules/update/domain/update-plan.js +69 -0
- package/dist/modules/update/use-case/update-exports.d.ts +21 -0
- package/dist/modules/update/use-case/update-exports.js +136 -0
- package/dist/modules/wiki/domain/wiki-filter.d.ts +9 -0
- package/dist/modules/wiki/domain/wiki-filter.js +11 -0
- package/dist/modules/wiki/domain/wiki-markdown.d.ts +1 -0
- package/dist/modules/wiki/domain/wiki-markdown.js +4 -0
- package/dist/modules/wiki/domain/wiki-path.d.ts +4 -0
- package/dist/modules/wiki/domain/wiki-path.js +25 -0
- package/dist/modules/wiki/domain/wiki-repository.d.ts +5 -0
- package/dist/modules/wiki/domain/wiki-repository.js +1 -0
- package/dist/modules/wiki/domain/wiki.d.ts +10 -0
- package/dist/modules/wiki/domain/wiki.js +1 -0
- package/dist/modules/wiki/repository/backlog-wiki-repository.d.ts +3 -0
- package/dist/modules/wiki/repository/backlog-wiki-repository.js +10 -0
- package/dist/modules/wiki/use-case/export-wikis.d.ts +14 -0
- package/dist/modules/wiki/use-case/export-wikis.js +43 -0
- package/dist/shared/backlog/http-client.d.ts +17 -0
- package/dist/shared/backlog/http-client.js +62 -0
- package/dist/shared/backlog/rate-limiter.d.ts +9 -0
- package/dist/shared/backlog/rate-limiter.js +25 -0
- package/dist/shared/backlog-url.d.ts +1 -0
- package/dist/shared/backlog-url.js +5 -0
- package/dist/shared/config/env.d.ts +3 -0
- package/dist/shared/config/env.js +21 -0
- package/dist/shared/console/progress.d.ts +1 -0
- package/dist/shared/console/progress.js +4 -0
- package/dist/shared/console/prompt.d.ts +3 -0
- package/dist/shared/console/prompt.js +19 -0
- package/dist/shared/file-name.d.ts +2 -0
- package/dist/shared/file-name.js +15 -0
- package/dist/shared/markdown/body-marker.d.ts +3 -0
- package/dist/shared/markdown/body-marker.js +5 -0
- package/dist/shared/ports.d.ts +4 -0
- package/dist/shared/ports.js +1 -0
- package/dist/shared/storage/markdown-store.d.ts +5 -0
- package/dist/shared/storage/markdown-store.js +21 -0
- package/dist/shared/storage/update-log.d.ts +1 -0
- package/dist/{utils/log.js → shared/storage/update-log.js} +1 -5
- package/oclif.manifest.json +118 -26
- package/package.json +15 -19
- package/dist/utils/backlog-api.d.ts +0 -74
- package/dist/utils/backlog-api.js +0 -448
- package/dist/utils/backlog.d.ts +0 -16
- package/dist/utils/backlog.js +0 -34
- package/dist/utils/common.d.ts +0 -25
- package/dist/utils/common.js +0 -54
- package/dist/utils/log.d.ts +0 -6
- package/dist/utils/settings.d.ts +0 -45
- package/dist/utils/settings.js +0 -63
- package/dist/utils/sleep.d.ts +0 -28
- package/dist/utils/sleep.js +0 -42
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type DocumentSaveAction = 'delete-stale-parent-index' | 'save' | 'skip-empty-parent' | 'skip-parent-index-collision' | 'skip-unchanged';
|
|
2
|
+
export declare function planDocumentSave(input: {
|
|
3
|
+
asParentIndex: boolean;
|
|
4
|
+
body: null | string | undefined;
|
|
5
|
+
lastUpdated?: string;
|
|
6
|
+
parentIndexAlreadyWrittenThisRun: boolean;
|
|
7
|
+
parentIndexExists: boolean;
|
|
8
|
+
updated: string;
|
|
9
|
+
}): DocumentSaveAction;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// 保存/スキップ/親index削除の判断。本文が空の親はファイルを作らず、空に変更された場合は古い親indexを削除する
|
|
2
|
+
export function planDocumentSave(input) {
|
|
3
|
+
if (input.asParentIndex && input.parentIndexAlreadyWrittenThisRun) {
|
|
4
|
+
return 'skip-parent-index-collision';
|
|
5
|
+
}
|
|
6
|
+
// 前回の更新日時チェック(親indexがまだ存在しない場合はバックフィルのため未更新でも保存する)
|
|
7
|
+
const backfillParentIndex = input.asParentIndex && !input.parentIndexExists;
|
|
8
|
+
if (input.lastUpdated && !backfillParentIndex && new Date(input.updated) <= new Date(input.lastUpdated)) {
|
|
9
|
+
return 'skip-unchanged';
|
|
10
|
+
}
|
|
11
|
+
if (input.asParentIndex && !input.body?.trim()) {
|
|
12
|
+
return input.parentIndexExists ? 'delete-stale-parent-index' : 'skip-empty-parent';
|
|
13
|
+
}
|
|
14
|
+
return 'save';
|
|
15
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface DocumentNode {
|
|
2
|
+
children: DocumentNode[];
|
|
3
|
+
emoji?: string;
|
|
4
|
+
emojiType?: string;
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
updated?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DocumentTree {
|
|
10
|
+
activeTree: {
|
|
11
|
+
children: DocumentNode[];
|
|
12
|
+
id: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface DocumentSummary {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
}
|
|
19
|
+
export interface DocumentDetail {
|
|
20
|
+
attachments: Array<{
|
|
21
|
+
created: string;
|
|
22
|
+
createdUser: {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
id: number;
|
|
27
|
+
name: string;
|
|
28
|
+
size: number;
|
|
29
|
+
}>;
|
|
30
|
+
created: string;
|
|
31
|
+
createdUser: {
|
|
32
|
+
id: number;
|
|
33
|
+
name: string;
|
|
34
|
+
};
|
|
35
|
+
emoji?: string;
|
|
36
|
+
id: string;
|
|
37
|
+
json: string;
|
|
38
|
+
plain: string;
|
|
39
|
+
statusId: number;
|
|
40
|
+
tags: Array<{
|
|
41
|
+
id: number;
|
|
42
|
+
name: string;
|
|
43
|
+
}>;
|
|
44
|
+
title: string;
|
|
45
|
+
updated: string;
|
|
46
|
+
updatedUser: {
|
|
47
|
+
id: number;
|
|
48
|
+
name: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function newBacklogDocumentRepository(client) {
|
|
2
|
+
return {
|
|
3
|
+
async fetchAllTitles(projectId) {
|
|
4
|
+
const titlesById = new Map();
|
|
5
|
+
const pageSize = 100;
|
|
6
|
+
let offset = 0;
|
|
7
|
+
for (;;) {
|
|
8
|
+
// eslint-disable-next-line no-await-in-loop
|
|
9
|
+
const page = await client.getJson('/documents', {
|
|
10
|
+
count: pageSize.toString(),
|
|
11
|
+
offset: offset.toString(),
|
|
12
|
+
'projectId[]': projectId.toString(),
|
|
13
|
+
});
|
|
14
|
+
for (const doc of page) {
|
|
15
|
+
titlesById.set(doc.id, doc.title);
|
|
16
|
+
}
|
|
17
|
+
if (page.length < pageSize) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
offset += pageSize;
|
|
21
|
+
}
|
|
22
|
+
return titlesById;
|
|
23
|
+
},
|
|
24
|
+
async fetchDetail(documentId) {
|
|
25
|
+
return client.getJson(`/documents/${documentId}`);
|
|
26
|
+
},
|
|
27
|
+
async fetchTree(projectId) {
|
|
28
|
+
return client.getJson('/documents/tree', { projectIdOrKey: projectId.toString() });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Logger } from '../../../shared/ports.js';
|
|
2
|
+
import { DocumentRepository } from '../domain/document-repository.js';
|
|
3
|
+
export interface ExportDocumentsDeps {
|
|
4
|
+
documentRepository: DocumentRepository;
|
|
5
|
+
logger: Logger;
|
|
6
|
+
}
|
|
7
|
+
export interface ExportDocumentsOptions {
|
|
8
|
+
documentIds?: string[];
|
|
9
|
+
domain: string;
|
|
10
|
+
keyword?: string;
|
|
11
|
+
lastUpdated?: string;
|
|
12
|
+
outputDir: string;
|
|
13
|
+
projectId: number;
|
|
14
|
+
projectIdOrKey: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function exportDocuments(deps: ExportDocumentsDeps, options: ExportDocumentsOptions): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeProgress } from '../../../shared/console/progress.js';
|
|
3
|
+
import { deleteFile, ensureDirectory, fileExists, writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
|
|
4
|
+
import { appendLog } from '../../../shared/storage/update-log.js';
|
|
5
|
+
import { buildDocumentMarkdown } from '../domain/document-markdown.js';
|
|
6
|
+
import { documentFileName, documentFolderPath, documentUrl, PARENT_DOCUMENT_INDEX_FILENAME, } from '../domain/document-path.js';
|
|
7
|
+
import { planDocumentSave } from '../domain/document-save-plan.js';
|
|
8
|
+
export async function exportDocuments(deps, options) {
|
|
9
|
+
const { documentRepository, logger } = deps;
|
|
10
|
+
logger.log('ドキュメントの取得を開始します...');
|
|
11
|
+
logger.log('ドキュメントツリーを取得しています...');
|
|
12
|
+
const documentTree = await documentRepository.fetchTree(options.projectId);
|
|
13
|
+
logger.log('アクティブなドキュメントツリーを処理します...');
|
|
14
|
+
const processedDocuments = [];
|
|
15
|
+
const writtenFiles = new Set();
|
|
16
|
+
const fetchAndSaveDocument = async (node, currentPath, asParentIndex = false) => {
|
|
17
|
+
try {
|
|
18
|
+
if (processedDocuments.includes(node.id)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (options.documentIds && options.documentIds.length > 0 && !options.documentIds.includes(node.id)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
processedDocuments.push(node.id);
|
|
25
|
+
writeProgress(`ドキュメント「${node.name}」を処理中...`);
|
|
26
|
+
const documentDetail = await documentRepository.fetchDetail(node.id);
|
|
27
|
+
const fileName = documentFileName(documentDetail.title, asParentIndex);
|
|
28
|
+
const filePath = path.join(options.outputDir, currentPath, fileName);
|
|
29
|
+
const action = planDocumentSave({
|
|
30
|
+
asParentIndex,
|
|
31
|
+
body: documentDetail.plain,
|
|
32
|
+
lastUpdated: options.lastUpdated,
|
|
33
|
+
parentIndexAlreadyWrittenThisRun: writtenFiles.has(filePath),
|
|
34
|
+
parentIndexExists: asParentIndex && (await fileExists(filePath)),
|
|
35
|
+
updated: documentDetail.updated,
|
|
36
|
+
});
|
|
37
|
+
switch (action) {
|
|
38
|
+
case 'delete-stale-parent-index': {
|
|
39
|
+
await deleteFile(filePath);
|
|
40
|
+
await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」の本文が空になったため ${PARENT_DOCUMENT_INDEX_FILENAME} を削除しました`);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case 'save': {
|
|
44
|
+
const backlogDocumentUrl = documentUrl(options.domain, options.projectIdOrKey, node.id);
|
|
45
|
+
await writeMarkdownFile(filePath, buildDocumentMarkdown(documentDetail, backlogDocumentUrl));
|
|
46
|
+
writtenFiles.add(filePath);
|
|
47
|
+
await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」を更新しました: ${backlogDocumentUrl}`);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'skip-parent-index-collision': {
|
|
51
|
+
logger.warn(`「${node.name}」内に「${PARENT_DOCUMENT_INDEX_FILENAME}」と同名になる子ドキュメントが存在するため、親ドキュメント本文の保存をスキップしました`);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
// No default
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger.warn(`ドキュメント ${node.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/* eslint-disable no-await-in-loop */
|
|
62
|
+
const processDocumentNode = async (node, currentPath) => {
|
|
63
|
+
if (node.children && node.children.length > 0) {
|
|
64
|
+
const folderRelPath = documentFolderPath(currentPath, node.name);
|
|
65
|
+
await ensureDirectory(path.join(options.outputDir, folderRelPath));
|
|
66
|
+
for (const child of node.children) {
|
|
67
|
+
await processDocumentNode(child, folderRelPath);
|
|
68
|
+
}
|
|
69
|
+
// 親自身の本文はフォルダ内の親indexとして子の後に保存する
|
|
70
|
+
await fetchAndSaveDocument(node, folderRelPath, true);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
await fetchAndSaveDocument(node, currentPath);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
for (const rootNode of documentTree.activeTree.children ?? []) {
|
|
77
|
+
await processDocumentNode(rootNode, '');
|
|
78
|
+
}
|
|
79
|
+
/* eslint-enable no-await-in-loop */
|
|
80
|
+
logger.log(`\n合計 ${processedDocuments.length}件のドキュメントが処理されました。`);
|
|
81
|
+
logger.log('ドキュメントのダウンロードが完了しました!');
|
|
82
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { CustomField, Issue, IssueComment } from './issue.js';
|
|
2
|
+
export declare function createCustomFieldsSection(customFields?: CustomField[]): string;
|
|
3
|
+
export declare function buildCommentsSection(comments: IssueComment[], backlogIssueUrl: string): string;
|
|
4
|
+
export declare function buildIssueMarkdown(issue: Issue, comments: IssueComment[], backlogIssueUrl: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { wrapBody } from '../../../shared/markdown/body-marker.js';
|
|
2
|
+
// Backlogの変更履歴(changeLog)のfieldを画面表示に合わせた日本語ラベルへ変換する
|
|
3
|
+
const CHANGE_FIELD_LABELS = {
|
|
4
|
+
actualHours: '実績時間',
|
|
5
|
+
assigner: '担当者',
|
|
6
|
+
attachment: '添付ファイル',
|
|
7
|
+
component: 'カテゴリー',
|
|
8
|
+
description: '詳細',
|
|
9
|
+
estimatedHours: '予定時間',
|
|
10
|
+
issueType: '種別',
|
|
11
|
+
limitDate: '期限日',
|
|
12
|
+
milestone: 'マイルストーン',
|
|
13
|
+
notification: 'お知らせ',
|
|
14
|
+
parentIssue: '親課題',
|
|
15
|
+
priority: '優先度',
|
|
16
|
+
resolution: '完了理由',
|
|
17
|
+
startDate: '開始日',
|
|
18
|
+
status: '状態',
|
|
19
|
+
summary: '件名',
|
|
20
|
+
version: '発生バージョン',
|
|
21
|
+
};
|
|
22
|
+
// 詳細やカスタム属性の変更値には複数行の長文が入ることがあり、
|
|
23
|
+
// そのまま出すとリスト構造が崩れるため1行化して切り詰める
|
|
24
|
+
function formatChangeValue(value) {
|
|
25
|
+
if (!value)
|
|
26
|
+
return '未設定';
|
|
27
|
+
const singleLine = value.replaceAll(/\s*\n\s*/g, ' ').trim();
|
|
28
|
+
return singleLine.length > 50 ? `${singleLine.slice(0, 50)}…` : singleLine;
|
|
29
|
+
}
|
|
30
|
+
function formatChange(change) {
|
|
31
|
+
const label = CHANGE_FIELD_LABELS[change.field] ?? change.field;
|
|
32
|
+
return `- ${label}: ${formatChangeValue(change.originalValue)} → ${formatChangeValue(change.newValue)}`;
|
|
33
|
+
}
|
|
34
|
+
function buildCommentBody(comment) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
if (comment.content) {
|
|
37
|
+
parts.push(comment.content);
|
|
38
|
+
}
|
|
39
|
+
if (comment.changeLog && comment.changeLog.length > 0) {
|
|
40
|
+
parts.push(['**変更内容**', ...comment.changeLog.map((change) => formatChange(change))].join('\n'));
|
|
41
|
+
}
|
|
42
|
+
return parts.length > 0 ? parts.join('\n\n') : '(内容なし)';
|
|
43
|
+
}
|
|
44
|
+
export function createCustomFieldsSection(customFields) {
|
|
45
|
+
if (!customFields || customFields.length === 0) {
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
let customFieldsSection = '\n\n## カスタム属性\n\n| 属性名 | 値 |\n|--------|----|\n';
|
|
49
|
+
for (const customField of customFields) {
|
|
50
|
+
let fieldValue = 'なし';
|
|
51
|
+
if (customField.value !== null && customField.value !== undefined) {
|
|
52
|
+
if (Array.isArray(customField.value)) {
|
|
53
|
+
// 配列の場合(複数選択など)
|
|
54
|
+
fieldValue = customField.value
|
|
55
|
+
.map((item) => item.name || item.value || String(item))
|
|
56
|
+
.join(', ');
|
|
57
|
+
}
|
|
58
|
+
else if (typeof customField.value === 'object' && customField.value !== null) {
|
|
59
|
+
// オブジェクトの場合(単一選択など)
|
|
60
|
+
const valueObj = customField.value;
|
|
61
|
+
fieldValue = valueObj.name || valueObj.value || String(customField.value);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// プリミティブ値の場合
|
|
65
|
+
fieldValue = String(customField.value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// テーブル内では改行をHTMLの<br>タグに変換し、パイプ文字をエスケープ
|
|
69
|
+
const escapedFieldValue = fieldValue.replaceAll('|', String.raw `\|`).replaceAll('\n', '<br>');
|
|
70
|
+
customFieldsSection += `| ${customField.name} | ${escapedFieldValue} |\n`;
|
|
71
|
+
}
|
|
72
|
+
return customFieldsSection;
|
|
73
|
+
}
|
|
74
|
+
export function buildCommentsSection(comments, backlogIssueUrl) {
|
|
75
|
+
if (comments.length === 0) {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
let commentsSection = '\n\n## コメント\n';
|
|
79
|
+
let commentIndex = 1;
|
|
80
|
+
for (const comment of comments) {
|
|
81
|
+
const commentDate = new Date(comment.created).toLocaleString('ja-JP');
|
|
82
|
+
const backlogCommentUrl = `${backlogIssueUrl}#comment-${comment.id}`;
|
|
83
|
+
commentsSection += `\n### コメント ${commentIndex}\n- **投稿者**: ${comment.createdUser.name}\n- **日時**: ${commentDate}\n- [Backlog Comment Link](${backlogCommentUrl})\n\n${buildCommentBody(comment)}\n\n---\n`;
|
|
84
|
+
commentIndex++;
|
|
85
|
+
}
|
|
86
|
+
// 最後の区切り線を削除
|
|
87
|
+
return commentsSection.slice(0, -5);
|
|
88
|
+
}
|
|
89
|
+
export function buildIssueMarkdown(issue, comments, backlogIssueUrl) {
|
|
90
|
+
const commentsSection = buildCommentsSection(comments, backlogIssueUrl);
|
|
91
|
+
const customFieldsSection = createCustomFieldsSection(issue.customFields);
|
|
92
|
+
const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
|
|
93
|
+
const startDate = issue.startDate ? new Date(issue.startDate).toLocaleDateString('ja-JP') : '未設定';
|
|
94
|
+
const dueDate = issue.dueDate ? new Date(issue.dueDate).toLocaleDateString('ja-JP') : '未設定';
|
|
95
|
+
return `# ${issue.summary}
|
|
96
|
+
|
|
97
|
+
## 基本情報
|
|
98
|
+
- 課題キー: ${issue.issueKey}
|
|
99
|
+
- 種別: ${issue.issueType.name}
|
|
100
|
+
- ステータス: ${issue.status.name}
|
|
101
|
+
- 優先度: ${issue.priority.name}
|
|
102
|
+
- 担当者: ${assigneeName}
|
|
103
|
+
- 開始日: ${startDate}
|
|
104
|
+
- 期限日: ${dueDate}
|
|
105
|
+
- 作成日時: ${new Date(issue.created).toLocaleString('ja-JP')}
|
|
106
|
+
- 更新日時: ${new Date(issue.updated).toLocaleString('ja-JP')}
|
|
107
|
+
- [Backlog Issue Link](${backlogIssueUrl})${customFieldsSection}
|
|
108
|
+
|
|
109
|
+
## 詳細
|
|
110
|
+
|
|
111
|
+
${wrapBody(issue.description || '詳細情報なし')}${commentsSection}`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
|
|
2
|
+
import { Issue } from './issue.js';
|
|
3
|
+
export declare function issueFileName(issue: {
|
|
4
|
+
issueKey: string;
|
|
5
|
+
summary: string;
|
|
6
|
+
}, useIssueKey: boolean): string;
|
|
7
|
+
export declare function issueRelativeDir(issue: {
|
|
8
|
+
created: string;
|
|
9
|
+
issueKey: string;
|
|
10
|
+
}, useIssueKeyFolder: boolean): string;
|
|
11
|
+
export declare function issueRelativePath(issue: {
|
|
12
|
+
created: string;
|
|
13
|
+
issueKey: string;
|
|
14
|
+
summary: string;
|
|
15
|
+
}, options: {
|
|
16
|
+
issueKeyFileName?: boolean;
|
|
17
|
+
issueKeyFolder?: boolean;
|
|
18
|
+
}): string;
|
|
19
|
+
export declare function issueUrl(domain: string, issueKey: string): string;
|
|
20
|
+
export declare function buildIssueExpectedPaths(issues: Issue[], options: {
|
|
21
|
+
issueKeyFileName?: boolean;
|
|
22
|
+
issueKeyFolder?: boolean;
|
|
23
|
+
}): ExpectedPaths;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { backlogOrigin } from '../../../shared/backlog-url.js';
|
|
3
|
+
import { sanitizeFileName } from '../../../shared/file-name.js';
|
|
4
|
+
export function issueFileName(issue, useIssueKey) {
|
|
5
|
+
return useIssueKey ? `${issue.issueKey}.md` : `${sanitizeFileName(issue.summary)}.md`;
|
|
6
|
+
}
|
|
7
|
+
export function issueRelativeDir(issue, useIssueKeyFolder) {
|
|
8
|
+
const createdYear = new Date(issue.created).getFullYear().toString();
|
|
9
|
+
return useIssueKeyFolder ? path.join(createdYear, issue.issueKey) : createdYear;
|
|
10
|
+
}
|
|
11
|
+
export function issueRelativePath(issue, options) {
|
|
12
|
+
return path.join(issueRelativeDir(issue, options.issueKeyFolder ?? false), issueFileName(issue, options.issueKeyFileName ?? false));
|
|
13
|
+
}
|
|
14
|
+
export function issueUrl(domain, issueKey) {
|
|
15
|
+
return `${backlogOrigin(domain)}/view/${issueKey}`;
|
|
16
|
+
}
|
|
17
|
+
// prune用: 保存時と同じ命名ロジックで期待パス集合を構築する
|
|
18
|
+
export function buildIssueExpectedPaths(issues, options) {
|
|
19
|
+
const expectedFiles = new Set();
|
|
20
|
+
const expectedDirs = new Set();
|
|
21
|
+
for (const issue of issues) {
|
|
22
|
+
expectedFiles.add(issueRelativePath(issue, options).normalize('NFC'));
|
|
23
|
+
let dir = issueRelativeDir(issue, options.issueKeyFolder ?? false);
|
|
24
|
+
while (dir && dir !== '.') {
|
|
25
|
+
expectedDirs.add(dir.normalize('NFC'));
|
|
26
|
+
dir = path.dirname(dir);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { expectedDirs, expectedFiles };
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Issue, IssueComment } from './issue.js';
|
|
2
|
+
export interface IssueRepository {
|
|
3
|
+
fetchAllComments(issueKey: string): Promise<IssueComment[]>;
|
|
4
|
+
fetchByIdOrKey(issueIdOrKey: string): Promise<Issue>;
|
|
5
|
+
fetchPage(options: {
|
|
6
|
+
count: number;
|
|
7
|
+
offset: number;
|
|
8
|
+
projectId: number;
|
|
9
|
+
statusId?: string;
|
|
10
|
+
}): Promise<Issue[]>;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface Issue {
|
|
2
|
+
assignee: null | {
|
|
3
|
+
id: number;
|
|
4
|
+
name: string;
|
|
5
|
+
};
|
|
6
|
+
created: string;
|
|
7
|
+
customFields: CustomField[];
|
|
8
|
+
description: string;
|
|
9
|
+
dueDate: null | string;
|
|
10
|
+
id: number;
|
|
11
|
+
issueKey: string;
|
|
12
|
+
issueType: {
|
|
13
|
+
id: number;
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
16
|
+
priority: {
|
|
17
|
+
id: number;
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
startDate: null | string;
|
|
21
|
+
status: {
|
|
22
|
+
id: number;
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
25
|
+
summary: string;
|
|
26
|
+
updated: string;
|
|
27
|
+
}
|
|
28
|
+
export interface CustomField {
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
value: unknown;
|
|
32
|
+
}
|
|
33
|
+
export interface IssueComment {
|
|
34
|
+
changeLog?: IssueCommentChange[];
|
|
35
|
+
content: null | string;
|
|
36
|
+
created: string;
|
|
37
|
+
createdUser: {
|
|
38
|
+
id: number;
|
|
39
|
+
name: string;
|
|
40
|
+
};
|
|
41
|
+
id: number;
|
|
42
|
+
}
|
|
43
|
+
export interface IssueCommentChange {
|
|
44
|
+
field: string;
|
|
45
|
+
newValue: null | string;
|
|
46
|
+
originalValue: null | string;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function newBacklogIssueRepository(client) {
|
|
2
|
+
return {
|
|
3
|
+
async fetchAllComments(issueKey) {
|
|
4
|
+
const allComments = [];
|
|
5
|
+
let minId;
|
|
6
|
+
for (;;) {
|
|
7
|
+
const params = { count: '100' };
|
|
8
|
+
if (minId !== undefined) {
|
|
9
|
+
params.minId = minId.toString();
|
|
10
|
+
}
|
|
11
|
+
// eslint-disable-next-line no-await-in-loop
|
|
12
|
+
const comments = await client.getJson(`/issues/${issueKey}/comments`, params);
|
|
13
|
+
allComments.push(...comments);
|
|
14
|
+
if (comments.length < 100) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
minId = comments.at(-1).id + 1;
|
|
18
|
+
}
|
|
19
|
+
// コメントを古い順(昇順)に並び替える
|
|
20
|
+
allComments.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
|
21
|
+
return allComments;
|
|
22
|
+
},
|
|
23
|
+
async fetchByIdOrKey(issueIdOrKey) {
|
|
24
|
+
return client.getJson(`/issues/${issueIdOrKey}`);
|
|
25
|
+
},
|
|
26
|
+
async fetchPage(options) {
|
|
27
|
+
const params = {
|
|
28
|
+
count: options.count.toString(),
|
|
29
|
+
offset: options.offset.toString(),
|
|
30
|
+
'projectId[]': options.projectId.toString(),
|
|
31
|
+
};
|
|
32
|
+
if (options.statusId) {
|
|
33
|
+
params['statusId[]'] = options.statusId;
|
|
34
|
+
}
|
|
35
|
+
return client.getJson('/issues', params);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Logger } from '../../../shared/ports.js';
|
|
2
|
+
import { IssueRepository } from '../domain/issue-repository.js';
|
|
3
|
+
export interface ExportIssuesDeps {
|
|
4
|
+
issueRepository: IssueRepository;
|
|
5
|
+
logger: Logger;
|
|
6
|
+
}
|
|
7
|
+
export interface ExportIssuesOptions {
|
|
8
|
+
count?: number;
|
|
9
|
+
domain: string;
|
|
10
|
+
issueIdOrKeys?: string[];
|
|
11
|
+
issueKeyFileName?: boolean;
|
|
12
|
+
issueKeyFolder?: boolean;
|
|
13
|
+
lastUpdated?: string;
|
|
14
|
+
outputDir: string;
|
|
15
|
+
projectId: number;
|
|
16
|
+
statusId?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function exportIssues(deps: ExportIssuesDeps, options: ExportIssuesOptions): Promise<void>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeProgress } from '../../../shared/console/progress.js';
|
|
3
|
+
import { writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
|
|
4
|
+
import { appendLog } from '../../../shared/storage/update-log.js';
|
|
5
|
+
import { filterIssuesUpdatedSince } from '../domain/issue-filter.js';
|
|
6
|
+
import { buildIssueMarkdown } from '../domain/issue-markdown.js';
|
|
7
|
+
import { issueRelativePath, issueUrl } from '../domain/issue-path.js';
|
|
8
|
+
export async function exportIssues(deps, options) {
|
|
9
|
+
const { logger } = deps;
|
|
10
|
+
logger.log('課題の取得を開始します...');
|
|
11
|
+
const allIssues = options.issueIdOrKeys && options.issueIdOrKeys.length > 0
|
|
12
|
+
? await fetchIssuesByIdOrKeys(deps, options.issueIdOrKeys)
|
|
13
|
+
: await fetchAllIssues(deps, options);
|
|
14
|
+
logger.log(`\n合計 ${allIssues.length}件の課題が見つかりました。`);
|
|
15
|
+
const filteredIssues = filterIssuesUpdatedSince(allIssues, options.lastUpdated);
|
|
16
|
+
if (options.lastUpdated) {
|
|
17
|
+
logger.log(`前回の更新日時(${options.lastUpdated})以降に更新された${filteredIssues.length}件の課題を処理します。`);
|
|
18
|
+
}
|
|
19
|
+
if (filteredIssues.length === 0) {
|
|
20
|
+
logger.log('更新が必要な課題はありません。');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
logger.log('課題を保存しています...');
|
|
24
|
+
for (const [index, issue] of filteredIssues.entries()) {
|
|
25
|
+
try {
|
|
26
|
+
writeProgress(`課題を保存中... (${index + 1}/${filteredIssues.length}件)`);
|
|
27
|
+
// eslint-disable-next-line no-await-in-loop
|
|
28
|
+
await saveIssue(deps, issue, options);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
logger.warn(`課題 ${issue.issueKey} の保存に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
logger.log('\n課題のダウンロードが完了しました!');
|
|
35
|
+
}
|
|
36
|
+
async function fetchIssuesByIdOrKeys(deps, issueIdOrKeys) {
|
|
37
|
+
const issues = [];
|
|
38
|
+
for (const [index, issueIdOrKey] of issueIdOrKeys.entries()) {
|
|
39
|
+
try {
|
|
40
|
+
writeProgress(`課題を取得中... (${index + 1}/${issueIdOrKeys.length}件)`);
|
|
41
|
+
// eslint-disable-next-line no-await-in-loop
|
|
42
|
+
issues.push(await deps.issueRepository.fetchByIdOrKey(issueIdOrKey));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
deps.logger.warn(`課題 ${issueIdOrKey} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return issues;
|
|
49
|
+
}
|
|
50
|
+
async function fetchAllIssues(deps, options) {
|
|
51
|
+
const pageSize = Math.min(options.count ?? 5000, 100);
|
|
52
|
+
const issues = [];
|
|
53
|
+
try {
|
|
54
|
+
for (;;) {
|
|
55
|
+
writeProgress(`課題を取得中... (${issues.length}件取得済み)`);
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop
|
|
57
|
+
const page = await deps.issueRepository.fetchPage({
|
|
58
|
+
count: pageSize,
|
|
59
|
+
offset: issues.length,
|
|
60
|
+
projectId: options.projectId,
|
|
61
|
+
statusId: options.statusId,
|
|
62
|
+
});
|
|
63
|
+
issues.push(...page);
|
|
64
|
+
if (page.length < pageSize) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new Error(`課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
return issues;
|
|
73
|
+
}
|
|
74
|
+
async function saveIssue(deps, issue, options) {
|
|
75
|
+
const backlogIssueUrl = issueUrl(options.domain, issue.issueKey);
|
|
76
|
+
// コメント取得に失敗しても課題本体は保存する
|
|
77
|
+
let comments = [];
|
|
78
|
+
try {
|
|
79
|
+
comments = await deps.issueRepository.fetchAllComments(issue.issueKey);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
const filePath = path.join(options.outputDir, issueRelativePath(issue, options));
|
|
85
|
+
await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl));
|
|
86
|
+
await appendLog(options.outputDir, `課題「${issue.summary}」を更新しました: ${backlogIssueUrl}`);
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function newBacklogProjectRepository(client) {
|
|
2
|
+
return {
|
|
3
|
+
async resolveProjectId(projectIdOrKey) {
|
|
4
|
+
// 数値の場合はそのままプロジェクトIDとして返す
|
|
5
|
+
if (!Number.isNaN(Number(projectIdOrKey))) {
|
|
6
|
+
return Number(projectIdOrKey);
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const projectData = await client.getJson(`/projects/${projectIdOrKey}`);
|
|
10
|
+
return projectData.id;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
throw new Error(`プロジェクトキー "${projectIdOrKey}" からプロジェクトIDの取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|