383 lines
11 KiB
JavaScript
383 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Fetch Linear data with sub-issues
|
|
*
|
|
* Fetches magazine parent issues and their sub-issues from Linear API.
|
|
* Parent issues are filtered by status labels (1.企画案ストック, 2.構成作成中, 3.原稿執筆中, 4.動画編集中).
|
|
* Sub-issues include status labels for process determination.
|
|
*/
|
|
|
|
import 'dotenv/config';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { LINEAR_API_URL, LINEAR_TEAM_KEY, LABEL_GROUPS, STATUS_LABELS } from '../config/settings.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const LINEAR_API_KEY = process.env.MAGAZINE_LINEAR_API_KEY;
|
|
|
|
/**
|
|
* Fetch magazines with sub-issues from Linear
|
|
*/
|
|
async function fetchLinearData() {
|
|
console.log('📊 Linearからマガジンデータ+サブイシューを取得中...');
|
|
|
|
if (!LINEAR_API_KEY) {
|
|
console.error('❌ MAGAZINE_LINEAR_API_KEY が設定されていません');
|
|
console.log('💡 環境変数 MAGAZINE_LINEAR_API_KEY=lin_api_xxxxx を設定してください');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Calculate date for 1 month ago
|
|
const oneMonthAgo = new Date();
|
|
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
|
|
|
// GraphQL query to fetch active (in-progress) parent issues with sub-issues
|
|
const activeQuery = `
|
|
query GetActiveMagazines {
|
|
issues(
|
|
first: 200,
|
|
filter: {
|
|
team: {
|
|
key: { eq: "${LINEAR_TEAM_KEY}" }
|
|
},
|
|
state: {
|
|
type: { in: ["backlog", "unstarted", "started"] }
|
|
},
|
|
parent: {
|
|
null: true
|
|
}
|
|
}
|
|
) {
|
|
nodes {
|
|
id
|
|
identifier
|
|
title
|
|
url
|
|
state {
|
|
id
|
|
name
|
|
type
|
|
}
|
|
labels {
|
|
nodes {
|
|
id
|
|
name
|
|
color
|
|
parent {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
assignee {
|
|
id
|
|
name
|
|
displayName
|
|
}
|
|
dueDate
|
|
createdAt
|
|
updatedAt
|
|
children {
|
|
nodes {
|
|
id
|
|
identifier
|
|
title
|
|
url
|
|
state {
|
|
id
|
|
name
|
|
type
|
|
}
|
|
labels {
|
|
nodes {
|
|
id
|
|
name
|
|
color
|
|
parent {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
dueDate
|
|
completedAt
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
// GraphQL query to fetch completed parent issues (within last month)
|
|
const completedQuery = `
|
|
query GetCompletedMagazines {
|
|
issues(
|
|
first: 200,
|
|
filter: {
|
|
team: {
|
|
key: { eq: "${LINEAR_TEAM_KEY}" }
|
|
},
|
|
state: {
|
|
type: { eq: "completed" }
|
|
},
|
|
parent: {
|
|
null: true
|
|
},
|
|
completedAt: {
|
|
gte: "${oneMonthAgo.toISOString()}"
|
|
}
|
|
}
|
|
) {
|
|
nodes {
|
|
id
|
|
identifier
|
|
title
|
|
url
|
|
state {
|
|
id
|
|
name
|
|
type
|
|
}
|
|
labels {
|
|
nodes {
|
|
id
|
|
name
|
|
color
|
|
parent {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
assignee {
|
|
id
|
|
name
|
|
displayName
|
|
}
|
|
dueDate
|
|
createdAt
|
|
updatedAt
|
|
completedAt
|
|
children {
|
|
nodes {
|
|
id
|
|
identifier
|
|
title
|
|
url
|
|
state {
|
|
id
|
|
name
|
|
type
|
|
}
|
|
labels {
|
|
nodes {
|
|
id
|
|
name
|
|
color
|
|
parent {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
dueDate
|
|
completedAt
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
// Fetch active magazines
|
|
const activeResponse = await fetch(LINEAR_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': LINEAR_API_KEY
|
|
},
|
|
body: JSON.stringify({ query: activeQuery })
|
|
});
|
|
|
|
if (!activeResponse.ok) {
|
|
throw new Error(`Linear API エラー: ${activeResponse.status} ${activeResponse.statusText}`);
|
|
}
|
|
|
|
const activeData = await activeResponse.json();
|
|
|
|
if (activeData.errors) {
|
|
console.error('GraphQL エラー:', activeData.errors);
|
|
throw new Error('Linear APIからデータ取得に失敗しました');
|
|
}
|
|
|
|
const activeIssues = activeData.data.issues.nodes;
|
|
console.log(`✅ ${activeIssues.length}件の未完了親イシューを取得しました`);
|
|
|
|
// Fetch completed magazines
|
|
const completedResponse = await fetch(LINEAR_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': LINEAR_API_KEY
|
|
},
|
|
body: JSON.stringify({ query: completedQuery })
|
|
});
|
|
|
|
if (!completedResponse.ok) {
|
|
throw new Error(`Linear API エラー: ${completedResponse.status} ${completedResponse.statusText}`);
|
|
}
|
|
|
|
const completedData = await completedResponse.json();
|
|
|
|
if (completedData.errors) {
|
|
console.error('GraphQL エラー:', completedData.errors);
|
|
throw new Error('Linear APIからデータ取得に失敗しました');
|
|
}
|
|
|
|
const completedIssues = completedData.data.issues.nodes;
|
|
console.log(`✅ ${completedIssues.length}件の1ヶ月以内に完了した親イシューを取得しました`);
|
|
|
|
// Merge active and completed issues
|
|
const allIssues = [...activeIssues, ...completedIssues];
|
|
console.log(`✅ 合計 ${allIssues.length}件の親イシューを取得しました`);
|
|
|
|
// Filter issues by magazine status label group
|
|
const magazines = allIssues.filter(issue => {
|
|
let hasStatusLabel = false;
|
|
let hasStockStatus = false;
|
|
|
|
for (const label of issue.labels.nodes) {
|
|
if (label.parent && label.parent.name === LABEL_GROUPS.parentStatus) {
|
|
hasStatusLabel = true;
|
|
if (label.name === STATUS_LABELS.stock) {
|
|
hasStockStatus = true;
|
|
// ストックラベルが見つかった時点でこれ以上の走査は不要
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasStatusLabel) {
|
|
return false;
|
|
}
|
|
|
|
if (issue.state?.type === 'backlog') {
|
|
// Backlog は「1.企画案ストック」ラベルが付いているもののみ残す
|
|
return hasStockStatus;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
console.log(`✅ ${magazines.length}件のマガジンイシューをフィルタしました`);
|
|
|
|
// Count sub-issues
|
|
const totalSubIssues = magazines.reduce((sum, mag) => sum + (mag.children?.nodes?.length || 0), 0);
|
|
console.log(`✅ 合計 ${totalSubIssues}件のサブイシューを取得しました`);
|
|
|
|
// Transform data structure
|
|
const transformedMagazines = magazines.map(issue => {
|
|
// Extract magazine status label (from the label group)
|
|
const statusLabel = issue.labels.nodes.find(label =>
|
|
label.parent && label.parent.name === LABEL_GROUPS.parentStatus
|
|
);
|
|
|
|
return {
|
|
id: issue.id,
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
url: issue.url,
|
|
assignee: issue.assignee ? {
|
|
id: issue.assignee.id,
|
|
name: issue.assignee.displayName || issue.assignee.name
|
|
} : null,
|
|
dueDate: issue.dueDate,
|
|
label: statusLabel?.name || null,
|
|
state: {
|
|
id: issue.state.id,
|
|
name: issue.state.name,
|
|
type: issue.state.type
|
|
},
|
|
subIssues: (issue.children?.nodes || []).map(sub => ({
|
|
id: sub.id,
|
|
identifier: sub.identifier,
|
|
title: sub.title,
|
|
url: sub.url,
|
|
dueDate: sub.dueDate,
|
|
completedAt: sub.completedAt || null,
|
|
state: {
|
|
id: sub.state.id,
|
|
name: sub.state.name,
|
|
type: sub.state.type
|
|
},
|
|
labels: sub.labels.nodes.map(label => ({
|
|
id: label.id,
|
|
name: label.name,
|
|
color: label.color,
|
|
parent: label.parent ? {
|
|
id: label.parent.id,
|
|
name: label.parent.name
|
|
} : null
|
|
})),
|
|
createdAt: sub.createdAt,
|
|
updatedAt: sub.updatedAt
|
|
})),
|
|
createdAt: issue.createdAt,
|
|
updatedAt: issue.updatedAt
|
|
};
|
|
});
|
|
|
|
// Prepare result
|
|
const result = {
|
|
fetchedAt: new Date().toISOString(),
|
|
totalCount: transformedMagazines.length,
|
|
magazines: transformedMagazines,
|
|
summary: {
|
|
'1.企画案ストック': transformedMagazines.filter(m => m.label === STATUS_LABELS.stock).length,
|
|
'2.構成作成中': transformedMagazines.filter(m => m.label === STATUS_LABELS.composition).length,
|
|
'3.原稿執筆中': transformedMagazines.filter(m => m.label === STATUS_LABELS.manuscript).length,
|
|
'4.動画編集中': transformedMagazines.filter(m => m.label === STATUS_LABELS.video).length
|
|
}
|
|
};
|
|
|
|
// Save to file
|
|
const outputPath = path.join(__dirname, '..', 'data', 'linear-data.json');
|
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
await fs.writeFile(outputPath, JSON.stringify(result, null, 2));
|
|
|
|
console.log('💾 データを data/linear-data.json に保存しました');
|
|
|
|
// Display summary
|
|
console.log('\n📈 マガジンステータス別サマリー:');
|
|
console.log(` 1.企画案ストック: ${result.summary['1.企画案ストック']}件`);
|
|
console.log(` 2.構成作成中: ${result.summary['2.構成作成中']}件`);
|
|
console.log(` 3.原稿執筆中: ${result.summary['3.原稿執筆中']}件`);
|
|
console.log(` 4.動画編集中: ${result.summary['4.動画編集中']}件`);
|
|
console.log(` 合計: ${result.totalCount}件\n`);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('❌ エラーが発生しました:', error.message);
|
|
if (error.stack) {
|
|
console.error(error.stack);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Execute if run directly
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
fetchLinearData();
|
|
}
|
|
|
|
export default fetchLinearData;
|