├── config ├── .gitignore ├── .later ├── config ├── 678c1fba-e06a-4be2-8e83-d87241cff5e2.issue ├── 980cc57f-c39a-479f-b7f5-a938abf079c8.issue ├── 9bc87e85-2947-4d02-9565-282f85d32844.issue ├── 0428c335-5de8-4529-82b1-8d433174ac86.issue ├── 57db597d-13fe-4744-bb98-fe4f41e23ef3.issue ├── 6b792d73-4230-441a-bd69-bf1f67777ea6.issue ├── ee902fe7-76c0-4aac-a7e1-7e98e83f869c.issue ├── e2df0108-57de-4752-94f4-4d71479cafc0.issue └── git.py ├── plugins ├── plugins.rst ├── git.py ├── delete.py ├── list_subdirs.py ├── deadlines.py ├── htmlreport.py └── revision.py ├── README.rst ├── properties.txt └── later /config: -------------------------------------------------------------------------------- 1 | username: ongspxm 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | .later/*.py 4 | -------------------------------------------------------------------------------- /.later/config: -------------------------------------------------------------------------------- 1 | username: zwinkau 2 | default_priority: 0 3 | -------------------------------------------------------------------------------- /.later/678c1fba-e06a-4be2-8e83-d87241cff5e2.issue: -------------------------------------------------------------------------------- 1 | status: closed 2 | responsible: nobody 3 | modified: 2010-08-21T17:06:16 4 | reporter: Andreas Zwinkau 5 | created: 2010-08-21T13:40:55 6 | 7 | list filters 8 | -------------------------------------------------------------------------------- /.later/980cc57f-c39a-479f-b7f5-a938abf079c8.issue: -------------------------------------------------------------------------------- 1 | status: closed 2 | reporter: zwinkau 3 | responsible: nobody 4 | modified: 2010-08-14T07:39:48 5 | created: 2010-08-12T08:48:19 6 | priority: 0 7 | 8 | Filters for issue list 9 | -------------------------------------------------------------------------------- /.later/9bc87e85-2947-4d02-9565-282f85d32844.issue: -------------------------------------------------------------------------------- 1 | status: reported 2 | responsible: nobody 3 | modified: 2010-10-05T09:27:09 4 | reporter: Andreas Zwinkau 5 | created: 2010-08-21T09:32:01 6 | 7 | Trac backend 8 | -------------------------------------------------------------------------------- /.later/0428c335-5de8-4529-82b1-8d433174ac86.issue: -------------------------------------------------------------------------------- 1 | status: reported 2 | responsible: nobody 3 | modified: 2010-10-05T09:27:01 4 | reporter: Andreas Zwinkau 5 | created: 2010-10-05T09:27:01 6 | 7 | Redmine backend 8 | -------------------------------------------------------------------------------- /.later/57db597d-13fe-4744-bb98-fe4f41e23ef3.issue: -------------------------------------------------------------------------------- 1 | status: closed 2 | responsible: nobody 3 | modified: 2010-08-21T17:42:52 4 | reporter: Andreas Zwinkau 5 | created: 2010-08-21T09:30:36 6 | 7 | list-subdirs command 8 | -------------------------------------------------------------------------------- /.later/6b792d73-4230-441a-bd69-bf1f67777ea6.issue: -------------------------------------------------------------------------------- 1 | status: confirmed 2 | responsible: nobody 3 | modified: 2010-08-22T11:56:18 4 | reporter: Andreas Zwinkau 5 | created: 2010-08-22T11:55:48 6 | 7 | What about attachments? 8 | 9 | Screenshots, media files, dumps, ... 10 | -------------------------------------------------------------------------------- /.later/ee902fe7-76c0-4aac-a7e1-7e98e83f869c.issue: -------------------------------------------------------------------------------- 1 | status: confirmed 2 | reporter: zwinkau 3 | responsible: nobody 4 | modified: 2010-10-05T09:26:45 5 | created: 2010-08-12T08:48:42 6 | priority: 0 7 | 8 | Interactive Web frontend 9 | 10 | In contrast to the htmlreport plugin, adding and editing issues should be possible. 11 | -------------------------------------------------------------------------------- /.later/e2df0108-57de-4752-94f4-4d71479cafc0.issue: -------------------------------------------------------------------------------- 1 | status: confirmed 2 | responsible: zwinkau 3 | modified: 2010-08-13T06:15:46 4 | reporter: Andreas Zwinkau 5 | created: 2010-08-13T06:12:19 6 | 7 | What to do on no-command-given? 8 | 9 | Possibilities: 10 | 11 | * Show an error message (current solution) 12 | * Show usage, just like help 13 | * Show an overview 14 | * Show issue list, just like list 15 | * Show a funny/motivating/provocative message, like fortune 16 | -------------------------------------------------------------------------------- /plugins/plugins.rst: -------------------------------------------------------------------------------- 1 | Later Plugins 2 | ============= 3 | 4 | Plugins for the *later* issue tracker are just .py files, 5 | which are put into the ``.later`` data directory. 6 | They contain a ``plugin_init`` function, 7 | which is called to start the plugin. 8 | A plugin can change the hooks. 9 | 10 | Example 11 | ------- 12 | 13 | If you use *later* within a git repository, 14 | copy the ``git.py`` plugin into your ``.later`` 15 | and *later* will use the git info to guess the reporter. 16 | 17 | -------------------------------------------------------------------------------- /.later/git.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | def plugin_init(hooks): 4 | guess = hooks['guess_username'] 5 | def git_guess_username(): 6 | try: 7 | user_name = subprocess.Popen(["git", "config", "user.name"], stdout=subprocess.PIPE).communicate()[0].strip() 8 | user_mail = subprocess.Popen(["git", "config", "user.email"], stdout=subprocess.PIPE).communicate()[0].strip() 9 | if user_name != "" and user_mail != "": 10 | return "%s <%s>" % (user_name, user_mail) 11 | return 12 | except: 13 | pass 14 | return guess() 15 | hooks['guess_username'] = git_guess_username 16 | 17 | 18 | -------------------------------------------------------------------------------- /plugins/git.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | def plugin_init(hooks): 4 | guess = hooks['guess_username'] 5 | def git_guess_username(): 6 | try: 7 | user_name = subprocess.Popen(["git", "config", "user.name"], stdout=subprocess.PIPE).communicate()[0].strip() 8 | user_mail = subprocess.Popen(["git", "config", "user.email"], stdout=subprocess.PIPE).communicate()[0].strip() 9 | if user_name != "" and user_mail != "": 10 | return "%s <%s>" % (user_name, user_mail) 11 | return 12 | except: 13 | pass 14 | return guess() 15 | hooks['guess_username'] = git_guess_username 16 | 17 | 18 | -------------------------------------------------------------------------------- /plugins/delete.py: -------------------------------------------------------------------------------- 1 | """ 2 | This plugin provides a delete and a delete-closed command, 3 | to clean up an issue database. 4 | """ 5 | 6 | _HOOKS=None 7 | 8 | def cmd_delete(args): 9 | """Delete a specific issue permanently.""" 10 | if not args: 11 | error("need guid argument") 12 | guid = _HOOKS.be_complete_guid(args[0]) 13 | if not guid: 14 | return 15 | _HOOKS.be_delete_issue(guid) 16 | 17 | def cmd_delete_closed(args): 18 | """Delete all closed issues permanently.""" 19 | assert len(args) == 0 20 | for guid in _HOOKS.be_all_guids(): 21 | cmd_delete([guid]) 22 | 23 | def plugin_init(hooks): 24 | global _HOOKS 25 | hooks["cmd_delete"] = cmd_delete 26 | hooks["cmd_delete-closed"] = cmd_delete_closed 27 | _HOOKS = hooks 28 | 29 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Later 2 | ===== 3 | 4 | A command-line issue tracker for a lazy developer 5 | 6 | Usage 7 | ----- 8 | 9 | There is no website or release version, yet. 10 | Clone the github repository git://github.com/beza1e1/later.git 11 | and use the ``later`` executable. 12 | Start with ``later help``. 13 | 14 | Default config available in ``config`` (found in same directory as 15 | the ``later`` executable. 16 | 17 | Philosphy 18 | --------- 19 | 20 | Reread the tag line above! 21 | It is the essence of the philosophy behind this tool. 22 | More precisely: 23 | 24 | 1. This is primarily **command-line**. The web is secondary. 25 | 2. "**A** developer" means big project management is out of scope. 26 | 3. Lots of shortcuts and defering for **lazy** devs like me. 27 | 4. End users, managers and translators should use something else. 28 | 29 | -------------------------------------------------------------------------------- /plugins/list_subdirs.py: -------------------------------------------------------------------------------- 1 | """ 2 | This later plugin provides a "list-subdirs" command 3 | as requested by bigfudge: http://news.ycombinator.com/item?id=1620445 4 | """ 5 | 6 | import os,sys 7 | 8 | def find_dotlater(path): 9 | for p in os.listdir(path): 10 | p = os.path.join(path, p) 11 | if p.endswith("/.later"): 12 | yield p 13 | elif os.path.isdir(p): 14 | for sub in find_dotlater(p): 15 | yield sub 16 | 17 | def cmd_list_subdirs(args): 18 | """Shows an issue list in every subdirectory with a later database. 19 | For details see "help list" """ 20 | cmd = sys.argv[0] 21 | for path in find_dotlater("."): 22 | assert path.endswith("/.later") 23 | path = path[:-7] 24 | if len(path) > 2: 25 | path = path[2:] 26 | print ":"*10, path 27 | ret = os.getcwd() 28 | os.chdir(path) 29 | os.system("%s list %s" % (cmd, " ".join(args))) 30 | os.chdir(ret) 31 | 32 | def plugin_init(hooks): 33 | hooks["cmd_list-subdirs"] = cmd_list_subdirs 34 | 35 | -------------------------------------------------------------------------------- /properties.txt: -------------------------------------------------------------------------------- 1 | A list of properties and their conventional semantics. 2 | This file serves as a standard to avoid plugin conflicts. 3 | 4 | ## status 5 | Set to "closed", if the issue should be hidden from now on. 6 | Anything else is arbitrary. 7 | 8 | ## responsible 9 | Name (and email) of the person, who is responsible for resolving the issue. 10 | Ask her for the progress. 11 | 12 | ## modified 13 | Date, when the issue was modified last. 14 | 15 | ## created 16 | Date, when the issue was originally created 17 | 18 | ## reporter 19 | Name (and email) of the person, who reported the issue. 20 | Ask her for uncertainties in the issue. 21 | 22 | 23 | # Optional 24 | The following properties are not inserted by default. 25 | 26 | ## component 27 | Name of the module/part/piece this issue affects. 28 | 29 | ## deadline 30 | Date, when the issue should be set to closed. 31 | 32 | ## manhours 33 | Estimation of how many hour are needed to resolve the issue. 34 | 35 | ## dependencies 36 | Space-separated GUIDs of other issues, which need to be resolved before. 37 | 38 | ## priority 39 | Positive integer value of importance. 40 | The greater the more important. 41 | The default is 2. 42 | Hence, 1 means unimportant and 3 and above means higher priority. 43 | Intentionally, there is no upper limit. 44 | -------------------------------------------------------------------------------- /plugins/deadlines.py: -------------------------------------------------------------------------------- 1 | """ 2 | This plugin calculated a schedule. 3 | It uses the issue properties deadline, manhours, and dependencies to do that. 4 | """ 5 | 6 | from datetime import timedelta, datetime 7 | 8 | _HOOKS=None 9 | 10 | MANHOURS_PER_DAY = 1 11 | def parse_datetime(string): 12 | return datetime.strptime(string, "%Y-%m-%dT%H:%M:%S") 13 | 14 | def manhours2timedelta(manhours): 15 | return timedelta(manhours / MANHOURS_PER_DAY) 16 | 17 | def schedule(): 18 | deadlined = list() 19 | toschedule = list() 20 | imap = dict() 21 | # init 22 | for guid in _HOOKS.be_all_guids(): 23 | issue = _HOOKS.be_load_issue(guid) 24 | if issue.properties["status"] == "closed": 25 | continue 26 | imap[issue.guid] = issue 27 | if "deadline" in issue.properties: 28 | issue.ideadline = parse_datetime(issue.properties["deadline"]) 29 | deadlined.append(issue) 30 | else: 31 | issue.ideadline = None 32 | toschedule.append(issue) 33 | # read dependencies 34 | for issue in imap.values(): 35 | issue.idependencies = list() 36 | guids = issue.properties.get("dependencies", "").split() 37 | for guid in guids: 38 | issue.idependencies.append(imap[guid]) 39 | # schedule 40 | while deadlined: 41 | deadi = deadlined.pop() 42 | delta = manhours2timedelta(deadi.properties.get("manhours", 1)) 43 | start = deadi.ideadline - delta 44 | for depi in deadi.idependencies: 45 | if depi.ideadline and depi.ideadline < start: 46 | continue 47 | depi.ideadline = start 48 | deadlined.append(depi) 49 | # return sorted 50 | def ideadline_cmp(a, b): 51 | if a.ideadline == None: 52 | return 1 53 | if b.ideadline == None: 54 | return -1 55 | return cmp(a.ideadline, b.ideadline) 56 | return sorted(imap.values(), ideadline_cmp) 57 | 58 | def cmd_schedule(args): 59 | """Output a schedule of all issues. 60 | Argument is how many manhours per day 61 | should be calculated. Default is '1'.""" 62 | if len(args) > 1: 63 | error("at most one argument: how many manhours per day") 64 | if args: 65 | global MANHOURS_PER_DAY 66 | MANHOURS_PER_DAY = float(args[0]) 67 | 68 | print datetime.now().strftime("%Y-%m-%d\n==========") 69 | 70 | for issue in schedule(): 71 | if issue.ideadline: 72 | print issue.ideadline.strftime("%Y/%m/%d"), 73 | else: 74 | print " ", 75 | print issue.shortString() 76 | 77 | def plugin_init(hooks): 78 | global _HOOKS 79 | hooks["cmd_schedule"] = cmd_schedule 80 | _HOOKS = hooks 81 | 82 | -------------------------------------------------------------------------------- /plugins/htmlreport.py: -------------------------------------------------------------------------------- 1 | """ 2 | This plugin provides a 'htmlreport' command, to generate an issue list in HTML form. 3 | """ 4 | 5 | from datetime import datetime 6 | 7 | _HTML_HEAD = """\ 8 | 9 | 10 | Later Do 11 | 34 | 35 | 36 |

Later Do

37 | """ 38 | 39 | _HTML_ISSUE = """ 40 |
41 | %(guid)s 42 |

%(title)s

43 | 44 | 45 | 46 |
status%(status)s
responsible%(responsible)s
47 |
%(msg)s
48 |
49 | """ 50 | 51 | _HTML_FOOT = """\ 52 | 56 | 57 | 58 | """ % datetime.now().strftime("%Y/%m/%d") 59 | 60 | _HOOKS=None 61 | 62 | class Stats: 63 | def __init__(self): 64 | self.open_issues = 0 65 | self.unassigned_issues = 0 66 | def process(self, info): 67 | if info['status'] != "closed": 68 | self.open_issues += 1 69 | if info['responsible'] == "nobody": 70 | self.unassigned_issues += 1 71 | def toHTML(self): 72 | string = '' 73 | string += '\n' % self.open_issues 74 | string += '\n' % self.unassigned_issues 75 | string += "
Open%d
Unassigned%d
" 76 | return string 77 | 78 | def cmd_htmlreport(args): 79 | """Generate an html issue list.""" 80 | # load data 81 | delayed = list() 82 | opened = list() 83 | stats = Stats() 84 | for guid in _HOOKS.be_all_guids(): 85 | guid = _HOOKS.be_complete_guid(guid) 86 | issue = _HOOKS.be_load_issue(guid) 87 | info = issue.properties.copy() 88 | i = issue.msg.find("\n") 89 | if i < 0: 90 | i = len(issue.msg) 91 | info['title'] = issue.msg[:i] 92 | info['msg'] = issue.msg[i:] 93 | info['guid'] = guid 94 | stats.process(info) 95 | if info['status'] != "closed": 96 | opened.append(info) 97 | else: 98 | delayed.append(info) 99 | # output 100 | print _HTML_HEAD 101 | print stats.toHTML() 102 | for info in opened: 103 | print _HTML_ISSUE % info 104 | if delayed: 105 | print "
" 106 | for info in delayed: 107 | print _HTML_ISSUE % info 108 | print _HTML_FOOT 109 | 110 | def plugin_init(hooks): 111 | global _HOOKS 112 | hooks["cmd_htmlreport"] = cmd_htmlreport 113 | _HOOKS = hooks 114 | 115 | 116 | -------------------------------------------------------------------------------- /plugins/revision.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | _HOOKS = None 4 | 5 | def rev_file(): 6 | """ 7 | Get path of revision file 8 | """ 9 | 10 | revs = os.path.join(_HOOKS.be_get_data_dir(), 'revisions') 11 | 12 | if not os.path.isfile(revs): 13 | fh = open(revs, 'w') 14 | fh.write('') 15 | fh.close() 16 | 17 | return revs 18 | 19 | def avail_name(name): 20 | """ 21 | Check that it is an available name (does not exist anymore) 22 | """ 23 | 24 | rev = rev_file() 25 | if not rev: return True 26 | 27 | fh = open(rev) 28 | for l in fh: 29 | if name.strip() == l.strip(): 30 | return False 31 | return True 32 | 33 | def all_issues(): 34 | return (_HOOKS.be_load_issue(g) for g in _HOOKS.be_all_guids()) 35 | 36 | def pending_issues(): 37 | return [iss for iss in all_issues() if not iss.properties.get('revision') and iss.properties.get('status')!='reported'] 38 | 39 | def revision_list(): 40 | """ 41 | List all revisions 42 | """ 43 | 44 | rev = rev_file() 45 | if not rev: return 46 | 47 | revs = [' '+l.strip() for l in open(rev).readlines() if l.strip()] 48 | if revs: 49 | print 'Revision (latest on top):' 50 | print '\n'.join(revs) 51 | else: 52 | print 'No revisions available' 53 | 54 | def revision_new(name): 55 | """ 56 | revision new Add closed issues to 57 | """ 58 | 59 | if not avail_name(name): 60 | print 'Cannot use %s, already taken'%name 61 | return 62 | 63 | issues = [iss for iss in pending_issues() if iss.properties['status']=='closed'] 64 | 65 | if not len(issues): 66 | print 'Error. Nothing to add to revision', name 67 | return 68 | 69 | print 'Issues under revision %s:'%name 70 | for iss in issues: 71 | iss.properties['revision'] = name 72 | _HOOKS.be_store_issue(iss) 73 | print iss.shortString() 74 | 75 | rev = rev_file() 76 | revs = [l.strip() for l in open(rev).readlines() if l.strip()] 77 | 78 | fh = open(rev, 'w') 79 | fh.write('\n'.join([name]+revs)) 80 | fh.close() 81 | 82 | def revision_show(name): 83 | if avail_name(name): 84 | print '%s does not exist, did you get the name right?'%name 85 | return 86 | 87 | issues = [iss.shortString() for iss in all_issues() if iss.properties.get('revision')==name] 88 | 89 | if not len(issues): 90 | print 'There is nothing in revision', name 91 | return 92 | 93 | print 'Issues under revision %s:'%name 94 | print '\n'.join(issues) 95 | 96 | def revision_rollback(): 97 | try: 98 | revs = open(rev_file()).readlines() 99 | rev = revs[-1].strip() 100 | assert (rev) 101 | except IndexError, AssertionError: 102 | print 'No revision available' 103 | return 104 | 105 | for iss in all_issues(): 106 | if iss.properties.get('revision')==rev: 107 | iss.properties['revision'] = None 108 | _HOOKS.be_store_issue(iss) 109 | 110 | revs = [rev.strip() for rev in revs[:-1] if rev.strip()] 111 | 112 | fh = open(rev_file(), 'w') 113 | fh.write('\n'.join(revs)+'\n') 114 | fh.close() 115 | 116 | print 'Revision %s removed'%rev 117 | 118 | def revision_status(): 119 | issues = pending_issues() 120 | 121 | if not issues: 122 | print 'There is nothing to add to your revision' 123 | 124 | for iss in issues: 125 | print iss.shortString() 126 | 127 | def revision_help(): 128 | print _HOOKS.get('cmd_revision').__doc__ 129 | 130 | def cmd_revision(args): 131 | """ 132 | revision list List all revisions 133 | revision new Add newly closed issues to 134 | revision rollback Remove all issues from most recent 135 | revision status Show issues queued for next revision 136 | revision show Show issues in 137 | """ 138 | 139 | if not len(args): 140 | return revision_help() 141 | 142 | cmd = args[0] 143 | args = args[1:] 144 | 145 | if cmd=='list': 146 | revision_list() 147 | elif cmd=='new': 148 | if not len(args): 149 | print 'Please insert valid revision name' 150 | print revision_new.__doc__ 151 | return 152 | 153 | revision_new(' '.join(args)) 154 | elif cmd=='show': 155 | if not len(args): 156 | print 'Please insert valid revision name' 157 | print revision_show.__doc__ 158 | return 159 | 160 | revision_show(' '.join(args)) 161 | elif cmd=='rollback': 162 | revision_rollback() 163 | elif cmd=='status': 164 | revision_status() 165 | else: 166 | print 'Error: unrecognized command\n' 167 | return revision_help() 168 | 169 | 170 | def plugin_init(hooks): 171 | global _HOOKS 172 | hooks['cmd_revision'] = cmd_revision 173 | _HOOKS = hooks 174 | -------------------------------------------------------------------------------- /later: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import getpass 6 | import imp 7 | from uuid import uuid4 8 | from glob import iglob 9 | from datetime import datetime 10 | from tempfile import mkstemp 11 | 12 | _SCRIPT_DIR = "/".join(sys.argv[0].split("/")[:-1]) 13 | _PLUGINS_DIR = _SCRIPT_DIR + "/plugins" 14 | _CONFIG_FILE_NAME = "config" 15 | _DATA_DIR = ".later" 16 | 17 | _CONFIG={ 18 | "default_piority": 2, 19 | "username": "ongspxm" 20 | } 21 | 22 | def error(msg): 23 | print "Error:", msg 24 | exit(2) 25 | 26 | class Hooks(object): 27 | def __getitem__(self, key): 28 | return self.__dict__[key] 29 | def __setitem__(self, key, value): 30 | self.__dict__[key] = value 31 | def get(self, key, default=None): 32 | return self.__dict__.get(key, default) 33 | 34 | _HOOKS=Hooks() 35 | def hooked(fun): 36 | """Decorator: make function available via _HOOKS""" 37 | _HOOKS[fun.__name__] = fun 38 | 39 | def find_config(): 40 | """Find config file starting from current directory upwards.""" 41 | current = os.path.abspath(os.path.curdir) 42 | trash = None 43 | while len(current) > 1: 44 | data = os.path.join(current, _DATA_DIR) 45 | config = os.path.join(data, _CONFIG_FILE_NAME) 46 | if os.path.isdir(data) and os.path.isfile(config): 47 | return config 48 | current, trash = os.path.split(current) 49 | return os.path.join(_SCRIPT_DIR, _CONFIG_FILE_NAME) 50 | 51 | @hooked 52 | def guess_username(): 53 | # try user login name 54 | return getpass.getuser() 55 | 56 | def load_plugins(): 57 | load_dir_plugins(_CONFIG.get("data_dir")) 58 | load_dir_plugins(_PLUGINS_DIR) 59 | 60 | def load_dir_plugins(parentDir): 61 | if not parentDir: return 62 | 63 | for path in iglob(os.path.join(parentDir, "*.py")): 64 | name = os.path.basename(path)[:-3] 65 | mod = imp.load_source("plugin:"+name, path) 66 | mod.plugin_init(_HOOKS) 67 | 68 | def load_config(): 69 | """Load config from file""" 70 | global _CONFIG 71 | 72 | path = find_config() 73 | if path and os.path.isfile(path): 74 | for line in open(path): 75 | i = line.find(": ") 76 | if i<0: continue 77 | 78 | _CONFIG[line[:i]] = line[i+2:-1] 79 | _CONFIG["data_dir"] = os.path.dirname(path) 80 | load_plugins() 81 | 82 | if not _CONFIG.get("username", None): 83 | _CONFIG["username"] = _HOOKS.guess_username() 84 | 85 | def store_config(path): 86 | """Store config to file""" 87 | fh = open(path, 'w') 88 | for key,val in _CONFIG.items(): 89 | if key == "data_dir": continue 90 | fh.write("%s: %s\n" % (key,val)) 91 | fh.close() 92 | 93 | _PROPERTY_CHARS="abcdefghijklmnopqrstuvwxyz_" 94 | def is_property_line(line): 95 | for i,c in enumerate(line): 96 | if not c in _PROPERTY_CHARS: 97 | if i > 0 and c == ":": 98 | return True 99 | else: 100 | return False 101 | return False 102 | 103 | def load_lfile(path): 104 | fh = open(path) 105 | props = dict() 106 | line = fh.readline() 107 | while is_property_line(line): 108 | i = line.index(":") 109 | props[line[:i]] = line[i+1:].strip() 110 | line = fh.readline() 111 | msg = "" 112 | while line: 113 | msg += line 114 | line = fh.readline() 115 | return msg.strip(), props 116 | 117 | def store_lfile(path, msg, props): 118 | fh = open(path, 'w') 119 | for key,val in props.items(): 120 | if not val: continue 121 | fh.write("%s: %s\n" % (key,val)) 122 | fh.write("\n") 123 | fh.write(msg) 124 | fh.write("\n") 125 | fh.close() 126 | 127 | def serialize_datetime(dt): 128 | return dt.strftime("%Y-%m-%dT%H:%M:%S%z") 129 | 130 | class Issue: 131 | def __init__(self, guid=None, properties=dict(), message="Something is rotten ..."): 132 | self.properties = dict( 133 | status="reported", 134 | #priority=_CONFIG["default_priority"], 135 | responsible="nobody", 136 | created=serialize_datetime(datetime.utcnow()), 137 | reporter=_CONFIG['username']) 138 | for k,v in properties.items(): 139 | self.properties[k] = v 140 | self.msg = message 141 | self.guid = guid or str(uuid4()) 142 | def shortString(self): 143 | """One line representation""" 144 | i = self.msg.find("\n") 145 | if i < 0: 146 | i = None 147 | string = self.msg[:i] 148 | status = self.properties.get("status", "?") 149 | return " %s %-50s %s"%(self.guid[:8], string, status) 150 | def longString(self): 151 | """Multi line representation""" 152 | string = "%s\n" % (self.guid) 153 | for k,v in self.properties.items(): 154 | string += "%s: %s\n" % (k,v) 155 | string += "\n" + self.msg 156 | return string 157 | 158 | # explicitly not @hooked, since backend specific 159 | def be_gen_path(guid): 160 | return os.path.join(_CONFIG.get("data_dir", ""), guid + ".issue") 161 | 162 | @hooked 163 | def be_all_guids(): 164 | for path in iglob(be_gen_path("*")): 165 | yield os.path.basename(path)[:-6] 166 | 167 | @hooked 168 | def be_complete_guid(halfguid): 169 | if len(halfguid) == 36: 170 | return halfguid # already complete 171 | found = None 172 | for guid in _HOOKS.be_all_guids(): 173 | if guid.startswith(halfguid): 174 | if found: 175 | print "The guid part is ambiguous:" 176 | print guid, "or" 177 | print found, "?" 178 | return 179 | else: 180 | found = guid 181 | return found 182 | 183 | @hooked 184 | def be_store_issue(issue): 185 | issue.properties["modified"] = serialize_datetime(datetime.utcnow()) 186 | store_lfile(be_gen_path(issue.guid), issue.msg, issue.properties) 187 | 188 | @hooked 189 | def be_load_issue(guid): 190 | assert len(guid) == 36 191 | path = be_gen_path(guid) 192 | m, p = load_lfile(path) 193 | return Issue(guid, p, m) 194 | 195 | @hooked 196 | def be_delete_issue(guid): 197 | assert len(guid) == 36 198 | path = be_gen_path(guid) 199 | os.remove(path) 200 | 201 | @hooked 202 | def be_get_data_dir(): 203 | return _CONFIG.get("data_dir", "") 204 | 205 | # explicitly not @hooked, since now plugins available at this point 206 | def cmd_init(args): 207 | """Create a new data directory for issues in the current dir.""" 208 | try: 209 | os.mkdir(_DATA_DIR) 210 | except OSError, e: 211 | error("Could not create data directory: " + str(e)) 212 | store_config(os.path.join(_DATA_DIR, _CONFIG_FILE_NAME)) 213 | 214 | @hooked 215 | def cmd_add(args): 216 | """Quickly add a new issue by specifying only a message.""" 217 | issue = Issue() 218 | issue.msg = args.pop(0) 219 | _HOOKS.be_store_issue(issue) 220 | print "issue stored as", issue.guid 221 | 222 | def gen_matcher(args): 223 | """Generate an issue matcher from command line arguments""" 224 | if not args: 225 | def open_matcher(issue): 226 | """Default: only list non closed issues""" 227 | return issue.properties["status"] != "closed" 228 | return open_matcher 229 | elif len(args) > 1: 230 | parts = [gen_matcher([arg]) for arg in args] 231 | def matcher_and(issue): 232 | """Every sub matcher must return True""" 233 | for p in parts: 234 | if not p(issue): 235 | return False 236 | return True 237 | return matcher_and 238 | else: 239 | assert len(args) == 1 240 | pattern = args[0] 241 | if "!=" in pattern: 242 | key, value = pattern.split("!=") 243 | def except_matcher(issue): 244 | return issue.properties.get(key, value) != value 245 | return except_matcher 246 | elif "=" in pattern: 247 | key, value = pattern.split("=") 248 | def matcher(issue): 249 | return issue.properties.get(key, None) == value 250 | return matcher 251 | error("Can not parse pattern: " + " ".join(args)) 252 | 253 | @hooked 254 | def cmd_list(args): 255 | """By default lists all issues, which are not closed. 256 | Search terms can be given, then only issues where all search terms match the issue properties. 257 | 258 | Examples: 259 | - `list status=closed` shows all issues with status closed. 260 | - `list reporter!=beza1e1 status=confirmed` shows all confirmed issues not reported by beza1e1. 261 | - `list bugs` shows issues beginning with "bug:" 262 | """ 263 | 264 | issues = (_HOOKS.be_load_issue(guid) for guid in _HOOKS.be_all_guids()) 265 | 266 | # Filtering for bugs 267 | if args.count('bugs'): 268 | issues = (iss for iss in issues if iss.msg[:3]=="bug") 269 | args.remove('bugs') 270 | 271 | matcher = gen_matcher(args) 272 | for issue in issues: 273 | # if args specified search in properties, 274 | # otherwise list all issues not closed. 275 | if matcher(issue): 276 | print issue.shortString() 277 | 278 | @hooked 279 | def cmd_edit(args): 280 | """Edit a specific issue. 281 | If none specified a new issue is generated (like add does.)""" 282 | try: 283 | guid = _HOOKS.be_complete_guid(args[0]) 284 | if not guid: 285 | return 286 | except IndexError: 287 | guid = None 288 | editor = os.getenv("EDITOR") 289 | if not editor: 290 | error("no $EDITOR environment variable") 291 | issue = _HOOKS.be_load_issue(guid) 292 | fh, filename = mkstemp(".issue") 293 | store_lfile(filename, issue.msg, issue.properties) 294 | cmd = "%s %s" % (editor, filename) 295 | os.system(cmd) 296 | m,p = load_lfile(filename) 297 | issue = Issue(issue.guid, p, m) 298 | _HOOKS.be_store_issue(issue) # updates modified properties 299 | if not guid: # generated a new issue 300 | print "Issue stored as", issue.guid 301 | 302 | @hooked 303 | def cmd_show(args): 304 | """Show a specific issue.""" 305 | if not args: 306 | error("need guid argument") 307 | guid = _HOOKS.be_complete_guid(args[0]) 308 | if not guid: 309 | return 310 | issue = _HOOKS.be_load_issue(guid) 311 | print issue.longString() 312 | 313 | @hooked 314 | def set_status(guid, status): 315 | """Change status of a specific issue""" 316 | guid = _HOOKS.be_complete_guid(guid) 317 | if not guid: 318 | return 319 | issue = _HOOKS.be_load_issue(guid) 320 | issue.properties["status"] = status 321 | _HOOKS.be_store_issue(issue) 322 | 323 | @hooked 324 | def cmd_close(args): 325 | """Set status of a specific issue to 'closed'""" 326 | if not args: 327 | error("need guid argument") 328 | _HOOKS.set_status(args[0], "closed") 329 | 330 | @hooked 331 | def cmd_confirm(args): 332 | """Set status of a specific issue to 'confirmed'""" 333 | if not args: 334 | error("need guid argument") 335 | _HOOKS.set_status(args[0], "confirmed") 336 | 337 | @hooked 338 | def cmd_assign(args): 339 | """Assign an issue to someone""" 340 | if not args or len(args) < 2: 341 | error("need guid and assignee") 342 | guid = _HOOKS.be_complete_guid(args[0]) 343 | issue = _HOOKS.be_load_issue(guid) 344 | 345 | issue.properties["responsible"] = " ".join(args[1:]) 346 | _HOOKS.be_store_issue(issue) 347 | 348 | _USAGE = """\ 349 | A command-line issue tracker for a lazy developer. 350 | Usage is "%s ...", where is one of 351 | 352 | init create dir in current directory 353 | add "msg" report new issue with title "msg" 354 | edit edit issue with the given guid 355 | close set status to closed 356 | confirm set status to confirmed 357 | assign set responsible to developer 358 | list show all issues in short 359 | show show issue completely 360 | help shows documentation for 361 | 362 | PLUGINS 363 | ======= 364 | 365 | schedule calculates/show deadlines 366 | htmlreport generate html report 367 | delete delete issue 368 | delete-closed delete all closed issues 369 | list-subdirs show issue list of subdirs 370 | revision manage revisions 371 | """ % (sys.argv[0]) 372 | 373 | @hooked 374 | def cmd_help(args=[]): 375 | """Provides general usage and help for every command.""" 376 | if len(args) == 0: 377 | print _USAGE 378 | return 379 | fun = _HOOKS.get("cmd_" + args[0], None) 380 | if not fun: 381 | print "Can not help you with that. Typo?" 382 | return 383 | # print docstring as help for a specific command 384 | print fun.__doc__ 385 | 386 | def main(): 387 | if len(sys.argv) <= 1: 388 | print _USAGE 389 | return 390 | 391 | if sys.argv[1] == "init": 392 | cmd_init(sys.argv[2:]) 393 | return 394 | load_config() 395 | 396 | # e.g. "add" leads to calling "cmd_add" 397 | fun = _HOOKS.get("cmd_" + sys.argv[1], None) 398 | if not fun: 399 | return _HOOKS.cmd_help() 400 | print 401 | 402 | fun(sys.argv[2:]) 403 | 404 | if __name__ == "__main__": 405 | main() 406 | 407 | --------------------------------------------------------------------------------