src/index.js
/* eslint camelcase: 0 */
// XXX: Trello APIパラメータでスネークケースを使用する必要があるためcamelcaseルールを解除
import request from 'superagent';
const NAME_BASE_REGEX = /^(\d+):\s+(?:\((?:(\d+)\/)?(\d+)\/(\d+)\)\s+)?(.*)$/;
const API_ENDPOINT = 'https://api.trello.com';
const AVATAR_ENDPOINT = 'https://trello-avatars.s3.amazonaws.com';
const STORY_TYPE = {
issue: 'issue',
story: 'story',
invalid: 'invalid'
};
const STORY_STATUS = {
open: 'open',
close: 'close',
waiting: 'waiting',
unknown: 'unknown'
};
/**
* @typedef {Object} Card
*
* @property {string} listName Trello list name
* @property {string[]} labels Trello label name list
* @property {number} pos Trello card position
* @property {url} string Trello card url
*/
/**
* @typedef {Object} Member
*
* @property {string} username Trello username
* @property {string} ?avatarUrl Trello avatar url
*/
/**
* @typedef {Object} Sprint
*
* @property {string} name sprint name
* @property {Date} due sprint due date
*/
/**
* @typedef {Object} Story
*
* @property {number} id story id
* @property {string} title story title
* @property {string} type story type
* @property {string} status story status
* @property {number} parentId parent story id
* @property {number[]} dependIds depends story id
* @property {Member[]} members member list
* @property {Story[]} children children story list
* @property {Sprint} sprint? sprint
* @property {{spent: ?number, es50: ?number, es90: ?number}} time? time
* @property {Card} card Trello card
*/
/**
* @typedef {Object} Issue
*
* @property {number} id issue id
* @property {string} title issue title
* @property {string} type issue type
* @property {string} status issue status
* @property {number[]} dependIds depends story id
* @property {Member[]} members member list
* @property {Story[]} children children story list
* @property {Card} card Trello card
*/
/**
* @typedef {Object} InvalidStory
*
* @property {string} title story title
* @property {string} type story type
* @property {Card} card Trello card
*/
/**
* @typedef {Story|InvalidStory|Issue} StoryNode
*/
/**
* Story Client
*/
export default class StoryClient {
/**
* constructor
*
* @param {string} apiToken trello api token
* @param {string} apiKey trello api key
* @param {string} boardId trello board id
*/
constructor(apiToken, apiKey, boardId) {
this.boardId = boardId;
this.apiToken = apiToken;
this.apiKey = apiKey;
}
/**
* Trello CardのnameからStoryの素オブジェクトに変換
*
* @param {string} name card name
* @return {Object}
*/
parseCardName(name) {
const match = NAME_BASE_REGEX.exec(name);
if (!match) {
return {
title: name,
type: STORY_TYPE.invalid
};
}
const [id, spent, es50, es90, body] = match.slice(1);
const parentMatch = body.match(/\s+#(\d+)/);
const dependsMatch = body.match(/\s+&(\d+)/g);
const parentId = parentMatch && parseInt(parentMatch[1], 10);
const baseStory = {
id: parseInt(id, 10),
title: body.replace(/(\s+#(\d+))|(\s+&(\d+))/g, '').trim(),
dependIds: dependsMatch ? dependsMatch.map(m => parseInt(/\d+/.exec(m)[0], 10)) : []
};
if (parentId) {
return Object.assign({}, baseStory, {
parentId,
type: STORY_TYPE.story,
time: {
spent: spent ? parseInt(spent, 10) : null,
es50: es50 ? parseInt(es50, 10) : null,
es90: es90 ? parseInt(es90, 10) : null
}
});
}
return Object.assign({}, baseStory, {
type: STORY_TYPE.issue
});
}
/**
* Trello ListのnameからSprintに変換
*
* @param {string} name trello list name
* @return {?Sprint}
*/
parseListName(name) {
const sprintMatches = /^Sprint\.\s*\d+\s*\((\d{4})(\d{2})(\d{2})\)/.exec(name);
if (!sprintMatches) {
return null;
}
const [year, month, day] = sprintMatches.slice(1).map(s => parseInt(s, 10));
return {
name,
due: new Date(year, month - 1, day)
};
}
/**
* label.nameのリストからStatusを取得
*
* @param {string[]} labels label list
* @return {string}
*/
getStatusFromLabels(labels) {
if (labels.indexOf(STORY_STATUS.waiting) >= 0) {
return STORY_STATUS.waiting;
} else if (labels.indexOf(STORY_STATUS.open) >= 0) {
return STORY_STATUS.open;
}
return STORY_STATUS.close;
}
/**
* Trello CardとBoardデータからStoryに変換
*
* @param {Object} card Trello Card
* @param {Object} board Trello Board
* @return {StoryNode}
*/
parseCard(card, board) {
const story = this.parseCardName(card.name);
const labels = card.labels.map(label => label.name);
const members = card.idMembers.map(mid => {
const member = board.members.find(m => m.id === mid);
if (!member) {
return null;
}
return {
username: member.username,
avatarUrl: member.avatarHash && `${AVATAR_ENDPOINT}/${member.avatarHash}/30.png`
};
}).filter(m => m);
const list = board.lists.find(l => l.id === card.idList);
const override = {
members,
status: this.getStatusFromLabels(labels),
card: {
labels,
url: card.shortUrl,
listName: list.name,
pos: card.pos
}
};
const sprint = this.parseListName(list.name);
if (sprint) {
return Object.assign({}, story, override, {sprint});
}
return Object.assign({}, story, override);
}
/**
* Story 一覧を取得する
*
* @return {Promise<StoryNode[], null>}
*/
getStories() {
return new Promise((resolve, reject) => {
request.get(`${API_ENDPOINT}/1/boards/${this.boardId}`)
.query({
cards: 'visible',
card_fields: 'labels,name,shortUrl,pos,idList,idMembers',
lists: 'open',
members: 'all',
member_fields: 'username,avatarHash',
token: this.apiToken,
key: this.apiKey
})
.end((err, res) => {
if (err) {
return reject(err);
}
const stories = res.body.cards.map(card => {
return this.parseCard(card, res.body);
});
resolve(stories);
});
});
}
}