diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8fe4fa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.project diff --git a/Code.gs b/Code.gs index 69c8baf..9ea736d 100644 --- a/Code.gs +++ b/Code.gs @@ -8,8 +8,8 @@ * - add feature to insert list of tickets (issue overview) based on available Jira filters */ -var BUILD = 0160; -var LOGGING = true; +var BUILD = 0180; +var LOGGING = false; /** * Add a nice menu option for the users. @@ -43,9 +43,10 @@ function addMenu() { // Add "Insert ..." menu with submenu .addSeparator() - .addSubMenu(SpreadsheetApp.getUi().createMenu('Insert...') - //.addItem('Worklog', 'mySecondFunction') - .addItem('List Issues from Filter', 'dialogIssueFromFilter')) + //.addSubMenu(SpreadsheetApp.getUi().createMenu('Insert...') + .addItem('List Issues from Filter', 'dialogIssueFromFilter') + .addItem('Create Time Report', 'dialogTimesheet') + //) .addSeparator() .addItem('Settings', 'dialogSettings') @@ -60,7 +61,12 @@ function addMenu() { * @param values Optional values to pass into format msg * @return void */ -function log(format, values, arg1, arg2) { - if(LOGGING == true) - Logger.log(format, values, arg1, arg2); +function log(format, values, arg1, arg2, arg3) { + if(LOGGING !== true) return; + + if(arguments.length == 0) { + Logger.log(format); + } else { + Logger.log(format, values, arg1, arg2, arg3); + } } diff --git a/IssueTable.gs b/IssueTable.gs index 56150b1..ee02a5b 100644 --- a/IssueTable.gs +++ b/IssueTable.gs @@ -51,7 +51,7 @@ function IssueTable(sheet, initRange, data) { // for some custom formatting switch(true) { case key.hasOwnProperty('date'): - key.value = key.date; + key.value = (key.date != null) ? key.date : ''; break; case key.hasOwnProperty('link'): key.value = '=HYPERLINK("' + key.link + '"; "' + key.value + '")'; @@ -75,8 +75,17 @@ function IssueTable(sheet, initRange, data) { .clearNote() .clearFormat() .setValues([ values ]) - .setNumberFormats([ formats ]); - } + .setNumberFormats([ formats ]) + .activate(); + + // flush sheet + if(i % 5 === 0) { + SpreadsheetApp.flush(); + } + + issue = null; + + }// END: for(data.issues.length) return this; }; @@ -98,6 +107,8 @@ function IssueTable(sheet, initRange, data) { .clearFormat() .setValues([ values ]) .setFontWeights([ formats ]); + + SpreadsheetApp.flush(); return this; }; @@ -115,6 +126,8 @@ function IssueTable(sheet, initRange, data) { .clearFormat() .getCell(1,1) .setValue(summary); + + SpreadsheetApp.flush(); return this; }; diff --git a/README.md b/README.md index aa1b3e0..19470a3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Jira Sheet Tools -[Jira](https://www.atlassian.com/software/jira) is a powerful and well established project management tool amoung small to enterprice businesses. Still we often end up using Google Sheets for some overview roadmaps, project dashboards and other purposes. +[Jira](https://www.atlassian.com/software/jira) is a powerful and well established project management tool amoung small to enterprise businesses. Still we often end up using Google Sheets for some overview roadmaps, project dashboards and other purposes. With this Google Sheet Add-on the, called "[Jira Sheet Tools](https://chrome.google.com/webstore/detail/jira-sheet-tools/ncijnapilmmnebhbdanhkbbofofcniao)" available in the Google Add-On store from within Google Sheet, you can now take your sheets with Jira information to the next level. [Jira Sheet Tools](https://chrome.google.com/webstore/detail/jira-sheet-tools/ncijnapilmmnebhbdanhkbbofofcniao) allows you to visualize the status of any Jira ticket you mention in a sheet. You can directly import entire issue lists with your Jira filters just from within Google sheet. +NEW! You can create time reports for any of your users based on the Jira worklogs. Enter your Jira server domain and user details once, and be able to use the Jira features in any sheet at any time. No manual status update copy&paste anymore. @@ -64,10 +65,19 @@ Even when used within text it will search for keys and add the status. If Jira issue key used in a single cell, the value will be linked automatically to the Jira issue page. ### List Issues From Filter -“Add-ons" > “Jira Sheet Tools” > "Insert...” > "List Issues from Filter" +“Add-ons" > “Jira Sheet Tools” > "List Issues from Filter" Allows you to add a table/list of all found Jira issues based on a Jira Filter. The dialog will let you choose from all your Jira filters and then insert all results into the active Google sheet. You can even decide which information to be shown in the resulting table. Most common Jira fields / columns are available to select from. +> Note: This feature is currently limited to list a maximum of 1000 jira issues. + +### Time Sheet +“Add-ons" > “Jira Sheet Tools” > "Time Sheet" + +Lets you pick a user from Jira and a date period to filter for and generates a nice Time sheet report based on all worklogs for the filtered user and date period. +Supports two different time report formats; "1d 7h 59m" for better readibility or "7.5" (work hours as decimal number) for better calculations in the sheet. + +> Careful when selecting to big date periods, can be slow and become a wide table. Start with 1 week and scale up. diff --git a/dialogAbout.html b/dialogAbout.html index e6e6f1b..e49cef7 100644 --- a/dialogAbout.html +++ b/dialogAbout.html @@ -19,7 +19,7 @@
- Jira Sheet Tools v0.16.0 + Jira Sheet Tools v0.18.0
diff --git a/dialogSettings.html b/dialogSettings.html index aa8ac7c..2e54f65 100644 --- a/dialogSettings.html +++ b/dialogSettings.html @@ -11,20 +11,34 @@
-
- - (https://YOURCOMPANY.atlassian.net/) - -
-
- - (Atlassian account name) - -
-
- - -
+
+ General + +
+ + (https://YOURCOMPANY.atlassian.net/) + +
+
+ + (Atlassian account name) + +
+
+ + +
+
+ +
+ Time Sheet + +
+ + + h +
+
diff --git a/dialogTimesheet.html b/dialogTimesheet.html new file mode 100644 index 0000000..0bd82ea --- /dev/null +++ b/dialogTimesheet.html @@ -0,0 +1,173 @@ + + + + + + + + + + + + +
+ Select User + +
+ + +
+ + +
+ +
+ Options + + +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/dialogs.gs b/dialogs.gs index ca5ecee..021fcff 100644 --- a/dialogs.gs +++ b/dialogs.gs @@ -27,8 +27,8 @@ function dialogSettings() { var dialog = getDialog('dialogSettings', getServerCfg()); dialog - .setWidth(320) - .setHeight(260) + .setWidth(340) + .setHeight(400) .setSandboxMode(HtmlService.SandboxMode.IFRAME); log('Processed: %s', dialog); @@ -45,32 +45,11 @@ function getServerCfg() { available: getCfg('available'), url: getCfg('jira_url'), username: getCfg('jira_username'), - password: getCfg('jira_password') + password: getCfg('jira_password'), + workhours: getVar('workhours') }; } -/** - * @desc Save Jira server settings, provided in dialog form and perform - * a connection test to Jira api. - * @param jsonFormData {object} JSON Form object of all form values - * @return {object} Object({status: [boolean], response: [string]}) - */ -function saveSettings(jsonFormData) { - var url = trimChar(jsonFormData.jira_url, "/"); - setCfg('available', false); - setCfg('jira_url', url); - setCfg('jira_username', jsonFormData.jira_username); - setCfg('jira_password', jsonFormData.jira_password); - - var test = testConnection(); - - if (url.indexOf('atlassian.net') == -1) { - setCfg('server_type', 'server'); - } - - return {status: test.status, message: test.response}; -} - /* Dialog: Settings - END */ /** @@ -129,7 +108,7 @@ function insertIssuesFromFilter(jsonFormData) { return; } - var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); + var sheet = getTicketSheet(); var cell = sheet.getActiveCell(); var table = new IssueTable(sheet, cell, responseData); @@ -139,6 +118,9 @@ function insertIssuesFromFilter(jsonFormData) { response.status = true; + // toast with status message + SpreadsheetApp.getActiveSpreadsheet().toast("Finished inserting " + (responseData.issues.length||"n/a") + " Jira issues.", "Status", 5); + } else { // Something funky is up with the JSON response. response.message = "Failed to retrieve jira issues!"; @@ -155,7 +137,7 @@ function insertIssuesFromFilter(jsonFormData) { var data = { jql: filter.jql, fields: jsonFormData['columns'] || [], - maxResults: LIST_ISSUES_MAX_RESULT, + maxResults: 1000, validateQuery: (getCfg('server_type') == 'onDemand') ? 'strict' : true }; @@ -187,4 +169,27 @@ function dialogAbout() { SpreadsheetApp.getUi().showModalDialog(dialog, 'About'); } -/* Dialog: About - END */ \ No newline at end of file +/* Dialog: About - END */ + + +/* Dialog: Worklog */ + +/** + * @desc Dialog to create worklog based on user/group selection + */ +function dialogTimesheet() { + if(!hasSettings(true)) return; + + var dialog = getDialog('dialogTimesheet'); + + dialog + .setWidth(420) + .setHeight(360) + .setSandboxMode(HtmlService.SandboxMode.IFRAME); + + log('Processed: %s', dialog); + + SpreadsheetApp.getUi().showModalDialog(dialog, 'Create Time Report'); +} + +/* Dialog: Worklog - END */ diff --git a/jiraApi.gs b/jiraApi.gs index fc4edc8..cf1c396 100644 --- a/jiraApi.gs +++ b/jiraApi.gs @@ -5,20 +5,28 @@ */ var restMethods = { 'onDemand': { - 'dashboard': '/dashboard', - 'issueStatus': {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}}, - 'filter': {method: '/filter/{filterId}'}, + 'dashboard' : '/dashboard', + 'issueStatus' : {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}}, + 'worklogOfIssue': {method: '/issue/{issueIdOrKey}/worklog'}, + 'filter' : {method: '/filter/{filterId}'}, //'search': {method: '/search', queryparams: {jql:'', fields: [], properties: [], maxResults: 100, validateQuery: 'strict'}} // GET - 'search': {method: '/search'}, // POST - 'myFilters': {method: '/filter/my', queryparams: {includeFavourites: 'false'}} + 'search' : {method: '/search'}, // POST + 'myFilters' : {method: '/filter/my', queryparams: {includeFavourites: 'false'}}, + + 'userSearch' : {method: '/user/search', queryparams: {startAt:0, maxResults: 1000, username:'%'}}, + 'groupSearch' : {method: '/groups/picker', queryparams: {maxResults: 1000, query: ''}} }, 'server': { - 'dashboard': '/dashboard', - 'issueStatus': {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}}, - 'filter': {method: '/filter/{filterId}'}, - 'search': {method: '/search'}, // POST + 'dashboard' : '/dashboard', + 'issueStatus' : {method: '/issue/{issueIdOrKey}', queryparams:{fields: ['status']}}, + 'worklogOfIssue': {method: '/issue/{issueIdOrKey}/worklog'}, + 'filter' : {method: '/filter/{filterId}'}, + 'search' : {method: '/search'}, // POST // server api doesnt support /filter/my - 'myFilters': {method: '/filter/favourite', queryparams: {includeFavourites: 'false'}} + 'myFilters' : {method: '/filter/favourite', queryparams: {includeFavourites: 'false'}}, + + 'userSearch' : {method: '/user/search', queryparams: {startAt:0, maxResults: 1000, username:'%'}}, + 'groupSearch' : {method: '/groups/picker', queryparams: {maxResults: 1000, query: ''}} } }; @@ -72,15 +80,17 @@ function testConnection() { */ function Request() { var statusCode, httpResponse, responseData, - available = getCfg('available'), - url = getCfg('jira_url'), - username = getCfg('jira_username'), - password = getCfg('jira_password'), + available, url, username, password, jiraMethod = null, jiraQueryParams = {}; this.init = function() { - // prepare for initialization if necessary + server_type = getCfg('server_type') || 'onDemand'; + available = getCfg('available'); + url = getCfg('jira_url'); + username = getCfg('jira_username'); + password = getCfg('jira_password'); + jiraMethod = null; }; /** @@ -145,7 +155,6 @@ function Request() { return this; } - var server_type = getCfg('server_type') || 'onDemand'; jiraMethod = (typeof restMethods[server_type][method] === 'object') ? restMethods[server_type][method].method : restMethods[server_type][method]; jiraQueryParams = (typeof restMethods[server_type][method] === 'object') ? restMethods[server_type][method].queryparams : {}; diff --git a/jiraCommon.gs b/jiraCommon.gs index e21d6cb..6472287 100644 --- a/jiraCommon.gs +++ b/jiraCommon.gs @@ -1,5 +1,3 @@ -var LIST_ISSUES_MAX_RESULT = 200; - // const not available, but better solution needed var CELLTYPE_EMPTY = -1; var CELLTYPE_JIRAID = 10; // entire cell includes Jira ticket id only ("JIRA-123" or "JIRA-123 [Status]") @@ -255,6 +253,27 @@ function getFilter(filterId) { return filter; } +/** + * @desc Fetch all active users and groups for dialog selection. + * @param {boolean} minimal Returning data only includes minimal info (displayName,name[,active]) + * @return {object} Object({"users":[{ b.displayName) ? 1 : ((b.displayName > a.displayName) ? -1 : 0);} ); + + return result; +} + /** * @desc Helper to convert indiv. jira field/property objects * into simple objects for using as cell data. @@ -321,7 +340,7 @@ function unifyIssueAttrib(attrib, data) { case 'duedate': // very dirty - just cant get arround timezone issue when date is in format 'YYYY-MM-DD' // @TODO: require proper generic solution for this - var _duedate = data.fields.duedate || 'n/a'; + var _duedate = data.fields.duedate || null; _duedate = (_duedate.length == 10) ? _duedate + 'T12:00:00' : _duedate; resp = { value: _duedate, @@ -368,6 +387,41 @@ function unifyIssueAttrib(attrib, data) { }; break; + case 'user': + resp = { + displayName: data.displayName + (data.active==true?'':' (X)'), + name: data.name, + emailAddress: data.emailAddress, + active: data.active, + + value: data.displayName, + format: "@" + }; + break; + case 'group': + var _dName = (data.labels.length > 0) ? data.labels[0].text : data.name; + resp = { + displayName: _dName, + name: data.name, + value: _dName, + format: "@" + }; + break; + case 'userMin': + resp = { + displayName: data.displayName + (data.active==true?'':' (X)'), + name: data.name, + active: data.active, + }; + break; + case 'groupMin': + var _dName = (data.labels.length > 0) ? data.labels[0].text : data.name; + resp = { + displayName: _dName, + name: data.name, + }; + break; + default: log('unifyIssueAttrib(' + attrib + ') no format defined yet.'); //log(data.fields[attrib]); diff --git a/jiraTicketInfo.gs b/jiraTicketInfo.gs index cbc937f..2ac247f 100644 --- a/jiraTicketInfo.gs +++ b/jiraTicketInfo.gs @@ -35,6 +35,9 @@ function refreshTickets() { rows.getCell(rowIdx, colIdx).setValue(newValue); break; } + + SpreadsheetApp.flush(); + } else { // Something funky is up with the JSON response. log(rowIdx, "Failed to retrieve ticket data for ID [" + jiraCell.ticketId + "]!"); diff --git a/jsLib.gs b/jsLib.gs index 8eb98f5..7e293cc 100644 --- a/jsLib.gs +++ b/jsLib.gs @@ -112,3 +112,148 @@ function trimChar(origString, charToTrim) { var regEx = new RegExp("^[" + charToTrim + "]+|[" + charToTrim + "]+$", "g"); return origString.replace(regEx, ""); } + +/** + * @desc Check if passed string is valid or not. + * @param date {String} String which gets validated as date (ie: '2017-05-31') + * @return {Boolean} + */ +function isDate(date) { + return ((new Date(date) !== "Invalid Date" && !isNaN(new Date(date)) ) ? true : false); +} + + +/** + * @desc The fill() method fills all the elements of an array from a start index to an end index with a static value. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fill + * @param value {mixed} Value to fill an array. + * @param start {integer} Optional, Start index, defaults to 0. + * @param end {integer} End index, defaults to this.length. + * @return {Array} The modified array. + */ +if (!Array.prototype.fill) { + Array.prototype.fill = function(value) { + + // Steps 1-2. + if (this == null) { + throw new TypeError('this is null or not defined'); + } + + var O = Object(this); + + // Steps 3-5. + var len = O.length >>> 0; + + // Steps 6-7. + var start = arguments[1]; + var relativeStart = start >> 0; + + // Step 8. + var k = relativeStart < 0 ? + Math.max(len + relativeStart, 0) : + Math.min(relativeStart, len); + + // Steps 9-10. + var end = arguments[2]; + var relativeEnd = end === undefined ? + len : end >> 0; + + // Step 11. + var final = relativeEnd < 0 ? + Math.max(len + relativeEnd, 0) : + Math.min(relativeEnd, len); + + // Step 12. + while (k < final) { + O[k] = value; + k++; + } + + // Step 13. + return O; + }; +} + +/** + * @desc Converts time difference into human readable format. + * Returns difference in %d %h %m %s + * + * Sample call: formatTimeDiff(183599000) returns '2d 2h 59m 59s' + * or: formatTimeDiff(new Date('2017-08-03T12:59:59'), new Date('2017-08-01T10:00:00')) return '2d 2h 59m 59s' + * + * @param {Integer|Date} Either the time difference in seconds as integer, + * or two Date() objects. + * @param {Date} Optional Date() object to compare with first param Date() + * @return {String} + */ +function formatTimeDiff() { + var delta, response = ''; + if(arguments.length == 1) { + // delta passed to convert + delta = arguments[0]; + } else if (arguments.length == 2) { + // get total seconds between the times + if ( arguments[1] > arguments[0] ) { + delta = Math.abs(arguments[1] - arguments[0]) / 1000; + } else { + delta = Math.abs(arguments[0] - arguments[1]) / 1000; + } + } else { + throw 'formatTime() accepts 1 or 2 arguments.'; + } + + // calculate (and subtract) whole days (workday=8h) + var workhoursInSeconds = parseFloat(getVar('workhours')) * 3600; + var days = Math.floor(delta / workhoursInSeconds); + delta -= days * workhoursInSeconds; + + // calculate (and subtract) whole hours + var hours = Math.floor(delta / 3600) % 24; + delta -= hours * 3600; + + // calculate (and subtract) whole minutes + var minutes = Math.floor(delta / 60) % 60; + delta -= minutes * 60; + + // what's left is seconds + var seconds = Math.floor(delta % 60); + + response += days > 0 ? days + 'd ' : ''; + response += hours > 0 ? hours + 'h ' : ''; + response += minutes > 0 ? minutes + 'm ' : ''; + response += seconds > 0 ? seconds + 's ' : ''; + + return response.trim(); +}; + +/** + * @desc Converts time difference or seconds passed into hours. + * + * Sample call: formatTimeDiff(5400) returns '1.5' (hours) + * or: formatTimeDiff(new Date('2017-08-01T08:30:00'), new Date('2017-08-01T10:00:00')) return '1.5' + * + * @param {Integer|Date} Either the time difference in seconds as integer, + * or 2 Date objects (from - to). + * @param {Date} Optional Date() object to compare with first param Date() + * @return {Number} + */ +function formatWorkhours() { + var delta, response = ''; + if(arguments.length == 1) { + // delta passed to convert + delta = arguments[0]; + } else if (arguments.length == 2) { + // get total seconds between the times + if ( arguments[1] > arguments[0] ) { + delta = Math.abs(arguments[1] - arguments[0]) / 1000; + } else { + delta = Math.abs(arguments[0] - arguments[1]) / 1000; + } + } else { + throw 'formatWorkhours() accepts 1 or 2 arguments.'; + } + + var hours = Math.round(delta / 3600 * 100) / 100; + + return hours; +} diff --git a/search.gs b/search.gs new file mode 100644 index 0000000..4923632 --- /dev/null +++ b/search.gs @@ -0,0 +1,242 @@ +/*function testSearch() { + var s = new Search('worklogDate>="2017-07-02" and worklogDate<="2017-07-11" and worklogAuthor="jrosemeier"'); + s.setOrderBy('updated', 'DESC') + .setFields(['id','key','issuetype','project','status','summary']); + + onSuccess = function(a,b,c) { + log('%s', '----------ON SUCCESS-----------'); + log('%s %s %s', JSON.stringify(a), b, c); + log('%s', '---------------------1'); + + log('AMOUNT: %s !', a.length); + }; + onFailure = function(a,b,c) { + log('%s', '----------ON FAILURE-----------'); + log('a:%s b:%s c:%s', a, b, c); + log('%s', '---------------------1'); + }; + + s.search() + .withSuccessHandler(onSuccess) + .withFailureHandler(onFailure) + ; +}*/ + + +/** + * @desc Class 'Search' API abstraction with pagination handling. + * Performs a JQL POST search request to JIRA Rest API. + * @param searchQuery {String} JQL Query statement + */ +function Search(searchQuery) { + var fields = ['key'], + startAt = 0, maxResults = 1000, maxPerPage = 500, + queryStr = searchQuery, orderBy = '', orderDir = 'ASC'; + var response = { + 'data': [], + 'status': -1, + 'errorMessage': '' + }; + + /** + * @desc Initialize anything necessary for the class object + * @return void + */ + this.init = function() {} + + /** + * @desc Set the list of jira issue fields to be returned in search response for each issue. + * @param aFields {Array} Array of jira issue fields + * @return {this} Allow chaining + */ + this.setFields = function(aFields) { + if(aFields.constructor == Array) { + fields = aFields; + } else { + throw '{aFields} is not an Array.'; + } + + return this; + } + + /** + * @desc Set result offset start for pagination of search results. + * @param iStartAt {Number} Number, default:0 + * @return {this} Allow chaining + */ + this.setStartAt = function(iStartAt) { + if(iStartAt.constructor == Number) { + startAt = iStartAt; + } else { + throw '{iStartAt} is not a Number.'; + } + + return this; + } + + /** + * @desc Set result offset limit for pagination of search results. + * @param iMaxResults {Number} Number, default:1000 + * @return {this} Allow chaining + */ + this.setMaxResults = function(iMaxResults) { + if(iMaxResults.constructor == Number) { + maxResults = iMaxResults; + } else { + throw '{iMaxResults} is not a Number.'; + } + + return this; + } + + /** + * @desc Set max results per page + * @param iMaxPerPage {Number} Integer of how many results max per page to fetch + * @return {this} Allow chaining + */ + this.setMaxPerPage = function(iMaxPerPage) { + if(iMaxPerPage.constructor == Number) { + maxPerPage = iMaxPerPage; + } else { + throw '{iMaxPerPage} is not a Number.'; + } + + return this; + } + + /** + * @desc Set Order of results (JQL order by clause) + * @param sOrderBy {String} Jira field to order by. Example: 'updated' + * @param sDir {String} Direction of order; 'ASC' or 'DESC' + * @return {this} Allow Chaining + */ + this.setOrderBy = function(sOrderBy, sDir) { + sOrderBy = sOrderBy || '', sDir = sDir || 'ASC'; + + if( sOrderBy != '' ) orderBy = sOrderBy; + if( sDir != '' ) orderDir = sDir; + + return this; + } + + /** + * @desc Callback Success handler + * @param fn {function} Method to call on successfull request (statue=200) + * @return {this} Allow chaining + */ + this.withSuccessHandler = function(fn) { + if(response.status === 200) { + fn.call(this, response.data, response.status, response.errorMessage); + } + return this; + }; + + /** + * @desc Callback Failure handler + * @param fn {function} Method to call on failed request (status!==200) + * @return {this} Allow chaining + */ + this.withFailureHandler = function(fn) { + if(response.status !== 200) { + log("withFailureHandler: %s", response); + fn.call(this, response.data, response.status, response.errorMessage); + } + return this; + }; + + /** + * @desc Prepare JQL search query + * @return {String} + */ + var getJql = function() { + var jql = queryStr + ' ORDER BY ' + orderBy + ' ' + orderDir; + log('Search JQL: [%s]', jql); + + //return encodeURIComponent(jql); //only when api call is performed as GET + return jql; + } + + /** + * @desc OnSuccess handler for search request + * @param resp {Object} JSON response object from Jira + * @param httpResp {Object} + * @param status {Number} + * @return void + */ + var onSuccess = function(resp, httpResp, status) { + var _total = parseInt(resp.total || 0); + + // nothing found - return class response + if( _total == 0 ) { + response = { + 'data' : resp.issues || resp, + 'status' : status, + 'errorMessage' : resp.hasOwnProperty('warningMessages') ? resp.warningMessages : 'No results found.' + }; + return; + } + + // add current results and status + response.data.push.apply(response.data, resp.issues || []); + response.status = status; + + // pagination / sub-requests required? + var _countTotalResults = parseInt(resp.startAt) + parseInt(resp.maxResults); + if( (_countTotalResults < _total) && (_countTotalResults < maxResults) ) { + // more data to fetch + + var subSearch = new Search( queryStr ); + subSearch.setOrderBy( orderBy, orderDir ) + .setFields( fields ) + .setMaxPerPage( maxPerPage ) + .setMaxResults( maxResults ) + .setStartAt( _countTotalResults ); + + subSearch.search().withSuccessHandler(function(data, status, msg) { + // append results to parent results + response.data.push.apply(response.data, data); + response.status = status; + }); // dont bubble up failure - 1st call was successfull so we soft-fail and response with results found so far + } + } + + /** + * @desc OnFailure handler for search request + * @param resp {Object} JSON response object from Jira + * @param httpResp {Object} + * @param status {Number} + * @return void + */ + var onFailure = function(resp, httpResp, status) { + Logger.log('search:onFailure: [%s] %s', status, resp); + + var msgs = resp.hasOwnProperty('errorMessages') ? resp.errorMessages : []; + msgs = msgs.concat((resp.hasOwnProperty('warningMessages') ? resp.warningMessages : [])); + + response.status = status; + response.errorMessage = msgs.join("\n"); + } + + /** + * @desc Perform Search + * @return {this} Allow chaining + */ + this.search = function() { + log("search with start:%s and maxResults:%s and field:[%s]", startAt, maxPerPage, fields); + var data = { + jql : getJql(), + fields : fields, + startAt : startAt, + maxResults : maxPerPage + }; + + var request = new Request(); + request.call('search', data, {'method' : 'post'}) + .withSuccessHandler(onSuccess) + .withFailureHandler(onFailure); + + return this; + } + + this.init(); +} diff --git a/storage.gs b/storage.gs index 33b4b27..ea5e88e 100644 --- a/storage.gs +++ b/storage.gs @@ -101,20 +101,46 @@ function initDefaults() { var build = getVar('BUILD') || 0; var isInitialized = getVar('defaults_initialized') || 'false'; if (isInitialized == 'true' && build == BUILD) return; - + setVar('BUILD', BUILD); - + // set default jira issue columns //var columnDefaults = getVar('jiraColumnDefault'); //columnDefaults = (columnDefaults != null) ? JSON.parse(columnDefaults) : jiraColumnDefault; columnDefaults = jiraColumnDefault; //@TODO: allow user to change default columns setVar('jiraColumnDefault', JSON.stringify(columnDefaults)); - + + setVar('workhours', 8); + // Jira onDemand or Server var server_type = getCfg('server_type'); if (server_type == null) server_type = 'onDemand'; setCfg('server_type', server_type); - + // set done setVar('defaults_initialized', 'true'); } + +/** + * @desc Save Jira server settings, provided in dialog form and perform + * a connection test to Jira api. + * @param jsonFormData {object} JSON Form object of all form values + * @return {object} Object({status: [boolean], response: [string]}) + */ +function saveSettings(jsonFormData) { + var url = trimChar(jsonFormData.jira_url, "/"); + setCfg('available', false); + setCfg('jira_url', url); + setCfg('jira_username', jsonFormData.jira_username); + setCfg('jira_password', jsonFormData.jira_password); + setVar('workhours', jsonFormData.ts_workhours); + + var test = testConnection(); + + if (url.indexOf('atlassian.net') == -1) { + setCfg('server_type', 'server'); + } + + return {status: test.status, message: test.response}; +} + diff --git a/userManager.gs b/userManager.gs new file mode 100644 index 0000000..7ae28b8 --- /dev/null +++ b/userManager.gs @@ -0,0 +1,136 @@ +/** + * @desc Returns a list of users that match the search string. + * This resource cannot be accessed anonymously. + * Sample: + * findUser('a%') + * findUser('ab%') + * findUser('abc%') + * findUser('z%') + * @param usernameTerm {string} A query string used to search username, name or e-mail address + * @param {boolean} minimal Def:FALSE; Returning data only includes minimal info (displayName,name[,active]) + * @return {Array} + */ +function findUser(usernameTerm, minimal) { + var method = 'userSearch', + usernameTerm = usernameTerm || '%', + minimal = minimal || false, + users = []; + + /** + * @desc OnSuccess handler + * @param resp {Object} JSON response object from Jira + * @param httpResp {Object} + * @param status {Number} + * @return Mixed + */ + var ok = function(resp, httpResp, status){ + if(resp) { + if(resp.length == 0) { + Browser.msgBox("No users were found to match your search.", Browser.Buttons.OK); + return users; + } + + var user; + for(var i=0; i Date.parse(wlDateTo) ) { + wlDateFrom = wlDateTo; + wlDateTo = _d; + } + wlQuery += 'worklogDate>="' + wlDateFrom.toISOString().substring(0, 10) +'" ' + + 'AND worklogDate<="' + wlDateTo.toISOString().substring(0, 10) + '"'; + + if(jsonFormData.wlAuthorG) { + wlQuery += ' AND worklogAuthor in membersOf("' + jsonFormData.wlAuthorG + '")'; + } else { + wlQuery += ' AND worklogAuthor="' + jsonFormData.wlAuthorU + '"'; + } + + var authorName = jsonFormData.wlAuthorName ? jsonFormData.wlAuthorName : ( + jsonFormData.wlAuthorG ? jsonFormData.wlAuthorG : jsonFormData.wlAuthorU + ); + + /* Get all affected jira issues */ + + // Search API returns max 20 worklogs per issue - we have to get worklog indiv. per issue later in iterated requests + var search = new Search(wlQuery); + search.setOrderBy('created', 'DESC') + .setFields(['id','key','issuetype','priority','status','summary']); + + /* OnSucess, start prepping Timesheet Table and perform subsequent api searches for all worklogs per individual jira issue */ + var onSuccess = function(data, status, errorMessage) { + log('Issue with worklogs founds: %s !', data.length); + //log('%s %s %s', JSON.stringify(data), status, errorMessage); + + if(data.length == 0) { + Browser.msgBox("Jira Worklog", + "Apparently there are no issues with worklogs available for \"" + authorName + "\" in the requested time period.", + Browser.Buttons.OK); + return; + } + + // prep new TimesheetTable then request actual worklogs + var timeSheetTable = new TimesheetTable1({ + periodFrom: wlDateFrom, + periodTo: wlDateTo + }); + timeSheetTable.setWorktimeFormat( (parseInt(jsonFormData.wlTimeFormat)==1 ? formatTimeDiff : formatWorkhours) ); + timeSheetTable.addHeader(authorName, 'Time Sheet'); + + // foreach jira issue, fetch worklogs and fill sheet row + (data || []).forEach(function(issue, index) { + log('============= (data || []).forEach() ================='); + log('issue= icon:%s; key:%s; summary:%s; priority:%s ', + unifyIssueAttrib('issuetype', issue), + unifyIssueAttrib('key', issue), + unifyIssueAttrib('summary', issue), + unifyIssueAttrib('priority', issue) + ); + + // perform worklog request + var request = new Request(); + request.call('worklogOfIssue',{issueIdOrKey: issue.id}) + .withFailureHandler(function(resp, httpResp, status) { + Logger.log("Failed to retrieve worklogs for issue with status [" + status + "]!\\n" + resp.errorMessages.join("\\n")); + }) + .withSuccessHandler(function(resp, httpResp, status) { + // we have all logs here for 1 jira issue + if(!resp) { return; } + + // get only the data we need and safe sme bytes + var worklogs = resp.worklogs.filter(function(wlog) { // get only logs for user we searched for + // remove some unused props + if(wlog.updateAuthor) wlog.updateAuthor = undefined; + if(wlog.author.avatarUrls) wlog.author.avatarUrls = undefined; + + //@TODO: make compatible with worklogs of groups ( memberOf("") ) + return wlog.author.name === jsonFormData.wlAuthorU; + })/*.forEach(function(wlog){ // pass to table, add row + timeSheetTable.addRow(issue, { + "started": wlog.started, + "authorName": wlog.author.name, + "timeSpentSeconds": wlog.timeSpentSeconds + }); + })*/ + ; + + timeSheetTable.addRow(issue, worklogs); + + }); // END: withSuccessHandler() + //END: request.call('worklogOfIssue') + });//END: (data || []).forEach() + + // add table footer + timeSheetTable.addFooter(); + }; //END: onSuccess() + + var onFailure = function(resp, status , errorMessage) { + log('worklog::onFailure: resp:%s status:%s msg:%s', resp, status, errorMessage); + Browser.msgBox("Jira Worklog", + "Failure during request to Jira server.\\nStatus:" + (status||-1) + " \\nMessage:'" + errorMessage + "'", + Browser.Buttons.OK); + }; + + search.search() + .withSuccessHandler(onSuccess) + .withFailureHandler(onFailure) + ; +} + + +/** + * @desc + * @param options {object} + * { + * sheet: , + * periodFrom: Date object of period starting date + * periodTo: Date object of period end date + * periodInterval: Interval to list period in columns ('day', 'week') + * periodFormat: Date format to use for period column headers + * } + * @return {TimesheetTable1} + */ +function TimesheetTable1(options) { + var sheet, initRange, currentRowIdx = 0, numIssueRows = 0, + dataRowFields = ['issuetype', 'key', 'summary', 'priority'], + numColumns = 0, + periodCfg = { + 'from' : null, + 'to' : null, + 'interval': 'day', + 'format' : "EEE\n d/MMM" + }, + periodTotals = {}, rowTimesTpl = {}, + worklogFormatFn = formatTimeDiff; + + /** + * @desc Initialization, validation + */ + this.init = function(options) { + sheet = options.sheet ? options.sheet : getTicketSheet(); + initRange = sheet.getActiveCell(); + currentRowIdx = initRange.getRow(), currentColIdx = initRange.getColumn(); + + if ( !options.periodFrom || !isDate(options.periodFrom)) { + throw '{periodFrom} in options has to be defined of type Date().'; + } + if ( !options.periodTo || !isDate(options.periodTo)) { + throw '{periodTo} in options has to be defined of type Date().'; + } + + periodCfg.from = options.periodFrom; + periodCfg.to = options.periodTo; + + if ( options.periodInterval && (options.periodInterval === 'day' || options.periodInterval === 'week')) { + periodCfg.interval = options.periodInterval; + } + periodCfg.format = (options.periodFormat === 'week') ? "'w'w ''yy" : periodCfg.format; + + // periods and their sub totals + var _dateIdx = periodCfg.from, _period; + while(_dateIdx <= periodCfg.to) { + periodTotals[Utilities.formatDate(_dateIdx, 'UTC', 'yyyy-MM-dd')] = 0; // init total seconds of work + // add 1 day or 1 week to date + _dateIdx = new Date(_dateIdx.setTime(_dateIdx.getTime() + (periodCfg.interval == 'week' ? 7 : 1) * 86400000)); + } + // set row times tpl + rowTimesTpl = JSON.parse(JSON.stringify(periodTotals)); + + // number of columns our table with consist of + numColumns = dataRowFields.length + Object.keys(periodTotals).length + 1; // count = data cols + periods + 1 row total + }; + + /** + * @desc Set function to be passed on every worklog time spent. For formatting of time. + * @param fn {Function} + * @return {this} Allow chaining + */ + this.setWorktimeFormat = function(fn) { + worklogFormatFn = fn || formatTimeDiff; + }; + + /** + * @desc Header of table (2 lines) + * @param author {String} Name of author we searched worklogs for + * @param title {String} Table title; default:'Time Sheet' + * @return {this} Allow chaining + */ + this.addHeader = function(author, title) { + title = title || 'Time Sheet'; + + var values = Array(numColumns-1).fill(''); // empty row of values + values.unshift(title); // set title to 1st cell + var formats = Array(numColumns).fill('bold'), + fontColors = Array(numColumns).fill('#000'), + bgColors = Array(numColumns).fill('#3399ff') + ; + + // header + range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length); + range.clearContent() + .clearNote() + .clearFormat() + .setBackgrounds([ bgColors ]) + .setFontColors([ fontColors ]) + .setValues([ values ]) + .setFontWeights([ formats ]); + + // 2. row - sub title + values = Array(dataRowFields.length-1).fill(''); + values.unshift('Summary for "' + author + '"'); + // attach period head lines + var _tz = SpreadsheetApp.getActive().getSpreadsheetTimeZone(); + + for(var key in periodTotals) { + values.push( Utilities.formatDate(new Date(key + 'T00:00:00.000+0000'), _tz, periodCfg.format) ); + } + values.push('Total'); + + bgColors = Array(numColumns).fill('#fff'); + + range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length); + range.clearContent() + .clearNote() + .clearFormat() + .setBackgrounds([ bgColors ]) + .setValues([ values ]) + .setFontWeights([ formats ]); + + // all period and total columns to be centered + sheet.getRange(currentRowIdx-1, currentColIdx+dataRowFields.length-1, 1, values.length-dataRowFields.length+1).setHorizontalAlignment("center"); + + // set cell widths + sheet.setColumnWidth(currentColIdx, 30); + sheet.setColumnWidth(currentColIdx+1, 80); + sheet.setColumnWidth(currentColIdx+2, 240); + sheet.setColumnWidth(currentColIdx+3, 30); + + SpreadsheetApp.flush(); + + return this; + }; + + /** + * @desc Add Table footer + * @return {this} Allow chaining + */ + this.addFooter = function() { + var values = Array(dataRowFields.length-1).fill(''); + values.unshift('Total (' + numIssueRows + ' issues):'); + var formats = Array(numColumns).fill('bold'), + fontColors = Array(numColumns).fill('#000'), + bgColors = Array(numColumns).fill('#3399ff') + ; + log('periodTotals: [%s]', periodTotals); + + // set totals on each period column + overall total column + var _totalTimeSpent = 0; + for (var key in periodTotals) { + _totalTimeSpent += periodTotals[key]; + values.push( worklogFormatFn(periodTotals[key]) ); + } + values.push(worklogFormatFn(_totalTimeSpent)); + + // footer + range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length); + range.clearContent() + .clearNote() + .clearFormat() + .setBackgrounds([ bgColors ]) + .setFontColors([ fontColors ]) + .setValues([ values ]) + .setFontWeights([ formats ]); + + // all period and total columns to be centered + sheet.getRange(currentRowIdx-1, currentColIdx+dataRowFields.length-1, 1, values.length-dataRowFields.length+1).setHorizontalAlignment("center"); + sheet.getRange(currentRowIdx-1, values.length).setHorizontalAlignment("right"); + + SpreadsheetApp.flush(); + + // set width of period columns + for(var c=(dataRowFields.length+1); c <= values.length; c++) { + sheet.setColumnWidth(c, 70); + } + + return this; + } + + /** + * @desc Add individual timesheet/worklog row to table + * @param issue {Object} JSON response object of an jira issue + * @param worklogs {ArrayOfObjects} Array of JSON objects from Jira worklog search response + * @return {this} Allow chaining + */ + this.addRow = function(issue, worklogs) { + var rowTimes = JSON.parse(JSON.stringify(rowTimesTpl)), // bad, but we want a clone and not a reference + rowTotal = 0, + values = [], + formats = Array(numColumns).fill('@'), + fontColors = Array(numColumns).fill('#000'), + bgColors = Array(numColumns).fill( (currentRowIdx % 2) ? '#fff' : '#e6ffe6') + ; + + // add timespent to issues period totals + worklogs.forEach(function(worklog) { + var _period = Utilities.formatDate(new Date(worklog.started), 'UTC', 'yyyy-MM-dd'); + if( rowTimes[_period] !== undefined ) { + rowTimes[_period] += parseInt(worklog.timeSpentSeconds); + } + }); + + // add jira issue data to first columns with some custom cell formatters + dataRowFields.forEach(function(field) { + var _val = unifyIssueAttrib(field, issue); + switch (field) { + case 'issuetype': + case 'priority': + _val = '=IMAGE("' + _val.iconUrl + '"; 4; 16; 16)'; + break; + case 'key': + _val = '=HYPERLINK("' + _val.link + '";"' + _val.value + '")'; + break; + case 'summary': + default: + _val = _val.value; + break; + } + + values.push(_val); + }); + + // add timespent to overall period totals + for(var key in rowTimes) { + rowTotal += rowTimes[key]; + values.push( worklogFormatFn(rowTimes[key]) ); + if(periodTotals[key] !== undefined) { + periodTotals[key] += parseInt(rowTimes[key]); + } + } + + values.push( worklogFormatFn(rowTotal) ); // row total + formats[formats.length - 1] = 'bold'; + + range = sheet.getRange(currentRowIdx++, currentColIdx, 1, values.length); + range.clearContent() + .clearNote() + .clearFormat() + .setBackgrounds([ bgColors ]) + .setValues([ values ]) + .setFontWeights([ formats ]) + .activate(); + + // 1st col IssueTypeIcon align center + sheet.getRange(currentRowIdx-1, 1, 1, 1).setHorizontalAlignment("center"); + // all period and total columns to be centered + sheet.getRange(currentRowIdx-1, currentColIdx+dataRowFields.length-1, 1, values.length-dataRowFields.length).setHorizontalAlignment("center"); + sheet.getRange(currentRowIdx-1, values.length, 1, values.length).setHorizontalAlignment("right"); + + ++numIssueRows; + + SpreadsheetApp.flush(); + + return this; + }; + + + this.init(options); +}