fogbugz.js
Version:
FogBugz client for nodejs.
972 lines (861 loc) • 22.2 kB
JavaScript
// based on http://help.fogcreek.com/8202/xml-api
(function(){
var extend, defer, promise, request, xml2js, parallel;
var env = 'browser';
if (typeof module !== 'undefined') {
env = 'node';
} else if (typeof Meteor !== 'undefined' && Meteor.isServer) {
env = 'meteor';
}
switch (env){
case 'node': {
console.log('fogbugz.js: running in node environment');
request = require('request');
xml2js = require('xml2js');
var Q = require('q');
extend = require('underscore').extend;
defer = Q.defer;
promise = Q;
parallel = require('async').parallel;
}
break;
case 'meteor': {
console.log('fogbugz.js: running in meteor environment');
request = Npm.require('request');
xml2js = Npm.require('xml2js');
var q = Npm.require('q');
extend = Npm.require('underscore').extend;
defer = q.defer;
promise = q;
parallel = Npm.require('async').parallel;
}
break;
default: {
console.log('fogbugz.js: running in browser environment');
extend = $.extend;
defer = $.Deferred;
promise = function(value){
return $.Deferred().resolve(value).promise();
};
// TODO ensure that body is string not document object
request = function(url, callback){
$.get(url).done(function(body, status, xhr){
callback(null, xhr.response, body);
}).fail(function(err){
callback(err, null, null);
});
};
// requires jQuery-xml2json to be included before fogbugz.js script
xml2js = {
parseString: function(xml, cb) {
try {
cb(null, $.xml2json(xml));
} catch (err) {
cb(err, null);
}
}
};
parallel = function(funcs, callback){
var count = 0;
var result = [];
var error = null;
function done(err,res){
count++;
result.push(res);
error = error || err;
if (count === funcs.length){
callback(error, result);
}
}
function run(fn){
fn(done);
}
funcs.forEach(run);
};
}
break;
}
var log = false;
function format(f) {
var args = [].slice.call(arguments, 1);
return f.replace(/\{(\d+)\}/g, function(match, i) {
return typeof args[i] !== 'undefined' ? args[i] : "";
});
}
function defer_promise(d){
return typeof d.promise === 'function' ? d.promise() : d.promise;
}
function getUrl(url) {
log && console.log("GET %s", url);
var d = defer();
request(url, function(err, res, body) {
if (err) {
log && console.log(err);
d.reject(err);
} else {
log && console.log(body);
d.resolve(body);
}
});
return defer_promise(d);
}
function parseXml(xml) {
var d = defer();
xml2js.parseString(xml, function(err, obj) {
if (err) {
log && console.log(err);
d.reject(err);
} else if (!obj.response) {
d.reject("unexpected response");
} else if (obj.response.error) {
err = obj.response.error;
if (Array.isArray(err)) {
err = err[0];
}
if (err._) {
err = err._;
}
d.reject(err);
} else {
d.resolve(obj.response);
}
});
return defer_promise(d);
}
function get() {
var url = format.apply(null, [].slice.call(arguments));
return getUrl(url).then(parseXml);
}
// converters from FogBugz XMLJS payloads to plain JS objects
var convert = (function(){
// resolves xmljs array
function getarr(d) {
var arr = [d];
for (var i = 1; i < arguments.length; i++) {
var p = arguments[i];
var v = arr[0][p];
if (v === null || v === undefined) {
return [];
}
arr = Array.isArray(v) ? v : [v];
if (arr.length === 0) {
return [];
}
}
return arr;
}
function bool(x) {
return typeof x === "string" ? x.toLowerCase() === 'true' : !!x;
}
function parse(key, val) {
// unwrap array
var v = Array.isArray(val) ? val[0] : val;
if (/^f.+$/.test(key)) {
return bool(v);
} else if (/^[in].+$/.test(key)) {
var i = parseInt(v, 10);
return isNaN(i) ? v : i;
} else if (/^dt.*$/.test(key)) {
// TODO handle invalid dates
return new Date(v);
}
return v;
}
function get(obj, key) {
var v;
if (/^.+\[]$/.test(key)) {
v = obj[key.substr(0, key.length - 2)] || [];
} else {
v = parse(key, obj[key]);
}
return v;
}
function mapget(arr, name) {
return arr.map(function(x) {
return get(x, name);
});
}
function evalchain(chain, it) {
for (var i = 0; i < chain.length; i++) {
var name = chain[i];
if (Array.isArray(it)) {
it = mapget(it, name);
} else {
it = get(it, name);
}
if (it === null || it === undefined) {
break;
}
}
return it;
}
// creates convertion function from given schema
function use(schema) {
return function(it) {
var props = Object.keys(schema).map(function(key) {
var p = schema[key];
var v;
if (typeof p === "string") {
var chain = p.split('.');
v = evalchain(chain, it);
if (v === null || v === undefined) {
return {};
}
} else if (typeof p === 'function') {
v = p(it);
} else {
v = use(p)(it);
}
var obj = {};
obj[key] = v;
return obj;
});
return extend.apply(null, props);
};
}
var person = use({
id: 'ixPerson',
name: 'sFullName',
email: 'sEmail',
phone: 'sPhone',
admin: 'fAdministrator',
community: 'fCommunity',
virtual: 'fVirtual',
deleted: 'fDeleted',
notify: 'fNotify',
expert: 'fExpert',
homepage: 'sHomepage',
locale: 'sLocale',
language: 'sLanguage',
workingOn: 'ixBugWorkingOn',
timeZoneKey: 'sTimeZoneKey'
});
var event = use({
id: 'ixBugEvent',
date: 'dt',
verb: 'sVerb',
description: 'evtDescription',
person: {
id: 'ixPerson',
name: 'sPerson'
},
assignee: 'ixPersonAssignedTo',
format: 'sFormat',
text: 's',
changes: 'sChanges',
html: function(it){
var val = it.sHTML || it.sHtml;
if (!val) {
return undefined;
}
if (Array.isArray(val)){
val = val[0];
}
if (typeof val === 'string'){
return val;
}
if (typeof val._ === 'string'){
return val._;
}
return undefined;
},
isHtml: 'fHTML',
isExternal: 'fExternal',
isEmail: 'fEmail',
message: {
from: 'sFrom',
to: 'sTo',
cc: 'sCC',
bcc: 'sBCC',
replyTo: 'sReplyTo',
subject: 'sSubject',
date: 'sDate',
bodyText: 'sBodyText',
bodyHTML: 'sBodyHTML'
}
});
return {
filters: function(d) {
return getarr(d, 'filters', 'filter').map(function(it) {
return {
id: it.$.sFilter,
name: it._,
type: it.$.type,
status: it.$.status
};
});
},
projects: function(d) {
return getarr(d, 'projects', 'project').map(use({
id: 'ixProject',
name: 'sProject',
owner: {
id: 'ixPersonOwner',
name: 'sPersonOwner'
},
email: 'sEmail',
phone: 'sPhone',
workflowId: 'ixWorkflow',
deleted: 'fDeleted',
inbox: 'fInbox',
type: 'iType',
group: 'sGroup'
}));
},
areas: function(d) {
return getarr(d, 'areas', 'area').map(use({
id: 'ixArea',
name: 'sArea',
project: {
id: 'ixProject',
name: 'sProject'
},
owner: {
id: 'ixPersonOwner',
name: 'sPersonOwner'
},
type: 'nType',
doc: 'cDoc'
}));
},
categories: function(d) {
return getarr(d, 'categories', 'category').map(use({
id: 'ixCategory',
name: 'sCategory',
plural: 'sPlural',
on: {
resolve: 'ixStatusDefault',
open: 'ixStatusDefaultActive'
},
isScheduleItem: 'fIsScheduleItem'
}));
},
priorities: function(d) {
return getarr(d, 'priorities', 'priority').map(use({
id: 'ixPriority',
name: 'sPriority',
isDefault: 'fDefault'
}));
},
statuses: function(d) {
return getarr(d, 'statuses', 'status').map(use({
id: 'ixStatus',
name: 'sStatus',
category: 'ixCategory',
isResolved: 'fResolved',
isDuplicate: 'fDuplicate',
isDeleted: 'fDeleted',
workDone: 'fWorkDone',
order: 'iOrder'
}));
},
milestones: function(d) {
return getarr(d, 'fixfors', 'fixfor').map(use({
id: 'ixFixFor',
name: 'sFixFor',
project: {
id: 'ixProject',
name: 'sProject'
},
deleted: 'fDeleted',
start: 'dtStart',
end: 'dt',
startNote: 'sStartNote'
}));
},
person: function(d) {
return getarr(d, 'person').map(person)[0];
},
people: function(d) {
return getarr(d, 'people', 'person').map(person);
},
searchCols: [
'ixBug',
'ixStatus',
'sStatus',
'ixPersonAssignedTo',
'sPersonAssignedTo',
'sEmailAssignedTo',
'ixPersonOpenedBy',
'ixPersonResolvedBy',
'ixBugParent',
'ixBugChildren',
'ixBugDuplicates',
'ixBugOriginal',
'sTitle',
'ixPriority',
'sPriority',
'ixCategory',
'sCategory',
'ixFixFor',
'sFixFor',
'sVersion',
'sComputer',
'sTicket',
'sLatestTextSummary',
'fOpen',
'tags',
'ixProject',
'sProject',
'ixArea',
'sArea',
'fSubscribed',
// TODO provide a way to configure this behavior
// exclude events since fogbugz could fail with runtime error
// on large number of cases (e.g. large product backlog)
// 'events',
// dates
'dtOpened',
'dtResolved',
'dtClosed',
'dtDue',
'dtFixFor'
].join(','),
cases: function(d) {
return getarr(d, 'cases', 'case').map(use({
id: 'ixBug',
status: {
id: 'ixStatus',
name: 'sStatus'
},
operations: '$.operations',
opened: 'dtOpened',
resolved: 'dtResolved',
closed: 'dtClosed',
due: 'dtDue',
assignee: {
id: 'ixPersonAssignedTo',
name: 'sPersonAssignedTo',
email: 'sEmailAssignedTo'
},
openedBy: {
id: 'ixPersonOpenedBy'
},
resolvedBy: {
id: 'ixPersonResolvedBy'
},
parentId: 'ixBugParent',
originalId: 'ixBugOriginal',
children: 'ixBugChildren',
duplicates: 'ixBugDuplicates',
title: 'sTitle',
priority: {
id: 'ixPriority',
name: 'sPriority'
},
category: {
id: 'ixCategory',
name: 'sCategory'
},
project: {
id: 'ixProject',
name: 'sProject'
},
area: {
id: 'ixArea',
name: 'sArea'
},
milestone: {
id: 'ixFixFor',
name: 'sFixFor',
end: 'dtFixFor'
},
version: 'sVersion',
computer: 'sComputer',
ticket: 'sTicket',
latestSummary: 'sLatestTextSummary',
isOpen: 'fOpen',
isSubscribed: 'fSubscribed',
tags: 'tags.tag[]',
events: function(it) {
return getarr(it, 'events', 'event').map(event);
}
}));
},
events: function(d) {
return getarr(d, 'cases', 'case', 'events', 'event').map(event);
}
};
})();
// extend functions for fogbugz objects
var fn = {
milestone: function(fb) {
return function(it) {
return extend(it, {
cases: function() {
var q = "";
if (it.project && it.project.name){
q += 'project:"' + it.project.name + '" AND ';
}
q += 'fixfor:"' + it.name + '"';
return fb.search(q);
}
});
};
}
};
// TODO verify api token
// creates new client with specified options
function fogbugz(options) {
if (!options) {
throw new Error("Options are not specified.");
}
if (!options.url || typeof options.url !== "string") {
throw new Error("Required url option is not specified.");
}
// TODO verbose flag per client
// use only for dev purposes!
if (!!options.verbose){
log = true;
}
// TODO if DEBUG only for testing purposes
// allow to replace request module for testing purposes
if (options.request){
request = options.request;
}
// normalize url
var apiUrl = options.url;
if (apiUrl.charAt(apiUrl.length - 1) !== '/') {
apiUrl += '/';
}
apiUrl += 'api.asp?';
function client(token) {
var clientUrl = format("{0}token={1}&", apiUrl, token);
var fb;
// returns function which map array elements with given function
function map(fn) {
return function(arr) {
return arr.map(fn);
};
}
// runs simple command without arguments
function simpleCmd(name) {
return get("{0}cmd={1}", clientUrl, name);
}
// runs list command
function list(name) {
var fns = [].slice.call(arguments, 1);
return function() {
var p = simpleCmd("list" + name);
fns.forEach(function(fn) {
p = p.then(fn);
});
return p;
};
}
// performs given command with specified arguments
function cmd(name) {
var url = format("{0}cmd={1}&", clientUrl, name);
var i = 1;
while (i + 1 < arguments.length) {
var arg = arguments[i++];
var val = arguments[i++];
if (val) {
url += "&" + arg;
url += "=" + encodeURIComponent(String(val));
}
}
return get(url);
}
// runs search command
function search(q, max, withoutEvents) {
return cmd("search", "q", q, "max", max, "cols", convert.searchCols)
.then(convert.cases)
.then(function(list){
if (withoutEvents){
return list;
}
function fetchFn(i){
var item = i;
return function(cb){
events(item.id).then(function(eventList){
item.events = eventList;
cb(null, item);
return item;
}).fail(function(err){
cb(err, null);
});
};
}
var d = defer();
parallel(list.map(fetchFn), function(){
d.resolve(list);
});
return defer_promise(d);
});
}
// fetch case details by given case number
function caseInfo(id){
return search("ixBug:" + id).then(function(list){
return list.length === 0 ? null : list[0];
});
}
// fetch events of given case
function events(id) {
return cmd("search", "q", "ixBug:" + id, "cols", "events").then(convert.events);
}
function caseCmd(cmdname, data) {
var userArg = isNaN(parseInt(data.user, 10)) ? "sPersonAssignedTo" : "ixPersonAssignedTo";
var statusArg = isNaN(parseInt(data.status, 10)) ? "sStatus" : "ixStatus";
return cmd(cmdname,
"ixBug", data.id,
"sTitle", data.title,
"sProject", data.project, // TODO id or name
"sArea", data.area, // TODO id or name
"sFixFor", data.milestone, // TODO id or name
"sCategory", data.category, // TODO map categories
userArg, data.user,
statusArg, data.status,
"sPriority", data.priority, // TODO id or name
"sTags", data.tags,
"sCustomerEmail", data.customerEmail,
"sEvent", data.comment
);
}
// creates new case with given info
function create(info) {
return caseCmd("new", info);
}
// edits specified case
function edit(info) {
return caseCmd("edit", info);
}
// adds comment to specified case
function comment(opts, text, format) {
var id;
// allow to pass options as first argument
if (typeof opts === 'object'){
id = opts.id;
text = opts.text || opts.comment || opts.message;
format = opts.format || '';
} else {
id = opts;
format = format || '';
}
// check required options
if (!id) {
throw new Error("case number is not specified");
}
if (!text) {
throw new Error("comment is not specified");
}
// TODO how to post comment in specified format?
return edit({id: id, comment: text, format: format});
}
// assigns given case to specified command with optional comment
function assign(id, user, comment) {
if (!id) {
throw new Error("case number is not specified");
}
if (!user) {
throw new Error("user is not specified");
}
var userArg = isNaN(parseInt(user, 10)) ? "sPersonAssignedTo" : "ixPersonAssignedTo";
return cmd("assign", "ixBug", id, userArg, user, "sEvent", comment);
}
// internal API for extenders
fb = {
search: search,
events: events
};
// fetch user info for given user id/email
function userInfo(user) {
if (user || user.id || user.email) {
if (user.id) {
return cmd("viewPerson", "ixPerson", user.id).then(convert.person);
}
if (user.email) {
return cmd("viewPerson", "sEmail", user.email).then(convert.person);
}
var userArg = isNaN(parseInt(user, 10)) ? "ixPerson" : "sEmail";
return cmd("viewPerson", userArg, user).then(convert.person);
}
return simpleCmd("viewPerson").then(convert.person);
}
// resolves info about currently logon user
function currentUser() {
return userInfo();
}
// assign given case to current user with optional comment
function take(id, comment) {
return currentUser().then(function(user) {
return assign(id, user.id, comment);
});
}
// closes given case with optional comment
function close(id, comment) {
return cmd("close", "ixBug", id, "sEvent", comment);
}
// reopens given case with optional comment
function reopen(id, comment) {
return cmd("reopen", "ixBug", id, "sEvent", comment);
}
// resolves specified case
function resolve(data) {
var userArg = isNaN(parseInt(data.user, 10)) ? "sPersonAssignedTo" : "ixPersonAssignedTo";
var statusArg = isNaN(parseInt(data.status, 10)) ? "sStatus" : "ixStatus";
return cmd("resolve",
"ixBug", data.id,
statusArg, data.status,
userArg, data.user,
"sEvent", data.comment);
}
// fetch user settings
function userSettings(){
return simpleCmd('viewSettings');
}
// start working on this case and charge time to it (start the stopwatch)
function startWork(id){
return cmd("startWork", "ixBug", id);
}
// stop working on everything (stop the stopwatch)
function stopWork(){
return simpleCmd("stopWork");
}
function starImpl(cmdname, id, type){
type = type || 'bug';
switch (type.toLowerCase()){
case 'bug':
type = 'Bug';
break;
case 'case':
type = 'Bug';
break;
case 'wikipage':
type = 'WikiPage';
break;
case 'wiki':
type = 'WikiPage';
break;
case 'discusstopic':
type = 'DiscussTopic';
break;
case 'discuss':
type = 'DiscussTopic';
break;
case 'topic':
type = 'DiscussTopic';
break;
}
return cmd(cmdname, "sType", type, "ixItem", id);
}
function star(id, type){
return starImpl('star', id, type);
}
function unstar(id, type){
return starImpl('unstar', id, type);
}
return {
token: token,
// runs logoff command
logout: function() {
return simpleCmd("logoff");
},
// fetch list of filters
filters: list("Filters", convert.filters),
// fetch list of projects
projects: list("Projects", convert.projects),
// fetch list of users
people: list("People", convert.people),
users: list("People", convert.people),
// fetch list of areas
areas: list("Areas", convert.areas),
// fetch list of available case categories
categories: list("Categories", convert.categories),
// fetch list of available case priorities
priorities: list("Priorities", convert.priorities),
// fetch list of available case statuses
statuses: list("Statuses", convert.statuses),
// fetch list of milestones
milestones: function(plain){
var p = list("FixFors", convert.milestones)();
if (!!plain){
return p;
}
return p.then(map(fn.milestone(fb)));
},
// TODO provide converters for lists below
mailboxes: list("Mailboxes"),
wikis: list("Wikis"),
templates: list("Templates"), // wiki templates
snippets: list("Snippets"),
// performs search with given options
search: search,
// fetch events of given case number
events: events,
// fetch case details by given case number
caseInfo: caseInfo,
// editing cases
// creates new case with given info
open: create,
"new": create,
create: create,
// edits specfied case updating fields from info
edit: edit,
// assigns given case to specified command with optional comment
assign: assign,
// assign given case to current user with optional comment
take: take,
// adds comment to given case
log: comment,
comment: comment,
// resolves specified case
resolve: resolve,
// closes given case with optional comment
close: close,
// reopens given case with optional comment
reopen: reopen,
// start working on this case and charge time to it (start the stopwatch)
start: startWork,
startWork: startWork,
// stop working on everything (stop the stopwatch)
stop: stopWork,
stopWork: stopWork,
star: star,
unstar: unstar,
// fetch user info
userInfo: userInfo,
// fetch user settings
userSettings: userSettings,
// extends specified milestone object with cases
milestone: function(m){
return fn.milestone(fb)(m);
}
};
}
// creating client with given token
if (typeof options.token === "string") {
if (!options.token) {
throw new Error("token option is empty.");
}
return promise(client(options.token));
}
// login then create client
var user = options.email || options.user;
var pwd = options.password || options.pwd;
if (!user || typeof user !== "string") {
throw new Error("Required email option is not specified.");
}
if (!pwd || typeof pwd !== "string") {
throw new Error("Required password option is not specified.");
}
return get("{0}cmd=logon&email={1}&password={2}", apiUrl, user, pwd).then(function(d) {
return client(Array.isArray(d.token) ? d.token[0] : d.token);
});
}
// expose public api for different environments
switch (env) {
case 'node':
module.exports = fogbugz;
break;
case 'meteor':
FogBugz = fogbugz;
// aliases
FogBugz.connect = fogbugz;
FogBugz.create = fogbugz;
break;
default:
window.fogbugz = fogbugz;
break;
}
})();