├── diffscuss ├── __init__.py ├── support │ ├── __init__.py │ ├── vimhelper.py │ ├── tests │ │ └── test_editor.py │ └── editor.py ├── mailbox │ ├── set_default_inbox.py │ ├── done.py │ ├── post.py │ ├── make_inbox.py │ ├── __init__.py │ ├── bounce.py │ ├── init_mailbox.py │ ├── common.py │ └── check.py ├── header.py ├── dates.py ├── tests │ ├── testfiles │ │ ├── comment_in_header.diffscuss │ │ ├── standard_template.diffscuss │ │ └── leading_hash_template.diffscuss │ ├── test_generate.py │ └── test_walker.py ├── generate.py ├── walker.py ├── local_source.py ├── find_local.py ├── cli.py └── github_import.py ├── MANIFEST.in ├── requirements.txt ├── .gitignore ├── doc ├── diffscuss-added-comment.png ├── diffscuss-created-review.png └── diffscuss-jump-to-source.png ├── diffscuss-mode ├── tests │ ├── runtests.sh │ ├── testfiles │ │ ├── short.diffscuss │ │ ├── short-post-open-body-line.diffscuss │ │ ├── short-post-open-interline.diffscuss │ │ ├── short-post-open-line-top.diffscuss │ │ ├── short-with-new-top-level.diffscuss │ │ ├── short-with-last-line-comment.diffscuss │ │ ├── short-with-new-hunk-level-comment.diffscuss │ │ ├── short-with-new-interdiff-comment.diffscuss │ │ ├── short-with-new-top-level-reply.diffscuss │ │ ├── short-with-new-second-level-reply.diffscuss │ │ ├── pre-fill.diffscuss │ │ └── post-fill.diffscuss │ ├── test-simple.el │ └── test-diffscuss.el └── run-bare.sh ├── CHANGES.txt ├── diffscuss.vim ├── ftdetect │ └── diffscuss.vim ├── syntax │ └── diffscuss.vim ├── README.md ├── plugin │ └── diffscuss.vim ├── doc │ └── diffscuss.txt └── ftplugin │ └── diffscuss.vim ├── bin └── diffscuss ├── test_requirements.txt ├── scripts └── convert_percent_to_pound.sh ├── test.sh ├── setup.py ├── LICENSE.txt └── diffscuss-notify └── diffscuss-notify.py /diffscuss/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | -------------------------------------------------------------------------------- /diffscuss/support/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyGithub==1.14.2 2 | argparse==1.2.1 3 | requests==1.2.0 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | tmp 4 | env 5 | gh-import.log 6 | gh-import 7 | build 8 | localdev 9 | -------------------------------------------------------------------------------- /doc/diffscuss-added-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomheon/diffscuss/HEAD/doc/diffscuss-added-comment.png -------------------------------------------------------------------------------- /doc/diffscuss-created-review.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomheon/diffscuss/HEAD/doc/diffscuss-created-review.png -------------------------------------------------------------------------------- /doc/diffscuss-jump-to-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomheon/diffscuss/HEAD/doc/diffscuss-jump-to-source.png -------------------------------------------------------------------------------- /diffscuss-mode/tests/runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | emacs -L . --batch --no-site-file --no-splash --load test-diffscuss.el 3 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | v1.0, 2013-05-01 -- Initial release. 2 | v2.0.0, 2014-07-09 -- Change review format (use # instead of %), incompatible with v1 reviews. 3 | -------------------------------------------------------------------------------- /diffscuss.vim/ftdetect/diffscuss.vim: -------------------------------------------------------------------------------- 1 | au BufNewFile *.diffscuss setlocal filetype=diffscuss 2 | au BufRead *.diffscuss setlocal filetype=diffscuss 3 | -------------------------------------------------------------------------------- /bin/diffscuss: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$DIFFSCUSS_PYTHON" == "" ]; then 4 | DIFFSCUSS_PYTHON="python" 5 | fi 6 | 7 | $DIFFSCUSS_PYTHON -m diffscuss.cli "$@" 8 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | # The following are not needed to run diffscuss normally, but are 2 | # required for the test suite (emacs is also required if you wish to 3 | # run the emacs tests) 4 | 5 | impermagit==1.0.0 6 | nose==1.3.0 7 | -------------------------------------------------------------------------------- /diffscuss-mode/run-bare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Run emacs with nothing else but diffscuss-mode.el loaded (e.g. no 4 | # user or site .emacs) to make it easier to see what's going on. 5 | 6 | emacs -q -L . --no-site-file --no-splash --load diffscuss-mode.el "$@" 7 | -------------------------------------------------------------------------------- /diffscuss/mailbox/set_default_inbox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Set your default inbox. 4 | """ 5 | 6 | from diffscuss.mailbox.common import check_inited, set_inbox_name 7 | 8 | 9 | def main(args): 10 | check_inited(args.git_exe) 11 | inbox = args.inbox 12 | set_inbox_name(inbox, args.git_exe) 13 | -------------------------------------------------------------------------------- /diffscuss/mailbox/done.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Remove a review from an inbox. 4 | """ 5 | 6 | from diffscuss.mailbox.common import dmb_done 7 | 8 | 9 | def main(args): 10 | review_path = dmb_done(args.file, args.from_inbox, args.git_exe) 11 | if args.print_review_path: 12 | print review_path 13 | -------------------------------------------------------------------------------- /scripts/convert_percent_to_pound.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Older diffscuss files used %* and %- instead of #* and #- for header 4 | # / body lines. Newer versions of the code won't read these older 5 | # files. This script takes an old diffscuss file on stdin and prints 6 | # it in the new version to stdout. 7 | 8 | sed -e 's/^%/#/' 9 | -------------------------------------------------------------------------------- /diffscuss/mailbox/post.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Post a review to one or more inboxes. 4 | """ 5 | 6 | from diffscuss.mailbox.common import dmb_post 7 | 8 | 9 | def main(args): 10 | review_path = dmb_post(args.file, [args.inbox] + args.inboxes, 11 | args.git_exe) 12 | if args.print_review_path: 13 | print review_path 14 | -------------------------------------------------------------------------------- /diffscuss/header.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | HEADER_LINE_RE = re.compile("^#[*]+ (\w+): (.*)$") 4 | 5 | 6 | def parse_header(header_line): 7 | """ 8 | Return (header-name, header-value), or None if the line can't be 9 | parsed. 10 | """ 11 | match = HEADER_LINE_RE.match(header_line) 12 | if not match: 13 | return None 14 | return (match.group(1), match.group(2)) 15 | -------------------------------------------------------------------------------- /diffscuss/mailbox/make_inbox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Create an inbox. 4 | """ 5 | 6 | from diffscuss.mailbox.common import get_inbox_name, get_inbox_path, \ 7 | check_inited, mkdir_for_keeps 8 | 9 | 10 | def main(args): 11 | check_inited(args.git_exe) 12 | inbox = args.inbox 13 | inbox_path = get_inbox_path(inbox, args.git_exe) 14 | mkdir_for_keeps(inbox_path) 15 | -------------------------------------------------------------------------------- /diffscuss.vim/syntax/diffscuss.vim: -------------------------------------------------------------------------------- 1 | if exists("b:current_syntax") 2 | finish 3 | endif 4 | 5 | runtime! syntax/diff.vim 6 | unlet b:current_syntax 7 | 8 | syn keyword diffscussHeaderField author email date contained 9 | syn match diffscussHeader "^#\*\+.*$" contains=diffscussHeaderField 10 | syn match diffscussBody "^#-\+.*$" 11 | hi link diffscussHeaderField Type 12 | hi link diffscussHeader Todo 13 | hi link diffscussBody Comment 14 | 15 | let b:current_syntax = "diffscuss" 16 | -------------------------------------------------------------------------------- /diffscuss/mailbox/__init__.py: -------------------------------------------------------------------------------- 1 | from diffscuss.mailbox import check, set_default_inbox, make_inbox, \ 2 | init_mailbox, post, bounce, done 3 | 4 | 5 | mod_map = {'check': check, 6 | 'set-default-inbox': set_default_inbox, 7 | 'make-inbox': make_inbox, 8 | 'init': init_mailbox, 9 | 'post': post, 10 | 'bounce': bounce, 11 | 'done': done} 12 | 13 | 14 | def main(args): 15 | mod_map[args.mailbox_subcommand_name].main(args) 16 | -------------------------------------------------------------------------------- /diffscuss.vim/README.md: -------------------------------------------------------------------------------- 1 | # Diffscuss.vim: a Vim plugin for Diffscuss workflows 2 | 3 | (This README describes the implementation of the plugin -- if you're looking 4 | for usage information, see the top-level README file.) 5 | 6 | This directory contains the Vim interface to Diffscuss. If you're looking to 7 | hack on the Vim plugin, this is a good entry point, but note that the plugin 8 | uses the Python code in ```support``` as a "backend" for moving within and 9 | transforming Diffscuss files. 10 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Run all the tests. 4 | 5 | # don't set -e so that all test invocations will run. 6 | 7 | nosetests 8 | 9 | NOSETEST_EXIT=$? 10 | 11 | pushd diffscuss-mode/tests > /dev/null 12 | ./runtests.sh 13 | EMACS_EXIT=$? 14 | popd > /dev/null 15 | 16 | # sum the exit codes of the test suites so all have to return 0 17 | # (assumes that no one returns a negative, I know, but these return 1 18 | # when they fail) 19 | 20 | TO_EXIT=$((NOSETEST_EXIT + EMACS_EXIT)) 21 | 22 | exit $TO_EXIT 23 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | diff --git a/.gitignore b/.gitignore 16 | index 4e59ca6..11a78a5 100644 17 | --- a/.gitignore 18 | +++ b/.gitignore 19 | @@ -1,4 +1,5 @@ 20 | *~ 21 | *.pyc 22 | tmp 23 | env 24 | +gh-export.log 25 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-post-open-body-line.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | #-- 16 | diff --git a/.gitignore b/.gitignore 17 | index 4e59ca6..11a78a5 100644 18 | --- a/.gitignore 19 | +++ b/.gitignore 20 | @@ -1,4 +1,5 @@ 21 | *~ 22 | *.pyc 23 | tmp 24 | env 25 | +gh-export.log 26 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-post-open-interline.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Tes 14 | #-- ting 2. 15 | #-- 16 | diff --git a/.gitignore b/.gitignore 17 | index 4e59ca6..11a78a5 100644 18 | --- a/.gitignore 19 | +++ b/.gitignore 20 | @@ -1,4 +1,5 @@ 21 | *~ 22 | *.pyc 23 | tmp 24 | env 25 | +gh-export.log 26 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-post-open-line-top.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* 3 | #* author: Edmund Jorgensen 4 | #* email: tomheon@gmail.com 5 | #* date: 2013-04-05T16:17:27-0400 6 | #* 7 | #- Testing 1. 8 | #- 9 | #** 10 | #** author: Edmund Jorgensen 11 | #** email: tomheon@gmail.com 12 | #** date: 2013-04-05T16:17:58-0400 13 | #** 14 | #-- Testing 2. 15 | #-- 16 | diff --git a/.gitignore b/.gitignore 17 | index 4e59ca6..11a78a5 100644 18 | --- a/.gitignore 19 | +++ b/.gitignore 20 | @@ -1,4 +1,5 @@ 21 | *~ 22 | *.pyc 23 | tmp 24 | env 25 | +gh-export.log 26 | -------------------------------------------------------------------------------- /diffscuss/mailbox/bounce.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Remove a review from one inbox and post it to one or more others. 4 | """ 5 | 6 | from diffscuss.mailbox.common import get_inbox_path, check_inited, \ 7 | dmb_post, dmb_done 8 | 9 | 10 | def main(args): 11 | diffscuss_fname = args.file 12 | recipients = [args.inbox] + args.inboxes 13 | 14 | review_path = dmb_post(diffscuss_fname, recipients, args.git_exe) 15 | dmb_done(diffscuss_fname, args.from_inbox, args.git_exe) 16 | if args.print_review_path: 17 | print review_path 18 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-with-new-top-level.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | #* 16 | #* author: Edmund Jorgensen 17 | #* email: tomheon@gmail.com 18 | #* date: 2013-04-05T17:56:45-0400 19 | #* 20 | #- NEW COMMENT TEXT 21 | #- 22 | diff --git a/.gitignore b/.gitignore 23 | index 4e59ca6..11a78a5 100644 24 | --- a/.gitignore 25 | +++ b/.gitignore 26 | @@ -1,4 +1,5 @@ 27 | *~ 28 | *.pyc 29 | tmp 30 | env 31 | +gh-export.log 32 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-with-last-line-comment.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | diff --git a/.gitignore b/.gitignore 16 | index 4e59ca6..11a78a5 100644 17 | --- a/.gitignore 18 | +++ b/.gitignore 19 | @@ -1,4 +1,5 @@ 20 | *~ 21 | *.pyc 22 | tmp 23 | env 24 | +gh-export.log 25 | #* 26 | #* author: Edmund Jorgensen 27 | #* email: tomheon@gmail.com 28 | #* date: 2013-04-05T19:55:51-0400 29 | #* 30 | #- NEW COMMENT TEXT 31 | #- 32 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-with-new-hunk-level-comment.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | diff --git a/.gitignore b/.gitignore 16 | index 4e59ca6..11a78a5 100644 17 | --- a/.gitignore 18 | +++ b/.gitignore 19 | @@ -1,4 +1,5 @@ 20 | #* 21 | #* author: Edmund Jorgensen 22 | #* email: tomheon@gmail.com 23 | #* date: 2013-04-05T19:51:46-0400 24 | #* 25 | #- NEW COMMENT TEXT 26 | #- 27 | *~ 28 | *.pyc 29 | tmp 30 | env 31 | +gh-export.log 32 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-with-new-interdiff-comment.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | diff --git a/.gitignore b/.gitignore 16 | index 4e59ca6..11a78a5 100644 17 | --- a/.gitignore 18 | +++ b/.gitignore 19 | @@ -1,4 +1,5 @@ 20 | *~ 21 | *.pyc 22 | tmp 23 | #* 24 | #* author: Edmund Jorgensen 25 | #* email: tomheon@gmail.com 26 | #* date: 2013-04-05T19:53:39-0400 27 | #* 28 | #- NEW COMMENT TEXT 29 | #- 30 | env 31 | +gh-export.log 32 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-with-new-top-level-reply.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | #** 16 | #** author: Edmund Jorgensen 17 | #** email: tomheon@gmail.com 18 | #** date: 2013-04-05T19:47:07-0400 19 | #** 20 | #-- NEW COMMENT TEXT 21 | #-- 22 | diff --git a/.gitignore b/.gitignore 23 | index 4e59ca6..11a78a5 100644 24 | --- a/.gitignore 25 | +++ b/.gitignore 26 | @@ -1,4 +1,5 @@ 27 | *~ 28 | *.pyc 29 | tmp 30 | env 31 | +gh-export.log 32 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/short-with-new-second-level-reply.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Testing 2. 14 | #-- 15 | #*** 16 | #*** author: Edmund Jorgensen 17 | #*** email: tomheon@gmail.com 18 | #*** date: 2013-04-05T19:49:43-0400 19 | #*** 20 | #--- NEW COMMENT TEXT 21 | #--- 22 | diff --git a/.gitignore b/.gitignore 23 | index 4e59ca6..11a78a5 100644 24 | --- a/.gitignore 25 | +++ b/.gitignore 26 | @@ -1,4 +1,5 @@ 27 | *~ 28 | *.pyc 29 | tmp 30 | env 31 | +gh-export.log 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='diffscuss', 5 | version='2.0.0', 6 | author='Edmund Jorgensen', 7 | author_email='edmund@hut8labs.com', 8 | packages=['diffscuss', 'diffscuss.support', 9 | 'diffscuss.mailbox'], 10 | scripts=['bin/diffscuss'], 11 | url='http://github.com/hut8labs/diffscuss/', 12 | license='LICENSE.txt', 13 | description='Plain-text code review format and tools.', 14 | long_description='Version 2 introduces a breaking format change (use # instead of % to introduce diffscuss lines).', 15 | install_requires=[ 16 | "PyGithub==1.14.2", 17 | "argparse==1.2.1", 18 | "requests==1.2.0" 19 | ], 20 | ) 21 | -------------------------------------------------------------------------------- /diffscuss/mailbox/init_mailbox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Init a diffscuss-mb code review directory. 4 | """ 5 | 6 | import os 7 | 8 | from diffscuss.mailbox.common import get_git_root, DIFFSCUSS_MB_FILE_NAME, \ 9 | USERS_DIR_NAME, REVIEWS_DIR_NAME, mkdir_for_keeps 10 | 11 | 12 | def main(args): 13 | git_root = get_git_root(args.git_exe) 14 | os.chdir(git_root) 15 | 16 | dmb_root_dir = args.directory 17 | mkdir_for_keeps(dmb_root_dir) 18 | 19 | with open(DIFFSCUSS_MB_FILE_NAME, 'wb') as fil: 20 | fil.write(dmb_root_dir) 21 | 22 | users_dir = os.path.join(dmb_root_dir, USERS_DIR_NAME) 23 | mkdir_for_keeps(users_dir) 24 | 25 | reviews_dir = os.path.join(dmb_root_dir, REVIEWS_DIR_NAME) 26 | mkdir_for_keeps(reviews_dir) 27 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/pre-fill.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Long line this is a long line a really long line hahahaha so long yup yup yup 14 | #-- 15 | #-- and this is one short line 16 | #-- and another short line 17 | #-- 18 | #-- and this is a perfectly filled paragraph already so it shouldn't 19 | #-- do anything when it's filled hahahahahah 20 | #-- 21 | diff --git a/.gitignore b/.gitignore 22 | index 4e59ca6..11a78a5 100644 23 | --- a/.gitignore 24 | +++ b/.gitignore 25 | @@ -1,4 +1,5 @@ 26 | *~ 27 | *.pyc 28 | tmp 29 | env 30 | +gh-export.log 31 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/testfiles/post-fill.diffscuss: -------------------------------------------------------------------------------- 1 | #* 2 | #* author: Edmund Jorgensen 3 | #* email: tomheon@gmail.com 4 | #* date: 2013-04-05T16:17:27-0400 5 | #* 6 | #- Testing 1. 7 | #- 8 | #** 9 | #** author: Edmund Jorgensen 10 | #** email: tomheon@gmail.com 11 | #** date: 2013-04-05T16:17:58-0400 12 | #** 13 | #-- Long line this is a long line a really long line hahahaha so long 14 | #-- yup yup yup 15 | #-- 16 | #-- and this is one short line and another short line 17 | #-- 18 | #-- and this is a perfectly filled paragraph already so it shouldn't 19 | #-- do anything when it's filled hahahahahah 20 | #-- 21 | diff --git a/.gitignore b/.gitignore 22 | index 4e59ca6..11a78a5 100644 23 | --- a/.gitignore 24 | +++ b/.gitignore 25 | @@ -1,4 +1,5 @@ 26 | *~ 27 | *.pyc 28 | tmp 29 | env 30 | +gh-export.log 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Hut 8 Labs, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /diffscuss.vim/plugin/diffscuss.vim: -------------------------------------------------------------------------------- 1 | " === Set up configuration defaults if necessary. 2 | 3 | function! s:Run(command) 4 | let s:output = system(a:command) 5 | return substitute(s:output, '\n\\*', '', 'g') 6 | endfunction 7 | 8 | if !exists("g:diffscuss_config") 9 | let g:diffscuss_config = {} 10 | endif 11 | if !has_key(g:diffscuss_config, "author") 12 | " Try name from git config, then real name from /etc/passwd, then $USER. 13 | let g:diffscuss_config["author"] = Run(" 14 | \ git config --get user.name || echo $USER") 15 | endif 16 | if !has_key(g:diffscuss_config, "email") 17 | " Try email from git config, then $USER@host. 18 | let g:diffscuss_config["email"] = Run(" 19 | \ git config --get user.email || echo $USER@$(hostname)") 20 | endif 21 | if !has_key(g:diffscuss_config, "diffscuss_dir") 22 | " Determine the path starting from this script file. 23 | let g:diffscuss_config["diffscuss_dir"] = expand(":p:h:h:h") 24 | endif 25 | 26 | 27 | " === Load Python functions 28 | 29 | let s:diffscuss_dir = g:diffscuss_config["diffscuss_dir"] 30 | execute printf("pyfile %s/diffscuss/support/vimhelper.py", s:diffscuss_dir) 31 | execute printf("pyfile %s/diffscuss/support/editor.py", s:diffscuss_dir) 32 | 33 | 34 | " === Global mappings 35 | 36 | nnoremap mc :call DiffscussMailboxCheck() 37 | 38 | function! DiffscussMailboxCheck() 39 | python open_preview(mailbox_check) 40 | endfunction 41 | -------------------------------------------------------------------------------- /diffscuss/dates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for diffscuss's notions of dates. 3 | """ 4 | 5 | import calendar 6 | from datetime import datetime, timedelta 7 | import re 8 | 9 | DT_FORMAT = "%Y-%m-%dT%H:%M:%S%z" 10 | 11 | 12 | # this is for internal parsing / formatting only, don't use it. 13 | INTERNAL_DT_FORMAT_NO_TZ = "%Y-%m-%dT%H:%M:%S" 14 | 15 | 16 | def utc_to_local(utc_dt): 17 | """ 18 | From http://stackoverflow.com/a/13287083 (thanks J.F. Sebastian). 19 | """ 20 | # get integer timestamp to avoid precision lost 21 | timestamp = calendar.timegm(utc_dt.timetuple()) 22 | local_dt = datetime.fromtimestamp(timestamp) 23 | assert utc_dt.resolution >= timedelta(microseconds=1) 24 | return local_dt.replace(microsecond=utc_dt.microsecond) 25 | 26 | 27 | 28 | def parse_to_local_dt(dt_s): 29 | return utc_to_local(parse_to_utc_dt(dt_s)) 30 | 31 | 32 | def parse_to_utc_dt(dt_s): 33 | dt_no_tz = dt_s[:-6] 34 | tz = dt_s[-5:] 35 | dt = datetime.strptime(dt_no_tz, INTERNAL_DT_FORMAT_NO_TZ) 36 | td, is_neg = _tz_to_timedelta(tz) 37 | if is_neg: 38 | dt += td 39 | else: 40 | dt -= td 41 | return dt 42 | 43 | 44 | TZ_RE = re.compile(r"(\+|-)(\d{2})(\d{2})") 45 | 46 | def _tz_to_timedelta(tz): 47 | match = TZ_RE.match(tz) 48 | hours = int(match.group(2)) 49 | minutes = int(match.group(3)) 50 | return (timedelta(seconds=((60 * minutes) + 51 | (60 * 60 * hours))), 52 | match.group(1) == "-") 53 | -------------------------------------------------------------------------------- /diffscuss/tests/testfiles/comment_in_header.diffscuss: -------------------------------------------------------------------------------- 1 | diff --git a/diffscuss-mode/diffscuss-mode.el b/diffscuss-mode/diffscuss-mode.el{COMMENT_IN_HEADER_1} 2 | index e95bace..404b745 100644{COMMENT_IN_HEADER_2} 3 | --- a/diffscuss-mode/diffscuss-mode.el{COMMENT_IN_HEADER_3} 4 | +++ b/diffscuss-mode/diffscuss-mode.el{COMMENT_IN_HEADER_4} 5 | @@ -345,6 +345,10 @@ 6 | 7 | ;; insert / reply to comment commands 8 | 9 | +(defun diffscuss-get-date-time () 10 | + "Get the current local date and time in ISO 8601." 11 | + (format-time-string "%Y-%m-%dT%T%z")) 12 | + 13 | (defun diffscuss-make-comment (leader) 14 | "Return a new comment." 15 | (let ((header (diffscuss-force-header leader))) 16 | diff --git a/diffscuss/walker.py b/diffscuss/walker.py{COMMENT_IN_HEADER_5} 17 | index 74384c1..5852f4a 100644{COMMENT_IN_HEADER_6} 18 | --- a/diffscuss/walker.py{COMMENT_IN_HEADER_7} 19 | +++ b/diffscuss/walker.py{COMMENT_IN_HEADER_8} 20 | @@ -72,10 +72,16 @@ def walk(fil): 21 | # level can increase by more than one.... 22 | if line_level - cur_comment_level > 1: 23 | raise BadNestingException() 24 | + 25 | # or if we've changed level mid-comment... 26 | - if line_level != cur_comment_level and not _is_author_line(line): 27 | + if (line_level != cur_comment_level 28 | + #and not _is_author_line(line) 29 | + and not _is_header(line)): 30 | raise BadNestingException() 31 | 32 | + # At this point, we accept the new line_level 33 | + cur_comment_level = line_level 34 | + 35 | # or if this is a header line of a comment and it's not 36 | # either following a header or is an author line or an empty line... 37 | if (is_header and 38 | -------------------------------------------------------------------------------- /diffscuss.vim/doc/diffscuss.txt: -------------------------------------------------------------------------------- 1 | *diffscuss.txt* Diffscuss.vim: Code Reviews. In Plain Text. *diffscuss* 2 | 3 | CONTENTS *diffscuss-contents* 4 | 5 | 1. About |diffscuss-about| 6 | 2. Configuration |diffscuss-config| 7 | 3. Keys |diffscuss-keys| 8 | 9 | =========================================================================== 10 | 1. ABOUT *diffscuss-about* 11 | 12 | Diffscuss.vim is a Vim plugin for working with Diffscuss files. It offers 13 | syntax highlighting, comment insertion, commands for jumping to source files, 14 | motions for comments and threads, and easy access to the Diffscuss mailbox 15 | scripts from within Vim. 16 | 17 | =========================================================================== 18 | 2. CONFIGURATION *diffscuss-config* 19 | 20 | The Vim plugin will use its environment and 'git config' to determine your name 21 | and email (for pre-filling comments) and for various runtime paths. 22 | 23 | If you wish, you can override these settings by specifying some or all of a 24 | `g:diffscuss_config` dictionary in your `.vimrc`: > 25 | 26 | let g:diffscuss_config = { 27 | \'author': 'Your Name', 28 | \'email': 'your.email@example.com', 29 | \'diffscuss_dir': '/path/to/diffscuss', 30 | \'python': '/path/to/python' 31 | \} 32 | < 33 | 34 | =========================================================================== 35 | 3. KEYS *diffscuss-keys* 36 | 37 | Inserting Comments 38 | 39 | dd insert a new comment contextually 40 | df insert a new review-level comment 41 | dr insert a new reply 42 | di insert a new comment 43 | 44 | Showing the Source 45 | 46 | do show the old source version 47 | dn show the new source version 48 | ds show the local source version 49 | 50 | Navigation 51 | 52 | ]d jump to the start of the next comment 53 | [d jump to the start of the previous comment 54 | ]D jump to the end of the next comment 55 | [D jump to the end of the previous comment 56 | ]t jump to the start of the next thread 57 | [t jump to the start of the previous thread 58 | ]T jump to the end of the next thread 59 | [T jump to the end of the previous thread 60 | 61 | Mailboxes 62 | 63 | mp posts the current Diffscuss file 64 | mb bounces the review and removes it from your inbox 65 | md marks the review as done and removes it from your inbox 66 | mc opens a preview window with a list of all incoming reviews 67 | 68 | 69 | vim:tw=78:ts=8:ft=help:norl: 70 | -------------------------------------------------------------------------------- /diffscuss/support/vimhelper.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions for adapting the generic buffer/cursor manipulation code in editor.py 3 | to Vim. 4 | """ 5 | import vim 6 | 7 | 8 | def config(): 9 | """ 10 | Returns the current configuration for diffscuss.vim from Vim as a dict. 11 | """ 12 | return dict(vim.eval('g:diffscuss_config')) 13 | 14 | 15 | def return_expr(func): 16 | """ 17 | Calls `func` with the current buffer and cursor as arguments, and returns 18 | the resulting expression as a string. 19 | """ 20 | win = vim.current.window 21 | return vim.command('return "%s"' % func(win.buffer, win.cursor)) 22 | 23 | 24 | def update_buffer(*funcs): 25 | """ 26 | For each function argument, updates the cursor to the result of calling 27 | the function on the current buffer and cursor. 28 | """ 29 | win = vim.current.window 30 | for func in funcs: 31 | win.cursor = func(win.buffer, win.cursor) 32 | 33 | 34 | def run_mailbox(func): 35 | """ 36 | Calls the function, passing it the name of the current buffer and a 37 | function with which it can prompt the user. 38 | 39 | If the function returns non-None, the result will be displayed to the user. 40 | If it raises an exception, the exception will be displayed to the user. 41 | """ 42 | buffer_name = vim.current.buffer.name 43 | prompt_func = lambda prompt: vim.eval('input("%s")' % prompt) 44 | try: 45 | message = func(buffer_name, prompt_func) 46 | vim.command("silent! normal! :bd %s\n" % buffer_name) 47 | if message: 48 | vim.command('echom "%s"' % message) 49 | except Exception, e: 50 | vim.command('echom "%s"' % e) 51 | 52 | 53 | def open_preview(func): 54 | """ 55 | Calls the function, passing it the current buffer and cursor and the path 56 | to a tempfile (managed by Vim, removed on Vim close). 57 | 58 | If the function does not raise an exception, the contents of the tempfile 59 | will be opened in the preview window. If the function returns non-None, 60 | that value will be used as the line number to jump to in the preview 61 | window. 62 | 63 | If the function raises an exception, the exception will be displayed to the 64 | user. 65 | """ 66 | tempfile = vim.eval('tempname()') 67 | win = vim.current.window 68 | try: 69 | lineno = func(win.buffer, win.cursor, tempfile) 70 | if lineno is not None: 71 | vim.command("normal! :pedit +%d %s\n" % (lineno, tempfile)) 72 | else: 73 | vim.command("normal! :pedit %s\n" % tempfile) 74 | except Exception, e: 75 | vim.command('echom "%s"' % e) 76 | 77 | 78 | def open_file(func): 79 | """ 80 | Calls the function, passing it the current buffer and cursor. 81 | 82 | If the function does not raise an exception, the return value of the 83 | function will be opened in the current window. 84 | 85 | If the function raises an exception, the exception will be displayed to the 86 | user. 87 | """ 88 | win = vim.current.window 89 | try: 90 | filename = func(win.buffer, win.cursor) 91 | vim.command("normal! :e %s\n" % filename) 92 | except Exception, e: 93 | vim.command('echom "%s"' % e) 94 | -------------------------------------------------------------------------------- /diffscuss-notify/diffscuss-notify.py: -------------------------------------------------------------------------------- 1 | """ 2 | An extremely dumb HTTP server intended to be a target for a Github 3 | webhook and notify people by email when a review is added to their 4 | inbox. 5 | 6 | It assumes that the name of the inbox is the same as the first part of 7 | the user's email--i.e., if an inbox is called "bob" and the domain 8 | passed on the command line is "example.com", notifications will be 9 | sent to "bob@example.com." 10 | 11 | SMTP settings are read from the following env settings and starttls is 12 | used: 13 | 14 | DIFFSCUSS_NOTIFY_SMTP_USER 15 | 16 | DIFFSCUSS_NOTIFY_SMTP_PASSWORD 17 | 18 | DIFFSCUSS_NOTIFY_FROM 19 | 20 | DIFFSCUSS_NOTIFY_SMTP_SERVER 21 | 22 | Usage is: 23 | 24 | For example: 25 | 26 | python diffscuss-notify.py 80 hut8labs.com diffscussions/users/ 27 | """ 28 | from collections import defaultdict 29 | import BaseHTTPServer 30 | import SocketServer 31 | import cgi 32 | import json 33 | import sys 34 | import os 35 | import smtplib 36 | 37 | 38 | def notify(addy, repo_name, repo_url, reviews): 39 | username = os.environ['DIFFSCUSS_NOTIFY_SMTP_USER'] 40 | password = os.environ['DIFFSCUSS_NOTIFY_SMTP_PASSWORD'] 41 | fromaddr = os.environ['DIFFSCUSS_NOTIFY_FROM'] 42 | toaddrs = addy 43 | msg = \ 44 | ("Subject: New Diffscussion in %s\r\n\r\n" 45 | "You have %s new diffscussions in %s (%s):\n\n%s") % ( 46 | repo_name, 47 | len(reviews), 48 | repo_name, 49 | repo_url, 50 | "\n".join(reviews)) 51 | 52 | server = smtplib.SMTP(os.environ['DIFFSCUSS_NOTIFY_SMTP_SERVER']) 53 | server.starttls() 54 | server.login(username, password) 55 | server.sendmail(fromaddr, toaddrs, msg) 56 | server.quit() 57 | 58 | 59 | class ServerHandler(BaseHTTPServer.BaseHTTPRequestHandler): 60 | 61 | def do_head(self): 62 | self.do_GET() 63 | 64 | def do_GET(self): 65 | self.send_response(200) 66 | self.send_header("Content-Length", 1) 67 | self.wfile.write(' ') 68 | 69 | def do_POST(self): 70 | global diffscuss_user_dir 71 | global domain 72 | 73 | # username -> list of added reviews 74 | folks_with_diffscussions = defaultdict(list) 75 | 76 | form = cgi.FieldStorage( 77 | fp=self.rfile, 78 | headers=self.headers, 79 | environ={"REQUEST_METHOD": "POST"}) 80 | 81 | json_payload = form["payload"].value 82 | payload = json.loads(json_payload) 83 | for commit in payload[u"commits"]: 84 | for fpath in commit[u"added"]: 85 | fpath = fpath.encode('utf-8') 86 | if fpath.startswith(diffscuss_user_dir): 87 | user, review = os.path.split( 88 | fpath[len(diffscuss_user_dir):]) 89 | folks_with_diffscussions[user].append(review) 90 | 91 | for (user, reviews) in folks_with_diffscussions.items(): 92 | notify("%s@%s" % (user, domain), 93 | payload["repository"]["name"], 94 | payload["repository"]["url"], 95 | reviews) 96 | 97 | 98 | self.do_GET() 99 | 100 | 101 | if __name__ == '__main__': 102 | port = int(sys.argv[1]) 103 | domain = sys.argv[2] 104 | diffscuss_user_dir = sys.argv[3] 105 | if not diffscuss_user_dir.endswith('/'): 106 | diffscuss_user_dir = diffscuss_user_dir + '/' 107 | 108 | Handler = ServerHandler 109 | 110 | httpd = SocketServer.TCPServer(("", port), Handler) 111 | 112 | print "serving at port", port 113 | httpd.serve_forever() 114 | -------------------------------------------------------------------------------- /diffscuss/tests/testfiles/standard_template.diffscuss: -------------------------------------------------------------------------------- 1 | {COMMENT_TOP}diff --git a/diffscuss-mode/diffscuss-mode.el b/diffscuss-mode/diffscuss-mode.el 2 | index e95bace..404b745 100644 3 | --- a/diffscuss-mode/diffscuss-mode.el 4 | +++ b/diffscuss-mode/diffscuss-mode.el 5 | @@ -345,6 +345,10 @@{COMMENT_AFTER_RANGE_1} 6 | 7 | ;; insert / reply to comment commands 8 | 9 | +(defun diffscuss-get-date-time () 10 | + "Get the current local date and time in ISO 8601."{COMMENT_IN_DIFF_1} 11 | + (format-time-string "%Y-%m-%dT%T%z")) 12 | + 13 | (defun diffscuss-make-comment (leader) 14 | "Return a new comment." 15 | (let ((header (diffscuss-force-header leader))){COMMENT_IN_DIFF_2} 16 | @@ -355,6 +359,10 @@{COMMENT_AFTER_RANGE_2} 17 | (diffscuss-get-author) 18 | "\n") 19 | header 20 | + " date: " 21 | + (diffscuss-get-date-time) 22 | + "\n" 23 | + header 24 | "\n" 25 | (diffscuss-force-body leader) 26 | " \n" 27 | @@ -384,12 +392,42 @@ 28 | (forward-line -1) 29 | (end-of-line)) 30 | 31 | +(defun diffscuss-insert-file-comment () 32 | + "Insert a file-level comment." 33 | + (interactive) 34 | + (beginning-of-buffer) 35 | + (insert (diffscuss-make-comment "%*")) 36 | + (newline) 37 | + (forward-line -2) 38 | + (end-of-line)) 39 | + 40 | +(defun diffscuss-in-header-p () 41 | + "True if we're in the header material." 42 | + ;; if we travel up until we hit a meta line, we'll hit a range line 43 | + ;; first if we're not in a header, otherwise we'll hit a different 44 | + ;; meta line. 45 | + (save-excursion 46 | + (while (and (not (diffscuss-meta-line-p)) 47 | + (zerop (forward-line -1)))) 48 | + (not (diffscuss-range-line-p)))) 49 | + 50 | (defun diffscuss-comment-or-reply () 51 | "Insert a comment or reply based on context." 52 | (interactive) 53 | - (if (diffscuss-parse-leader) 54 | - (diffscuss-reply-to-comment) 55 | - (diffscuss-insert-comment))) 56 | + ;; if at the very top of the file, insert a comment for the entire 57 | + ;; file (meaning before any of the diff headers or lines) 58 | + (if (= (point) 1) 59 | + (diffscuss-insert-file-comment) 60 | + ;; otherwise, if we're already in a comment, reply to it. 61 | + (if (diffscuss-parse-leader) 62 | + (diffscuss-reply-to-comment) 63 | + ;; if we're on a meta piece, go just past it 64 | + (if (diffscuss-in-header-p) 65 | + (progn (while (and (not (diffscuss-range-line-p)) 66 | + (zerop (forward-line 1)))) 67 | + (diffscuss-insert-comment)) 68 | + ;; otherwise, new top-level comment. 69 | + (diffscuss-insert-comment))))) 70 | 71 | ;; intelligent newline 72 | 73 | @@ -442,7 +480,7 @@ 74 | "Non nil if the current line is part of hunk's meta data." 75 | (save-excursion 76 | (beginning-of-line) 77 | - (not (looking-at "^[% +<>\n\\-]")))) 78 | + (not (looking-at "^[% +\n\\-]")))) 79 | 80 | (defun diffscuss-get-source-file (old-or-new) 81 | "Get the name of the source file." 82 | diff --git a/diffscuss/walker.py b/diffscuss/walker.py 83 | index 74384c1..5852f4a 100644 84 | --- a/diffscuss/walker.py 85 | +++ b/diffscuss/walker.py 86 | @@ -72,10 +72,16 @@ def walk(fil): 87 | # level can increase by more than one.... 88 | if line_level - cur_comment_level > 1: 89 | raise BadNestingException() 90 | + 91 | # or if we've changed level mid-comment... 92 | - if line_level != cur_comment_level and not _is_author_line(line):{COMMENT_IN_DIFF_3} 93 | + if (line_level != cur_comment_level 94 | + #and not _is_author_line(line) 95 | + and not _is_header(line)): 96 | raise BadNestingException() 97 | 98 | + # At this point, we accept the new line_level 99 | + cur_comment_level = line_level 100 | + 101 | # or if this is a header line of a comment and it's not 102 | # either following a header or is an author line or an empty line... 103 | if (is_header and 104 | \ No newline at end of file 105 | {COMMENT_BOTTOM} -------------------------------------------------------------------------------- /diffscuss/tests/testfiles/leading_hash_template.diffscuss: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # also this weird thing 3 | {COMMENT_TOP}diff --git a/diffscuss-mode/diffscuss-mode.el b/diffscuss-mode/diffscuss-mode.el 4 | index e95bace..404b745 100644 5 | --- a/diffscuss-mode/diffscuss-mode.el 6 | +++ b/diffscuss-mode/diffscuss-mode.el 7 | @@ -345,6 +345,10 @@{COMMENT_AFTER_RANGE_1} 8 | 9 | ;; insert / reply to comment commands 10 | 11 | +(defun diffscuss-get-date-time () 12 | + "Get the current local date and time in ISO 8601."{COMMENT_IN_DIFF_1} 13 | + (format-time-string "%Y-%m-%dT%T%z")) 14 | + 15 | (defun diffscuss-make-comment (leader) 16 | "Return a new comment." 17 | (let ((header (diffscuss-force-header leader))){COMMENT_IN_DIFF_2} 18 | @@ -355,6 +359,10 @@{COMMENT_AFTER_RANGE_2} 19 | (diffscuss-get-author) 20 | "\n") 21 | header 22 | + " date: " 23 | + (diffscuss-get-date-time) 24 | + "\n" 25 | + header 26 | "\n" 27 | (diffscuss-force-body leader) 28 | " \n" 29 | @@ -384,12 +392,42 @@ 30 | (forward-line -1) 31 | (end-of-line)) 32 | 33 | +(defun diffscuss-insert-file-comment () 34 | + "Insert a file-level comment." 35 | + (interactive) 36 | + (beginning-of-buffer) 37 | + (insert (diffscuss-make-comment "%*")) 38 | + (newline) 39 | + (forward-line -2) 40 | + (end-of-line)) 41 | + 42 | +(defun diffscuss-in-header-p () 43 | + "True if we're in the header material." 44 | + ;; if we travel up until we hit a meta line, we'll hit a range line 45 | + ;; first if we're not in a header, otherwise we'll hit a different 46 | + ;; meta line. 47 | + (save-excursion 48 | + (while (and (not (diffscuss-meta-line-p)) 49 | + (zerop (forward-line -1)))) 50 | + (not (diffscuss-range-line-p)))) 51 | + 52 | (defun diffscuss-comment-or-reply () 53 | "Insert a comment or reply based on context." 54 | (interactive) 55 | - (if (diffscuss-parse-leader) 56 | - (diffscuss-reply-to-comment) 57 | - (diffscuss-insert-comment))) 58 | + ;; if at the very top of the file, insert a comment for the entire 59 | + ;; file (meaning before any of the diff headers or lines) 60 | + (if (= (point) 1) 61 | + (diffscuss-insert-file-comment) 62 | + ;; otherwise, if we're already in a comment, reply to it. 63 | + (if (diffscuss-parse-leader) 64 | + (diffscuss-reply-to-comment) 65 | + ;; if we're on a meta piece, go just past it 66 | + (if (diffscuss-in-header-p) 67 | + (progn (while (and (not (diffscuss-range-line-p)) 68 | + (zerop (forward-line 1)))) 69 | + (diffscuss-insert-comment)) 70 | + ;; otherwise, new top-level comment. 71 | + (diffscuss-insert-comment))))) 72 | 73 | ;; intelligent newline 74 | 75 | @@ -442,7 +480,7 @@ 76 | "Non nil if the current line is part of hunk's meta data." 77 | (save-excursion 78 | (beginning-of-line) 79 | - (not (looking-at "^[% +<>\n\\-]")))) 80 | + (not (looking-at "^[% +\n\\-]")))) 81 | 82 | (defun diffscuss-get-source-file (old-or-new) 83 | "Get the name of the source file." 84 | diff --git a/diffscuss/walker.py b/diffscuss/walker.py 85 | index 74384c1..5852f4a 100644 86 | --- a/diffscuss/walker.py 87 | +++ b/diffscuss/walker.py 88 | @@ -72,10 +72,16 @@ def walk(fil): 89 | # level can increase by more than one.... 90 | if line_level - cur_comment_level > 1: 91 | raise BadNestingException() 92 | + 93 | # or if we've changed level mid-comment... 94 | - if line_level != cur_comment_level and not _is_author_line(line):{COMMENT_IN_DIFF_3} 95 | + if (line_level != cur_comment_level 96 | + #and not _is_author_line(line) 97 | + and not _is_header(line)): 98 | raise BadNestingException() 99 | 100 | + # At this point, we accept the new line_level 101 | + cur_comment_level = line_level 102 | + 103 | # or if this is a header line of a comment and it's not 104 | # either following a header or is an author line or an empty line... 105 | if (is_header and 106 | \ No newline at end of file 107 | {COMMENT_BOTTOM} -------------------------------------------------------------------------------- /diffscuss.vim/ftplugin/diffscuss.vim: -------------------------------------------------------------------------------- 1 | " === Mappings and setup 2 | 3 | " Comment insertion 4 | nnoremap di :call DiffscussInsertComment() 5 | nnoremap df :call DiffscussInsertFileComment() 6 | nnoremap dr :call DiffscussReplyToComment() 7 | nnoremap dd :call DiffscussInsertContextualComment() 8 | 9 | " Showing source 10 | nnoremap dn :call DiffscussShowNewSource() 11 | nnoremap do :call DiffscussShowOldSource() 12 | nnoremap ds :call DiffscussShowLocalSource() 13 | 14 | " Mailboxes 15 | nnoremap mp :call DiffscussMailboxPost() 16 | nnoremap mb :call DiffscussMailboxBounce() 17 | nnoremap md :call DiffscussMailboxDone() 18 | 19 | " Navigation 20 | nnoremap ]d :call DiffscussNextComment() 21 | nnoremap [d :call DiffscussPrevComment() 22 | nnoremap ]D :call DiffscussNextCommentEnd() 23 | nnoremap [D :call DiffscussPrevCommentEnd() 24 | nnoremap ]t :call DiffscussNextThread() 25 | nnoremap [t :call DiffscussPrevThread() 26 | nnoremap ]T :call DiffscussNextThreadEnd() 27 | nnoremap [T :call DiffscussPrevThreadEnd() 28 | 29 | " Auto-formatting 30 | set comments=nb:#-,nb:#*,nb:#--,nb:#**,nb:#---,nb:#***,nb:#----,nb:#****,nb:#-----,nb:#******,nb:#------,nb:#*******,nb:#-------,nb:#******** 31 | set formatoptions=tcqron 32 | 33 | " Folding 34 | " set foldmethod=expr 35 | set foldexpr=DiffscussFold(v:lnum) 36 | 37 | function! DiffscussFold(lnum) 38 | let match = matchlist(getline(a:lnum), "^#[*-]\\+") 39 | if match != [] 40 | return string(len(match[0]) - 1) 41 | else 42 | return '0' 43 | endif 44 | endfunction 45 | 46 | 47 | " === Comment insertion 48 | 49 | function! DiffscussInsertComment() 50 | python update_buffer(insert_comment) 51 | start! 52 | endfunction 53 | 54 | function! DiffscussInsertFileComment() 55 | python update_buffer(insert_file_comment) 56 | start! 57 | endfunction 58 | 59 | function! DiffscussReplyToComment() 60 | python update_buffer(reply_to_comment) 61 | start! 62 | endfunction 63 | 64 | function! DiffscussInsertContextualComment() 65 | python update_buffer(insert_contextual_comment) 66 | start! 67 | endfunction 68 | 69 | " === Showing source 70 | 71 | function! DiffscussShowOldSource() 72 | python open_preview(show_old_source) 73 | endfunction 74 | 75 | function! DiffscussShowNewSource() 76 | python open_preview(show_new_source) 77 | endfunction 78 | 79 | function! DiffscussShowLocalSource() 80 | python open_file(show_local_source) 81 | endfunction 82 | 83 | " === Mailboxes 84 | 85 | function! DiffscussMailboxPost() 86 | python run_mailbox(mailbox_post) 87 | endfunction 88 | 89 | function! DiffscussMailboxBounce() 90 | python run_mailbox(mailbox_bounce) 91 | endfunction 92 | 93 | function! DiffscussMailboxDone() 94 | python run_mailbox(mailbox_done) 95 | endfunction 96 | 97 | " === Navigation 98 | 99 | function! DiffscussNextComment() 100 | python update_buffer(find_next_comment) 101 | endfunction 102 | 103 | function! DiffscussPrevComment() 104 | python update_buffer(find_prev_comment) 105 | endfunction 106 | 107 | function! DiffscussNextThread() 108 | python update_buffer(find_next_thread) 109 | endfunction 110 | 111 | function! DiffscussPrevThread() 112 | python update_buffer(find_prev_thread) 113 | endfunction 114 | 115 | function! DiffscussNextCommentEnd() 116 | python update_buffer(find_next_comment_end) 117 | endfunction 118 | 119 | function! DiffscussPrevCommentEnd() 120 | python update_buffer(find_prev_comment_end) 121 | endfunction 122 | 123 | function! DiffscussNextThreadEnd() 124 | python update_buffer(find_next_thread_end) 125 | endfunction 126 | 127 | function! DiffscussPrevThreadEnd() 128 | python update_buffer(find_prev_thread_end) 129 | endfunction 130 | -------------------------------------------------------------------------------- /diffscuss/generate.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import time 4 | 5 | 6 | def _check_output(*popenargs, **kwargs): 7 | """ 8 | Run command with arguments and return its output as a byte string. 9 | 10 | Backported from Python 2.7 as it's implemented as pure python on 11 | stdlib. 12 | 13 | >>> check_output(['/usr/bin/python', '--version']) 14 | Python 2.6.2 15 | 16 | Copied from https://gist.github.com/edufelipe/1027906. 17 | """ 18 | process = subprocess.Popen(stdout=subprocess.PIPE, 19 | stderr=subprocess.PIPE, 20 | *popenargs, **kwargs) 21 | output, err = process.communicate() 22 | retcode = process.poll() 23 | if retcode: 24 | cmd = kwargs.get("args") 25 | if cmd is None: 26 | cmd = popenargs[0] 27 | error = subprocess.CalledProcessError(retcode, cmd) 28 | error.output = '\n'.join([output, err]) 29 | raise error 30 | return output 31 | 32 | 33 | if 'check_output' not in dir(subprocess): 34 | check_output = _check_output 35 | else: 36 | check_output = subprocess.check_output 37 | 38 | 39 | def _git_config(config_name, git_exe): 40 | return check_output(_git_cmd(git_exe, 41 | ["config", 42 | "--get", 43 | config_name])).strip() 44 | 45 | 46 | def _git_cmd(git_exe, cmd): 47 | if git_exe: 48 | git_cmd = [git_exe.split()] 49 | else: 50 | git_cmd = ["/usr/bin/env", "git"] 51 | return git_cmd + cmd 52 | 53 | 54 | def _git_user_name(git_exe): 55 | return _git_config("user.name", git_exe) 56 | 57 | 58 | def _git_user_email(git_exe): 59 | return _git_config("user.email", git_exe) 60 | 61 | 62 | def _iso_time(): 63 | return time.strftime("%Y-%m-%dT%H:%M:%S%z") 64 | 65 | 66 | def _git_log(revision, git_exe): 67 | """ 68 | Return a list of log lines. 69 | """ 70 | return check_output(_git_cmd(git_exe, 71 | ["log", "--pretty=format:%B%n", 72 | "--reverse", revision])).split('\n') 73 | 74 | 75 | def _write_diff(revision, path, lines_context, output_f, git_exe): 76 | output_f.write(check_output(_git_cmd(git_exe, 77 | ["diff", 78 | "--unified=%d" % lines_context, 79 | revision] + path))) 80 | 81 | 82 | def _write_diffscuss_header(output_f, author, email, git_exe): 83 | if author is None: 84 | author = _git_user_name(git_exe) 85 | if email is None: 86 | email = _git_user_email(git_exe) 87 | 88 | iso_time = _iso_time() 89 | 90 | header_lines = ['', 91 | "author: %s" % author, 92 | "email: %s" % email, 93 | "date: %s" % iso_time, 94 | ''] 95 | header_lines = ['#* %s' % s for s in header_lines] 96 | 97 | output_f.write('\n'.join(header_lines)) 98 | output_f.write('\n') 99 | 100 | 101 | def _write_diffscuss_body(output_f, revision, git_exe): 102 | log_lines = ['#- %s' % s for s in _git_log(revision, git_exe)] 103 | output_f.write('\n'.join(log_lines)) 104 | output_f.write('\n') 105 | 106 | 107 | def _main(args): 108 | revision = args.git_revision_range 109 | path = args.path 110 | lines_context = args.lines_context 111 | output_fname = args.output_file 112 | author = args.author 113 | email = args.email 114 | git_exe = args.git_exe 115 | 116 | if output_fname is None or output_fname == '-': 117 | output_f = sys.stdout 118 | else: 119 | output_f = open(output_fname, 'wb') 120 | 121 | _write_diffscuss_header(output_f, author, email, git_exe) 122 | _write_diffscuss_body(output_f, revision, git_exe) 123 | _write_diff(revision, path, lines_context, output_f, git_exe) 124 | 125 | if output_fname is not None and output_fname != '-': 126 | output_f.close() 127 | 128 | 129 | def main(args): 130 | try: 131 | _main(args) 132 | except subprocess.CalledProcessError, e: 133 | print >> sys.stderr, e 134 | print >> sys.stderr, e.output 135 | sys.exit(e.returncode) 136 | -------------------------------------------------------------------------------- /diffscuss/walker.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import re 3 | 4 | 5 | class BadNestingException(Exception): 6 | pass 7 | 8 | 9 | class MissingAuthorException(Exception): 10 | pass 11 | 12 | 13 | class EmptyCommentException(Exception): 14 | pass 15 | 16 | 17 | class CommentInHeaderException(Exception): 18 | pass 19 | 20 | 21 | DIFF_HEADER = 'DIFF_HEADER' 22 | DIFF = 'DIFF' 23 | COMMENT_HEADER = 'COMMENT_HEADER' 24 | COMMENT_BODY = 'COMMENT_BODY' 25 | 26 | def walk(fil): 27 | """ 28 | Walk a Diffscuss file, yielding either: 29 | 30 | (DIFF, line) 31 | 32 | For each line that is part of a diff, 33 | 34 | (DIFF_HEADER, line) 35 | 36 | For each diff header line (e.g. Index lines, range lines), 37 | 38 | (COMMENT_HEADER, line) 39 | 40 | for each diffscuss comment header line, or 41 | 42 | (COMMENT_BODY, line) 43 | 44 | for each diffscuss body line. 45 | 46 | @fil: a file-like object containing Diffscuss. 47 | 48 | The default error handler raises the following exceptions: 49 | 50 | MissingAuthorException: if there's no author header at the start 51 | of a comment. 52 | 53 | BadNestingException: if a comment is improperly nested. 54 | 55 | EmptyCommentException: if a comment has no body. 56 | 57 | CommentInHeaderException: if a comment appears in a diff header. 58 | """ 59 | line = fil.readline() 60 | in_header = False 61 | 62 | # allow the normal magic header lines (such as encoding), but 63 | # don't consider them part of the diffscuss file. 64 | while line.startswith("#") and not _is_diffscuss_line(line): 65 | line = fil.readline() 66 | 67 | while True: 68 | 69 | if not line: 70 | break 71 | 72 | if _is_diffscuss_line(line): 73 | if in_header: 74 | raise CommentInHeaderException() 75 | tagged_comment_lines, line = _read_comment(line, fil) 76 | for tag, comment_line in tagged_comment_lines: 77 | yield (tag, comment_line) 78 | # continue so we don't read another line at the bottom 79 | continue 80 | elif in_header or _is_not_diff_line(line): 81 | # check for non-diff line has to come second, since the 82 | # --- and +++ in the header will read as diff lines 83 | # otherwise 84 | yield (DIFF_HEADER, line) 85 | in_header = not _is_range_line(line) 86 | else: 87 | yield (DIFF, line) 88 | 89 | line = fil.readline() 90 | 91 | 92 | def _read_comment(line, fil): 93 | header_lines, line = _read_header(line, fil) 94 | _check_header(header_lines) 95 | body_lines, line = _read_body(line, fil) 96 | _check_body(body_lines) 97 | return ([(COMMENT_HEADER, header_line) 98 | for header_line 99 | in header_lines] + 100 | [(COMMENT_BODY, body_line) 101 | for body_line 102 | in body_lines], 103 | line) 104 | 105 | 106 | def _check_body(body_lines): 107 | if not body_lines: 108 | raise EmptyCommentException() 109 | 110 | 111 | def _check_header(header_lines): 112 | for line in header_lines: 113 | if _is_author_line(line): 114 | return 115 | if not _is_empty_header(line): 116 | raise MissingAuthorException() 117 | raise MissingAuthorException() 118 | 119 | 120 | def _level(line): 121 | header_match = _is_header(line) 122 | if header_match: 123 | return len(header_match.group(1)) - 1 124 | 125 | body_match = _is_body(line) 126 | if body_match: 127 | return len(body_match.group(1)) - 1 128 | 129 | return None 130 | 131 | 132 | def _read_header(line, fil): 133 | return _read_comment_part(line, fil, _is_header) 134 | 135 | 136 | def _read_body(line, fil): 137 | return _read_comment_part(line, fil, _is_body) 138 | 139 | 140 | def _read_comment_part(line, fil, pred): 141 | part_lines = [] 142 | level = _level(line) 143 | 144 | while True: 145 | if not pred(line): 146 | break 147 | if _level(line) != level: 148 | raise BadNestingException() 149 | part_lines.append(line) 150 | line = fil.readline() 151 | 152 | return part_lines, line 153 | 154 | 155 | HEADER_RE = re.compile(r'^(#[*]+)( |$)') 156 | EMPTY_HEADER_RE = re.compile(r'^(#[*]+)\s*$') 157 | 158 | 159 | def _is_header(line): 160 | return HEADER_RE.match(line) 161 | 162 | 163 | def _is_empty_header(line): 164 | return EMPTY_HEADER_RE.match(line) 165 | 166 | 167 | AUTHOR_RE = re.compile(r'^(#[*]+) author: ') 168 | 169 | 170 | def _is_author_line(line): 171 | return AUTHOR_RE.match(line) 172 | 173 | 174 | BODY_RE = re.compile(r'^(#[-]+)( |$)') 175 | 176 | 177 | def _is_body(line): 178 | return BODY_RE.match(line) 179 | 180 | 181 | def _is_range_line(line): 182 | return line.startswith('@@') 183 | 184 | 185 | def _is_diffscuss_line(line): 186 | return line.startswith('#*') or line.startswith('#-') 187 | 188 | 189 | # legal starts to a unified diff line inside a hunk 190 | DIFF_CHARS = (' ', '+', '-', '\\') 191 | 192 | 193 | def _is_not_diff_line(line): 194 | """ 195 | Treat a totally blank line as a diff line to be flexible, since emacs 196 | can strip trailing spaces. 197 | """ 198 | return line.strip() and not line.startswith(DIFF_CHARS) 199 | 200 | -------------------------------------------------------------------------------- /diffscuss/mailbox/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | import subprocess 5 | from subprocess import check_call 6 | 7 | 8 | def _git_cmd(git_exe, cmd): 9 | if git_exe is None: 10 | git_exe = ["/usr/bin/env", "git"] 11 | else: 12 | git_exe = [git_exe] 13 | return git_exe + cmd 14 | 15 | 16 | def _check_output(*popenargs, **kwargs): 17 | """ 18 | Run command with arguments and return its output as a byte string. 19 | 20 | Backported from Python 2.7 as it's implemented as pure python on 21 | stdlib. 22 | 23 | >>> check_output(['/usr/bin/python', '--version']) 24 | Python 2.6.2 25 | 26 | Copied from https://gist.github.com/edufelipe/1027906. 27 | """ 28 | process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs) 29 | output, unused_err = process.communicate() 30 | retcode = process.poll() 31 | if retcode: 32 | cmd = kwargs.get("args") 33 | if cmd is None: 34 | cmd = popenargs[0] 35 | error = subprocess.CalledProcessError(retcode, cmd) 36 | error.output = output 37 | raise error 38 | return output 39 | 40 | 41 | if 'check_output' not in dir(subprocess): 42 | check_output = _check_output 43 | else: 44 | check_output = subprocess.check_output 45 | 46 | 47 | DIFFSCUSS_MB_FILE_NAME = '.diffscuss-mb' 48 | USERS_DIR_NAME = 'users' 49 | REVIEWS_DIR_NAME = 'reviews' 50 | 51 | 52 | def mkdir_for_keeps(dir_name): 53 | if not os.path.exists(dir_name): 54 | os.mkdir(dir_name) 55 | with open(os.path.join(dir_name, '.gitkeep'), 'wb') as fil: 56 | fil.write(' ') 57 | 58 | 59 | def get_inbox_name(git_exe): 60 | return check_output(_git_cmd(git_exe, 61 | ["config", 62 | "--get", 63 | "diffscuss-mb.inbox"])).strip() 64 | 65 | def set_inbox_name(inbox_name, git_exe): 66 | return check_call(_git_cmd(git_exe, 67 | ["config", 68 | "diffscuss-mb.inbox", 69 | inbox_name])) 70 | 71 | 72 | def get_git_root(git_exe): 73 | return check_output(_git_cmd(git_exe, 74 | ["rev-parse", 75 | "--show-toplevel"])).strip() 76 | 77 | 78 | def real_abs_join(*args): 79 | return os.path.abspath(os.path.realpath(os.path.join(*args))) 80 | 81 | 82 | def get_dmb_root(git_exe): 83 | git_root = get_git_root(git_exe) 84 | marker_fname = real_abs_join(git_root, DIFFSCUSS_MB_FILE_NAME) 85 | codereview_dir_name = None 86 | try: 87 | with open(marker_fname, 'rb') as fil: 88 | codereview_dir_name = fil.read().strip() 89 | except IOError: 90 | raise Exception("Please run 'diffscuss mailbox init'") 91 | return real_abs_join(git_root, codereview_dir_name) 92 | 93 | 94 | def get_reviews_dir(git_exe): 95 | return real_abs_join(get_dmb_root(git_exe), REVIEWS_DIR_NAME) 96 | 97 | 98 | def check_inited(git_exe): 99 | dmb_root = get_dmb_root(git_exe) 100 | for d in ['', 'reviews', 'users']: 101 | to_check = os.path.join(dmb_root, d) 102 | if not os.path.isdir(to_check): 103 | raise Exception("%s does not exist, please " 104 | "run 'diffscuss mailbox init'" % 105 | to_check) 106 | 107 | 108 | def get_inbox_path(inbox_name, git_exe): 109 | return os.path.join(get_dmb_root(git_exe), 110 | USERS_DIR_NAME, 111 | inbox_name) 112 | 113 | 114 | def dmb_done(diffscuss_fname, inbox, git_exe): 115 | check_inited(git_exe) 116 | if not inbox: 117 | inbox = get_inbox_name(git_exe) 118 | inbox_path = get_inbox_path(inbox, git_exe) 119 | if not os.path.exists(inbox_path): 120 | _exit("Inbox '%s' doesn't exist, create " 121 | "it with 'diffscuss mailbox make-inbox'" % inbox, 2) 122 | 123 | diffscuss_fpath = os.path.abspath(os.path.realpath(diffscuss_fname)) 124 | 125 | for fname in os.listdir(inbox_path): 126 | if fname == '.gitkeep': 127 | continue 128 | fpath = os.path.join(inbox_path, fname) 129 | target = os.path.abspath(os.path.realpath(fpath)) 130 | if target == diffscuss_fpath: 131 | os.remove(fpath) 132 | 133 | return diffscuss_fpath 134 | 135 | 136 | def _error(msg): 137 | print >> sys.stderr, msg 138 | 139 | 140 | def _move_to_reviews_dir(diffscuss_fname, git_exe): 141 | diffscuss_fpath = os.path.abspath(os.path.realpath(diffscuss_fname)) 142 | reviews_dir = get_reviews_dir(git_exe) 143 | if os.path.commonprefix([reviews_dir, 144 | diffscuss_fpath]) != reviews_dir: 145 | # if the review file is not in the reviews folder, we need to 146 | # move it in. 147 | shutil.move(diffscuss_fpath, reviews_dir) 148 | review_fpath = os.path.join(reviews_dir, 149 | os.path.basename(diffscuss_fpath)) 150 | else: 151 | # otherwise, just use the path to the file as it's already in 152 | # reviews. 153 | review_fpath = diffscuss_fpath 154 | 155 | return review_fpath 156 | 157 | 158 | def _link(review_fpath, inbox_path): 159 | relative_path = os.path.relpath(review_fpath, inbox_path) 160 | dest = os.path.join(inbox_path, os.path.basename(review_fpath)) 161 | if os.path.exists(dest): 162 | os.remove(dest) 163 | os.symlink(relative_path, dest) 164 | 165 | 166 | def dmb_post(diffscuss_fname, recipients, git_exe): 167 | check_inited(git_exe) 168 | review_fpath = _move_to_reviews_dir(diffscuss_fname, git_exe) 169 | 170 | for recipient in recipients: 171 | inbox_path = get_inbox_path(recipient, git_exe) 172 | if not os.path.isdir(inbox_path): 173 | _error( 174 | "Inbox %s doesn't seem to exist, please create it or specify another.") 175 | _link(review_fpath, inbox_path) 176 | 177 | return review_fpath 178 | -------------------------------------------------------------------------------- /diffscuss/tests/test_generate.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | from nose.tools import ok_, eq_ 5 | from impermagit import fleeting_repo 6 | 7 | from diffscuss import generate 8 | 9 | 10 | def test_gen_diffscuss_basics(): 11 | testy_mc = "Testy McTesterson " 12 | with fleeting_repo() as repo: 13 | repo.do_git(["config", "user.name", "Testy McTesterson"]) 14 | repo.do_git(["config", "user.email", "testy@example.com"]) 15 | repo.commit([('README.txt', 'dweezel')], 16 | commit_msg="Initial commit") 17 | repo.commit([('test.txt', '\n'.join(['this is the first line', 18 | 'this is the second line', 19 | '']))], 20 | author=testy_mc, 21 | commit_msg="First commit message.") 22 | repo.commit([('test.txt', '\n'.join(['this is the changed first line', 23 | 'this is the second line', 24 | 'this is the new third line', 25 | '']))], 26 | author=testy_mc, 27 | commit_msg="Second commit message.") 28 | 29 | diffscussion = _run_gen_diffscuss(cwd=repo.repo_root, 30 | revs="HEAD~2..HEAD") 31 | 32 | # do some cheesy tests to make sure strings we expect have / 33 | # haven't have made it into the diffscussion, before we do 34 | # line by line comparison, to make it easier to see what's 35 | # actually going wrong 36 | 37 | # we shouldn't have the initial commit msg 38 | ok_("Initial commit" not in diffscussion) 39 | # or the initial commit's diffs 40 | ok_('README.txt' not in diffscussion) 41 | ok_('dweezel' not in diffscussion) 42 | 43 | # both included commit logs msgs should be in there 44 | ok_("First commit message" in diffscussion) 45 | ok_("Second commit message" in diffscussion) 46 | # and they should be in chrono order 47 | ok_(diffscussion.find("First commit message") < 48 | diffscussion.find("Second commit message")) 49 | 50 | # make sure the diffs came through 51 | ok_("+this is the changed first line" in diffscussion) 52 | ok_("+this is the second line" in diffscussion) 53 | ok_("+this is the new third line" in diffscussion) 54 | 55 | # make sure the author was picked up 56 | ok_("author: Testy McTesterson" in diffscussion) 57 | # and the email 58 | ok_("email: testy@example.com" in diffscussion) 59 | 60 | # and some cheesy line by line structure 61 | lines = diffscussion.split("\n") 62 | 63 | eq_("#* ", lines[0]) 64 | eq_("#* author: Testy McTesterson", lines[1]) 65 | eq_("#* email: testy@example.com", lines[2]) 66 | ok_(lines[3].startswith("#* date: ")) 67 | eq_("#* ", lines[4]) 68 | eq_("#- First commit message.", lines[5]) 69 | eq_("#- ", lines[6]) 70 | eq_("#- ", lines[7]) 71 | eq_("#- Second commit message.", lines[8]) 72 | eq_("#- ", lines[9]) 73 | eq_("#- ", lines[10]) 74 | ok_(lines[11].startswith("diff --git")) 75 | ok_(lines[12].startswith("new file mode")) 76 | ok_(lines[13].startswith("index")) 77 | ok_(lines[14].startswith("---")) 78 | ok_(lines[15].startswith("+++")) 79 | ok_(lines[16].startswith("@@")) 80 | eq_("+this is the changed first line", lines[17]) 81 | eq_("+this is the second line", lines[18]) 82 | eq_("+this is the new third line", lines[19]) 83 | 84 | def test_gen_diffscuss_with_path(): 85 | testy_mc = "Testy McTesterson " 86 | with fleeting_repo() as repo: 87 | repo.do_git(["config", "user.name", "Testy McTesterson"]) 88 | repo.do_git(["config", "user.email", "testy@example.com"]) 89 | repo.commit([('README.txt', 'dweezel')], 90 | commit_msg="Initial commit") 91 | repo.commit([('test.txt', 'test!'), 92 | ('subdir/foo.txt', 'foo file')], 93 | author=testy_mc, 94 | commit_msg="First commit message.") 95 | repo.commit([('test.txt', 'teeest!!'), 96 | ('subdir/bar.txt', 'bar file')], 97 | author=testy_mc, 98 | commit_msg="Second commit message.") 99 | 100 | diffscussion = _run_gen_diffscuss(cwd=repo.repo_root, 101 | revs="HEAD~2..HEAD", 102 | path=['subdir']) 103 | 104 | # Again, not the first commit 105 | ok_("Initial commit" not in diffscussion) 106 | 107 | # But the yes to the other two... 108 | ok_("First commit message" in diffscussion) 109 | ok_("Second commit message" in diffscussion) 110 | 111 | # Nothing about test.txt 112 | ok_("test.txt" not in diffscussion) 113 | ok_("+this is the changed first line" not in diffscussion) 114 | ok_("+this is the second line" not in diffscussion) 115 | ok_("+this is the new third line" not in diffscussion) 116 | 117 | # But everything else on 'subdir' 118 | ok_("subdir/foo.txt" in diffscussion) 119 | ok_("subdir/bar.txt" in diffscussion) 120 | ok_("+foo file" in diffscussion) 121 | ok_("+bar file" in diffscussion) 122 | 123 | # Everything else should be already proven in 124 | # `test_gen_diffscuss_basics` 125 | 126 | 127 | class Args(object): 128 | 129 | def __init__(self, git_revision_range, output_file, path=None): 130 | self.git_revision_range = git_revision_range 131 | self.path = path or [] 132 | self.output_file = output_file 133 | self.lines_context = 20 134 | self.author = None 135 | self.email = None 136 | self.git_exe = None 137 | 138 | 139 | def _run_gen_diffscuss(cwd, revs, path=None): 140 | old_dir = os.getcwd() 141 | try: 142 | os.chdir(cwd) 143 | with tempfile.NamedTemporaryFile() as fil: 144 | args = Args(revs, fil.name, path) 145 | generate._main(args) 146 | return fil.read() 147 | finally: 148 | os.chdir(old_dir) 149 | 150 | 151 | 152 | def _gen_diffscuss_cmd(revs): 153 | this_dir = os.path.dirname(__file__) 154 | diffscuss_dir = os.path.join(this_dir, '..') 155 | abs_diffscuss_dir = os.path.abspath(diffscuss_dir) 156 | abs_gen_diffscuss = os.path.join(abs_diffscuss_dir, 157 | 'gen-diffscuss.py') 158 | return [abs_gen_diffscuss, revs] 159 | -------------------------------------------------------------------------------- /diffscuss/mailbox/check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Check your inbox for code reviews. 4 | """ 5 | 6 | from collections import defaultdict 7 | from optparse import OptionParser 8 | import os 9 | import sys 10 | from textwrap import dedent 11 | 12 | from diffscuss.mailbox.common import get_inbox_name, get_inbox_path, \ 13 | check_inited 14 | from diffscuss import walker, header, dates 15 | 16 | 17 | def _exit(msg, exit_code): 18 | print >> sys.stderr, msg 19 | sys.exit(exit_code) 20 | 21 | 22 | class HeaderExtractor(object): 23 | 24 | def __init__(self, header_names): 25 | if not isinstance(header_names, list): 26 | header_names = [header_names] 27 | self.header_names = header_names 28 | 29 | def process_line(self, line_tup): 30 | (line_type, line) = line_tup 31 | if line_type == walker.COMMENT_HEADER: 32 | parsed_header = header.parse_header(line) 33 | if (parsed_header and 34 | parsed_header[0] in self.header_names): 35 | self.extract_from_header(parsed_header) 36 | 37 | def extract_from_header(self, parsed_header): 38 | # for override 39 | pass 40 | 41 | def get(self): 42 | # for override 43 | pass 44 | 45 | 46 | class OrigAuthorExtractor(HeaderExtractor): 47 | 48 | def __init__(self): 49 | super(OrigAuthorExtractor, self).__init__('author') 50 | self.author = None 51 | 52 | def extract_from_header(self, parsed_header): 53 | if self.author is None: 54 | self.author = parsed_header[1] 55 | 56 | def get(self): 57 | return self.author 58 | 59 | 60 | class PostedAtExtractor(HeaderExtractor): 61 | 62 | def __init__(self): 63 | super(PostedAtExtractor, self).__init__('date') 64 | self.posted_at = None 65 | 66 | def extract_from_header(self, parsed_header): 67 | if self.posted_at is None: 68 | self.posted_at = parsed_header[1] 69 | 70 | def get(self): 71 | return dates.parse_to_local_dt(self.posted_at) 72 | 73 | 74 | class LastCommentExtractor(HeaderExtractor): 75 | 76 | def __init__(self): 77 | super(LastCommentExtractor, self).__init__(['author', 'date']) 78 | self.last_comment_at = None 79 | self.last_comment_by = None 80 | self.last_author_seen = None 81 | 82 | def extract_from_header(self, parsed_header): 83 | if parsed_header[0] == 'author': 84 | self.last_author_seen = parsed_header[1] 85 | if parsed_header[0] == 'date' and self._is_latest(parsed_header[1]): 86 | self.last_comment_at = parsed_header[1] 87 | self.last_comment_by = self.last_author_seen 88 | 89 | def _is_latest(self, dt_s): 90 | return (self.last_comment_at is None or 91 | dates.parse_to_utc_dt(self.last_comment_at) < 92 | dates.parse_to_utc_dt(dt_s)) 93 | 94 | def get(self): 95 | return "%s by %s" % (dates.parse_to_local_dt(self.last_comment_at), 96 | self.last_comment_by) 97 | 98 | 99 | class LineCounter(object): 100 | 101 | def __init__(self, line_types): 102 | self.line_types = line_types 103 | self.line_count = 0 104 | 105 | def process_line(self, line_tup): 106 | (line_type, line) = line_tup 107 | if line_type in self.line_types: 108 | self.line_count += 1 109 | 110 | def get(self): 111 | return self.line_count 112 | 113 | 114 | class TopAuthorsExtractor(HeaderExtractor): 115 | 116 | def __init__(self): 117 | super(TopAuthorsExtractor, self).__init__('author') 118 | self.author_counts = defaultdict(int) 119 | 120 | def extract_from_header(self, parsed_header): 121 | self.author_counts[parsed_header[1]] += 1 122 | 123 | def get(self): 124 | cnt_auth_tups = [(cnt, auth) 125 | for (auth, cnt) 126 | in self.author_counts.items()] 127 | cnt_auth_tups.sort() 128 | cnt_auth_tups.reverse() 129 | return ", ".join(["%s (%d)" % (auth, cnt) 130 | for (cnt, auth) 131 | in cnt_auth_tups[:3]]) 132 | 133 | 134 | class NumCommentsExtractor(HeaderExtractor): 135 | 136 | def __init__(self): 137 | super(NumCommentsExtractor, self).__init__('author') 138 | self.comment_count = 0 139 | 140 | def extract_from_header(self, parsed_header): 141 | self.comment_count += 1 142 | 143 | def get(self): 144 | return self.comment_count 145 | 146 | 147 | def _parse(listing): 148 | fname = os.path.basename(listing) 149 | 150 | extractors = [OrigAuthorExtractor(), 151 | PostedAtExtractor(), 152 | LastCommentExtractor(), 153 | LineCounter([walker.DIFF_HEADER, walker.DIFF]), 154 | NumCommentsExtractor(), 155 | TopAuthorsExtractor()] 156 | 157 | with open(listing, 'rb') as fil: 158 | for line_tup in walker.walk(fil): 159 | for extractor in extractors: 160 | extractor.process_line(line_tup) 161 | 162 | return [fname] + [e.get() for e in extractors] 163 | 164 | 165 | def _gen_summary(listing): 166 | try: 167 | return dedent("""\ 168 | File: %s 169 | Posted-By: %s 170 | Posted-At: %s 171 | Last-Comment: %s 172 | Diff-Lines: %d 173 | Comments: %d 174 | Top-Commenters: %s 175 | """ % tuple(_parse(listing))) 176 | except: 177 | return "Trouble parsing diffscuss, no summary available.\n" 178 | 179 | 180 | def _format_listing(listing, emacs, short): 181 | f_line = listing 182 | 183 | if emacs: 184 | f_line = "%s:1:1" % listing 185 | 186 | if short: 187 | return f_line 188 | else: 189 | return "%s%s\n\n" % (_gen_summary(listing), 190 | f_line) 191 | 192 | 193 | def main(args): 194 | check_inited(args.git_exe) 195 | inbox = args.inbox 196 | if not inbox: 197 | try: 198 | inbox = get_inbox_name(args.git_exe) 199 | except: 200 | _exit("Could not find default inbox, please run " 201 | "'diffscuss mailbox set-default-inbox'", 3) 202 | inbox_path = get_inbox_path(inbox, args.git_exe) 203 | if not os.path.exists(inbox_path): 204 | _exit("Inbox '%s' doesn't exist, create it " 205 | "with 'diffscuss mailbox make-inbox'" % inbox_path, 2) 206 | for review in os.listdir(inbox_path): 207 | if review != '.gitkeep': 208 | print _format_listing(os.path.join(inbox_path, review), 209 | args.emacs, args.short) 210 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/test-simple.el: -------------------------------------------------------------------------------- 1 | ;;; test-simple.el --- Simple Unit Test Framework for Emacs Lisp 2 | ;; Rewritten from Phil Hagelberg's behave.el by rocky 3 | 4 | ;; Copyright (C) 2010, 2012 Rocky Bernstein 5 | 6 | ;; Author: Rocky Bernstein 7 | ;; URL: http://github.com/rocky/emacs-test-simple 8 | ;; Keywords: unit-test 9 | 10 | ;; This file is NOT part of GNU Emacs. 11 | 12 | ;; This is free software; you can redistribute it and/or modify it under 13 | ;; the terms of the GNU General Public License as published by the Free 14 | ;; Software Foundation; either version 3, or (at your option) any later 15 | ;; version. 16 | 17 | ;; This file is distributed in the hope that it will be useful, but 18 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of 19 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 20 | ;; General Public License for more details. 21 | 22 | ;; You should have received a copy of the GNU General Public License 23 | ;; along with Emacs; see the file COPYING, or type `C-h C-c'. If not, 24 | ;; write to the Free Software Foundation at this address: 25 | 26 | ;; Free Software Foundation 27 | ;; 51 Franklin Street, Fifth Floor 28 | ;; Boston, MA 02110-1301 29 | ;; USA 30 | 31 | ;;; Commentary: 32 | 33 | ;; test-simple.el allows you to write tests for your Emacs Lisp 34 | ;; code. Executable specifications allow you to check that your code 35 | ;; is working correctly in an automated fashion that you can use to 36 | ;; drive the focus of your development. (It's related to Test-Driven 37 | ;; Development.) You can read up on it at http://behaviour-driven.org. 38 | 39 | ;; Assertions may have docstrings so that when the specifications 40 | ;; aren't met it is easy to see what caused the failure. 41 | 42 | ;; When "note" is used subsequent tests are grouped assumed to be 43 | ;; related to that not. 44 | 45 | ;; When you want to run the specs, evaluate the buffer. Or evaluate 46 | ;; individual assertions. Results are save in the 47 | ;; *test-simple* buffer. 48 | 49 | ;;; Implementation 50 | 51 | ;; Contexts are stored in the *test-simple-contexts* list as structs. Each 52 | ;; context has a "specs" slot that contains a list of its specs, which 53 | ;; are stored as closures. The expect form ensures that expectations 54 | ;; are met and signals test-simple-spec-failed if they are not. 55 | 56 | ;; Warning: the variable CONTEXT is used within macros 57 | ;; in such a way that they could shadow variables of the same name in 58 | ;; the code being tested. Future versions will use gensyms to solve 59 | ;; this issue, but in the mean time avoid relying upon variables with 60 | ;; those names. 61 | 62 | ;;; To do: 63 | 64 | ;; Main issues: more expect predicates 65 | 66 | ;;; Usage: 67 | 68 | (require 'time-date) 69 | 70 | (eval-when-compile 71 | (byte-compile-disable-warning 'cl-functions) 72 | ;; Somehow disabling cl-functions causes the erroneous message: 73 | ;; Warning: the function `reduce' might not be defined at runtime. 74 | ;; FIXME: isolate, fix and/or report back to Emacs developers a bug 75 | ;; (byte-compile-disable-warning 'unresolved) 76 | (require 'cl) 77 | ) 78 | (require 'cl) 79 | 80 | (defvar test-simple-debug-on-error nil 81 | "If non-nil raise an error on the first failure") 82 | 83 | (defvar test-simple-verbosity 0 84 | "The greater the number the more verbose output") 85 | 86 | (defstruct test-info 87 | description ;; description of last group of tests 88 | (assert-count 0) ;; total number of assertions run 89 | (failure-count 0) ;; total number of failures seen 90 | (start-time (current-time)) ;; Time run started 91 | ) 92 | 93 | (defvar test-simple-info (make-test-info) 94 | "Variable to store testing information for a buffer") 95 | 96 | (defun note (description &optional test-info) 97 | "Adds a name to a group of tests." 98 | (if (getenv "USE_TAP") 99 | (test-simple-msg (format "# %s" description) 't) 100 | (if (> test-simple-verbosity 0) 101 | (test-simple-msg (concat "\n" description) 't)) 102 | (unless test-info 103 | (setq test-info test-simple-info)) 104 | (setf (test-info-description test-info) description) 105 | )) 106 | 107 | (defmacro test-simple-start (&optional test-start-msg) 108 | `(test-simple-clear nil 109 | (or ,test-start-msg 110 | (if (and (functionp '__FILE__) (__FILE__)) 111 | (file-name-nondirectory (__FILE__)) 112 | (buffer-name))) 113 | )) 114 | 115 | (defun test-simple-clear (&optional test-info test-start-msg) 116 | "Initializes and resets everything to run tests. You should run 117 | this before running any assertions. Running more than once clears 118 | out information from the previous run." 119 | 120 | (interactive) 121 | 122 | (unless test-info 123 | (unless test-simple-info 124 | (make-variable-buffer-local (defvar test-simple-info (make-test-info)))) 125 | (setq test-info test-simple-info)) 126 | 127 | (setf (test-info-description test-info) "none set") 128 | (setf (test-info-start-time test-info) (current-time)) 129 | (setf (test-info-assert-count test-info) 0) 130 | (setf (test-info-failure-count test-info) 0) 131 | 132 | (with-current-buffer (get-buffer-create "*test-simple*") 133 | (let ((old-read-only inhibit-read-only)) 134 | (setq inhibit-read-only 't) 135 | (delete-region (point-min) (point-max)) 136 | (if test-start-msg (insert (format "%s\n" test-start-msg))) 137 | (setq inhibit-read-only old-read-only))) 138 | (unless noninteractive 139 | (message "Test-Simple: test information cleared"))) 140 | 141 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 142 | ;; Assertion tests 143 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 144 | 145 | (defmacro assert-raises (error-condition body &optional fail-message test-info) 146 | (let ((fail-message (or fail-message 147 | (format "assert-raises did not get expected %s" 148 | error-condition)))) 149 | (list 'condition-case nil 150 | (list 'progn body 151 | (list 'assert-t nil fail-message test-info "assert-raises")) 152 | (list error-condition '(assert-t t))))) 153 | 154 | (defun assert-op (op expected actual &optional fail-message test-info) 155 | "expectation is that ACTUAL should be equal to EXPECTED." 156 | (unless test-info (setq test-info test-simple-info)) 157 | (incf (test-info-assert-count test-info)) 158 | (if (not (funcall op actual expected)) 159 | (let* ((fail-message 160 | (if fail-message 161 | (format "Message: %s" fail-message) 162 | "")) 163 | (expect-message 164 | (format "\n Expected: %s\n Got: %s" expected actual)) 165 | (test-info-mess 166 | (if (boundp 'test-info) 167 | (test-info-description test-info) 168 | "unset"))) 169 | (add-failure (format "assert-%s" op) test-info-mess 170 | (concat fail-message expect-message))) 171 | (ok-msg fail-message))) 172 | 173 | (defun assert-equal (expected actual &optional fail-message test-info) 174 | "expectation is that ACTUAL should be equal to EXPECTED." 175 | (assert-op 'equal expected actual fail-message test-info)) 176 | 177 | (defun assert-eq (expected actual &optional fail-message test-info) 178 | "expectation is that ACTUAL should be EQ to EXPECTED." 179 | (assert-op 'eql expected actual fail-message test-info)) 180 | 181 | (defun assert-eql (expected actual &optional fail-message test-info) 182 | "expectation is that ACTUAL should be EQL to EXPECTED." 183 | (assert-op 'eql expected actual fail-message test-info)) 184 | 185 | (defun assert-matches (expected-regexp actual &optional fail-message test-info) 186 | "expectation is that ACTUAL should match EXPECTED-REGEXP." 187 | (unless test-info (setq test-info test-simple-info)) 188 | (incf (test-info-assert-count test-info)) 189 | (if (not (string-match expected-regexp actual)) 190 | (let* ((fail-message 191 | (if fail-message 192 | (format "\n\tMessage: %s" fail-message) 193 | "")) 194 | (expect-message 195 | (format "\tExpected Regexp: %s\n\tGot: %s" 196 | expected-regexp actual)) 197 | (test-info-mess 198 | (if (boundp 'test-info) 199 | (test-info-description test-info) 200 | "unset"))) 201 | (add-failure "assert-equal" test-info-mess 202 | (concat expect-message fail-message))) 203 | (progn (test-simple-msg ".") t))) 204 | 205 | (defun assert-t (actual &optional fail-message test-info) 206 | "expectation is that ACTUAL is not nil." 207 | (assert-nil (not actual) fail-message test-info "assert-t")) 208 | 209 | (defun assert-nil (actual &optional fail-message test-info assert-type) 210 | "expectation is that ACTUAL is nil. FAIL-MESSAGE is an optional 211 | additional message to be displayed. Since several assertions 212 | funnel down to this one, ASSERT-TYPE is an optional type." 213 | (unless test-info (setq test-info test-simple-info)) 214 | (incf (test-info-assert-count test-info)) 215 | (if actual 216 | (let* ((fail-message 217 | (if fail-message 218 | (format "\n\tMessage: %s" fail-message) 219 | "")) 220 | (test-info-mess 221 | (if (boundp 'test-simple-info) 222 | (test-info-description test-simple-info) 223 | "unset"))) 224 | (add-failure "assert-nil" test-info-mess fail-message test-info)) 225 | (ok-msg fail-message))) 226 | 227 | (defun add-failure(type test-info-msg fail-msg &optional test-info) 228 | (unless test-info (setq test-info test-simple-info)) 229 | (incf (test-info-failure-count test-info)) 230 | (let ((failure-msg 231 | (format "\nDescription: %s, type %s\n%s" test-info-msg type fail-msg)) 232 | (old-read-only inhibit-read-only) 233 | ) 234 | (save-excursion 235 | (not-ok-msg fail-msg) 236 | (test-simple-msg failure-msg 't) 237 | (unless noninteractive 238 | (if test-simple-debug-on-error 239 | (signal 'test-simple-assert-failed failure-msg) 240 | ;;(message failure-msg) 241 | ))))) 242 | 243 | (defun end-tests (&optional test-info) 244 | "Give a tally of the tests run" 245 | (interactive) 246 | (unless test-info (setq test-info test-simple-info)) 247 | (test-simple-describe-failures test-info) 248 | (if noninteractive 249 | (progn 250 | (switch-to-buffer "*test-simple*") 251 | (message "%s" (buffer-substring (point-min) (point-max))) 252 | ) 253 | (switch-to-buffer-other-window "*test-simple*") 254 | )) 255 | 256 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 257 | ;; Reporting 258 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 259 | 260 | (defun test-simple-msg(msg &optional newline) 261 | (switch-to-buffer "*test-simple*") 262 | (let ((old-read-only inhibit-read-only)) 263 | (setq inhibit-read-only 't) 264 | (insert msg) 265 | (if newline (insert "\n")) 266 | (setq inhibit-read-only old-read-only) 267 | (switch-to-buffer nil) 268 | )) 269 | 270 | (defun ok-msg(fail-message &optional test-info) 271 | (unless test-info (setq test-info test-simple-info)) 272 | (let ((msg (if (getenv "USE_TAP") 273 | (if (equal fail-message "") 274 | (format "ok %d\n" (test-info-assert-count test-info)) 275 | (format "ok %d - %s\n" 276 | (test-info-assert-count test-info) 277 | fail-message)) 278 | "."))) 279 | (test-simple-msg msg)) 280 | 't) 281 | 282 | (defun not-ok-msg(fail-message &optional test-info) 283 | (unless test-info (setq test-info test-simple-info)) 284 | (let ((msg (if (getenv "USE_TAP") 285 | (format "not ok %d\n" (test-info-assert-count test-info)) 286 | "F"))) 287 | (test-simple-msg msg)) 288 | nil) 289 | 290 | (defun test-simple-summary-line(info) 291 | (let* 292 | ((failures (test-info-failure-count info)) 293 | (asserts (test-info-assert-count info)) 294 | (problems (concat (number-to-string failures) " failure" 295 | (unless (= 1 failures) "s"))) 296 | (tests (concat (number-to-string asserts) " assertion" 297 | (unless (= 1 asserts) "s"))) 298 | (elapsed-time (time-since (test-info-start-time info))) 299 | ) 300 | (if (getenv "USE_TAP") 301 | (format "1..%d" asserts) 302 | (format "\n%s in %s (%g seconds)" problems tests 303 | (float-time elapsed-time)) 304 | ))) 305 | 306 | (defun test-simple-describe-failures(&optional test-info) 307 | (unless test-info (setq test-info test-simple-info)) 308 | (goto-char (point-max)) 309 | (test-simple-msg (test-simple-summary-line test-info))) 310 | 311 | (provide 'test-simple) 312 | ;;; test-simple.el ends here 313 | -------------------------------------------------------------------------------- /diffscuss/local_source.py: -------------------------------------------------------------------------------- 1 | """ 2 | A utility for guessing which file and line of a local source repo are 3 | the best referent for a line in a diffscuss file. 4 | 5 | Intended mainly for use in editors to facilitate jumping to source 6 | from a diffscuss file. 7 | """ 8 | from collections import namedtuple 9 | from optparse import OptionParser 10 | import os 11 | import re 12 | import sys 13 | from textwrap import dedent 14 | 15 | from diffscuss.walker import walk, DIFF, DIFF_HEADER 16 | 17 | 18 | # exit codes for various error conditions 19 | NO_GIT_DIR = 2 20 | CANNOT_FIND_CANDIDATES = 3 21 | 22 | 23 | Candidate = namedtuple('Candidate', 'fname line_num line_text') 24 | 25 | 26 | LocalCandidate = namedtuple('LocalCandidate', 27 | 'found_match local_line_num candidate') 28 | 29 | 30 | def _exit(msg, exit_code): 31 | print >> sys.stderr, msg 32 | sys.exit(exit_code) 33 | 34 | 35 | # used to indicate some default args 36 | DEFAULT = '-' 37 | 38 | 39 | def _fil_or_default(fname, default_f): 40 | if fname == DEFAULT: 41 | return default_f, False 42 | else: 43 | return open(fname, 'rb'), True 44 | 45 | 46 | def _str_or_default(s, default_s): 47 | if s == DEFAULT: 48 | return default_s 49 | else: 50 | return s 51 | 52 | 53 | def _has_git_dir(directory): 54 | """ 55 | True if @directory exists and contains a '.git' subdir. 56 | """ 57 | return os.path.isdir(os.path.join(directory, '.git')) 58 | 59 | 60 | def _find_git_repo(starting_dir): 61 | """ 62 | Starting from @starting_dir, find the lowest directory which 63 | represents the top of a git repo (that is, which contains a '.git' 64 | subdir). 65 | 66 | If no such directory is found before walking up to the root 67 | directory, return None. 68 | """ 69 | cur_dir = os.path.abspath(starting_dir) 70 | while cur_dir != os.path.dirname(cur_dir): 71 | if _has_git_dir(cur_dir): 72 | return cur_dir 73 | cur_dir = os.path.dirname(cur_dir) 74 | return None 75 | 76 | 77 | FNAME_RE = re.compile(r'^(---|\+\+\+) (.*)') 78 | REL_FNAME_RE = re.compile(r'^(a|b)/(.*)') 79 | 80 | 81 | def _parse_fname(line): 82 | """ 83 | Parse the file name out of a git diff @line, stripping the leading 84 | a/ or b/ if it's present. 85 | """ 86 | line = line.strip() 87 | fname = FNAME_RE.match(line).group(2) 88 | rel_match = REL_FNAME_RE.match(fname) 89 | if rel_match: 90 | return rel_match.group(2) 91 | else: 92 | return fname 93 | 94 | 95 | def _maybe_update_fname(item, prefix, cur_fname): 96 | """ 97 | If @item is a diff header which starts with @prefix, parse and 98 | return the file name from it. 99 | 100 | Otherwise, return cur_fname. 101 | """ 102 | if item[0] == DIFF_HEADER and item[1].startswith(prefix): 103 | return _parse_fname(item[1]) 104 | else: 105 | return cur_fname 106 | 107 | 108 | def _maybe_update_old_fname(item, cur_old_fname): 109 | """ 110 | If @item is a diff header which starts with ---, parse and return 111 | the file name from it. 112 | 113 | Otherwise, return cur_fname. 114 | """ 115 | return _maybe_update_fname(item, '---', cur_old_fname) 116 | 117 | 118 | def _maybe_update_new_fname(item, cur_new_fname): 119 | """ 120 | If @item is a diff header which starts with +++, parse and return 121 | the file name from it. 122 | 123 | Otherwise, return cur_fname. 124 | """ 125 | return _maybe_update_fname(item, '+++', cur_new_fname) 126 | 127 | 128 | RANGE_RE = re.compile(r'^@@ -(\d+),\d+ \+(\d+),\d+ @@') 129 | 130 | 131 | def _line_applies(item, old_or_new): 132 | """ 133 | Return 1 if the line in @item applies in the context of 134 | @old_or_new, else 0. 135 | 136 | A line applies in the case that it's a DIFF line and: 137 | 138 | - if @old_or_new is 'old', the diff line doesn't begin with + 139 | 140 | - if @old_or_new is 'new', the diff line doesn't begin with - 141 | """ 142 | if old_or_new == 'old': 143 | exclude = '+' 144 | else: 145 | exclude = '-' 146 | 147 | if item[0] == DIFF and not item[1].startswith(exclude): 148 | return 1 149 | else: 150 | return 0 151 | 152 | 153 | def _maybe_update_diff_line_num(item, cur_line_num, old_or_new): 154 | """ 155 | Return the possibly adjusted @cur_line_num, where the adjustment 156 | rules are: 157 | 158 | - bumping it by 1 if @item represents a diff line in the 159 | @old_or_new context. 160 | 161 | - if @item represents a range line in a diff header, parsing out 162 | the appropriate line number for the @old_or_new context 163 | """ 164 | if old_or_new == 'old': 165 | group_num = 1 166 | else: 167 | group_num = 2 168 | 169 | if item[0] == DIFF_HEADER: 170 | match = RANGE_RE.match(item[1]) 171 | if match: 172 | return int(match.group(group_num)) 173 | 174 | if item[0] == DIFF: 175 | return cur_line_num + _line_applies(item, old_or_new) 176 | else: 177 | return cur_line_num 178 | 179 | 180 | def _maybe_update_old_line_num(item, cur_old_line_num): 181 | """ 182 | Return @cur_old_line_num, possibly bumped by one if @item is a an 183 | old line. 184 | """ 185 | return _maybe_update_diff_line_num(item, cur_old_line_num, 'old') 186 | 187 | 188 | def _maybe_update_new_line_num(item, cur_new_line_num): 189 | """ 190 | Return @cur_new_line_num, possibly bumped by one if @item is a a 191 | new line. 192 | """ 193 | return _maybe_update_diff_line_num(item, cur_new_line_num, 'new') 194 | 195 | 196 | def _safe_decr(line_num): 197 | """ 198 | Return @line_num decremented by 1, if @line_num is non None, else 199 | None. 200 | """ 201 | if line_num is not None: 202 | return line_num - 1 203 | 204 | 205 | def _maybe_update_old_line(item, cur_old_line): 206 | """ 207 | Return the line from @item if it's an old line, otherwise return 208 | @cur_old_line. 209 | """ 210 | if _line_applies(item, 'old'): 211 | return item[1] 212 | else: 213 | return cur_old_line 214 | 215 | 216 | def _maybe_update_new_line(item, cur_new_line): 217 | """ 218 | Return the line from @item if it's a new line, otherwise return 219 | @cur_new_line. 220 | """ 221 | if _line_applies(item, 'new'): 222 | return item[1] 223 | else: 224 | return cur_new_line 225 | 226 | 227 | def _find_candidates(input_f, line_number): 228 | """ 229 | If @line_number is a line number in the diffscuss file in 230 | @input_f, return a list of two Candidate tuples: 231 | 232 | - one for the new version of the source in the diffscuss file 233 | 234 | - one for the old version of the source in the diffscuss file 235 | 236 | The new version is always listed first. 237 | 238 | If line_number ends up being greater than the number of lines in 239 | input_f, returns an empty list. 240 | """ 241 | cur_old_fname = None 242 | cur_new_fname = None 243 | cur_old_line_num = None 244 | cur_new_line_num = None 245 | cur_old_line = None 246 | cur_new_line = None 247 | 248 | for (index, item) in enumerate(walk(input_f)): 249 | # walk the diffscuss file line by line, maintaing the updated 250 | # file names, line numbers, and line texts for both the old 251 | # and new versions of the source 252 | cur_old_fname = _maybe_update_old_fname(item, cur_old_fname) 253 | cur_new_fname = _maybe_update_new_fname(item, cur_new_fname) 254 | cur_old_line_num = _maybe_update_old_line_num(item, cur_old_line_num) 255 | cur_new_line_num = _maybe_update_new_line_num(item, cur_new_line_num) 256 | cur_old_line = _maybe_update_old_line(item, cur_old_line) 257 | cur_new_line = _maybe_update_new_line(item, cur_new_line) 258 | cur_line_num = index + 1 259 | 260 | # once we've walked past the line number in the diffscuss 261 | # file, maintaining the file / line number context for both 262 | # old and new versions of the source as we went, we have our 263 | # candidates for matching source. 264 | if cur_line_num >= line_number: 265 | return [Candidate(fname=cur_new_fname, 266 | line_num=_safe_decr(cur_new_line_num), 267 | line_text=cur_new_line), 268 | Candidate(fname=cur_old_fname, 269 | line_num=_safe_decr(cur_old_line_num), 270 | line_text=cur_old_line)] 271 | 272 | return list() 273 | 274 | 275 | def _candidate_exists(candidate, git_repo): 276 | """ 277 | Return true if the @candidate.fname is not /dev/null, and exists 278 | under git_repo. 279 | """ 280 | # this is how git diffs represent a file that didn't exist in an 281 | # earlier revision 282 | if not candidate.fname or candidate.fname == '/dev/null': 283 | return False 284 | try: 285 | with open(os.path.join(git_repo, candidate.fname), 'rb'): 286 | return True 287 | except IOError: 288 | return False 289 | 290 | 291 | def _best_local_candidate(local_candidates, git_repo): 292 | """ 293 | Given @local_candidates, a list of LocalCandidate named tuples, 294 | scraped from a diffscuss file, return the best candidate. 295 | 296 | The best candidate is: 297 | 298 | * the earliest candidate in the list where the matching line was 299 | found 300 | 301 | * or the earliest candidate in the list, if none of the matching 302 | lines were found 303 | """ 304 | best_candidate = None 305 | 306 | for candidate in local_candidates: 307 | if best_candidate is None: 308 | best_candidate = candidate 309 | elif candidate.found_match and not best_candidate.found_match: 310 | best_candidate = candidate 311 | 312 | return best_candidate 313 | 314 | 315 | def _closest_line_num(fil, orig_line_num, orig_line): 316 | """ 317 | Find the line in @fil that best matches the @orig_line found at 318 | @orig_line_num. 319 | 320 | This is currently done by: 321 | 322 | - finding all the lines in @fil that, when stripped, match the 323 | @orig_line exactly 324 | 325 | - returning the number the matching line with the smallest 326 | absolutely distance from @orig_line_num, in a tuple (True, 327 | line_num) 328 | 329 | - if no matching lines are found, returning (False, @orig_line_num) 330 | 331 | This works decently for a broad number of cases, but could also be 332 | improved for cases in which the @orig_line has subsequently been 333 | modified. 334 | """ 335 | # skip the first char, which is either +, -, or ' ', since 336 | # orig_line is a diff line 337 | orig_line = orig_line[1:].strip() 338 | matching_line_nums = [] 339 | 340 | for ind, line in enumerate(fil): 341 | line = line.strip() 342 | if orig_line and orig_line == line: 343 | matching_line_nums.append(ind + 1) 344 | 345 | if not matching_line_nums: 346 | return (False, orig_line_num) 347 | 348 | matching_line_nums = [(abs(line_num - orig_line_num), line_num) 349 | for line_num 350 | in matching_line_nums] 351 | matching_line_nums.sort() 352 | 353 | return (True, matching_line_nums[0][1]) 354 | 355 | 356 | def _localize_candidate(candidate, git_repo): 357 | """ 358 | Given @candidate, return a LocalCandidate, which includes the best 359 | guess for a local_line_num matching the line_text in @candidate, 360 | and whether or not the local line matches the text exactly. 361 | """ 362 | with open(os.path.join(git_repo, candidate.fname), 'rU') as fil: 363 | (found_match, local_line_num) = _closest_line_num(fil, 364 | candidate.line_num, 365 | candidate.line_text) 366 | return LocalCandidate(found_match=found_match, 367 | local_line_num=local_line_num, 368 | candidate=candidate) 369 | 370 | 371 | def main(directory, input_fname, output_fname, line_number): 372 | input_f, close_input = _fil_or_default(input_fname, sys.stdin) 373 | output_f, close_output = _fil_or_default(output_fname, sys.stdout) 374 | directory = _str_or_default(directory, os.getcwd()) 375 | 376 | git_repo = _find_git_repo(directory) 377 | if git_repo is None: 378 | _exit("Cannot find git repo at or above %s" % directory, NO_GIT_DIR) 379 | 380 | candidates = _find_candidates(input_f, line_number) 381 | existing_candidates = [c 382 | for c 383 | in candidates 384 | if _candidate_exists(c, git_repo)] 385 | local_candidates = [_localize_candidate(c, git_repo) 386 | for c 387 | in existing_candidates] 388 | best_candidate = _best_local_candidate(local_candidates, git_repo) 389 | 390 | if not best_candidate: 391 | _exit("Cannot find any candidate files.", CANNOT_FIND_CANDIDATES) 392 | 393 | output_f.write("%s %d" % (os.path.join(git_repo, 394 | best_candidate.candidate.fname), 395 | best_candidate.local_line_num)) 396 | 397 | if close_input: 398 | input_f.close() 399 | if close_output: 400 | output_f.close() 401 | 402 | 403 | -------------------------------------------------------------------------------- /diffscuss/find_local.py: -------------------------------------------------------------------------------- 1 | """ 2 | A utility for guessing which file and line of a local source repo are 3 | the best referent for a line in a diffscuss file. 4 | 5 | Intended mainly for use in editors to facilitate jumping to source 6 | from a diffscuss file. 7 | """ 8 | from collections import namedtuple 9 | from optparse import OptionParser 10 | import os 11 | import re 12 | import sys 13 | from textwrap import dedent 14 | 15 | from diffscuss.walker import walk, DIFF, DIFF_HEADER 16 | 17 | 18 | # exit codes for various error conditions 19 | NO_GIT_DIR = 2 20 | CANNOT_FIND_CANDIDATES = 3 21 | 22 | 23 | Candidate = namedtuple('Candidate', 'fname line_num line_text') 24 | 25 | 26 | LocalCandidate = namedtuple('LocalCandidate', 27 | 'found_match local_line_num candidate') 28 | 29 | 30 | def _exit(msg, exit_code): 31 | print >> sys.stderr, msg 32 | sys.exit(exit_code) 33 | 34 | 35 | # used to indicate some default args 36 | DEFAULT = '-' 37 | 38 | 39 | def _fil_or_default(fname, default_f): 40 | if fname == DEFAULT: 41 | return default_f, False 42 | else: 43 | return open(fname, 'rb'), True 44 | 45 | 46 | def _str_or_default(s, default_s): 47 | if s == DEFAULT: 48 | return default_s 49 | else: 50 | return s 51 | 52 | 53 | def _has_git_dir(directory): 54 | """ 55 | True if @directory exists and contains a '.git' subdir. 56 | """ 57 | return os.path.isdir(os.path.join(directory, '.git')) 58 | 59 | 60 | def _find_git_repo(starting_dir): 61 | """ 62 | Starting from @starting_dir, find the lowest directory which 63 | represents the top of a git repo (that is, which contains a '.git' 64 | subdir). 65 | 66 | If no such directory is found before walking up to the root 67 | directory, return None. 68 | """ 69 | cur_dir = os.path.abspath(starting_dir) 70 | while cur_dir != os.path.dirname(cur_dir): 71 | if _has_git_dir(cur_dir): 72 | return cur_dir 73 | cur_dir = os.path.dirname(cur_dir) 74 | return None 75 | 76 | 77 | FNAME_RE = re.compile(r'^(---|\+\+\+) (.*)') 78 | REL_FNAME_RE = re.compile(r'^(a|b)/(.*)') 79 | 80 | 81 | def _parse_fname(line): 82 | """ 83 | Parse the file name out of a git diff @line, stripping the leading 84 | a/ or b/ if it's present. 85 | """ 86 | line = line.strip() 87 | fname = FNAME_RE.match(line).group(2) 88 | rel_match = REL_FNAME_RE.match(fname) 89 | if rel_match: 90 | return rel_match.group(2) 91 | else: 92 | return fname 93 | 94 | 95 | def _maybe_update_fname(item, prefix, cur_fname): 96 | """ 97 | If @item is a diff header which starts with @prefix, parse and 98 | return the file name from it. 99 | 100 | Otherwise, return cur_fname. 101 | """ 102 | if item[0] == DIFF_HEADER and item[1].startswith(prefix): 103 | return _parse_fname(item[1]) 104 | else: 105 | return cur_fname 106 | 107 | 108 | def _maybe_update_old_fname(item, cur_old_fname): 109 | """ 110 | If @item is a diff header which starts with ---, parse and return 111 | the file name from it. 112 | 113 | Otherwise, return cur_fname. 114 | """ 115 | return _maybe_update_fname(item, '---', cur_old_fname) 116 | 117 | 118 | def _maybe_update_new_fname(item, cur_new_fname): 119 | """ 120 | If @item is a diff header which starts with +++, parse and return 121 | the file name from it. 122 | 123 | Otherwise, return cur_fname. 124 | """ 125 | return _maybe_update_fname(item, '+++', cur_new_fname) 126 | 127 | 128 | RANGE_RE = re.compile(r'^@@ -(\d+),\d+ \+(\d+),\d+ @@') 129 | 130 | 131 | def _line_applies(item, old_or_new): 132 | """ 133 | Return 1 if the line in @item applies in the context of 134 | @old_or_new, else 0. 135 | 136 | A line applies in the case that it's a DIFF line and: 137 | 138 | - if @old_or_new is 'old', the diff line doesn't begin with + 139 | 140 | - if @old_or_new is 'new', the diff line doesn't begin with - 141 | """ 142 | if old_or_new == 'old': 143 | exclude = '+' 144 | else: 145 | exclude = '-' 146 | 147 | if item[0] == DIFF and not item[1].startswith(exclude): 148 | return 1 149 | else: 150 | return 0 151 | 152 | 153 | def _maybe_update_diff_line_num(item, cur_line_num, old_or_new): 154 | """ 155 | Return the possibly adjusted @cur_line_num, where the adjustment 156 | rules are: 157 | 158 | - bumping it by 1 if @item represents a diff line in the 159 | @old_or_new context. 160 | 161 | - if @item represents a range line in a diff header, parsing out 162 | the appropriate line number for the @old_or_new context 163 | """ 164 | if old_or_new == 'old': 165 | group_num = 1 166 | else: 167 | group_num = 2 168 | 169 | if item[0] == DIFF_HEADER: 170 | match = RANGE_RE.match(item[1]) 171 | if match: 172 | return int(match.group(group_num)) 173 | 174 | if item[0] == DIFF: 175 | return cur_line_num + _line_applies(item, old_or_new) 176 | else: 177 | return cur_line_num 178 | 179 | 180 | def _maybe_update_old_line_num(item, cur_old_line_num): 181 | """ 182 | Return @cur_old_line_num, possibly bumped by one if @item is a an 183 | old line. 184 | """ 185 | return _maybe_update_diff_line_num(item, cur_old_line_num, 'old') 186 | 187 | 188 | def _maybe_update_new_line_num(item, cur_new_line_num): 189 | """ 190 | Return @cur_new_line_num, possibly bumped by one if @item is a a 191 | new line. 192 | """ 193 | return _maybe_update_diff_line_num(item, cur_new_line_num, 'new') 194 | 195 | 196 | def _safe_decr(line_num): 197 | """ 198 | Return @line_num decremented by 1, if @line_num is non None, else 199 | None. 200 | """ 201 | if line_num is not None: 202 | return line_num - 1 203 | 204 | 205 | def _maybe_update_old_line(item, cur_old_line): 206 | """ 207 | Return the line from @item if it's an old line, otherwise return 208 | @cur_old_line. 209 | """ 210 | if _line_applies(item, 'old'): 211 | return item[1] 212 | else: 213 | return cur_old_line 214 | 215 | 216 | def _maybe_update_new_line(item, cur_new_line): 217 | """ 218 | Return the line from @item if it's a new line, otherwise return 219 | @cur_new_line. 220 | """ 221 | if _line_applies(item, 'new'): 222 | return item[1] 223 | else: 224 | return cur_new_line 225 | 226 | 227 | def _find_candidates(input_f, line_number): 228 | """ 229 | If @line_number is a line number in the diffscuss file in 230 | @input_f, return a list of two Candidate tuples: 231 | 232 | - one for the new version of the source in the diffscuss file 233 | 234 | - one for the old version of the source in the diffscuss file 235 | 236 | The new version is always listed first. 237 | 238 | If line_number ends up being greater than the number of lines in 239 | input_f, returns an empty list. 240 | """ 241 | cur_old_fname = None 242 | cur_new_fname = None 243 | cur_old_line_num = None 244 | cur_new_line_num = None 245 | cur_old_line = None 246 | cur_new_line = None 247 | 248 | for (index, item) in enumerate(walk(input_f)): 249 | # walk the diffscuss file line by line, maintaing the updated 250 | # file names, line numbers, and line texts for both the old 251 | # and new versions of the source 252 | cur_old_fname = _maybe_update_old_fname(item, cur_old_fname) 253 | cur_new_fname = _maybe_update_new_fname(item, cur_new_fname) 254 | cur_old_line_num = _maybe_update_old_line_num(item, cur_old_line_num) 255 | cur_new_line_num = _maybe_update_new_line_num(item, cur_new_line_num) 256 | cur_old_line = _maybe_update_old_line(item, cur_old_line) 257 | cur_new_line = _maybe_update_new_line(item, cur_new_line) 258 | cur_line_num = index + 1 259 | 260 | # once we've walked past the line number in the diffscuss 261 | # file, maintaining the file / line number context for both 262 | # old and new versions of the source as we went, we have our 263 | # candidates for matching source. 264 | if cur_line_num >= line_number: 265 | return [Candidate(fname=cur_new_fname, 266 | line_num=_safe_decr(cur_new_line_num), 267 | line_text=cur_new_line), 268 | Candidate(fname=cur_old_fname, 269 | line_num=_safe_decr(cur_old_line_num), 270 | line_text=cur_old_line)] 271 | 272 | return list() 273 | 274 | 275 | def _candidate_exists(candidate, git_repo): 276 | """ 277 | Return true if the @candidate.fname is not /dev/null, and exists 278 | under git_repo. 279 | """ 280 | # this is how git diffs represent a file that didn't exist in an 281 | # earlier revision 282 | if not candidate.fname or candidate.fname == '/dev/null': 283 | return False 284 | try: 285 | with open(os.path.join(git_repo, candidate.fname), 'rb'): 286 | return True 287 | except IOError: 288 | return False 289 | 290 | 291 | def _best_local_candidate(local_candidates, git_repo): 292 | """ 293 | Given @local_candidates, a list of LocalCandidate named tuples, 294 | scraped from a diffscuss file, return the best candidate. 295 | 296 | The best candidate is: 297 | 298 | * the earliest candidate in the list where the matching line was 299 | found 300 | 301 | * or the earliest candidate in the list, if none of the matching 302 | lines were found 303 | """ 304 | best_candidate = None 305 | 306 | for candidate in local_candidates: 307 | if best_candidate is None: 308 | best_candidate = candidate 309 | elif candidate.found_match and not best_candidate.found_match: 310 | best_candidate = candidate 311 | 312 | return best_candidate 313 | 314 | 315 | def _closest_line_num(fil, orig_line_num, orig_line): 316 | """ 317 | Find the line in @fil that best matches the @orig_line found at 318 | @orig_line_num. 319 | 320 | This is currently done by: 321 | 322 | - finding all the lines in @fil that, when stripped, match the 323 | @orig_line exactly 324 | 325 | - returning the number the matching line with the smallest 326 | absolutely distance from @orig_line_num, in a tuple (True, 327 | line_num) 328 | 329 | - if no matching lines are found, returning (False, @orig_line_num) 330 | 331 | This works decently for a broad number of cases, but could also be 332 | improved for cases in which the @orig_line has subsequently been 333 | modified. 334 | """ 335 | # skip the first char, which is either +, -, or ' ', since 336 | # orig_line is a diff line 337 | orig_line = orig_line[1:].strip() 338 | matching_line_nums = [] 339 | 340 | for ind, line in enumerate(fil): 341 | line = line.strip() 342 | if orig_line and orig_line == line: 343 | matching_line_nums.append(ind + 1) 344 | 345 | if not matching_line_nums: 346 | return (False, orig_line_num) 347 | 348 | matching_line_nums = [(abs(line_num - orig_line_num), line_num) 349 | for line_num 350 | in matching_line_nums] 351 | matching_line_nums.sort() 352 | 353 | return (True, matching_line_nums[0][1]) 354 | 355 | 356 | def _localize_candidate(candidate, git_repo): 357 | """ 358 | Given @candidate, return a LocalCandidate, which includes the best 359 | guess for a local_line_num matching the line_text in @candidate, 360 | and whether or not the local line matches the text exactly. 361 | """ 362 | with open(os.path.join(git_repo, candidate.fname), 'rU') as fil: 363 | (found_match, local_line_num) = _closest_line_num(fil, 364 | candidate.line_num, 365 | candidate.line_text) 366 | return LocalCandidate(found_match=found_match, 367 | local_line_num=local_line_num, 368 | candidate=candidate) 369 | 370 | 371 | def main(args): 372 | directory = args.directory 373 | input_fname = args.input_file 374 | output_fname = args.output_file 375 | line_number = args.line_number 376 | input_f, close_input = _fil_or_default(input_fname, sys.stdin) 377 | output_f, close_output = _fil_or_default(output_fname, sys.stdout) 378 | directory = _str_or_default(directory, os.getcwd()) 379 | 380 | git_repo = _find_git_repo(directory) 381 | if git_repo is None: 382 | _exit("Cannot find git repo at or above %s" % directory, NO_GIT_DIR) 383 | 384 | candidates = _find_candidates(input_f, line_number) 385 | existing_candidates = [c 386 | for c 387 | in candidates 388 | if _candidate_exists(c, git_repo)] 389 | local_candidates = [_localize_candidate(c, git_repo) 390 | for c 391 | in existing_candidates] 392 | best_candidate = _best_local_candidate(local_candidates, git_repo) 393 | 394 | if not best_candidate: 395 | _exit("Cannot find any candidate files.", CANNOT_FIND_CANDIDATES) 396 | 397 | output_f.write("%s %d" % (os.path.join(git_repo, 398 | best_candidate.candidate.fname), 399 | best_candidate.local_line_num)) 400 | 401 | if close_input: 402 | input_f.close() 403 | if close_output: 404 | output_f.close() 405 | 406 | 407 | -------------------------------------------------------------------------------- /diffscuss-mode/tests/test-diffscuss.el: -------------------------------------------------------------------------------- 1 | (require 'test-simple) 2 | 3 | (test-simple-start) ;; Zero counters and start the stop watch. 4 | 5 | ;; Use (load-file) below because we want to always to read the source. 6 | ;; Also, we don't want no stinking compiled source. 7 | (assert-t (load-file "../diffscuss-mode.el") 8 | "Can't load diffscuss-mode.el - are you in the right 9 | directory?" ) 10 | 11 | (note "Test parsing the leader") 12 | 13 | (defun run-parse (line) 14 | "Make a temporary buffer with contents line and run 15 | diffscuss-parse-leader against it." 16 | (with-temp-buffer 17 | (insert line) 18 | (beginning-of-buffer) 19 | (diffscuss-parse-leader))) 20 | 21 | (defun test-parse-leader (line-and-expected-leader) 22 | "Run a single test of diffscuss-parse-leader" 23 | (let ((line (car line-and-expected-leader)) 24 | (expected-leader (cdr line-and-expected-leader))) 25 | (if expected-leader 26 | (assert-equal expected-leader (run-parse line)) 27 | (assert-nil (run-parse line))))) 28 | 29 | (mapcar 'test-parse-leader 30 | '(("-hi\n" . nil) 31 | ("" . nil) 32 | ("#" . nil) 33 | (" #-- too many" . nil) 34 | ("#-- test" . "#--") 35 | ("#--" . "#--") 36 | ("#--- " . "#---") 37 | ("#- first" . "#-") 38 | ("#- -" . "#-") 39 | ("#* author: edmund" . "#*") 40 | ("#** something: else" . "#**") 41 | ("#** *something: else" . "#**") 42 | )) 43 | 44 | 45 | (defun test-get-revs (text-and-expected-revs) 46 | "" 47 | (let ((text (nth 0 text-and-expected-revs)) 48 | (old-rev (nth 1 text-and-expected-revs)) 49 | (new-rev (nth 2 text-and-expected-revs))) 50 | (assert-equal old-rev (with-temp-buffer 51 | (insert text) 52 | (end-of-buffer) 53 | (diffscuss-get-old-rev))) 54 | (assert-equal new-rev (with-temp-buffer 55 | (insert text) 56 | (end-of-buffer) 57 | (diffscuss-get-new-rev))))) 58 | 59 | (mapcar 'test-get-revs 60 | '(("diff --git a/diffscuss-mode/diffscuss-mode.el b/diffscuss-mode/diffscuss-mode.el\nindex eb23955..bac296b 100644\n--- a/diffscuss-mode/diffscuss-mode.el\n+++ b/diffscuss-mode/diffscuss-mode.el\n@@ -224,41 +224,6 @@\n (end-of-line)\n (point))))\n \n-(defun diffscuss-find-paragraph-start ()\n- \"Return the beginning of the current comment paragraph\"\n" "eb23955" "bac296b"))) 61 | 62 | (defun test-navigation (filename initial-point diffscuss-nav-func expected-point) 63 | (with-temp-buffer 64 | (insert-file-contents filename) 65 | (goto-char initial-point) 66 | (funcall diffscuss-nav-func) 67 | (assert-equal expected-point (point)))) 68 | 69 | ;; file with two comments in one thread 70 | ;; 71 | ;; comment 1 = 1:341 72 | ;; 73 | ;; comment 2 = 342:461 74 | ;; 75 | ;; comment by comment movement 76 | (test-navigation "testfiles/testnav1.diffscuss" 1 'diffscuss-next-comment 342) 77 | (test-navigation "testfiles/testnav1.diffscuss" 15 'diffscuss-next-comment 342) 78 | (test-navigation "testfiles/testnav1.diffscuss" 342 'diffscuss-previous-comment 1) 79 | (test-navigation "testfiles/testnav1.diffscuss" 346 'diffscuss-previous-comment 1) 80 | (test-navigation "testfiles/testnav1.diffscuss" 342 'diffscuss-next-comment 29483) 81 | (test-navigation "testfiles/testnav1.diffscuss" 29483 'diffscuss-previous-comment 342) 82 | (test-navigation "testfiles/testnav1.diffscuss" 2000 'diffscuss-previous-comment 342) 83 | ;; thread by thread movements 84 | (test-navigation "testfiles/testnav1.diffscuss" 1 'diffscuss-next-thread 29483) 85 | (test-navigation "testfiles/testnav1.diffscuss" 15 'diffscuss-next-thread 29483) 86 | (test-navigation "testfiles/testnav1.diffscuss" 342 'diffscuss-next-thread 29483) 87 | (test-navigation "testfiles/testnav1.diffscuss" 346 'diffscuss-next-thread 29483) 88 | (test-navigation "testfiles/testnav1.diffscuss" 1 'diffscuss-previous-thread 1) 89 | (test-navigation "testfiles/testnav1.diffscuss" 15 'diffscuss-previous-thread 1) 90 | (test-navigation "testfiles/testnav1.diffscuss" 342 'diffscuss-previous-thread 1) 91 | (test-navigation "testfiles/testnav1.diffscuss" 346 'diffscuss-previous-thread 1) 92 | (test-navigation "testfiles/testnav1.diffscuss" 29483 'diffscuss-previous-thread 1) 93 | 94 | ;; file with two threads of two comments each 95 | ;; 96 | ;; comment 1 = 1:341 97 | ;; 98 | ;; comment 2 = 342:461 99 | ;; 100 | ;; comment 3 = 839:955 101 | ;; 102 | ;; comment 4 = 956:1097 103 | ;; 104 | ;; comment by comment 105 | (test-navigation "testfiles/testnav2.diffscuss" 1 'diffscuss-next-comment 342) 106 | (test-navigation "testfiles/testnav2.diffscuss" 25 'diffscuss-next-comment 342) 107 | (test-navigation "testfiles/testnav2.diffscuss" 342 'diffscuss-next-comment 839) 108 | (test-navigation "testfiles/testnav2.diffscuss" 350 'diffscuss-next-comment 839) 109 | (test-navigation "testfiles/testnav2.diffscuss" 563 'diffscuss-next-comment 839) 110 | (test-navigation "testfiles/testnav2.diffscuss" 839 'diffscuss-next-comment 956) 111 | (test-navigation "testfiles/testnav2.diffscuss" 850 'diffscuss-next-comment 956) 112 | (test-navigation "testfiles/testnav2.diffscuss" 956 'diffscuss-next-comment 29742) 113 | (test-navigation "testfiles/testnav2.diffscuss" 15000 'diffscuss-next-comment 29742) 114 | (test-navigation "testfiles/testnav2.diffscuss" 1 'diffscuss-previous-comment 1) 115 | (test-navigation "testfiles/testnav2.diffscuss" 25 'diffscuss-previous-comment 1) 116 | (test-navigation "testfiles/testnav2.diffscuss" 342 'diffscuss-previous-comment 1) 117 | (test-navigation "testfiles/testnav2.diffscuss" 350 'diffscuss-previous-comment 1) 118 | (test-navigation "testfiles/testnav2.diffscuss" 563 'diffscuss-previous-comment 342) 119 | (test-navigation "testfiles/testnav2.diffscuss" 839 'diffscuss-previous-comment 342) 120 | (test-navigation "testfiles/testnav2.diffscuss" 850 'diffscuss-previous-comment 342) 121 | (test-navigation "testfiles/testnav2.diffscuss" 956 'diffscuss-previous-comment 839) 122 | (test-navigation "testfiles/testnav2.diffscuss" 15000 'diffscuss-previous-comment 956) 123 | ;; thread by thread 124 | (test-navigation "testfiles/testnav2.diffscuss" 1 'diffscuss-next-thread 839) 125 | (test-navigation "testfiles/testnav2.diffscuss" 25 'diffscuss-next-thread 839) 126 | (test-navigation "testfiles/testnav2.diffscuss" 342 'diffscuss-next-thread 839) 127 | (test-navigation "testfiles/testnav2.diffscuss" 350 'diffscuss-next-thread 839) 128 | (test-navigation "testfiles/testnav2.diffscuss" 563 'diffscuss-next-thread 839) 129 | (test-navigation "testfiles/testnav2.diffscuss" 839 'diffscuss-next-thread 29742) 130 | (test-navigation "testfiles/testnav2.diffscuss" 850 'diffscuss-next-thread 29742) 131 | (test-navigation "testfiles/testnav2.diffscuss" 956 'diffscuss-next-thread 29742) 132 | (test-navigation "testfiles/testnav2.diffscuss" 15000 'diffscuss-next-thread 29742) 133 | (test-navigation "testfiles/testnav2.diffscuss" 1 'diffscuss-previous-thread 1) 134 | (test-navigation "testfiles/testnav2.diffscuss" 25 'diffscuss-previous-thread 1) 135 | (test-navigation "testfiles/testnav2.diffscuss" 342 'diffscuss-previous-thread 1) 136 | (test-navigation "testfiles/testnav2.diffscuss" 350 'diffscuss-previous-thread 1) 137 | (test-navigation "testfiles/testnav2.diffscuss" 563 'diffscuss-previous-thread 1) 138 | (test-navigation "testfiles/testnav2.diffscuss" 839 'diffscuss-previous-thread 1) 139 | (test-navigation "testfiles/testnav2.diffscuss" 850 'diffscuss-previous-thread 1) 140 | (test-navigation "testfiles/testnav2.diffscuss" 956 'diffscuss-previous-thread 1) 141 | (test-navigation "testfiles/testnav2.diffscuss" 15000 'diffscuss-previous-thread 839) 142 | 143 | ;; test inserting comments 144 | 145 | (defun strip-dates (instr) 146 | (replace-regexp-in-string "date: .*" "DATELINE" instr)) 147 | 148 | (defun test-comment-insert (test-filename init-position comment-func expected-filename) 149 | (with-temp-buffer 150 | (insert-file-contents test-filename) 151 | (goto-char init-position) 152 | (funcall comment-func) 153 | (insert "NEW COMMENT TEXT") 154 | ;; since the testfiles get created in emacs, delete trailing 155 | ;; whitespace to prevent tears when only whitespace differs 156 | (delete-trailing-whitespace) 157 | (let ((actual-results (buffer-string))) 158 | (erase-buffer) 159 | (insert-file-contents expected-filename) 160 | (assert-equal (strip-dates (buffer-string)) (strip-dates actual-results))))) 161 | 162 | (let ((diffscuss-author "Edmund Jorgensen") 163 | (diffscuss-email "tomheon@gmail.com")) 164 | ;; short.diffscuss has two comments in one thread 165 | ;; 166 | ;; comment one 1:113 167 | ;; 168 | ;; comment two 114:233 169 | ;; 170 | ;; File level comments 171 | (test-comment-insert "testfiles/short.diffscuss" 1 'diffscuss-insert-contextual-comment 172 | "testfiles/short-with-new-top-level.diffscuss") 173 | (test-comment-insert "testfiles/short.diffscuss" 1 'diffscuss-insert-file-comment 174 | "testfiles/short-with-new-top-level.diffscuss") 175 | (test-comment-insert "testfiles/short.diffscuss" 323 'diffscuss-insert-file-comment 176 | "testfiles/short-with-new-top-level.diffscuss") 177 | ;; replies in various forms 178 | (test-comment-insert "testfiles/short.diffscuss" 2 'diffscuss-insert-contextual-comment 179 | "testfiles/short-with-new-top-level-reply.diffscuss") 180 | (test-comment-insert "testfiles/short.diffscuss" 2 'diffscuss-reply-to-comment 181 | "testfiles/short-with-new-top-level-reply.diffscuss") 182 | (test-comment-insert "testfiles/short.diffscuss" 1 'diffscuss-reply-to-comment 183 | "testfiles/short-with-new-top-level-reply.diffscuss") 184 | (test-comment-insert "testfiles/short.diffscuss" 114 'diffscuss-reply-to-comment 185 | "testfiles/short-with-new-second-level-reply.diffscuss") 186 | (test-comment-insert "testfiles/short.diffscuss" 221 'diffscuss-reply-to-comment 187 | "testfiles/short-with-new-second-level-reply.diffscuss") 188 | ;; new comment at hunk level 189 | (test-comment-insert "testfiles/short.diffscuss" 234 'diffscuss-insert-contextual-comment 190 | "testfiles/short-with-new-hunk-level-comment.diffscuss") 191 | (test-comment-insert "testfiles/short.diffscuss" 340 'diffscuss-insert-contextual-comment 192 | "testfiles/short-with-new-hunk-level-comment.diffscuss") 193 | (test-comment-insert "testfiles/short.diffscuss" 350 'diffscuss-insert-contextual-comment 194 | "testfiles/short-with-new-hunk-level-comment.diffscuss") 195 | ;; new comment inter diff 196 | (test-comment-insert "testfiles/short.diffscuss" 362 'diffscuss-insert-contextual-comment 197 | "testfiles/short-with-new-interdiff-comment.diffscuss") 198 | (test-comment-insert "testfiles/short.diffscuss" 364 'diffscuss-insert-contextual-comment 199 | "testfiles/short-with-new-interdiff-comment.diffscuss") 200 | ;; new comment at last line 201 | (test-comment-insert "testfiles/short.diffscuss" 372 'diffscuss-insert-contextual-comment 202 | "testfiles/short-with-last-line-comment.diffscuss") 203 | (test-comment-insert "testfiles/short.diffscuss" 386 'diffscuss-insert-contextual-comment 204 | "testfiles/short-with-last-line-comment.diffscuss")) 205 | 206 | (defun test-newline (test-filename init-position newline-func expected-filename) 207 | (with-temp-buffer 208 | (insert-file-contents test-filename) 209 | (goto-char init-position) 210 | (funcall newline-func) 211 | ;; since the testfiles get created in emacs, delete trailing 212 | ;; whitespace to prevent tears when only whitespace differs 213 | (delete-trailing-whitespace) 214 | (let ((actual-results (buffer-string))) 215 | (erase-buffer) 216 | (insert-file-contents expected-filename) 217 | (assert-equal (buffer-string) actual-results)))) 218 | 219 | ;; newline stuff 220 | (test-newline "testfiles/short.diffscuss" 1 'diffscuss-newline-and-indent 221 | "testfiles/short-post-open-line-top.diffscuss") 222 | (test-newline "testfiles/short.diffscuss" 1 'diffscuss-open-line 223 | "testfiles/short-post-open-line-top.diffscuss") 224 | (test-newline "testfiles/short.diffscuss" 230 'diffscuss-open-line 225 | "testfiles/short-post-open-body-line.diffscuss") 226 | (test-newline "testfiles/short.diffscuss" 230 'diffscuss-newline-and-indent 227 | "testfiles/short-post-open-body-line.diffscuss") 228 | (test-newline "testfiles/short.diffscuss" 222 'diffscuss-newline-and-indent 229 | "testfiles/short-post-open-interline.diffscuss") 230 | 231 | (require 'cl) 232 | 233 | ;; fills 234 | (defun test-fill (test-filename fill-positions fill-func expected-filename) 235 | (with-temp-buffer 236 | (insert-file-contents test-filename) 237 | (loop for pos in fill-positions 238 | do 239 | (goto-char pos) 240 | (funcall fill-func)) 241 | ;; since the testfiles get created in emacs, delete trailing 242 | ;; whitespace to prevent tears when only whitespace differs 243 | (delete-trailing-whitespace) 244 | (let ((actual-results (buffer-string))) 245 | (erase-buffer) 246 | (insert-file-contents expected-filename) 247 | (assert-equal (buffer-string) actual-results)))) 248 | 249 | (test-fill "testfiles/pre-fill.diffscuss" '(518 436 432 358 268) 250 | 'diffscuss-fill-comment-paragraph 251 | "testfiles/post-fill.diffscuss") 252 | 253 | (test-fill "testfiles/pre-fill.diffscuss" '(518 436 432 358 268) 254 | 'diffscuss-fill-paragraph 255 | "testfiles/post-fill.diffscuss") 256 | 257 | (end-tests) ;; Stop the clock and print a summary 258 | -------------------------------------------------------------------------------- /diffscuss/tests/test_walker.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import itertools 4 | import os 5 | from StringIO import StringIO 6 | from textwrap import dedent 7 | 8 | from nose.tools import eq_, ok_ 9 | 10 | from diffscuss.walker import walk, MissingAuthorException, \ 11 | EmptyCommentException, BadNestingException, \ 12 | CommentInHeaderException, DIFF_HEADER, DIFF, \ 13 | COMMENT_HEADER, COMMENT_BODY 14 | 15 | 16 | def _test_fname(fname): 17 | return os.path.join(os.path.dirname(__file__), 18 | 'testfiles', 19 | fname) 20 | 21 | 22 | def _apply_to_template(template_fname, template_defaults, to_apply): 23 | subs = dict(template_defaults) 24 | for (k, v) in to_apply.items(): 25 | if k not in ('COMMENT_TOP', 'COMMENT_BOTTOM'): 26 | # COMMENT_TOP and COMMENT_BOTTOM don't need a leading 27 | # newline, everything else does, but COMMENT_TOP and 28 | # COMMENT_BOTTOM also need a trailing newline 29 | v = '\n' + v 30 | else: 31 | v = v + '\n' 32 | subs[k] = v 33 | 34 | with open(_test_fname(template_fname), 'rb') as fil: 35 | template = fil.read() 36 | return StringIO(template.format(**subs)) 37 | 38 | 39 | def _standard_template_defaults(): 40 | return {'COMMENT_TOP': '', 41 | 'COMMENT_AFTER_RANGE_1': '', 42 | 'COMMENT_AFTER_RANGE_2': '', 43 | 'COMMENT_IN_DIFF_1': '', 44 | 'COMMENT_IN_DIFF_2': '', 45 | 'COMMENT_IN_DIFF_3': '', 46 | 'COMMENT_BOTTOM': ''} 47 | 48 | 49 | STANDARD_TEMPLATE_PARSED = \ 50 | ['COMMENT_TOP', 51 | (DIFF_HEADER, 'diff --git a/diffscuss-mode/diffscuss-mode.el b/diffscuss-mode/diffscuss-mode.el\n'), 52 | (DIFF_HEADER, 'index e95bace..404b745 100644\n'), 53 | (DIFF_HEADER, '--- a/diffscuss-mode/diffscuss-mode.el\n'), 54 | (DIFF_HEADER, '+++ b/diffscuss-mode/diffscuss-mode.el\n'), 55 | (DIFF_HEADER, '@@ -345,6 +345,10 @@\n'), 56 | 'COMMENT_AFTER_RANGE_1', 57 | (DIFF, '\n'), 58 | (DIFF, ' ;; insert / reply to comment commands\n'), 59 | (DIFF, '\n'), 60 | (DIFF, '+(defun diffscuss-get-date-time ()\n'), 61 | (DIFF, '+ "Get the current local date and time in ISO 8601."\n'), 62 | 'COMMENT_IN_DIFF_1', 63 | (DIFF, '+ (format-time-string "%Y-%m-%dT%T%z"))\n'), 64 | (DIFF, '+\n'), 65 | (DIFF, ' (defun diffscuss-make-comment (leader)\n'), 66 | (DIFF, ' "Return a new comment."\n'), 67 | (DIFF, ' (let ((header (diffscuss-force-header leader)))\n'), 68 | 'COMMENT_IN_DIFF_2', 69 | (DIFF_HEADER, '@@ -355,6 +359,10 @@\n'), 70 | 'COMMENT_AFTER_RANGE_2', 71 | (DIFF, ' (diffscuss-get-author)\n'), 72 | (DIFF, ' "\\n")\n'), 73 | (DIFF, ' header\n'), 74 | (DIFF, '+ " date: "\n'), 75 | (DIFF, '+ (diffscuss-get-date-time)\n'), 76 | (DIFF, '+ "\\n"\n'), 77 | (DIFF, '+ header\n'), 78 | (DIFF, ' "\\n"\n'), 79 | (DIFF, ' (diffscuss-force-body leader)\n'), 80 | (DIFF, ' " \\n"\n'), 81 | (DIFF_HEADER, '@@ -384,12 +392,42 @@\n'), 82 | (DIFF, ' (forward-line -1)\n'), 83 | (DIFF, ' (end-of-line))\n'), 84 | (DIFF, '\n'), 85 | (DIFF, '+(defun diffscuss-insert-file-comment ()\n'), 86 | (DIFF, '+ "Insert a file-level comment."\n'), 87 | (DIFF, '+ (interactive)\n'), 88 | (DIFF, '+ (beginning-of-buffer)\n'), 89 | (DIFF, '+ (insert (diffscuss-make-comment "%*"))\n'), 90 | (DIFF, '+ (newline)\n'), 91 | (DIFF, '+ (forward-line -2)\n'), 92 | (DIFF, '+ (end-of-line))\n'), 93 | (DIFF, '+\n'), 94 | (DIFF, '+(defun diffscuss-in-header-p ()\n'), 95 | (DIFF, '+ "True if we\'re in the header material."\n'), 96 | (DIFF, "+ ;; if we travel up until we hit a meta line, we'll hit a range line\n"), 97 | (DIFF, "+ ;; first if we're not in a header, otherwise we'll hit a different\n"), 98 | (DIFF, '+ ;; meta line.\n'), 99 | (DIFF, '+ (save-excursion\n'), 100 | (DIFF, '+ (while (and (not (diffscuss-meta-line-p))\n'), 101 | (DIFF, '+ (zerop (forward-line -1))))\n'), 102 | (DIFF, '+ (not (diffscuss-range-line-p))))\n'), 103 | (DIFF, '+\n'), 104 | (DIFF, ' (defun diffscuss-comment-or-reply ()\n'), 105 | (DIFF, ' "Insert a comment or reply based on context."\n'), 106 | (DIFF, ' (interactive)\n'), 107 | (DIFF, '- (if (diffscuss-parse-leader)\n'), 108 | (DIFF, '- (diffscuss-reply-to-comment)\n'), 109 | (DIFF, '- (diffscuss-insert-comment)))\n'), 110 | (DIFF, '+ ;; if at the very top of the file, insert a comment for the entire\n'), 111 | (DIFF, '+ ;; file (meaning before any of the diff headers or lines)\n'), 112 | (DIFF, '+ (if (= (point) 1)\n'), 113 | (DIFF, '+ (diffscuss-insert-file-comment)\n'), 114 | (DIFF, "+ ;; otherwise, if we're already in a comment, reply to it.\n"), 115 | (DIFF, '+ (if (diffscuss-parse-leader)\n'), 116 | (DIFF, '+ (diffscuss-reply-to-comment)\n'), 117 | (DIFF, "+ ;; if we're on a meta piece, go just past it\n"), 118 | (DIFF, '+ (if (diffscuss-in-header-p)\n'), 119 | (DIFF, '+ (progn (while (and (not (diffscuss-range-line-p))\n'), 120 | (DIFF, '+ (zerop (forward-line 1))))\n'), 121 | (DIFF, '+ (diffscuss-insert-comment))\n'), 122 | (DIFF, '+ ;; otherwise, new top-level comment.\n'), 123 | (DIFF, '+ (diffscuss-insert-comment)))))\n'), 124 | (DIFF, '\n'), 125 | (DIFF, ' ;; intelligent newline\n'), 126 | (DIFF, '\n'), 127 | (DIFF_HEADER, '@@ -442,7 +480,7 @@\n'), 128 | (DIFF, ' "Non nil if the current line is part of hunk\'s meta data."\n'), 129 | (DIFF, ' (save-excursion\n'), 130 | (DIFF, ' (beginning-of-line)\n'), 131 | (DIFF, '- (not (looking-at "^[% +<>\\n\\\\-]"))))\n'), 132 | (DIFF, '+ (not (looking-at "^[% +\\n\\\\-]"))))\n'), 133 | (DIFF, '\n'), 134 | (DIFF, ' (defun diffscuss-get-source-file (old-or-new)\n'), 135 | (DIFF, ' "Get the name of the source file."\n'), 136 | (DIFF_HEADER, 'diff --git a/diffscuss/walker.py b/diffscuss/walker.py\n'), 137 | (DIFF_HEADER, 'index 74384c1..5852f4a 100644\n'), 138 | (DIFF_HEADER, '--- a/diffscuss/walker.py\n'), 139 | (DIFF_HEADER, '+++ b/diffscuss/walker.py\n'), 140 | (DIFF_HEADER, '@@ -72,10 +72,16 @@ def walk(fil):\n'), 141 | (DIFF, ' # level can increase by more than one....\n'), 142 | (DIFF, ' if line_level - cur_comment_level > 1:\n'), 143 | (DIFF, ' raise BadNestingException()\n'), 144 | (DIFF, '+\n'), 145 | (DIFF, " # or if we've changed level mid-comment...\n"), 146 | (DIFF, '- if line_level != cur_comment_level and not _is_author_line(line):\n'), 147 | 'COMMENT_IN_DIFF_3', 148 | (DIFF, '+ if (line_level != cur_comment_level\n'), 149 | (DIFF, '+ #and not _is_author_line(line)\n'), 150 | (DIFF, '+ and not _is_header(line)):\n'), 151 | (DIFF, ' raise BadNestingException()\n'), 152 | (DIFF, '\n'), 153 | (DIFF, '+ # At this point, we accept the new line_level\n'), 154 | (DIFF, '+ cur_comment_level = line_level\n'), 155 | (DIFF, '+\n'), 156 | (DIFF, " # or if this is a header line of a comment and it's not\n"), 157 | (DIFF, ' # either following a header or is an author line or an empty line...\n'), 158 | (DIFF, ' if (is_header and\n'), 159 | (DIFF, '\\ No newline at end of file\n'), 160 | 'COMMENT_BOTTOM'] 161 | 162 | 163 | 164 | def _comment_in_header_defaults(): 165 | return {'COMMENT_IN_HEADER_1': '', 166 | 'COMMENT_IN_HEADER_2': '', 167 | 'COMMENT_IN_HEADER_3': '', 168 | 'COMMENT_IN_HEADER_4': '', 169 | 'COMMENT_IN_HEADER_5': '', 170 | 'COMMENT_IN_HEADER_6': '', 171 | 'COMMENT_IN_HEADER_7': '', 172 | 'COMMENT_IN_HEADER_8': ''} 173 | 174 | 175 | MISSING_AUTHOR_COMMENT = (dedent("""\ 176 | #* 177 | #* email: test@example.com 178 | #* 179 | #- yeah 180 | """), 181 | MissingAuthorException) 182 | 183 | AUTHOR_SECOND_COMMENT = (dedent("""\ 184 | #* 185 | #* email: test@example.com 186 | #* author: oh hai 187 | #* 188 | #- yeah 189 | """), 190 | MissingAuthorException) 191 | 192 | EMPTY_COMMENT = (dedent("""\ 193 | #* 194 | #* author: test@example.com 195 | #*"""), 196 | EmptyCommentException) 197 | 198 | BAD_NESTING_COMMENT = (dedent("""\ 199 | #* 200 | #* author: test@example.com 201 | #* 202 | #- body 203 | #-- bad 204 | #- body"""), 205 | BadNestingException) 206 | 207 | SIMPLE_COMMENT = (dedent("""\ 208 | #* 209 | #* author: Testy McTesterson 210 | #* email: hi@example.com 211 | #* 212 | #- 213 | #- body 1 ☃ 214 | #- 215 | #- body 2 216 | #-"""), 217 | [(COMMENT_HEADER, '#*\n'), 218 | (COMMENT_HEADER, '#* author: Testy McTesterson\n'), 219 | (COMMENT_HEADER, '#* email: hi@example.com\n'), 220 | (COMMENT_HEADER, '#*\n'), 221 | (COMMENT_BODY, '#-\n'), 222 | (COMMENT_BODY, '#- body 1 ☃\n'), 223 | (COMMENT_BODY, '#-\n'), 224 | (COMMENT_BODY, '#- body 2\n'), 225 | (COMMENT_BODY, '#-\n'),]) 226 | 227 | SIMPLE_THREAD = (dedent("""\ 228 | #* 229 | #* author: Testy McTesterson 230 | #* email: hi@example.com 231 | #* 232 | #- 233 | #- body 1 234 | #- 235 | #- body 2 236 | #- 237 | #** 238 | #** author: Fakity McFakerson 239 | #** email: bye@example.com 240 | #** 241 | #-- 242 | #-- rbody 1 243 | #-- 244 | #-- rbody 2 245 | #--"""), 246 | [(COMMENT_HEADER, '#*\n'), 247 | (COMMENT_HEADER, '#* author: Testy McTesterson\n'), 248 | (COMMENT_HEADER, '#* email: hi@example.com\n'), 249 | (COMMENT_HEADER, '#*\n'), 250 | (COMMENT_BODY, '#-\n'), 251 | (COMMENT_BODY, '#- body 1\n'), 252 | (COMMENT_BODY, '#-\n'), 253 | (COMMENT_BODY, '#- body 2\n'), 254 | (COMMENT_BODY, '#-\n'), 255 | (COMMENT_HEADER, '#**\n'), 256 | (COMMENT_HEADER, '#** author: Fakity McFakerson\n'), 257 | (COMMENT_HEADER, '#** email: bye@example.com\n'), 258 | (COMMENT_HEADER, '#**\n'), 259 | (COMMENT_BODY, '#--\n'), 260 | (COMMENT_BODY, '#-- rbody 1\n'), 261 | (COMMENT_BODY, '#--\n'), 262 | (COMMENT_BODY, '#-- rbody 2\n'), 263 | (COMMENT_BODY, '#--\n'),]) 264 | 265 | TEMPLATE_TESTS = [ 266 | ('standard_template.diffscuss', _standard_template_defaults(), STANDARD_TEMPLATE_PARSED, 267 | [ 268 | MISSING_AUTHOR_COMMENT, 269 | # EMPTY_COMMENT, 270 | # BAD_NESTING_COMMENT, 271 | # SIMPLE_COMMENT, 272 | # SIMPLE_THREAD, 273 | # AUTHOR_SECOND_COMMENT, 274 | ]), 275 | 276 | # ('leading_hash_template.diffscuss', _standard_template_defaults(), STANDARD_TEMPLATE_PARSED, 277 | # [ 278 | # MISSING_AUTHOR_COMMENT, 279 | # EMPTY_COMMENT, 280 | # BAD_NESTING_COMMENT, 281 | # SIMPLE_COMMENT, 282 | # SIMPLE_THREAD, 283 | # AUTHOR_SECOND_COMMENT, 284 | # ]), 285 | 286 | # ('comment_in_header.diffscuss', _comment_in_header_defaults(), [], 287 | # [ 288 | # (SIMPLE_COMMENT[0], CommentInHeaderException), 289 | # (SIMPLE_THREAD[0], CommentInHeaderException), 290 | # ]), 291 | ] 292 | 293 | 294 | def _check_walker(template_fname, template_defaults, template_key, comment, template_parsed, expected): 295 | fil = _apply_to_template(template_fname, template_defaults, {template_key: comment}) 296 | if isinstance(expected, type): 297 | try: 298 | list(walk(fil)) 299 | ok_(False, "Expected exception %s" % expected) 300 | except expected: 301 | ok_(True) 302 | else: 303 | walked = list(walk(fil)) 304 | 305 | expected_parsed = [] 306 | for elem in template_parsed: 307 | if isinstance(elem, tuple): 308 | # not a template holder? goes in as is. 309 | expected_parsed.append(elem) 310 | elif elem == template_key: 311 | # substitute in what we expect for the comment 312 | for e in expected: 313 | expected_parsed.append(e) 314 | else: 315 | # it's a template key we don't care about, skip. 316 | pass 317 | 318 | eq_(expected_parsed, walked) 319 | 320 | 321 | def test_walker(): 322 | for (template_fname, template_defaults, template_parsed, tests) in TEMPLATE_TESTS: 323 | for (comment, expected) in tests: 324 | for template_key in template_defaults.keys(): 325 | yield _check_walker, template_fname, template_defaults, template_key, comment, template_parsed, expected 326 | -------------------------------------------------------------------------------- /diffscuss/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command line interface for use by the top-level 'diffscuss' 3 | executable. 4 | """ 5 | 6 | import argparse 7 | import sys 8 | from textwrap import dedent 9 | 10 | from diffscuss import find_local, generate, github_import, mailbox 11 | 12 | 13 | def print_usage(): 14 | usage = dedent("""\ 15 | This is a program. 16 | 17 | Woot. 18 | """) 19 | print >> sys.stderr, usage 20 | 21 | 22 | def _add_gen_subparser(subparsers): 23 | epilog = dedent("""\ 24 | The git_revision_range will be passed as-is to 25 | git diff and git log. For example, you could use 26 | HEAD~3..HEAD or some_branch..master. Note that you 27 | probably don't want just a branch name, as git log 28 | will start the log from that point and run 29 | backwards to the initial commit in the repo. Use 30 | something like master..branchname instead. 31 | """) 32 | gen_subparser = subparsers.add_parser('generate', 33 | epilog=epilog) 34 | gen_subparser.add_argument("-a", "--author", 35 | help="The author for the diffscuss review " 36 | "(defaults to the result of 'git config " 37 | "--get user.name')") 38 | gen_subparser.add_argument("-e", "--email", 39 | help="The email address for the diffscuss " 40 | "review (defaults to the result of 'git " 41 | "config --get user.email')") 42 | gen_subparser.add_argument("-g", "--git-exe", 43 | help="The path to the git executable" 44 | " (defaults to '/usr/bin/env git')") 45 | gen_subparser.add_argument("-o", "--output-file", 46 | help="File to write the output to" 47 | " (defaults to stdout if not supplied or -)") 48 | gen_subparser.add_argument("-c", "--lines-context", 49 | type=int, 50 | help="Number of lines of context " 51 | "to show in the diff (defaults to 20)", 52 | default=20) 53 | gen_subparser.add_argument("git_revision_range", 54 | help="The revisions to include in the " 55 | "review, in a form recognizable to git log " 56 | "or diff (e.g. HEAD~3..HEAD)") 57 | gen_subparser.add_argument("path", default=[], 58 | help="Paths to include in review. Defaults to " 59 | "every file changed in the revision. Works " 60 | "exactly as 'git log -- '", 61 | nargs='*') 62 | 63 | 64 | def _add_find_local_subparser(subparsers): 65 | epilog = dedent("""\ 66 | Accepts a diffscuss file (either through the 67 | -i argument or stdin) and a line number within 68 | that file, and outputs, on either the file 69 | specified by -o or stdout, the path and line 70 | number of the best-guess local source file. 71 | 72 | The output format is "file_path line_number". 73 | 74 | The line number is 1 based. 75 | 76 | Exits with a non-0 return code if local source 77 | cannot be found. 78 | """) 79 | 80 | find_local_subparser = subparsers.add_parser('find-local', 81 | epilog=epilog) 82 | find_local_subparser.add_argument('-i', '--input-file', 83 | default='-', 84 | help="Diffscuss file to read, " 85 | "stdin if not provided or -") 86 | find_local_subparser.add_argument('-o', '--output-file', 87 | default='-', 88 | help="File to write results to," 89 | " stdout if not provided or -") 90 | find_local_subparser.add_argument('-d', '--directory', 91 | default='-', 92 | help="Directory in which to start" 93 | " searching for the local source, " 94 | "current dir if not provided") 95 | find_local_subparser.add_argument("line_number", 96 | type=int, 97 | help="The line number in the" 98 | " supplied diffscuss file for which" 99 | " to find the corresponding local" 100 | " source") 101 | 102 | 103 | def _add_github_import_subparser(subparsers): 104 | gh_subparser = subparsers.add_parser('github-import') 105 | gh_subparser.add_argument("-p", "--passfile", 106 | help="File containg github password " 107 | "to use for api (required)", 108 | required=True) 109 | gh_subparser.add_argument("-u", "--username", 110 | help="Github user name to use for api (required)", 111 | required=True) 112 | gh_subparser.add_argument("-o", "--output-dir", 113 | help="Directory in which to put output" 114 | " (defaults to 'gh-import')", 115 | default="gh-import") 116 | gh_subparser.add_argument("-l", "--logfile", 117 | help="File to use for logging" 118 | " (defaults to gh-import.log)", 119 | default="gh-import.log") 120 | gh_subparser.add_argument("-d", "--debug", 121 | help="Turn on debug level logging.", 122 | action="store_true", default=False) 123 | gh_subparser.add_argument("pull_request_spec", 124 | help=""" 125 | [/repo_name[:pull_request_id]] 126 | specifies a single pull request 127 | (e.g. 'hut8labs/diffscuss:15'), all 128 | pull requests for a repo 129 | (e.g. 'tomheon/git_by_a_bus') or 130 | all pull requests for a user or 131 | organization (e.g. 'hut8labs') 132 | """) 133 | 134 | 135 | def _add_check_mb_subparser(mb_subparser): 136 | check_subparser = mb_subparser.add_parser('check') 137 | check_subparser.add_argument("-g", "--git-exe", 138 | help="The path to the git executable" 139 | " (defaults to '/usr/bin/env git')") 140 | check_subparser.add_argument("-i", "--inbox", 141 | help="""Inbox name (if not supplied, 142 | will use the return of 'git config 143 | --get diffscuss-mb.inbox'""") 144 | check_subparser.add_argument("-e", "--emacs", 145 | action="store_true", 146 | default=False, 147 | help="Format for emacs compilation mode") 148 | check_subparser.add_argument("-s", "--short", 149 | action="store_true", default=False, 150 | help="List only reviews, no info about them") 151 | 152 | 153 | def _add_set_inbox_subparser(mb_subparser): 154 | epilog = "Set default inbox name" 155 | inbox_subparser = mb_subparser.add_parser('set-default-inbox', 156 | epilog=epilog) 157 | inbox_subparser.add_argument("-g", "--git-exe", 158 | help="The path to the git executable" 159 | " (defaults to '/usr/bin/env git')") 160 | inbox_subparser.add_argument("inbox", 161 | help="The inbox to make the default") 162 | 163 | 164 | def _add_make_inbox_subparser(mb_subparser): 165 | epilog = "Create an inbox" 166 | inbox_subparser = mb_subparser.add_parser('make-inbox', 167 | epilog=epilog) 168 | inbox_subparser.add_argument("-g", "--git-exe", 169 | help="The path to the git executable" 170 | " (defaults to '/usr/bin/env git')") 171 | inbox_subparser.add_argument("inbox", 172 | help="The name of the inbox to create") 173 | 174 | 175 | def _add_init_subparser(mb_subparser): 176 | epilog = """Initialize a diffscuss mailbox directory (must 177 | be run within a git checkout)""" 178 | init_subparser = mb_subparser.add_parser('init', 179 | epilog=epilog) 180 | init_subparser.add_argument("-g", "--git-exe", 181 | help="The path to the git executable" 182 | " (defaults to '/usr/bin/env git')") 183 | init_subparser.add_argument("-d", "--directory", 184 | default="diffscussions", 185 | help="""The mailbox directory to create 186 | (defaults to 'diffscussions')""") 187 | 188 | 189 | def _add_post_subparser(mb_subparser): 190 | epilog = """Post a review to one or more inboxes""" 191 | post_subparser = mb_subparser.add_parser('post', 192 | epilog=epilog) 193 | post_subparser.add_argument("-g", "--git-exe", 194 | help="The path to the git executable" 195 | " (defaults to '/usr/bin/env git')") 196 | post_subparser.add_argument("file", 197 | help="Diffscuss file to post for review") 198 | post_subparser.add_argument("-p", "--print-review-path", 199 | action="store_true", 200 | default=False, 201 | help="""Print the path of the posted review 202 | before exiting""") 203 | post_subparser.add_argument("inbox", 204 | help="Inbox to post to") 205 | post_subparser.add_argument("inboxes", 206 | nargs=argparse.REMAINDER, 207 | help="Other inboxes to post to") 208 | 209 | 210 | def _add_bounce_subparser(mb_subparser): 211 | epilog = """Bounce a review to one or more inboxes""" 212 | bounce_subparser = mb_subparser.add_parser('bounce', 213 | epilog=epilog) 214 | bounce_subparser.add_argument("-g", "--git-exe", 215 | help="The path to the git executable" 216 | " (defaults to '/usr/bin/env git')") 217 | bounce_subparser.add_argument("file", 218 | help="Diffscuss file to bounce for review") 219 | bounce_subparser.add_argument("-p", "--print-review-path", 220 | action="store_true", 221 | default=False, 222 | help="""Print the path of the bounced review 223 | before exiting""") 224 | bounce_subparser.add_argument("-f", "--from-inbox", 225 | help="""Inbox to remove file from 226 | (if not supplied, will use the return 227 | of 'git config --get 228 | diffscuss-mb.inbox'""") 229 | bounce_subparser.add_argument("inbox", 230 | help="Inbox to bounce to") 231 | bounce_subparser.add_argument("inboxes", 232 | nargs=argparse.REMAINDER, 233 | help="Other inboxes to bounce to") 234 | 235 | 236 | def _add_done_subparser(mb_subparser): 237 | epilog = """Remove a review from an inbox""" 238 | done_subparser = mb_subparser.add_parser('done', 239 | epilog=epilog) 240 | done_subparser.add_argument("-g", "--git-exe", 241 | help="The path to the git executable" 242 | " (defaults to '/usr/bin/env git')") 243 | done_subparser.add_argument("-p", "--print-review-path", 244 | action="store_true", 245 | default=False, 246 | help="""Print the path of the review 247 | before exiting""") 248 | done_subparser.add_argument("-f", "--from-inbox", 249 | help="""Inbox to remove file from 250 | (if not supplied, will use the return 251 | of 'git config --get 252 | diffscuss-mb.inbox'""") 253 | done_subparser.add_argument("file", 254 | help="Diffscuss file to done for review") 255 | 256 | 257 | 258 | def _add_mailbox_subparser(subparsers): 259 | mb_parser = subparsers.add_parser('mailbox') 260 | mb_subparser = mb_parser.add_subparsers(dest="mailbox_subcommand_name") 261 | _add_check_mb_subparser(mb_subparser) 262 | _add_set_inbox_subparser(mb_subparser) 263 | _add_make_inbox_subparser(mb_subparser) 264 | _add_init_subparser(mb_subparser) 265 | _add_post_subparser(mb_subparser) 266 | _add_bounce_subparser(mb_subparser) 267 | _add_done_subparser(mb_subparser) 268 | 269 | 270 | mod_map = {'find-local': find_local, 271 | 'generate': generate, 272 | 'github-import': github_import, 273 | 'mailbox': mailbox} 274 | 275 | 276 | if __name__ == '__main__': 277 | parser = argparse.ArgumentParser(prog='diffscuss') 278 | subparsers = parser.add_subparsers(dest="subcommand_name") 279 | 280 | _add_gen_subparser(subparsers) 281 | _add_find_local_subparser(subparsers) 282 | _add_github_import_subparser(subparsers) 283 | _add_mailbox_subparser(subparsers) 284 | 285 | args = parser.parse_args() 286 | mod = mod_map.get(args.subcommand_name) 287 | mod.main(args) 288 | -------------------------------------------------------------------------------- /diffscuss/support/tests/test_editor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test cases for the editor module. 3 | """ 4 | from functools import wraps 5 | 6 | from nose.tools import eq_ 7 | 8 | from diffscuss.support import editor 9 | 10 | 11 | class patch(object): 12 | """ 13 | Quick and dirty patching. Use it as a decorator or as a context manager. 14 | """ 15 | PATCH_REMOVE = object() 16 | 17 | def __init__(self, obj, attr, new): 18 | self.obj = obj 19 | self.attr = attr 20 | self.new = new 21 | self.patch_attr = '_patched_' + attr 22 | 23 | def _patch(self): 24 | """ 25 | Sets `obj.attr` to `new`, saving the original value of `obj.attr` (if 26 | there was one) for later patching. 27 | """ 28 | if not hasattr(self.obj, self.patch_attr): 29 | setattr(self.obj, self.patch_attr, []) 30 | saved = getattr(self.obj, self.attr, self.PATCH_REMOVE) 31 | getattr(self.obj, self.patch_attr).append(saved) 32 | setattr(self.obj, self.attr, self.new) 33 | 34 | def _unpatch(self): 35 | """ 36 | Unsets `obj.attr`, restoring its original value if there was one. 37 | """ 38 | assert hasattr(self.obj, self.patch_attr) 39 | restore_list = getattr(self.obj, self.patch_attr) 40 | to_restore = restore_list.pop() 41 | if to_restore is self.PATCH_REMOVE: 42 | delattr(self.obj, self.attr) 43 | else: 44 | setattr(self.obj, self.attr, to_restore) 45 | if not restore_list: 46 | delattr(self.obj, self.patch_attr) 47 | 48 | def __call__(self, func): 49 | """ 50 | A decorator that patches `obj.attr` to `new` within the decorated 51 | function. 52 | """ 53 | @wraps(func) 54 | def _wrapped(*args, **kwargs): 55 | with self: 56 | return func(*args, **kwargs) 57 | return _wrapped 58 | 59 | def __enter__(self): 60 | self._patch() 61 | 62 | def __exit__(self, exc_type, value, traceback): 63 | self._unpatch() 64 | 65 | 66 | config_patch = patch(editor, 'config', 67 | lambda: dict(author='Test', email='test@example.com')) 68 | 69 | 70 | def setup_module(): 71 | # Patch in a config() function with test defaults. 72 | config_patch._patch() 73 | 74 | 75 | def teardown_module(): 76 | config_patch._unpatch() 77 | 78 | 79 | class BufferWrapper(list): 80 | """ 81 | Adapts a Python list to the Vim buffer interface. 82 | """ 83 | def append(self, obj, index=None): 84 | if not hasattr(obj, '__iter__'): 85 | obj = [obj] 86 | if index is None: 87 | for item in obj: 88 | list.append(self, obj) 89 | else: 90 | for item in obj: 91 | list.insert(self, index, item) 92 | index += 1 93 | 94 | 95 | TEST_LINE_PROPERTIES = [ 96 | ('@@ -0,0 +1,2 @@', 97 | dict(is_diff_meta=True, is_diff_range=True, 98 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 99 | ('diff --git a/some/file', 100 | dict(is_diff_meta=True, is_diff_range=False, 101 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 102 | ('--- a/some/file', 103 | dict(is_diff_meta=True, is_diff_range=False, 104 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 105 | ('+++ a/some/file', 106 | dict(is_diff_meta=True, is_diff_range=False, 107 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 108 | ('index rev1...rev2 100644', 109 | dict(is_diff_meta=True, is_diff_range=False, 110 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 111 | ('-diff line', 112 | dict(is_diff_meta=False, is_diff_range=False, 113 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 114 | ('+diff line', 115 | dict(is_diff_meta=False, is_diff_range=False, 116 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 117 | (' diff line', 118 | dict(is_diff_meta=False, is_diff_range=False, 119 | is_header=False, is_body=False, is_diffscuss=False, depth=0)), 120 | ('#* Header', 121 | dict(is_diff_meta=False, is_diff_range=False, 122 | is_header=True, is_body=False, is_diffscuss=True, depth=1)), 123 | ('#- Body', 124 | dict(is_diff_meta=False, is_diff_range=False, 125 | is_header=False, is_body=True, is_diffscuss=True, depth=1)), 126 | ('#***** Deep header', 127 | dict(is_diff_meta=False, is_diff_range=False, 128 | is_header=True, is_body=False, is_diffscuss=True, depth=5)), 129 | ('#----- Deep body', 130 | dict(is_diff_meta=False, is_diff_range=False, 131 | is_header=False, is_body=True, is_diffscuss=True, depth=5)), 132 | ('#*- Strange header', 133 | dict(is_diff_meta=False, is_diff_range=False, 134 | is_header=True, is_body=False, is_diffscuss=True, depth=1)), 135 | ('#-* Strange body', 136 | dict(is_diff_meta=False, is_diff_range=False, 137 | is_header=False, is_body=True, is_diffscuss=True, depth=1)) 138 | ] 139 | 140 | 141 | def test_line_properties(): 142 | for line, expected_attrs in TEST_LINE_PROPERTIES: 143 | yield _check_line_properties, line, expected_attrs 144 | 145 | 146 | def _check_line_properties(line, expected_attrs): 147 | props = editor.LineProperties(line) 148 | for attr, value in expected_attrs.iteritems(): 149 | eq_(value, getattr(props, attr)) 150 | 151 | 152 | TEST_BUFFER_NONE = """ 153 | diff --git a/some/file b/some/file 154 | index rev1..rev2 100644 155 | --- a/some/file 156 | +++ b/some/file 157 | @@ -1,1 +1,2 @@ 158 | +diff1 159 | diff2 160 | diff3 161 | """.strip().split('\n') 162 | 163 | TEST_BUFFER_FILE = """ 164 | #* 165 | #* author: Test 166 | #* email: test@example.com 167 | #* date: 2013-01-01T00:00:00-0500 168 | #* 169 | #- This is a test comment. 170 | #- 171 | #** 172 | #** author: Test 173 | #** email: test@example.com 174 | #** date: 2013-01-01T00:01:00-0500 175 | #** 176 | #-- This is a test reply. 177 | #-- 178 | diff --git a/some/file b/some/file 179 | index rev1..rev2 100644 180 | --- a/some/file 181 | +++ b/some/file 182 | @@ -1,1 +1,2 @@ 183 | +diff1 184 | diff2 185 | diff3 186 | """.strip().split('\n') 187 | 188 | TEST_BUFFER_BODY = """ 189 | diff --git a/some/file b/some/file 190 | index rev1..rev2 100644 191 | --- a/some/file 192 | +++ b/some/file 193 | @@ -1,1 +1,2 @@ 194 | +diff1 195 | #* 196 | #* author: Test 197 | #* email: test@example.com 198 | #* date: 2013-01-01T00:00:00-0500 199 | #* 200 | #- This is a test comment. 201 | #- 202 | #** 203 | #** author: Test 204 | #** email: test@example.com 205 | #** date: 2013-01-01T00:01:00-0500 206 | #** 207 | #-- This is a test reply. 208 | #-- 209 | diff2 210 | diff3 211 | """.strip().split('\n') 212 | 213 | TEST_BUFFER_END = """ 214 | diff --git a/some/file b/some/file 215 | index rev1..rev2 100644 216 | --- a/some/file 217 | +++ b/some/file 218 | @@ -1,1 +1,2 @@ 219 | +diff1 220 | diff2 221 | diff3 222 | #* 223 | #* author: Test 224 | #* email: test@example.com 225 | #* date: 2013-01-01T00:00:00-0500 226 | #* 227 | #- This is a test comment. 228 | #- 229 | """.strip().split('\n') 230 | 231 | 232 | def test_find_header_start(): 233 | for i in range(1, len(TEST_BUFFER_NONE) + 1): 234 | result = editor.find_header_start(TEST_BUFFER_NONE, (i, i)) 235 | yield eq_, (i, i), result 236 | 237 | for i in range(1, len(TEST_BUFFER_FILE) + 1): 238 | result = editor.find_header_start(TEST_BUFFER_FILE, (i, 1)) 239 | if i <= TEST_BUFFER_FILE.index('#**'): 240 | yield eq_, (1, 1), result 241 | elif i <= TEST_BUFFER_FILE.index('diff --git a/some/file b/some/file'): 242 | yield eq_, (7, 1), result 243 | else: 244 | yield eq_, (i, 1), result 245 | 246 | for i in range(1, len(TEST_BUFFER_BODY) + 1): 247 | result = editor.find_header_start(TEST_BUFFER_BODY, (i, 1)) 248 | if i <= TEST_BUFFER_BODY.index('#*'): 249 | yield eq_, (i, 1), result 250 | elif i <= TEST_BUFFER_BODY.index('#**'): 251 | yield eq_, (6, 1), result 252 | elif i <= TEST_BUFFER_BODY.index(' diff2'): 253 | yield eq_, (13, 1), result 254 | else: 255 | yield eq_, (i, 1), result 256 | 257 | 258 | def test_find_body_end(): 259 | for i in range(1, len(TEST_BUFFER_NONE) + 1): 260 | result = editor.find_body_end(TEST_BUFFER_NONE, (i, i)) 261 | yield eq_, (i, i), result 262 | 263 | for i in range(1, len(TEST_BUFFER_END) + 1): 264 | result = editor.find_body_end(TEST_BUFFER_END, (i, i)) 265 | yield eq_, (i, i), result 266 | 267 | for i in range(1, len(TEST_BUFFER_FILE) + 1): 268 | result = editor.find_body_end(TEST_BUFFER_FILE, (i, 1)) 269 | if i <= TEST_BUFFER_FILE.index('#**'): 270 | yield eq_, (7, 1), result 271 | elif i <= TEST_BUFFER_FILE.index('diff --git a/some/file b/some/file'): 272 | yield eq_, (14, 1), result 273 | else: 274 | yield eq_, (i, 1), result 275 | 276 | for i in range(1, len(TEST_BUFFER_BODY) + 1): 277 | result = editor.find_body_end(TEST_BUFFER_BODY, (i, 1)) 278 | if i <= TEST_BUFFER_BODY.index('#*'): 279 | yield eq_, (i, 1), result 280 | elif i <= TEST_BUFFER_BODY.index('#**'): 281 | yield eq_, (13, 1), result 282 | elif i <= TEST_BUFFER_BODY.index(' diff2'): 283 | yield eq_, (20, 1), result 284 | else: 285 | yield eq_, (i, 1), result 286 | 287 | 288 | def test_find_subthread_end(): 289 | for i in range(1, len(TEST_BUFFER_NONE) + 1): 290 | result = editor.find_subthread_end(TEST_BUFFER_NONE, (i, i)) 291 | yield eq_, (i, i), result 292 | 293 | for i in range(1, len(TEST_BUFFER_END) + 1): 294 | result = editor.find_subthread_end(TEST_BUFFER_END, (i, i)) 295 | yield eq_, (i, i), result 296 | 297 | for i in range(1, len(TEST_BUFFER_FILE) + 1): 298 | result = editor.find_subthread_end(TEST_BUFFER_FILE, (i, 1)) 299 | if i <= TEST_BUFFER_FILE.index('#**'): 300 | yield eq_, (14, 1), result 301 | elif i <= TEST_BUFFER_FILE.index('diff --git a/some/file b/some/file'): 302 | yield eq_, (14, 1), result 303 | else: 304 | yield eq_, (i, 1), result 305 | 306 | for i in range(1, len(TEST_BUFFER_BODY) + 1): 307 | result = editor.find_subthread_end(TEST_BUFFER_BODY, (i, 1)) 308 | if i <= TEST_BUFFER_BODY.index('#*'): 309 | yield eq_, (i, 1), result 310 | elif i <= TEST_BUFFER_BODY.index('#**'): 311 | yield eq_, (20, 1), result 312 | elif i <= TEST_BUFFER_BODY.index(' diff2'): 313 | yield eq_, (20, 1), result 314 | else: 315 | yield eq_, (i, 1), result 316 | 317 | 318 | def test_find_thread_end(): 319 | for i in range(1, len(TEST_BUFFER_NONE) + 1): 320 | result = editor.find_thread_end(TEST_BUFFER_NONE, (i, i)) 321 | yield eq_, (i, i), result 322 | 323 | for i in range(1, len(TEST_BUFFER_FILE) + 1): 324 | result = editor.find_thread_end(TEST_BUFFER_FILE, (i, 1)) 325 | if i <= TEST_BUFFER_FILE.index('#**'): 326 | yield eq_, (14, 1), result 327 | elif i <= TEST_BUFFER_FILE.index('diff --git a/some/file b/some/file'): 328 | yield eq_, (14, 1), result 329 | else: 330 | yield eq_, (i, 1), result 331 | 332 | for i in range(1, len(TEST_BUFFER_BODY) + 1): 333 | result = editor.find_thread_end(TEST_BUFFER_BODY, (i, 1)) 334 | if i <= TEST_BUFFER_BODY.index('#*'): 335 | yield eq_, (i, 1), result 336 | elif i <= TEST_BUFFER_BODY.index('#**'): 337 | yield eq_, (20, 1), result 338 | elif i <= TEST_BUFFER_BODY.index(' diff2'): 339 | yield eq_, (20, 1), result 340 | else: 341 | yield eq_, (i, 1), result 342 | 343 | 344 | def test_find_range(): 345 | for buf in [TEST_BUFFER_NONE, TEST_BUFFER_FILE, TEST_BUFFER_BODY]: 346 | for i in range(1, len(buf) + 1): 347 | result = editor.find_range(buf, (i, i)) 348 | if i <= buf.index('@@ -1,1 +1,2 @@'): 349 | yield eq_, (buf.index('@@ -1,1 +1,2 @@') + 1, i), result 350 | else: 351 | yield eq_, (i, i), result 352 | 353 | 354 | @patch(editor.time, 'strftime', lambda arg: '2013-01-01T01:01:01-0500') 355 | def test_make_comment(): 356 | eq_(['#*', '#* author: Test', '#* email: test@example.com', 357 | '#* date: 2013-01-01T01:01:01-0500', '#*', '#- ', '#-'], 358 | editor.make_comment(depth=1)) 359 | eq_(['#**', '#** author: Test', '#** email: test@example.com', 360 | '#** date: 2013-01-01T01:01:01-0500', '#**', '#-- ', '#--'], 361 | editor.make_comment(depth=2)) 362 | eq_(['#*', '#* author: Test', '#* email: test@example.com', 363 | '#* date: 2013-01-01T01:01:01-0500', '#*', '#- ', '#-'], 364 | editor.make_comment(depth=0)) 365 | 366 | with patch(editor, 'config', lambda: dict()): 367 | eq_(['#*', '#* author: Unknown', '#* email: Unknown', 368 | '#* date: 2013-01-01T01:01:01-0500', '#*', '#- ', '#-'], 369 | editor.make_comment(depth=1)) 370 | 371 | 372 | @patch(editor.time, 'strftime', lambda arg: '2013-01-01T00:00:00-0500') 373 | def test_inject_comment(): 374 | new_buf = BufferWrapper(list(TEST_BUFFER_NONE)) 375 | result = editor.inject_comment(new_buf, (6, 1)) 376 | eq_((12, 3), result) 377 | eq_(['diff --git a/some/file b/some/file', 378 | 'index rev1..rev2 100644', 379 | '--- a/some/file', 380 | '+++ b/some/file', 381 | '@@ -1,1 +1,2 @@', 382 | '+diff1', 383 | '#*', 384 | '#* author: Test', 385 | '#* email: test@example.com', 386 | '#* date: 2013-01-01T00:00:00-0500', 387 | '#*', 388 | '#- ', 389 | '#-', 390 | ' diff2', 391 | ' diff3'], new_buf) 392 | 393 | 394 | @patch(editor.time, 'strftime', lambda arg: '2013-01-01T00:00:00-0500') 395 | def test_insert_comment(): 396 | new_buf = BufferWrapper(list(TEST_BUFFER_BODY)) 397 | result = editor.insert_comment(new_buf, (7, 1)) 398 | eq_((26, 3), result) 399 | eq_(['diff --git a/some/file b/some/file', 400 | 'index rev1..rev2 100644', 401 | '--- a/some/file', 402 | '+++ b/some/file', 403 | '@@ -1,1 +1,2 @@', 404 | '+diff1', 405 | '#*', 406 | '#* author: Test', 407 | '#* email: test@example.com', 408 | '#* date: 2013-01-01T00:00:00-0500', 409 | '#*', 410 | '#- This is a test comment.', 411 | '#-', 412 | '#**', 413 | '#** author: Test', 414 | '#** email: test@example.com', 415 | '#** date: 2013-01-01T00:01:00-0500', 416 | '#**', 417 | '#-- This is a test reply.', 418 | '#--', 419 | '#*', 420 | '#* author: Test', 421 | '#* email: test@example.com', 422 | '#* date: 2013-01-01T00:00:00-0500', 423 | '#*', 424 | '#- ', 425 | '#-', 426 | ' diff2', 427 | ' diff3'], new_buf) 428 | -------------------------------------------------------------------------------- /diffscuss/github_import.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import errno 4 | import itertools 5 | import logging 6 | import os 7 | import shutil 8 | from StringIO import StringIO 9 | import sys 10 | from textwrap import dedent, wrap 11 | 12 | from github import Github, GithubException 13 | import requests 14 | 15 | from diffscuss.walker import walk, DIFF, DIFF_HEADER, COMMENT_HEADER, \ 16 | COMMENT_BODY 17 | 18 | 19 | def _echo(line): 20 | return lambda: line 21 | 22 | 23 | def _compose(line_func_one, line_func_two): 24 | return lambda: u''.join([line_func_one(), line_func_two()]) 25 | 26 | 27 | class DiffscussComposer(object): 28 | """ 29 | Allows us to insert content into the original diff without 30 | changing the line numbers, so we can sync up positions in the 31 | diffscuss with positions in the diff even after adding comments. 32 | """ 33 | 34 | def __init__(self, orig_diff): 35 | self.orig_diff = orig_diff 36 | self.composed_lines = [] 37 | self.top_matter = _echo('') 38 | 39 | # we want to maintain things like trailing newline or not, so 40 | # use readline instead of the line iterators 41 | diff_s = StringIO(orig_diff) 42 | line = diff_s.readline() 43 | 44 | while line != u'': 45 | self.composed_lines.append(_echo(line)) 46 | line = diff_s.readline() 47 | 48 | def append_at(self, index, text): 49 | logging.debug("Appending at index %s text %s", 50 | index, 51 | text) 52 | if index == -1: 53 | self.top_matter = _compose(self.top_matter, 54 | _echo(text)) 55 | elif index >= 0: 56 | self.composed_lines[index] = _compose(self.composed_lines[index], 57 | _echo(text)) 58 | else: 59 | raise Exception("Index must be >= -1.") 60 | 61 | def render(self): 62 | yield self.top_matter() 63 | for line_func in self.composed_lines: 64 | yield line_func() 65 | 66 | 67 | def _mkdir_p(path): 68 | try: 69 | os.makedirs(path) 70 | except OSError, e: 71 | if e.errno != errno.EEXIST or not os.path.isdir(path): 72 | raise 73 | 74 | 75 | def _password(passfile): 76 | with open(passfile, 'rb') as passfil: 77 | return passfil.read().rstrip() 78 | 79 | 80 | def _pull_requests_from_repo(repo): 81 | logging.debug("Finding all pull requests in repo %s", 82 | repo.url) 83 | for state in ['open', 'closed']: 84 | logging.debug("Finding %s pull requests in repo %s", 85 | state, 86 | repo.url) 87 | for pull_request in repo.get_pulls(state=state): 88 | logging.debug("Found pull request %s", 89 | pull_request.url) 90 | yield pull_request 91 | 92 | 93 | def _user_or_org(gh, user_or_org_name): 94 | # note that gh will not return private users for an org, even if 95 | # the api user has access, unless you access the organization as 96 | # an organization, so first try to treat the name as an org, and 97 | # fall back to user. 98 | logging.debug("Finding user or org with name %s", user_or_org_name) 99 | try: 100 | logging.debug("Trying org %s", user_or_org_name) 101 | user_or_org = gh.get_organization(user_or_org_name) 102 | logging.debug("Found org %s", user_or_org_name) 103 | except GithubException, e: 104 | if e.status == 404: 105 | logging.debug("No org %s, trying user", user_or_org_name) 106 | user_or_org = gh.get_user(user_or_org_name) 107 | else: 108 | raise 109 | return user_or_org 110 | 111 | 112 | def _pull_requests_from_spec(gh, spec): 113 | logging.info("Parsing spec %s", spec) 114 | if ':' in spec: 115 | repo_name, pr_id = spec.split(':') 116 | repo = gh.get_repo(repo_name) 117 | user_or_org = _user_or_org(gh, repo_name.split('/')[0]) 118 | logging.info("Spec is for pr #%s in repo %s", 119 | pr_id, 120 | repo_name) 121 | yield user_or_org, repo, repo.get_pull(int(pr_id)) 122 | elif '/' in spec: 123 | repo = gh.get_repo(spec) 124 | user_or_org = _user_or_org(gh, spec.split('/')[0]) 125 | logging.info("Spec is for all prs in repo %s", 126 | repo.url) 127 | for pull_request in _pull_requests_from_repo(repo): 128 | yield user_or_org, repo, pull_request 129 | else: 130 | user_or_org = _user_or_org(gh, spec) 131 | logging.info("Spec is for all prs in all repos for user/org %s", 132 | spec) 133 | for repo in user_or_org.get_repos(): 134 | for pull_request in _pull_requests_from_repo(repo): 135 | yield user_or_org, repo, pull_request 136 | 137 | 138 | def _pull_requests_from_specs(gh, args): 139 | for spec in args: 140 | for user_or_org, repo, pull_request in _pull_requests_from_spec(gh, spec): 141 | logging.info("Found pull request %s", 142 | pull_request.url) 143 | yield user_or_org, repo, pull_request 144 | 145 | 146 | def _get_diff_text(username, password, pull_request): 147 | logging.info("Requesting diff from url %s", 148 | pull_request.diff_url) 149 | resp = requests.get(pull_request.diff_url, auth=(username, password)) 150 | if not resp.ok: 151 | raise Exception("Error pulling %s: %s" % (pull_request.diff_url, 152 | resp)) 153 | return resp.text 154 | 155 | 156 | def _gh_time_to_diffscuss_time(gh_time): 157 | # gh times are all zulu, and come in w/out timezones through 158 | # PyGithub, so we hardcode the offset 159 | return unicode(gh_time.strftime("%Y-%m-%dT%T-0000")) 160 | 161 | 162 | def _make_header_line(depth, header, value): 163 | if header is None: 164 | return u"%%%s \n" % (u'*' * depth) 165 | return u"%%%s %s: %s\n" % (u'*' * depth, header, value) 166 | 167 | 168 | def _make_body_line(depth, body): 169 | return u"%%%s %s\n" % (u'-' * depth, body) 170 | 171 | 172 | def _make_comment(depth, body, headers): 173 | header_lines = [_make_header_line(depth, None, None)] 174 | body_lines = [] 175 | for header, value in headers: 176 | header_lines.append(_make_header_line(depth, header, value)) 177 | header_lines.append(_make_header_line(depth, None, None)) 178 | body_s = StringIO(body) 179 | wrap_body_lines_at = 80 - depth - 2 # 2 for the % and space just 180 | # in case there's some amazingly deep test or something. 181 | wrap_body_lines_at = max(wrap_body_lines_at, 40) 182 | for body_line in body_s: 183 | body_line = body_line.strip() 184 | if body_line: 185 | for wrapped_body_line in wrap(body_line.rstrip(), 186 | width=wrap_body_lines_at): 187 | body_lines.append(_make_body_line(depth, wrapped_body_line)) 188 | else: 189 | body_lines.append(_make_body_line(depth, u'')) 190 | body_lines.append(_make_body_line(depth, u'')) 191 | return u''.join(header_lines + body_lines) 192 | 193 | 194 | def _overlay_pr_top_level(composer, gh, pull_request): 195 | """ 196 | At the top of the diffscuss file, build a thread out of: 197 | 198 | - the title and initial body of the pull request 199 | - the comments on the associated issue 200 | """ 201 | logging.info("Overlaying top-level thread for pr %s", 202 | pull_request.url) 203 | init_comment = _make_comment( 204 | depth=1, 205 | body=(pull_request.title + u"\n\n" + pull_request.body), 206 | headers=[(u'author', pull_request.user.login), 207 | (u'email', pull_request.user.email), 208 | (u'date', _gh_time_to_diffscuss_time(pull_request.created_at)), 209 | (u'x-github-pull-request-url', pull_request.url), 210 | (u'x-github-updated-at', 211 | _gh_time_to_diffscuss_time(pull_request.updated_at)),]) 212 | init_thread = init_comment + _make_thread(sorted(list(pull_request.get_issue_comments()), 213 | key=lambda ic: ic.created_at), 214 | init_offset=1) 215 | logging.debug("Init thread is %s", init_thread) 216 | composer.append_at(-1, init_thread) 217 | 218 | 219 | def _overlay_pr_comments(composer, pull_request): 220 | """ 221 | Get the inline comments into the diffscuss file (github makes 222 | these contextual comments available as "review comments" as 223 | opposed to "issue comments." 224 | """ 225 | logging.info("Overlaying review comments for pr %s", 226 | pull_request.url) 227 | _overlay_comments(composer, 228 | pull_request.get_review_comments()) 229 | 230 | 231 | def _overlay_comments(composer, comments): 232 | comments = list(comments) 233 | logging.info("Overlaying %d total comments", len(comments)) 234 | get_path = lambda rc: rc.path 235 | for (path, path_comments) in itertools.groupby(sorted(comments, 236 | key=get_path), 237 | get_path): 238 | if path: 239 | logging.info("Found path %s, will write path comments", path) 240 | _overlay_path_comments(composer, path, path_comments) 241 | else: 242 | logging.info("Could not find path, writing review level comments.") 243 | # if there's no path, make a new thread at the top of the 244 | # review. 245 | _overlay_review_level_comments(composer, path_comments) 246 | 247 | 248 | def _overlay_review_level_comments(composer, comments): 249 | comments = list(comments) 250 | logging.info("Overlaying %d review level comments", 251 | len(comments)) 252 | thread = _make_thread(sorted(comments, 253 | key=lambda ic: ic.created_at)) 254 | # note that we're assuming here that the pr thread has already 255 | # been created. 256 | logging.debug("Thread is %s", thread) 257 | composer.append_at(-1, thread) 258 | 259 | 260 | def _is_range_line(tagged_line): 261 | return tagged_line[0] == DIFF_HEADER and tagged_line[1].startswith(u'@@') 262 | 263 | 264 | def _path_match(diff_header_line, path): 265 | return diff_header_line.startswith((u'--- a/%s' % path, 266 | u'+++ b/%s' % path)) 267 | 268 | 269 | def _is_target_path(tagged_line, path): 270 | return tagged_line[0] == DIFF_HEADER and _path_match(tagged_line[1], 271 | path) 272 | 273 | 274 | def _find_base_target_idx(orig_diff, path): 275 | logging.debug("Finding base target index for %s", path) 276 | looking_for_range_line = False 277 | 278 | for (i, tagged_line) in enumerate(walk(StringIO(orig_diff))): 279 | logging.debug("Checking at index %d tagged line %s", 280 | i, tagged_line) 281 | assert(tagged_line[0] not in [COMMENT_HEADER, COMMENT_BODY]) 282 | if looking_for_range_line and _is_range_line(tagged_line): 283 | logging.debug("Found range line at index %d", i) 284 | return i 285 | if _is_target_path(tagged_line, path): 286 | logging.debug("Found path %s at index %s, now looking for range line", 287 | path, i) 288 | looking_for_range_line = True 289 | logging.info("Could not find path %s in diff", path) 290 | return None 291 | 292 | 293 | def _make_thread(gh_comments, init_offset=0): 294 | comments = [] 295 | for (i, gh_comment) in enumerate(gh_comments): 296 | comment = _make_comment( 297 | depth=i + 1 + init_offset, 298 | body=(gh_comment.body), 299 | headers=[(u'author', gh_comment.user.login), 300 | (u'email', gh_comment.user.email), 301 | (u'date', _gh_time_to_diffscuss_time(gh_comment.created_at)), 302 | (u'x-github-comment-url', gh_comment.url), 303 | (u'x-github-updated-at', 304 | _gh_time_to_diffscuss_time(gh_comment.updated_at)),]) 305 | comments.append(comment) 306 | return u''.join(comments) 307 | 308 | 309 | def _overlay_path_comments(composer, path, path_comments): 310 | logging.info("Overlaying comments for path %s", path) 311 | base_target_idx = _find_base_target_idx(composer.orig_diff, path) 312 | logging.debug("Base target index for path %s is %s", path, base_target_idx) 313 | if base_target_idx is None: 314 | logging.warn("Couldn't find target for path %s (likely outdated diff)", 315 | path) 316 | return 317 | 318 | get_position = lambda pc: pc.position 319 | for (position, position_comments) in itertools.groupby(sorted(list(path_comments), 320 | key=get_position), 321 | get_position): 322 | position_comments = list(position_comments) 323 | if position is None: 324 | logging.info("Null position in path %s for %d comments (assuming outdated diff)", 325 | path, 326 | len(position_comments)) 327 | continue 328 | 329 | logging.info("Writing %d comments for path %s at position %s.", 330 | len(position_comments), 331 | path, position) 332 | target_idx = base_target_idx + position 333 | composer.append_at(target_idx, 334 | _make_thread(sorted(position_comments, 335 | key=lambda pc: pc.created_at))) 336 | 337 | 338 | def _overlay_encoding(composer): 339 | composer.append_at(-1, u"# -*- coding: utf-8 -*-\n") 340 | 341 | 342 | def _safe_get_commit(repo, sha): 343 | try: 344 | return repo.get_commit(sha) 345 | except GithubException, e: 346 | if e.status == 404: 347 | return None 348 | else: 349 | raise 350 | 351 | 352 | def _overlay_commit_comments(composer, pull_request): 353 | logging.info("Overlaying all commit comments.") 354 | for commit in pull_request.get_commits(): 355 | logging.info("Overlaying commit %s", commit.sha) 356 | # the commit comments seem generally to be in the head, but 357 | # let's make sure. 358 | for part in [pull_request.base, pull_request.head]: 359 | repo = part.repo 360 | logging.debug("Checking for commit in repo %s", repo.url) 361 | repo_commit = _safe_get_commit(repo, commit.sha) 362 | logging.debug("Found commit: %s", repo_commit) 363 | if repo_commit: 364 | logging.info("Will overlay commit %s from repo %s", 365 | commit.sha, 366 | repo.url) 367 | _overlay_comments(composer, repo_commit.get_comments()) 368 | 369 | 370 | def _import_to_diffscuss(gh, username, password, user_or_org, repo, 371 | pull_request, output_dir): 372 | logging.info("Getting diff text for pull request %s", pull_request.url) 373 | diff_text = _get_diff_text(username, password, pull_request) 374 | composer = DiffscussComposer(diff_text) 375 | _overlay_encoding(composer) 376 | _overlay_pr_top_level(composer, gh, pull_request) 377 | _overlay_pr_comments(composer, pull_request) 378 | _overlay_commit_comments(composer, pull_request) 379 | 380 | dest_dir = os.path.join(output_dir, user_or_org.login, repo.name) 381 | logging.debug("Destination dir is %s", dest_dir) 382 | _mkdir_p(dest_dir) 383 | dest_fname = os.path.join(dest_dir, u"%s.diffscuss" % pull_request.number) 384 | logging.debug("Destination filename is %s", dest_fname) 385 | dest_fname_partial = u"%s.partial" % dest_fname 386 | logging.debug("Writing partial results to %s", dest_fname_partial) 387 | 388 | with open(dest_fname_partial, 'wb') as dest_fil: 389 | for line in composer.render(): 390 | dest_fil.write(line.encode('utf-8')) 391 | 392 | logging.info("Moving final results to %s", dest_fname) 393 | shutil.move(dest_fname_partial, dest_fname) 394 | 395 | 396 | def main(args): 397 | log_level = logging.INFO 398 | if args.debug: 399 | log_level = logging.DEBUG 400 | logging.basicConfig(filename=args.logfile, level=log_level, 401 | format='[%(levelname)s %(asctime)s] %(message)s') 402 | 403 | logging.info("Starting run.") 404 | 405 | password = _password(args.passfile) 406 | gh = Github(args.username, password) 407 | 408 | for (user_or_org, 409 | repo, 410 | pull_request) in _pull_requests_from_specs(gh, 411 | [args.pull_request_spec]): 412 | logging.info("Importing %s/%s:%s", 413 | user_or_org.login, 414 | repo.name, 415 | pull_request.number) 416 | _import_to_diffscuss(gh, args.username, password, 417 | user_or_org, repo, pull_request, 418 | args.output_dir) 419 | sys.exit(0) 420 | 421 | 422 | -------------------------------------------------------------------------------- /diffscuss/support/editor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Diffscuss editor support. 3 | 4 | For the most part there's nothing too editor-specific in here: functions 5 | assumed they'll be passed functions they can call to get at editor-specific 6 | stuff, or (in the common case) a buffer and cursor. (Buffers are a 0-indexed 7 | list of lines implementing the Vim Python interface; cursors are a 2-tuple of 8 | integers for the 1-indexed row and column.) 9 | """ 10 | import time 11 | import os 12 | import re 13 | import subprocess 14 | 15 | 16 | class LineProperties(object): 17 | """ 18 | Determines the properties of a single line in a diffcuss file. 19 | 20 | The line can be one of the following types: 21 | 22 | * a vanilla diff metadata line (a line that is not diff content) 23 | * a vanilla diff range line (a diff @@ line) 24 | * a diffscuss header line 25 | * a diffscuss body line 26 | 27 | If the line is not one of the diffscuss types, it has a depth of 0. If it 28 | is a diffscuss line type, its depth is 1 for top-level diffscuss headers 29 | and bodies and increases by 1 for each reply level below that. 30 | """ 31 | def __init__(self, line): 32 | self.line = line 33 | 34 | @property 35 | def is_diff_meta(self): 36 | """ 37 | Returns True if the line is a metadata line from plain old diff. 38 | 39 | >>> LineProperties('@@ -0,0 +1,2 @@').is_diff_meta 40 | True 41 | >>> LineProperties('diff --git a/some/file').is_diff_meta 42 | True 43 | >>> LineProperties('#* This is a header line').is_diff_meta 44 | False 45 | """ 46 | if self.line.startswith(('---', '+++')): 47 | return True 48 | return not self.line.startswith((' ', '#', '\n', '-', '+')) 49 | 50 | @property 51 | def is_diff_range(self): 52 | """ 53 | Returns True if the line is a range line from plain old diff. 54 | 55 | >>> LineProperties('@@ -0,0 +1,2 @@').is_diff_range 56 | True 57 | >>> LineProperties('diff --git a/some/file').is_diff_range 58 | False 59 | """ 60 | return self.line.startswith('@@') 61 | 62 | @property 63 | def is_header(self): 64 | """ 65 | Returns True if the line is a diffscuss header line. 66 | 67 | >>> LineProperties('#* This is a header line').is_header 68 | True 69 | >>> LineProperties('#- This is a body line').is_header 70 | False 71 | """ 72 | return self.line.startswith('#*') 73 | 74 | @property 75 | def is_body(self): 76 | """ 77 | Returns True if the line is a diffscuss body line. 78 | 79 | >>> LineProperties('#* This is a header line').is_body 80 | False 81 | >>> LineProperties('#- This is a body line').is_body 82 | True 83 | """ 84 | return self.line.startswith('#-') 85 | 86 | @property 87 | def is_diffscuss(self): 88 | """ 89 | Returns True if the line is a diffscuss header or body line. 90 | 91 | >>> LineProperties('#* This is a header line').is_diffscuss 92 | True 93 | >>> LineProperties('#- This is a body line').is_diffscuss 94 | True 95 | """ 96 | return self.depth > 0 97 | 98 | @property 99 | def depth(self): 100 | """ 101 | Returns 0 if the line is not a diffscuss line, 1 if it is a top-level 102 | diffscuss header or body line, or an integer according to the depth of 103 | the reply for non-top-level diffscuss header or body lines. 104 | 105 | >>> LineProperties('@@ -0,0 +1,2 @@').depth 106 | 0 107 | >>> LineProperties('#---- This is a deep reply body line').depth 108 | 4 109 | """ 110 | match = re.search(r'^#((\*|-)\2*)', self.line) 111 | if match: 112 | return len(match.group(1)) 113 | return 0 114 | 115 | 116 | ### Comment insertion 117 | 118 | def find_header_start(buf, (row, col)): 119 | """ 120 | If the cursor is in a diffscuss comment, returns the row of the start of 121 | the header for that comment and the current column. Otherwise, returns the 122 | current row and column. 123 | """ 124 | if not LineProperties(buf[row - 1]).is_diffscuss: 125 | return row, col 126 | 127 | # Skip body lines. 128 | for body_offset, line in enumerate(reversed(buf[:row])): 129 | if not LineProperties(line).is_body: 130 | break 131 | 132 | # Find the first non-header line. 133 | for offset, line in enumerate(reversed(buf[:row - body_offset])): 134 | if not LineProperties(line).is_header: 135 | return (row - body_offset - offset, col) 136 | return 1, col 137 | 138 | 139 | def find_body_end(buf, (row, col)): 140 | """ 141 | If the cursor is in a diffscuss comment, returns the row of the end of the 142 | body for that comment and the current column. Otherwise, returns the 143 | current row and column. 144 | """ 145 | if not LineProperties(buf[row - 1]).is_diffscuss: 146 | return row, col 147 | 148 | # Skip header lines. 149 | for header_offset, line in enumerate(buf[row - 1:]): 150 | if not LineProperties(line).is_header: 151 | break 152 | 153 | # Find the first non-body line. 154 | for offset, line in enumerate(buf[row - 1 + header_offset:]): 155 | if not LineProperties(line).is_body: 156 | return (row + header_offset + offset - 1, col) 157 | return row, col 158 | 159 | 160 | def find_subthread_end(buf, (row, col)): 161 | """ 162 | If the cursor is in a diffscuss comment, returns the row of the end of the 163 | comment's subthread and the current column. Otherwise, returns the current 164 | row and column. 165 | """ 166 | start_line_props = LineProperties(buf[row - 1]) 167 | if not start_line_props.is_diffscuss: 168 | return row, col 169 | 170 | row, col = find_body_end(buf, (row, col)) 171 | for offset, line in enumerate(buf[row:]): 172 | line_props = LineProperties(line) 173 | if line_props.depth <= start_line_props.depth: 174 | return row + offset, col 175 | return row, col 176 | 177 | 178 | def find_thread_end(buf, (row, col)): 179 | """ 180 | If the cursor is in a diffscuss comment, returns the row of the end of the 181 | comment's thread and the current column. Otherwise, returns the current row 182 | and column. 183 | """ 184 | if LineProperties(buf[row - 1]).is_diffscuss: 185 | for offset, line in enumerate(buf[row:]): 186 | if not LineProperties(line).is_diffscuss: 187 | break 188 | return row + offset, col 189 | return row, col 190 | 191 | 192 | def find_range(buf, (row, col)): 193 | """ 194 | Returns the row of the next diff range line, if one could be found, and the 195 | current column. If none was found, returns the current row and column. 196 | """ 197 | for offset, line in enumerate(buf[row - 1:]): 198 | if LineProperties(line).is_diff_range: 199 | return row + offset, col 200 | return row, col 201 | 202 | 203 | def make_comment(depth=1): 204 | """ 205 | Returns a string using the values from `config()` for a comment of depth 206 | `depth`. 207 | """ 208 | depth = max(depth, 1) 209 | header = '#' + '*' * depth 210 | body = '#' + '-' * depth 211 | 212 | fields = config() 213 | fields['date'] = time.strftime('%Y-%m-%dT%T%z') 214 | 215 | lines = [header] 216 | for field_name in ['author', 'email', 'date']: 217 | field_value = fields.get(field_name, 'Unknown') 218 | lines.append('%s %s: %s' % (header, field_name, field_value)) 219 | lines.extend([header, body + ' ', body]) 220 | return lines 221 | 222 | 223 | def inject_comment(buf, (row, col), depth=1): 224 | """ 225 | Injects a comment of depth `depth` at the current cursor position, and 226 | returns the position to which the cursor should be moved for editing. 227 | """ 228 | lines = make_comment(depth=depth) 229 | buf.append(lines, row) 230 | return (row + len(lines) - 1, len(lines[-2])) 231 | 232 | 233 | def insert_comment(buf, (row, col), depth=1): 234 | """ 235 | Inserts a comment of depth `depth` at the end of the current thread (if 236 | there is one) or at the current position, and returns the position to which 237 | the cursor should be moved for editing. 238 | """ 239 | row, col = find_thread_end(buf, (row, col)) 240 | return inject_comment(buf, (row, col), depth=depth) 241 | 242 | 243 | def insert_file_comment(buf, (row, col)): 244 | """ 245 | Inserts a new comment at the top of the file or at the end of the existing 246 | top-level diffscuss thread, and returns the position to which the cursor 247 | should be moved for editing. 248 | """ 249 | row, col = 0, 0 250 | if LineProperties(buf[0]).is_diffscuss: 251 | row, col = find_thread_end(buf, (row, col)) 252 | return inject_comment(buf, (row, col)) 253 | 254 | 255 | def reply_to_comment(buf, (row, col)): 256 | """ 257 | Inserts a new reply to the current diffscuss comment at a depth one greater 258 | than that comment, and returns the position to which the cursor should be 259 | moved for editing. 260 | """ 261 | depth = LineProperties(buf[row - 1]).depth 262 | row, col = find_subthread_end(buf, (row, col)) 263 | return inject_comment(buf, (row, col), depth=depth + 1) 264 | 265 | 266 | def insert_contextual_comment(buf, (row, col)): 267 | """ 268 | Inserts a comment based on the current context: file-level if the cursor is 269 | at the top of the file, a reply if positioned in a diffscuss comment, after 270 | the next range line if in diff metadata, or at the current cursor position 271 | if none of the previous conditions were met. Returns the position to which 272 | the cursor should be moved for editing. 273 | """ 274 | if row == 1: 275 | return insert_file_comment(buf, (row, col)) 276 | 277 | line_props = LineProperties(buf[row - 1]) 278 | if line_props.is_diffscuss: 279 | return reply_to_comment(buf, (row, col)) 280 | elif line_props.is_diff_meta: 281 | row, col = find_range(buf, (row, col)) 282 | return insert_comment(buf, (row, col)) 283 | 284 | return insert_comment(buf, (row, col)) 285 | 286 | 287 | ### Showing source 288 | 289 | def show_local_source(buf, (row, col)): 290 | """ 291 | Returns the line number and path of the file corresponding to the change 292 | under the cursor. 293 | """ 294 | cmd = """ 295 | {diffscuss} find-local -i {buffer_name} {row} 296 | """.format(diffscuss=_get_script(), 297 | buffer_name=buf.name, row=row) 298 | output = _get_output(cmd) 299 | filename, line = output.rsplit(' ', 1) 300 | return '+%s %s' % (line, filename) 301 | 302 | 303 | def show_old_source(buf, (row, col), tempfile): 304 | """ 305 | Writes the old version of the file to the path given by `tempfile`, and 306 | returns the line number corresponding to the change under the cursor. 307 | """ 308 | return show_source(buf, (row, col), tempfile, { 309 | 'marker': '---', 310 | 'short': '-', 311 | 'skip': '+', 312 | 'range_pattern': '^@@ -(\d+)', 313 | 'index_pattern': '^index ([a-f0-9]+)' 314 | }) 315 | 316 | 317 | def show_new_source(buf, (row, col), tempfile): 318 | """ 319 | Writes the new version of the file to the path given by `tempfile`, and 320 | returns the line number corresponding to the change under the cursor. 321 | """ 322 | return show_source(buf, (row, col), tempfile, { 323 | 'marker': '+++', 324 | 'short': '+', 325 | 'skip': '-', 326 | 'range_pattern': '^@@ -\d+,\d+ \+(\d+)', 327 | 'index_pattern': '^index [a-f0-9]+\.+([a-f0-9]+)' 328 | }) 329 | 330 | 331 | def _get_source_file(buf, (row, col), marker): 332 | """ 333 | Returns the source file name from a git diff for a line starting with the 334 | string `marker`, working backward from the current cursor position. 335 | """ 336 | for line in reversed(buf[:row - 1]): 337 | if line.startswith(marker): 338 | match = re.search('\s*(a|b)\/(.*)', line) 339 | if match: 340 | return match.group(2) 341 | return None 342 | 343 | 344 | def show_source(buf, (row, col), tempfile, conf): 345 | """ 346 | Writes a version of the file to the path given by `tempfile`, and returns 347 | the line number corresponding to the change under the cursor, as configured 348 | by `conf`. (See `show_old_source` and `show_new_source`.) 349 | """ 350 | filename = _get_source_file(buf, (row, col), conf['marker']) 351 | 352 | # Skip lines we don't care about (- if showing the new version of the file, 353 | # + if showing the old version), so they don't mess up our final line 354 | # number. 355 | for skip_offset, line in enumerate(reversed(buf[:row - 1])): 356 | if not line.startswith(conf['skip']): 357 | break 358 | 359 | # Work backward to the nearest range line, counting how many lines we 360 | # had to move through to get there. 361 | range_line = None 362 | offset = 0 363 | for line in reversed(buf[:row - 1 - skip_offset]): 364 | props = LineProperties(line) 365 | if props.is_diff_range: 366 | range_line = line 367 | break 368 | if line.startswith((' ', conf['short'])): 369 | offset += 1 370 | 371 | if not range_line: 372 | raise Exception('No range line') 373 | 374 | # Extract the line number from the range line and add our offset to compute 375 | # the line number we'll want to show. 376 | match = re.search(conf['range_pattern'], range_line) 377 | lineno = offset + int(match.group(1)) 378 | 379 | # Find the git revision from the index line. 380 | rev = None 381 | for line in reversed(buf[:row - 1]): 382 | match = re.search(conf['index_pattern'], line) 383 | if match: 384 | rev = match.group(1) 385 | break 386 | 387 | if not rev: 388 | raise Exception('No revision') 389 | 390 | # Use git to dump the version of the file at that revision, and return the 391 | # line number. 392 | cmd = """ 393 | git show {rev}:{filename} > {tempfile} 394 | """.format(rev=rev, filename=filename, tempfile=tempfile) 395 | _get_output(cmd) 396 | return lineno 397 | 398 | 399 | ### Shelling out 400 | 401 | def _get_script(): 402 | """ 403 | Returns the path to the diffscuss CLI executable, relative to the 404 | installation directory. 405 | """ 406 | return os.path.join(config()['diffscuss_dir'], 'bin', 'diffscuss') 407 | 408 | 409 | def _get_output(command): 410 | """ 411 | Runs `command` in a shell and returns its output (from stdout), or raises 412 | an exception if the command failed. 413 | 414 | (Used instead of `check_output` for pre-2.7 compatibility.) 415 | """ 416 | proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) 417 | output = proc.communicate()[0] 418 | if proc.wait() != 0: 419 | raise Exception('"%s" failed with status %d' % (command, 420 | proc.returncode)) 421 | return output 422 | 423 | 424 | ### Mailboxes 425 | 426 | def mailbox_check(_buffer, _cursor, tempfile): 427 | """ 428 | Writes the output of the mailbox check script to `tempfile` for preview 429 | display. 430 | """ 431 | cmd = """ 432 | {diffscuss} mailbox check > {tempfile} 433 | """.format(diffscuss=_get_script(), 434 | tempfile=tempfile) 435 | _get_output(cmd) 436 | 437 | 438 | def mailbox_post(buffer_name, prompt_func): 439 | """ 440 | Queries the user for a list of recipients to post the review to and calls 441 | the mailbox post script. 442 | """ 443 | recips = prompt_func('Post to: ') 444 | cmd = """ 445 | {diffscuss} mailbox post -p {diffscuss_file} {recips} 446 | """.format(diffscuss=_get_script(), 447 | diffscuss_file=buffer_name, 448 | recips=recips) 449 | result = _get_output(cmd) 450 | return 'Posted %s' % result 451 | 452 | 453 | def mailbox_bounce(buffer_name, prompt_func): 454 | """ 455 | Queries the user for a list of recipients to post the review to and calls 456 | the mailbox bounce script. 457 | """ 458 | recips = prompt_func('Bounce to: ') 459 | cmd = """ 460 | {diffscuss} mailbox bounce -p {diffscuss_file} {recips} 461 | """.format(diffscuss=_get_script(), 462 | diffscuss_file=buffer_name, 463 | recips=recips) 464 | result = _get_output(cmd) 465 | return 'Bounced %s' % result 466 | 467 | 468 | def mailbox_done(buffer_name, _prompt_func): 469 | """ 470 | Calls the mailbox done script. 471 | """ 472 | cmd = """ 473 | {diffscuss} mailbox done -p {diffscuss_file} 474 | """.format(diffscuss=_get_script(), 475 | diffscuss_file=buffer_name) 476 | result = _get_output(cmd) 477 | return 'Completed %s' % result 478 | 479 | 480 | ### Navigation 481 | 482 | def _find_first(buf, (row, col), predicate, reverse=False): 483 | """ 484 | Finds the first row for which the predicate (a function called on a single 485 | line) goes from False to True, moving forward or backward through the file 486 | according to `reverse`. Returns that row and the current column. 487 | """ 488 | if not reverse: 489 | skip_gen = enumerate(buf[row:]) 490 | else: 491 | skip_gen = enumerate(reversed(buf[:row - 1])) 492 | 493 | # Skip rows where the predicate returns False. 494 | skip_offset = 0 495 | for skip_offset, line in skip_gen: 496 | if not predicate(line): 497 | break 498 | 499 | if not reverse: 500 | search_gen = enumerate(buf[row + skip_offset:]) 501 | factor = 1 502 | else: 503 | search_gen = enumerate(reversed(buf[:row - 1 - skip_offset])) 504 | factor = -1 505 | 506 | # Move through the file until we find that the predicate returns True. 507 | for offset, line in search_gen: 508 | if predicate(line): 509 | return row + factor * (1 + offset + skip_offset), col 510 | return row, col 511 | 512 | 513 | def _find_last(buf, (row, col), predicate, reverse=False): 514 | """ 515 | Finds the row for which the predicate (a function called on a single line) 516 | goes from True to False, moving forward or backward through the file 517 | according to `reverse`. Returns the row before that row (i.e. the last 518 | predicate-matching row) and the current column. 519 | """ 520 | if not reverse: 521 | skip_gen = enumerate(buf[row:]) 522 | else: 523 | skip_gen = enumerate(reversed(buf[:row - 1])) 524 | 525 | # Skip rows until the predicate returns False. If we didn't skip any, don't 526 | # keep track of the offset. 527 | skipped = False 528 | for skip_offset, line in skip_gen: 529 | if predicate(line): 530 | skipped = True 531 | break 532 | if not skipped: 533 | skip_offset = 0 534 | 535 | if not reverse: 536 | search_gen = enumerate(buf[row + skip_offset:]) 537 | factor = 1 538 | else: 539 | search_gen = enumerate(reversed(buf[:row - 1 - skip_offset])) 540 | factor = -1 541 | 542 | # Move through the file until we find that the predicate returns False. 543 | for offset, line in search_gen: 544 | if not predicate(line): 545 | return row + factor * (offset + skip_offset), col 546 | return row, col 547 | 548 | 549 | def find_next_comment(buf, (row, col)): 550 | """ 551 | Returns the row of the start of the next diffscuss comment, and the current 552 | column. 553 | """ 554 | predicate = lambda line: LineProperties(line).is_header 555 | return _find_first(buf, (row, col), predicate, reverse=False) 556 | 557 | 558 | def find_next_comment_end(buf, (row, col)): 559 | """ 560 | Returns the row of the end of the next diffscuss comment, and the current 561 | column. 562 | """ 563 | predicate = lambda line: LineProperties(line).is_body 564 | return _find_last(buf, (row, col), predicate, reverse=False) 565 | 566 | 567 | def find_next_thread(buf, (row, col)): 568 | """ 569 | Returns the row of the start of the next diffscuss thread, and the current 570 | column. 571 | """ 572 | predicate = lambda line: LineProperties(line).is_diffscuss 573 | return _find_first(buf, (row, col), predicate, reverse=False) 574 | 575 | 576 | def find_next_thread_end(buf, (row, col)): 577 | """ 578 | Returns the row of the end of the next diffscuss thread, and the current 579 | column. 580 | """ 581 | predicate = lambda line: LineProperties(line).is_diffscuss 582 | return _find_last(buf, (row, col), predicate, reverse=False) 583 | 584 | 585 | def find_prev_comment(buf, (row, col)): 586 | """ 587 | Returns the row of the start of the previous diffscuss comment, and the 588 | current column. 589 | """ 590 | predicate = lambda line: LineProperties(line).is_header 591 | return _find_last(buf, (row, col), predicate, reverse=True) 592 | 593 | 594 | def find_prev_comment_end(buf, (row, col)): 595 | """ 596 | Returns the row of the end of the previous diffscuss comment, and the 597 | current column. 598 | """ 599 | predicate = lambda line: LineProperties(line).is_body 600 | return _find_first(buf, (row, col), predicate, reverse=True) 601 | 602 | 603 | def find_prev_thread(buf, (row, col)): 604 | """ 605 | Returns the row of the start of the previous diffscuss thread, and the 606 | current column. 607 | """ 608 | predicate = lambda line: LineProperties(line).is_diffscuss 609 | return _find_last(buf, (row, col), predicate, reverse=True) 610 | 611 | 612 | def find_prev_thread_end(buf, (row, col)): 613 | """ 614 | Returns the row of the end of the previous diffscuss thread, and the 615 | current column. 616 | """ 617 | predicate = lambda line: LineProperties(line).is_diffscuss 618 | return _find_first(buf, (row, col), predicate, reverse=True) 619 | --------------------------------------------------------------------------------