├── .gitignore ├── LICENSE ├── commit-msg ├── README.md └── sprintly /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_store 2 | MANIFEST 3 | dist 4 | .project 5 | .pydevproject 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013 Next Big Sound, Inc. and other contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | import re 5 | import os 6 | import subprocess 7 | 8 | 9 | def process(commit_msg_path): 10 | """ 11 | Process the commit message. If this method returns, 12 | it is assumed the message has been validated. An 13 | Exception is the best way to exit in case of error. 14 | """ 15 | 16 | # read in commit message 17 | commit_msg_file = open(commit_msg_path, 'r') 18 | commit_msg = commit_msg_file.read() 19 | commit_msg_file.close() 20 | 21 | # check to see if message is properly formatted 22 | valid = validate_message(commit_msg) 23 | if valid and len(valid) == 2: 24 | new_commit_msg = valid[1] 25 | else: 26 | 27 | # present sprint.ly items to user 28 | display_sprintly_items() 29 | 30 | # prompt user for item(s) 31 | items = get_sprintly_items() 32 | 33 | # check if they opted out 34 | if '0' in items: 35 | print 'Proceeding without Sprint.ly item number.' 36 | return 37 | 38 | # convert items into string 39 | items_string = ' '.join(map(lambda x: 'Re #' + str(x) + '.', items)) 40 | 41 | # create new commit message 42 | new_commit_msg = items_string + ' ' + commit_msg 43 | 44 | # save it (overwrite existing) 45 | commit_msg_file = open(commit_msg_path, 'w') 46 | commit_msg = commit_msg_file.write(new_commit_msg) 47 | commit_msg_file.close() 48 | 49 | 50 | def validate_message(message): 51 | """ 52 | If the message contains (at any position) a sprint.ly 53 | keyword followed by a space, pound, number, then accept 54 | the message as is and return (True, message). 55 | 56 | If the message begins with a pound, number, prepend 57 | message with 'References ' and return (True, modified message). 58 | 59 | Otherwise, return false. 60 | """ 61 | 62 | messageLower = message.lower() 63 | valid_keywords = ['close', 'closes', 'closed', 'fix', 'fixed', 'fixes', 'addresses', 're', 'ref', 'refs', 'references', 'see', 'breaks', 'unfixes', 'reopen', 'reopens', 're-open', 're-opens'] 64 | try: 65 | # match pound-number-(space or period) 66 | result = re.match(r'^(#[0-9]+[\.\s$]).*$', message) 67 | if result: 68 | return (True, 'References %s' % message) 69 | 70 | # match any keyword followed by a pound-number-(space or period) 71 | pattern = r'.*\b(' + '|'.join(valid_keywords) + r')\b\s(#[0-9]+([\.\s]|$)).*' 72 | result = re.match(pattern, messageLower) 73 | if result: 74 | return (True, message) 75 | 76 | except Exception as e: 77 | pass 78 | return False 79 | 80 | 81 | def display_sprintly_items(): 82 | """ 83 | Use the sprintly command line tool to display a list of sprintly 84 | items. 85 | """ 86 | 87 | try: 88 | subprocess.call(['sprintly'], stdin=open('/dev/tty', 'r')) 89 | except: 90 | print 'Command-line tool \'sprintly\' not found. Please ensure it is installed and on your path.' 91 | 92 | print '#0 - Proceed without Sprint.ly item number.' 93 | 94 | 95 | def get_sprintly_items(): 96 | """ 97 | Ask the user until they give a list of one or more 98 | integers delimited by space. Only non-negative 99 | integers are allowed. It is acceptable for integers 100 | to be preceded by a # symbol. 101 | """ 102 | 103 | # enable user input 104 | sys.stdin = open('/dev/tty', 'r') 105 | 106 | while True: 107 | sprintly_items = raw_input('Enter 1 or more item numbers separated by a space: ').split(' ') 108 | result = map(lambda x: parse_item_number(x), sprintly_items) 109 | if not None in result: 110 | return result 111 | 112 | 113 | def parse_item_number(s): 114 | """ 115 | Returns the item number from strings of format: '12', '#12' 116 | """ 117 | result = re.match('^#?([0-9]+)$', s) 118 | if result: 119 | return result.group(1) 120 | else: 121 | return None 122 | 123 | 124 | if __name__ == '__main__': 125 | try: 126 | if len(sys.argv) > 1: 127 | process(commit_msg_path=sys.argv[1]) 128 | else: 129 | # Should never happen, but just in case... 130 | raise Exception('Commit message was not received.') 131 | except KeyboardInterrupt: 132 | print '\n\nProgram interrupted. Commit aborted.' 133 | sys.exit(1) 134 | except Exception as e: 135 | print '\n\nError occurred. Commit aborted.' 136 | sys.exit(1) 137 | 138 | # Execute the original commit hook. Note: We don't want realpath because 139 | # sprintly links /usr/local/share/sprintly/commit-msg to 140 | # .git/hooks/commit-msg and we want to be in the hooks directory. 141 | original_commit_msg = os.path.dirname(__file__) + '/commit-msg.original' 142 | if os.path.exists(original_commit_msg): 143 | sys.exit(subprocess.call([original_commit_msg, sys.argv[1]])) 144 | else: 145 | sys.exit(0) 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Sprintly-GitHub 2 | 3 | [Sprint.ly](http://sprint.ly/ 'Sprint.ly') is a great tool for managing work items; [GitHub](http://github.com 'GitHub') is a great tool for managing your code. The tools in this repository will help you take advantage of the integration with GitHub that Sprint.ly offers without drawing your attention away from the terminal. Use the `sprintly` command line tool to get a list of all items assigned to you. Install the `commit-msg` hook to facilitate Sprint.ly's GitHub integration. 4 | 5 | #How to use `sprintly` 6 | 7 | ```shell 8 | Usage: sprintly [options] 9 | 10 | By default, your sprintly items will be shown. 11 | 12 | Options: 13 | -h, --help show this help message and exit 14 | --install install this tool 15 | --update update this tool 16 | --install-hook install commit-msg hook in current directory (must be a 17 | git repository) 18 | --uninstall-hook uninstall commit-msg hook in current directory (must be a 19 | git repository) 20 | --update-config edit configuration 21 | ``` 22 | 23 | #Installing `sprintly` 24 | 25 | The `sprintly` tool can now install itself. Follow the instructions below to get started: 26 | 27 | Install with Curl 28 | 29 | ```shell 30 | # download the latest version of the tool 31 | curl -L -O https://raw.githubusercontent.com/sprintly/Sprintly-GitHub/master/sprintly 32 | 33 | # install 34 | sudo python sprintly --install 35 | 36 | # clean up 37 | rm sprintly 38 | ``` 39 | 40 | Install with Git 41 | 42 | ```shell 43 | # download the latest version of the tool 44 | git clone https://github.com/sprintly/Sprintly-GitHub.git 45 | 46 | # change to the repo directory 47 | cd Sprintly-GitHub 48 | 49 | # install 50 | sudo python sprintly --install 51 | 52 | # clean up 53 | cd ../ 54 | sudo rm -R Sprintly-Github 55 | ``` 56 | 57 | Once the installation is complete, run `sprintly` from any directory to get started. If you have never used the tool before, it will walk you through adding your Sprint.ly credentials: 58 | 59 | ```shell 60 | $ sprintly 61 | Creating config... 62 | Enter sprint.ly username (email): user@company.com 63 | Enter sprint.ly API Key: 3536bae19bacd16831fb5100b13e34d2 64 | Enter default sprint.ly product id (117 - Company, 129 - Secret): 117 65 | Configuration successfully created. 66 | ``` 67 | 68 | *Note: (1) details on how to find your Sprint.ly API Key can be found [here](http://help.sprint.ly/knowledgebase/articles/85725-where-do-i-find-my-api-key). (2) you will only be asked to enter a sprint.ly product id if you have more than one product.* 69 | 70 | To update `sprintly`, type `sudo sprintly --update`. When updating, you will see a warning: 71 | 72 | ```shell 73 | $ sudo sprintly --update 74 | Downloading latest version of sprintly tool from GitHub... 75 | A file already exists at /usr/local/bin/sprintly. Overwrite file? 76 | ``` 77 | 78 | Entering `y` will overwrite the old installation with the latest version. *Note: if you have another tool installed at `/usr/local/bin/sprintly`, enter `n` and manually install it under a different name.* 79 | 80 | #GitHub Integration: Installing the `commit-msg` hook. 81 | 82 | The `sprintly` tool can install the hook for you. Navigate to a git repository and run: 83 | 84 | ```shell 85 | $ sprintly --install-hook 86 | Creating symlink... 87 | Hook was installed at /.git/hooks/commit-msg 88 | ``` 89 | 90 | **Important: you MUST install this manually in every git repository. This is a limitation of the way git implements hooks. Don't blame me!** 91 | 92 | Tip: make sure that your git user.email matches your Sprint.ly username, or the hook won't work. If this happens, you will see the following message: 93 | 94 | ```shell 95 | $ sprintly --install-hook 96 | Creating symlink... 97 | Hook was installed at /.git/hooks/commit-msg 98 | WARNING: Your git email (user@site.org) does not match your sprint.ly username (user@company.com) 99 | WARNING: Don't worry - there is an easy fix. Simply run one of the following: 100 | 'git config --global user.email user@company.com' (all repos) 101 | 'git config user.email user@company.com' (this repo only) 102 | ``` 103 | 104 | *Note: the hook installed is actually a symbolic link to a shared copy of the hook found at /usr/local/share/sprintly/commit-msg. By doing this, the hook can be easily updated for all users and all repositories by calling `sprintly --update`.* 105 | 106 | ###Uninstalling the `commit-msg` hook. 107 | 108 | The `sprintly` tool can uninstall the hook for you as well. Navigate to the git repository in question and run: 109 | 110 | ```shell 111 | $ sprintly --uninstall-hook 112 | Hook has been uninstalled. 113 | ``` 114 | 115 | #Changing the Configuration 116 | 117 | If, at some point, you wish to change your configuration (you get a new username, API key, or wish to change the default product), type `sprintly --update-config`. Pro tip: you don't have to re-type everything just to change your API Key. Simply press enter to keep the old version of any individual item: 118 | 119 | ```shell 120 | $ sprintly --update-config 121 | Updating config... Press enter to accept default value shown in brackets. 122 | Enter sprint.ly username (email) [user@company.com]: 123 | Enter sprint.ly API Key [3536bae19bacd16831fb5100b13e34d2]: 5c11931f26b4c5f6a435983d1a734839 124 | Enter default sprint.ly product id (117 - Company, 129 - Secret): 125 | Configuration successfully updated. 126 | ``` 127 | 128 | #Sample Output 129 | 130 | Let's run through a few examples of how to take advantage of this tool. 131 | 132 | Type `sprintly` at a command prompt to see a list of your current Sprint.ly items: 133 | 134 | ```shell 135 | Product: Example Company (https://sprint.ly/product/#/) 136 | #1: As a developer, I want to open source our Sprintly-Github tools so that... 137 | #5: Publish it. 138 | #4: Write README 139 | #2: As a developer, I want a better set of unit tests so that changes to our... 140 | #3: Add tests to widget creation page 141 | ``` 142 | 143 | With the `commit-msg` hook installed, whenever a commit is made and pushed, a comment will be automatically published on the corresponding Sprint.ly item. Head to Sprint.ly for more [details](https://sprintly.uservoice.com/knowledgebase/articles/108139-available-scm-vcs-commands 'Sprint.ly SCM/VCS Commands'). 144 | 145 | ```shell 146 | $ git commit -m "Normal commit message here." 147 | Product: Example Company (https://sprint.ly/product/#/) 148 | #1: As a developer, I want to open source our Sprintly-Github tools so that... 149 | #5: Publish it. 150 | #4: Write README 151 | #2: As a developer, I want a better set of unit tests so that changes to our... 152 | #3: Add tests to widget creation page 153 | #0 - Proceed without Sprint.ly item number. 154 | Enter 1 or more item numbers separated by a space: 4 155 | [master 1e71283] References #4. Normal commit message here. 156 | 1 files changed, 1 insertions(+), 1 deletions(-) 157 | ``` 158 | 159 | To save time, include an item number at the beginning of your commit to automatically reference that item. You won't be prompted to select an item number if you go this route: 160 | 161 | ```shell 162 | $ git commit -m "#42 Adding README" 163 | [master 555a912] References #42 Adding README 164 | 0 files changed, 0 insertions(+), 0 deletions(-) 165 | create mode 100644 README 166 | ``` 167 | 168 | Or include Sprint.ly keywords followed by an item number anywhere in your message. Again, you won't be prompted to select an item number if you go this route: 169 | 170 | ```shell 171 | $ git commit -m "Adding some samples. Closes #42. Refs #54." 172 | [master bfb7a8b] Adding some samples. Closes #42. Refs #54. 173 | 0 files changed, 0 insertions(+), 0 deletions(-) 174 | create mode 100644 sample 175 | ``` -------------------------------------------------------------------------------- /sprintly: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | 4 | import sys 5 | import os 6 | import locale 7 | import urllib2 8 | import json 9 | import shutil 10 | import subprocess 11 | import re 12 | import string 13 | import logging 14 | from curses import setupterm, tigetstr, tigetnum, tparm 15 | from time import time 16 | from optparse import OptionParser 17 | 18 | # force utf-8 encoding 19 | reload(sys) 20 | sys.setdefaultencoding('utf-8') 21 | 22 | logging.basicConfig() 23 | logger = logging.getLogger(__name__) 24 | 25 | # constants 26 | CONFIG_VERSION = '2.1' 27 | SPRINTLY_NAME = 'sprintly' 28 | SPRINTLY_DIR = '/usr/local/bin/' 29 | HOOK_NAME = 'commit-msg' 30 | HOOK_DIR = '/usr/local/share/sprintly/' 31 | SPRINTLY_SOURCE_URL = 'https://raw.githubusercontent.com/sprintly/Sprintly-GitHub/master/sprintly' 32 | COMMIT_MSG_SOURCE_URL = 'https://raw.githubusercontent.com/sprintly/Sprintly-GitHub/master/commit-msg' 33 | 34 | # non-editable constants 35 | SPRINTLY_PATH = SPRINTLY_DIR + SPRINTLY_NAME 36 | HOOK_PATH = HOOK_DIR + HOOK_NAME 37 | 38 | # tty colors 39 | DEFAULT = '\x1b[39m' 40 | BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, LIGHT_GREY = [('\x1b[%dm' % (30 + i)) for i in range(8)] 41 | GREY, BRIGHT_RED, BRIGHT_GREEN, BRIGHT_YELLOW, BRIGHT_BLUE, BRIGHT_MAGENTA, BRIGHT_CYAN, WHITE = [('\x1b[%dm' % (90 + i)) for i in range(8)] 42 | RESET, NORMAL, BOLD, DIM, UNDERLINE, INVERT, HIDDEN = [('\x1b[%dm' % i) for i in (0, 22, 1, 2, 4, 7, 8)] 43 | ATTRS = { 44 | 'DEFAULT': DEFAULT, 45 | 'BLACK': BLACK, 'RED': RED, 'GREEN': GREEN, 'YELLOW': YELLOW, 'BLUE': BLUE, 'MAGENTA': MAGENTA, 'CYAN': CYAN, 'LIGHT_GREY': LIGHT_GREY, 46 | 'GREY': GREY, 'BRIGHT_RED': BRIGHT_RED, 'BRIGHT_GREEN': BRIGHT_GREEN, 'BRIGHT_YELLOW': BRIGHT_YELLOW, 'BRIGHT_BLUE': BRIGHT_BLUE, 'BRIGHT_MAGENTA': BRIGHT_MAGENTA, 'BRIGHT_CYAN': BRIGHT_CYAN, 'WHITE': WHITE, 47 | 'RESET': RESET, 'NORMAL': NORMAL, 'BOLD': BOLD, 'DIM': DIM, 'UNDERLINE': UNDERLINE, 'INVERT': INVERT, 'HIDDEN': HIDDEN 48 | } 49 | 50 | ITEM_COLORS = { 51 | 'story': 'GREEN', 52 | 'task': 'GREY', 53 | 'defect': 'RED', 54 | 'test': 'CYAN' 55 | } 56 | 57 | ITEM_STATUSES = { 58 | 'someday': "Someday", 59 | 'backlog': "Backlog", 60 | 'in-progress': "In Progress", 61 | 'completed': "Completed", 62 | 'accepted': "Accepted", 63 | } 64 | 65 | class SprintlyTool: 66 | """ 67 | A command line tool for displaying your stories, tasks, tests, and defects 68 | from Sprint.ly. 69 | """ 70 | 71 | def __init__(self, term_stream=sys.stdout): 72 | """ 73 | Initialize instance variables. 74 | """ 75 | 76 | # Set up terminal 77 | locale.setlocale(locale.LC_ALL, '') 78 | self._encoding = locale.getpreferredencoding() 79 | 80 | self._term = term_stream or sys.__stdout__ 81 | self._is_tty = False 82 | self._has_color = False 83 | self._cols = 80 84 | 85 | if hasattr(term_stream, "isatty") and term_stream.isatty(): 86 | try: 87 | setupterm() 88 | self._is_tty = True 89 | self._has_color = tigetnum('colors') > 2 90 | self._cols = tigetnum('cols') 91 | except: 92 | pass 93 | else: 94 | try: 95 | (_, columns) = os.popen('stty size', 'r').read().split() 96 | self._cols = int(columns) 97 | except: 98 | pass 99 | 100 | self._config = {} 101 | self._sprintlyDirectoryPath = None 102 | self._sprintlyConfigPath = None 103 | self._sprintlyCachePath = None 104 | 105 | def run(self, scr=None): 106 | """ 107 | Application flow. 108 | """ 109 | 110 | try: 111 | usage = 'Usage: %prog [options]\n\nBy default, your Sprint.ly items will be shown.\n\nWhen using the commit-msg hook, you will be prompted for a Sprint.ly item number unless you include a Sprint.ly keyword/item number in your commit message:\n\n \'Commit message goes here. References #54. Closes #65.\'\n\nValid Sprint.ly keywords are:\n\n close, closes, closed, fix, fixed, fixes, addresses, re, ref, refs, references, see, breaks, unfixes, reopen, reopens, re-open, re-opens\n\nAs a shortcut, you may also include an item number as the first word in your commit message:\n\n \'#26 Message goes here\'\n\nThe hook will automatically prepend the keyword \'references\' and you won\'t be asked to provide an item number. This shortcut only works at the beginning of the message and does not support multiple item numbers.' 112 | parser = OptionParser(usage=usage) 113 | parser.add_option('--install', dest='install', help='install this tool', action='store_true', default=False) 114 | parser.add_option('--update', dest='update', help='update this tool', action='store_true', default=False) 115 | parser.add_option('--install-hook', dest='installHook', help='install commit-msg hook in current directory (must be a git repository)', action='store_true', default=False) 116 | parser.add_option('--uninstall-hook', dest='uninstallHook', help='uninstall commit-msg hook in current directory (must be a git repository)', action='store_true', default=False) 117 | parser.add_option('--update-config', dest='updateConfig', help='edit configuration', action='store_true', default=False) 118 | 119 | (options, _) = parser.parse_args() 120 | 121 | # if the user wants to install, do it before we initialize (they will re-run once installed) 122 | if options.install or options.update: 123 | try: 124 | self.install(options.update) 125 | except SprintlyException as se: 126 | type = 'install' 127 | if options.update: 128 | type = 'update' 129 | self.cprint('Unable to %s. Try running again with sudo.' % (type), attr=RED) 130 | return 131 | 132 | # ensure that the ~/.sprintly/ folder exists, we have credentials, etc 133 | self.initialize() 134 | 135 | # run the requested option (note: install/update are handled above) 136 | if options.installHook: 137 | self.installHook() 138 | elif options.uninstallHook: 139 | self.uninstallHook() 140 | elif options.updateConfig: 141 | self.updateConfig() 142 | else: 143 | self.listSprintlyItems() 144 | 145 | except Exception as e: 146 | die('Fatal Error: %s', e) 147 | 148 | def install(self, update): 149 | """ 150 | Install this tool at SPRINTLY_PATH. If another file already 151 | exists with the same name, user will be prompted to replace the file. 152 | """ 153 | 154 | print 'Downloading latest version of sprintly from GitHub...' 155 | 156 | # get the file 157 | try: 158 | response = urllib2.urlopen(SPRINTLY_SOURCE_URL) 159 | sprintly_file_contents = response.read() 160 | except Exception: 161 | raise SprintlyException('Unable to obtain commit-msg from %s' % SPRINTLY_SOURCE_URL) 162 | 163 | # verify nothing exists at the target path 164 | target = SPRINTLY_PATH 165 | if os.path.isfile(target): 166 | overwrite = raw_input(self.render('${BRIGHT_YELLOW}A file already exists at %s.${RESET} Overwrite file? ' % target, trim=False)) 167 | while overwrite != 'y' and overwrite != 'n': 168 | overwrite = raw_input('Please enter y/n: ') 169 | 170 | if overwrite == 'n': 171 | self.cprint('Unable to install. Please install manually.', attr=RED) 172 | return 173 | 174 | # remove existing file 175 | print 'Deleting %s...' % target 176 | try: 177 | os.unlink(target) 178 | except Exception: 179 | raise SprintlyException('Unable to remove %s' % target) 180 | 181 | # copy file to target 182 | try: 183 | if not os.path.isdir(SPRINTLY_DIR): 184 | os.makedirs(SPRINTLY_DIR) 185 | target_file = open(target, 'w') 186 | target_file.write(sprintly_file_contents) 187 | target_file.close() 188 | except Exception: 189 | raise SprintlyException('Unable to save file to %s' % target) 190 | 191 | # ensure it is executable 192 | try: 193 | subprocess.call(['chmod', '+x', target]) 194 | except Exception: 195 | raise SprintlyException('Unable to make %s executable.' % target) 196 | 197 | # done! 198 | self.cprint('Successfully installed sprintly to %s' % target, attr=GREEN) 199 | 200 | # except update the hook too 201 | self.updateHook() 202 | 203 | # if this is not an update, install 204 | if not update: 205 | print '' 206 | self.cprint('That\'s all! Type \'sprintly\' and hit enter to get started.', attr=BRIGHT_MAGENTA) 207 | print '' 208 | 209 | def initialize(self): 210 | """ 211 | Ultimate goal is to get the user and key from the config file. 212 | If the config file cannot be found, a config file will be 213 | created via prompts displayed to the user. A cache file will 214 | also be created during this step. 215 | """ 216 | 217 | # get the users home directory 218 | home = os.path.expanduser('~') 219 | if home == '~': 220 | raise SprintlyException('Unable to expand home directory.') 221 | 222 | # set the sprintly directory path (create if it doesn't exist) 223 | self._sprintlyDirectoryPath = os.path.join(home, '.sprintly') 224 | if not os.path.isdir(self._sprintlyDirectoryPath): 225 | os.mkdir(self._sprintlyDirectoryPath, 0700) 226 | if not os.path.isdir(self._sprintlyDirectoryPath): 227 | raise SprintlyException('Unable to create folder at %s' % self._sprintlyDirectoryPath) 228 | 229 | # set the sprintly config path (create if it doesn't exist) 230 | self._sprintlyConfigPath = os.path.join(self._sprintlyDirectoryPath, 'sprintly.config') 231 | if not os.path.isfile(self._sprintlyConfigPath): 232 | self.createSprintlyConfig() 233 | 234 | # set the sprintly cache path (create if it doesn't exist) 235 | self._sprintlyCachePath = os.path.join(self._sprintlyDirectoryPath, 'sprintly.cache') 236 | if not os.path.isfile(self._sprintlyCachePath): 237 | try: 238 | # "touch" cache file 239 | open(self._sprintlyCachePath, 'w').close() 240 | except Exception: 241 | raise SprintlyException('Unable to create file at %s' % self._sprintlyCachePath) 242 | 243 | # load config values 244 | self.loadFromConfig() 245 | 246 | def createSprintlyConfig(self, update=False): 247 | """ 248 | Create the Sprint.ly config. Prompt user for all necessary values. 249 | 250 | When 'update' is set to True and an existing value is present for 251 | a given configuration item, allow user to keep old value. 252 | 253 | Note: if update is True, this must be called after initialize. Failure 254 | to do so wil result in a new config being created, as the values in 255 | self._config will not yet be set. 256 | """ 257 | 258 | if not update: 259 | print 'Creating config...' 260 | else: 261 | print 'Updating config... Press enter to accept default value shown in brackets.' 262 | 263 | # set version 264 | self._config['version'] = CONFIG_VERSION 265 | 266 | # used to simplify prompting user with optional default 267 | def getConfigItem(message, default=None): 268 | if default: 269 | item = raw_input(self.render('%s [${YELLOW}%s${RESET}]: ' % (message, default), trim=False)) or default 270 | else: 271 | item = raw_input('%s: ' % message) 272 | return item 273 | 274 | # prompt for user 275 | name = 'user' 276 | message = 'Enter Sprint.ly username (email)' 277 | if update and name in self._config: 278 | self._config[name] = getConfigItem(message, self._config[name]) 279 | else: 280 | self._config[name] = getConfigItem(message) 281 | 282 | # prompt for key 283 | name = 'key' 284 | message = 'Enter Sprint.ly API Key' 285 | if update and name in self._config: 286 | self._config[name] = getConfigItem(message, self._config[name]) 287 | else: 288 | self._config[name] = getConfigItem(message) 289 | 290 | # try and use API with these values to determine validity 291 | response = self.sprintlyAPICall('user/whoami.json') 292 | if not response or 'code' in response: 293 | raise SprintlyException('Invalid credentials. Unable to authenticate with Sprint.ly.') 294 | if response['email'] != self._config['user']: 295 | raise SprintlyException('Invalid credentials. Please ensure you are using your own API Key.') 296 | 297 | # add user id to config 298 | self._config['id'] = response['id'] 299 | 300 | # get a list of products and prompt user for default product if more than 1 301 | products = self.sprintlyAPICall('products.json') 302 | if not products: 303 | raise SprintlyException('Unable to get product list.') 304 | productMap = {} 305 | 306 | for product in products: 307 | productId = str(product['id']) 308 | productMap[productId] = product 309 | 310 | productCount = len(productMap) 311 | 312 | if productCount == 0: 313 | raise SprintlyException('It appears that you have no products associated with your Sprint.ly account. Please add at least one and then try again.') 314 | elif productCount == 1: 315 | self._config['product'] = productMap.values()[0] 316 | else: 317 | # prompt user for a product until they enter one found in the map 318 | productList = ', '.join(['%d - %s' % (p['id'], p['name']) for p in productMap.values()]) 319 | defaultProductId = '0' 320 | while defaultProductId not in productMap.keys(): 321 | message = 'Enter default Sprint.ly product id (%s)' % productList 322 | if update and 'product' in self._config: 323 | defaultProductId = getConfigItem(message, str(self._config['product']['id'])) 324 | else: 325 | defaultProductId = getConfigItem(message) 326 | self._config['product'] = productMap[defaultProductId] 327 | 328 | # write config file if all is good 329 | serialized_config = json.dumps(self._config) 330 | 331 | try: 332 | config_file = open(self._sprintlyConfigPath, 'w') 333 | config_file.write(serialized_config) 334 | config_file.close() 335 | if not update: 336 | self.cprint('Configuration successfully created.', attr=GREEN) 337 | else: 338 | self.cprint('Configuration successfully updated.', attr=GREEN) 339 | except: 340 | raise SprintlyException('Unable to write configuration to disk at %s' % self._sprintlyConfigPath) 341 | 342 | def loadFromConfig(self): 343 | """ 344 | Load user and key from the config file. Validate here that the version 345 | of this config is readable by this version of the tool. 346 | """ 347 | 348 | try: 349 | config_file = open(self._sprintlyConfigPath, 'r') 350 | serialized_config = config_file.readline() 351 | config_file.close() 352 | self._config = json.loads(serialized_config) 353 | except: 354 | raise SprintlyException('Unable to read credentials from disk at %s' % self._sprintlyConfigPath) 355 | 356 | # validate version 357 | if 'version' not in self._config or self._config['version'] != CONFIG_VERSION: 358 | self.cprint('Your configuration needs to be updated. You will now be prompted to update it.', attr=YELLOW) 359 | self.updateConfig() 360 | 361 | def updateConfig(self): 362 | """ 363 | Prompt user to update configuration settings. 364 | Defaults will be original config values if present. 365 | """ 366 | self.createSprintlyConfig(True) 367 | 368 | def listSprintlyItems(self): 369 | """ 370 | Lists all items for the current user from the Sprint.ly API. 371 | """ 372 | 373 | # populate the cache from the API if possible (may not be possible, 374 | # e.g. in the case of offline access) 375 | self.populateCache() 376 | products = self.readCache() 377 | self.printList(products) 378 | 379 | def printList(self, products): 380 | """ 381 | Print a list of Sprint.ly items. 382 | """ 383 | 384 | statusTree = { 385 | 'someday': {}, 386 | 'backlog': {}, 387 | 'in-progress': {}, 388 | 'completed': {}, 389 | 'accepted': {}, 390 | } 391 | 392 | for product in products: 393 | for item in product['items']: 394 | if not product['id'] in statusTree[item['status']]: 395 | statusTree[item['status']][product['id']] = [] 396 | 397 | statusTree[item['status']][product['id']].append(item) 398 | 399 | for key, status in iter(sorted(statusTree.items())): 400 | if not len(status): 401 | continue 402 | 403 | self.cprint(ITEM_STATUSES[key], attr=[BRIGHT_MAGENTA, UNDERLINE]) 404 | 405 | for product_id in status: 406 | items = status[product_id] 407 | name = items[0]['product']['name'] 408 | productId = str(items[0]['product']['id']) 409 | printProduct = '${DEFAULT}Product: ${BOLD}${BRIGHT_BLUE}' + name + '${NORMAL}${GREY} (https://sprint.ly/product/' + productId + '/)' 410 | self.cprint(printProduct) 411 | 412 | title_color = 'DEFAULT' 413 | for item in items: 414 | attr = DIM if item['status'] in ('completed', 'accepted') else None 415 | color = ITEM_COLORS.get(item['type']) 416 | 417 | printItem = '${%s} #%d${DEFAULT}:${%s} %s' % (color, item['number'], title_color, item['title']) 418 | self.cprint(printItem, attr=attr) 419 | 420 | if 'children' in item: 421 | for child in item['children']: 422 | attr = DIM if child['status'] in ('completed', 'accepted') else None 423 | childColor = ITEM_COLORS.get(child['type']) 424 | title = child['title'] 425 | if child['status'] == 'in-progress': 426 | title = u'${GREEN}⧁ ${%s}%s' % (title_color, title) 427 | 428 | printChild = u'${%s} #%d${DEFAULT}:${%s} %s' % (childColor, child['number'], title_color, title) 429 | self.cprint(printChild, attr=attr) 430 | 431 | self.cprint('') 432 | 433 | def populateCache(self): 434 | """ 435 | Populate the cache from the Sprint.ly API if possible. 436 | """ 437 | 438 | try: 439 | cache = {} 440 | 441 | cache['updated_at'] = time() 442 | cache['products'] = [] 443 | 444 | products = [] 445 | 446 | # use product from config file 447 | products.append(self._config['product']) 448 | 449 | # get products from the API 450 | # products = self.sprintlyAPICall('products.json') 451 | # if not products: 452 | # raise SprintlyException('Unable to get product list.') 453 | 454 | # iterate over products 455 | for product in products: 456 | 457 | productName = product['name'] 458 | productId = str(product['id']) 459 | productNameWithUrl = '\'' + productName + '\' (https://sprint.ly/product/' + productId + '/)' 460 | 461 | # get all items assigned to current user 462 | items = [] 463 | offset = 0 464 | limit = 100 465 | while True: 466 | itemsPartial = self.sprintlyAPICall('products/' + productId + '/items.json?assigned_to=' + str(self._config['id']) + '&children=1&limit=' + str(limit) + '&offset=' + str(offset)) 467 | 468 | # if we get nothing, an empty list, an error, quit 469 | if not itemsPartial or len(itemsPartial) == 0 or 'code' in items: 470 | break 471 | # otherwise, add on these items and increase the offset 472 | else: 473 | items = items + itemsPartial 474 | offset = offset + limit 475 | 476 | # if we got less than a full response, no need to check again 477 | if len(itemsPartial) < limit: 478 | break 479 | 480 | # if anything went wrong, print an error message 481 | if 'code' in items: 482 | # include message if applicable 483 | message = '' 484 | if 'message' in items: 485 | message = ': %s' % items['message'] 486 | print 'Warning: unable to get items for %s%s' % (productNameWithUrl, message) 487 | continue 488 | # if there are no items, display message 489 | elif len(items) == 0: 490 | print 'No assigned items for %s' % (productNameWithUrl) 491 | # a 'parent' is any item without a parent key 492 | # a 'child' is any item with a parent key 493 | # sort so that all parents appear first and all children appear after ordered by their number 494 | items.sort(key=lambda item: item['number'] if 'parent' in item else sys.maxint, reverse=True) 495 | 496 | # turn flat list into tree 497 | itemsTree = [] 498 | parentMapping = {} # allow parents to be looked up by number 499 | 500 | for item in items: 501 | number = str(item['number']) 502 | 503 | # if item is not a child 504 | if 'parent' not in item: 505 | itemsTree.append(item) 506 | parentMapping[number] = item 507 | 508 | # if item is a child... 509 | else: 510 | parent = item['parent'] # get reference to parent 511 | del item['parent'] # remove parent from child 512 | parentNumber = str(parent['number']) 513 | 514 | # if we have the parent, nest under parent 515 | if parentNumber in parentMapping: 516 | 517 | # we sorted items above to ensure all parents will be in map before any child is encountered 518 | parent = parentMapping[parentNumber] 519 | if 'children' not in parent: 520 | parent['children'] = [] 521 | parent['children'].append(item) 522 | 523 | # if we don't have the parent, add placeholder parent to preserve tree structure 524 | else: 525 | parent['children'] = [item] 526 | parentMapping[parentNumber] = parent 527 | itemsTree.append(parent) 528 | 529 | # sort items by (status, then first child, if it exists, else number) 530 | itemsTree.sort(key=lambda item: item['children'][0]['number'] if 'children' in item else item['number'], reverse=True) 531 | product['items'] = itemsTree 532 | cache['products'].append(product) 533 | 534 | serialized_cache = json.dumps(cache) 535 | 536 | cache_file = open(self._sprintlyCachePath, 'w') 537 | cache_file.write(serialized_cache) 538 | cache_file.close() 539 | except Exception: 540 | print '\033[91m' 541 | print 'Unable to populate cache. List may not be up to date.' 542 | print '\033[0m' 543 | 544 | def readCache(self): 545 | """ 546 | Read from the cache and return a list of Sprint.ly items. 547 | """ 548 | 549 | cache_file = open(self._sprintlyCachePath, 'r') 550 | serialized_cache = cache_file.readline() 551 | cache_file.close() 552 | 553 | try: 554 | cache = json.loads(serialized_cache) 555 | except Exception: 556 | raise SprintlyException('Cache is empty or invalid. Please try running the tool again.') 557 | 558 | return cache['products'] 559 | 560 | def sprintlyAPICall(self, url): 561 | """ 562 | Wraps up a call to the Sprint.ly api. Returns a map representing 563 | the JSON response or false if the call could not be completed. 564 | """ 565 | 566 | url = 'https://sprint.ly/api/%s' % url 567 | 568 | try: 569 | userData = 'Basic ' + (self._config['user'] + ':' + self._config['key']).encode('base64').replace("\n",'') 570 | req = urllib2.Request(url) 571 | req.add_header('Accept', 'application/json') 572 | req.add_header('Authorization', userData) 573 | res = urllib2.urlopen(req) 574 | response = res.read() 575 | return json.loads(response) 576 | except urllib2.HTTPError, error: 577 | response = error.read() 578 | return json.loads(response) 579 | except Exception: 580 | return False 581 | 582 | def installHook(self): 583 | """ 584 | A symlink will be created between the /.git/hooks/commit-msg 585 | and ~/.sprintly/commit-msg 586 | """ 587 | 588 | # ensure the current directory is a git repository 589 | directory = os.getcwd() 590 | git_directory = os.path.join(directory, '.git') 591 | if not os.path.isdir(git_directory): 592 | raise SprintlyException('This command can only be run from the root of a git repository.') 593 | hooks_directory = os.path.join(git_directory, 'hooks') 594 | if not os.path.isdir(hooks_directory): 595 | raise SprintlyException('You do not appear to have a .git/hooks directory in your git repository.') 596 | # ensure hook is installed 597 | if not os.path.isfile(HOOK_PATH): 598 | raise SprintlyException('Please run \'sprintly --update\' first to install the hook.') 599 | 600 | # create a symlink to the commit-msg file 601 | destination = os.path.join(hooks_directory, HOOK_NAME) 602 | 603 | # if the destination is a file, move it; if it's a symlink, delete it 604 | try: 605 | if os.path.isfile(destination) and not os.path.islink(destination): 606 | shutil.move(destination, destination + '.original') 607 | elif os.path.islink(destination): 608 | os.unlink(destination) 609 | except Exception: 610 | raise SprintlyException('File already exists at %s. Please delete it before proceeding.' % destination) 611 | 612 | print 'Creating symlink...' 613 | 614 | try: 615 | os.symlink(HOOK_PATH, destination) 616 | except Exception: 617 | raise SprintlyException('Unable to create symlink.') 618 | 619 | print 'Hook was installed at %s' % destination 620 | 621 | # check to see if the email associated with git matches the Sprint.ly email 622 | # if not, Sprint.ly won't be able to create comments 623 | try: 624 | process = subprocess.Popen(['git', 'config', 'user.email'], stdout=subprocess.PIPE) 625 | gitEmail = process.stdout.read().strip() 626 | if gitEmail != self._config['user']: 627 | print 'WARNING: Your git email (' + gitEmail + ') does not match your Sprint.ly username (' + self._config['user'] + ')' 628 | print 'WARNING: Don\'t worry - there is an easy fix. Simply run one of the following:' 629 | print '\t\'git config --global user.email ' + self._config['user'] + '\' (all repos)' 630 | print '\t\'git config user.email ' + self._config['user'] + '\' (this repo only)' 631 | except Exception: 632 | print 'Unable to verify that \'git config user.email\' matches your Sprint.ly account email.' 633 | 634 | def uninstallHook(self): 635 | """ 636 | Remove the symlink we created. If the hook is not a symlink, don't remove it. 637 | """ 638 | 639 | # ensure the current directory is a git repository 640 | directory = os.getcwd() 641 | hooks_directory = os.path.join(directory, '.git', 'hooks') 642 | if not os.path.isdir(hooks_directory): 643 | raise SprintlyException('This command can only be run from the root of a git repository.') 644 | 645 | # get path to commit-msg file 646 | destination = os.path.join(hooks_directory, HOOK_NAME) 647 | 648 | # if the destination is a file, error; if it's a symlink, delete it 649 | try: 650 | if os.path.isfile(destination) and not os.path.islink(destination): 651 | raise SprintlyException('The commit-msg hook was not installed by this tool. Please remove it manually.') 652 | elif os.path.islink(destination): 653 | os.unlink(destination) 654 | else: 655 | print 'Hook is already uninstalled.' 656 | return 657 | except SprintlyException as e: 658 | raise e 659 | except Exception: 660 | raise SprintlyException('File already exists at %s. Please delete it before proceeding.' % destination) 661 | 662 | print 'Hook has been uninstalled.' 663 | 664 | def updateHook(self): 665 | """ 666 | Download the commit-msg hook to ~/.sprintly/commit-msg. 667 | Replace existing file if present. Make executable. 668 | Returns the path of the file. 669 | """ 670 | 671 | print 'Downloading latest version of commit-msg hook from GitHub...' 672 | 673 | # get the file 674 | try: 675 | response = urllib2.urlopen(COMMIT_MSG_SOURCE_URL) 676 | commit_msg_file_contents = response.read() 677 | except Exception: 678 | raise SprintlyException('Unable to obtain commit-msg from %s' % COMMIT_MSG_SOURCE_URL) 679 | 680 | # ensure directory exists 681 | try: 682 | if not os.path.exists(HOOK_DIR): 683 | os.makedirs(HOOK_DIR, 0777) 684 | except Exception: 685 | raise SprintlyException('Unable to create directory %s' % HOOK_DIR) 686 | 687 | # save the file 688 | try: 689 | commit_msg_file = open(HOOK_PATH, 'w') 690 | commit_msg_file.write(commit_msg_file_contents) 691 | commit_msg_file.close() 692 | except Exception: 693 | raise SprintlyException('Unable to save file to %s' % HOOK_PATH) 694 | 695 | # make sure user can read, write, and execute 696 | try: 697 | os.chmod(HOOK_PATH, 0777) 698 | except Exception: 699 | raise SprintlyException('Unable to make %s executable.' % HOOK_PATH) 700 | 701 | self.cprint('Hook was updated at %s' % HOOK_PATH, attr=GREEN) 702 | 703 | def cprint(self, str, attr=None, trim=True): 704 | self._term.write(self.render(str, attr, trim) + '\r\n') 705 | 706 | def render(self, str, attr=None, trim=True): 707 | if self._has_color: 708 | if attr: 709 | if isinstance(attr, list): 710 | attr = ''.join(attr) 711 | else: 712 | attr = '' 713 | 714 | seq = re.sub(r'\$\$|\${\w+}', self._render_sub, str) 715 | if trim: 716 | seq = self._trim(seq) 717 | 718 | return attr + seq + RESET 719 | else: 720 | seq = re.sub(r'\$\$|\${\w+}', '', str) 721 | if trim and len(seq) > self._cols: 722 | return seq[0:self._cols - 1] + u'\u2026' 723 | return seq 724 | 725 | def _render_sub(self, match): 726 | s = match.group() 727 | if s == '$$': return s 728 | else: return ATTRS.get(s[2:-1], '') 729 | 730 | def _trim(self, raw): 731 | # TODO: >>> This could probably be much simpler if I was smarter 732 | seq = '' 733 | str_len = 0 734 | i = 0 735 | matchiter = re.finditer(r'(\x1b.*?m)', raw.strip()) 736 | for match in matchiter: 737 | chunk = raw[i:match.start()] 738 | i = match.end() 739 | if str_len + len(chunk) > self._cols: 740 | chunk = chunk[0:self._cols - str_len - 1] + u'\u2026' 741 | str_len = str_len + len(chunk) 742 | seq = seq + chunk + match.group() 743 | 744 | if (str_len >= self._cols): 745 | break 746 | 747 | if str_len < self._cols: 748 | chunk = raw[i:] 749 | if str_len + len(chunk) > self._cols: 750 | chunk = chunk[0:self._cols - str_len - 1] + u'\u2026' 751 | seq = seq + chunk 752 | 753 | return seq 754 | 755 | def elipsify(self, seq): 756 | return seq[0:-1].strip(string.punctuation) + u'\u2026' 757 | 758 | 759 | class SprintlyException(Exception): 760 | """ 761 | Exception used to pass known exceptions throughout 762 | the sprintly tool. 763 | """ 764 | def __init__(self, value): 765 | self.value = value 766 | 767 | def __str__(self): 768 | return repr(self.value) 769 | 770 | 771 | def die(message=None, *args): 772 | """ 773 | Prints the message, if present, and then exits. 774 | """ 775 | 776 | if message: 777 | logger.error(message, *args, exc_info=True) 778 | print 'Program exiting.' 779 | sys.exit(1) 780 | 781 | if __name__ == '__main__': 782 | sprintlyTool = SprintlyTool() 783 | sprintlyTool.run() 784 | --------------------------------------------------------------------------------