├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md └── gunsub.py /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | next-since 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu 2 | MAINTAINER jerome.petazzoni@dotcloud.com 3 | RUN apt-get install -qy python python-pandas 4 | ADD gunsub.py /gunsub.py 5 | CMD python /gunsub.py 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013 Jérôme Petazzoni 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gunsub: Github UNSUBscribe 2 | 3 | Gunsub will automatically unsubscribe you from Github issues, pull requests, 4 | comments, etc. if you are not directly involved in the conversation. Being 5 | "directly involved" means talking or being mentioned in the conversation, 6 | as well as explicitly subscribing to it. 7 | 8 | 9 | ## What's the point? 10 | 11 | I wrote this because I wanted to be aware of everything happening in a given 12 | Github repository (the [Docker](https://github.com/dotcloud/docker) repository 13 | in that case), but I was only interested in the *first* message of each 14 | conversation (e.g. when an issue is created); not in the following messages 15 | (e.g. when further comments are added to an issue). If I want to get involved 16 | into an issue, I just have to comment on it, or quietly subscribe to the 17 | issue; and then I will receive future notifications. Likewise, if someone 18 | mentions me (by adding `@jpetazzo` into a comment), I will automatically be 19 | re-subscribed. But I will not receive multiple e-mail notifications for 20 | "normal" issues. 21 | 22 | 23 | ## How does it work? 24 | 25 | Gunsub uses the Github API; specifically, the `/notifications` endpoint. 26 | It checks all the notifications that I have received. For each notification, 27 | the API indicates the *reason* of the notification: is it because I was 28 | mentioned there? Or automatically subscribed because I'm watching the 29 | repository? Or something else? If I was automatically subscribed, then 30 | Gunsub checks if there is a subscription information for that thread. 31 | If there is a subscription information, it can be either to indicate a 32 | manual subscription, or conversely, to indicate that I'm already ignoring 33 | that thread; in either case, Gunsub doesn't change the subscription setting. 34 | However, if there is no subscription information, Gunsub will unsubscribe 35 | me from further notifications. The subscription information gets overridden 36 | if I comment or get mentioned anyway. 37 | 38 | 39 | ## This is awesome, how can I use it too? 40 | 41 | Thank you! Gunsub only uses the basic Python library, so you don't need 42 | to install anything fancy. You only need to set two environment variables, 43 | `GITHUB_USER` and `GITHUB_PASSWORD`, and run it with `python gunsub.py`. 44 | 45 | Optionally, you may set `GITHUB_INCLUDE_REPOS` or `GITHUB_EXCLUDE_REPOS` 46 | to a comma-separated list of repositories to include or exclude. If you 47 | do not specify anything, by default, Gunsub will act upon all your 48 | repositories; if you specify `GITHUB_INCLUDE_REPOS`, it will act *only* 49 | on those; and if you specify `GITHUB_EXCLUDE_REPOS`, it will act on 50 | all repositories *except* those. If you specify both, it will be a little 51 | bit silly, but it will work anyway, operating on all included repositories 52 | except those in the exclude list. 53 | 54 | By default, Gunsub will do one pass over your notifications, unsubscribe 55 | from the "passive" notifications, and exit. But you can also set the 56 | `GITHUB_POLL_INTERVAL` environment variable to be a delay (in seconds): 57 | in that case, it will run in a loop, waiting for the indicated delay 58 | between each iteration. 59 | 60 | 61 | ## This seriously sucks, there are better ways to do it! 62 | 63 | Please let me know. This is the first time I do something meaningful 64 | with the Github API. I found the documentation to be technically 65 | accurate, but a lot of explanations were missing. For instance, when 66 | posting a subscription, there are two boolean flags: `ignore` and 67 | `subscribe`. Everywhere I looked, they were XORed (i.e., if `ignore` 68 | is `true` then `subscribe` is `false` and vice-versa). Is it 69 | meaningful to have them both to `true` or `false`? I don't know. 70 | So if you know more efficient ways to do that, I'd love to hear 71 | about it! 72 | 73 | 74 | ## You should use the If-Modified-Since... 75 | 76 | Yes, I understand that it would be nicer; and I might implement 77 | this soon enough. Consider this as a Minimimal Viable Product :-) 78 | 79 | 80 | ## Running from Docker 81 | 82 | Gunsub is so simple, that it can probably run literally anywhere, even 83 | on Windows or OS X machines. However, in an ongoing effort to CONTAINERIZE 84 | ALL THE THINGS!, I wrote a tiny Dockerfile to run it inside a Docker 85 | container, and I uploaded it to the Docker registry. 86 | 87 | If you already have a Docker installation, you can do something like this: 88 | 89 | ```bash 90 | docker run -d -e GITHUB_USER=johndoe GITHUB_PASSWORD=SecretSesame \ 91 | GITHUB_POLL_INTERVAL=300 jpetazzo/gunsub 92 | ``` 93 | 94 | ... and Docker will start a Gunsub container, running the main loop 95 | every five minutes. 96 | 97 | 98 | -------------------------------------------------------------------------------- /gunsub.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import base64 3 | import email 4 | import email.mime.text 5 | import fnmatch 6 | import httplib 7 | import json 8 | import logging 9 | import os 10 | from pandas import Timestamp 11 | import smtplib 12 | import sys 13 | from textwrap import wrap, dedent 14 | import time 15 | 16 | 17 | log = logging 18 | 19 | 20 | def iterpage(): 21 | page = 1 22 | while True: 23 | yield page 24 | page += 1 25 | 26 | 27 | def send_email(address, notification): 28 | notification_type = notification['subject']['type'] 29 | title = notification['subject']['title'] 30 | url = notification['subject']['url'].replace('api.', '', 1).\ 31 | replace('/repos/', '/', 1) 32 | if notification_type == 'PullRequest': 33 | url = url.replace('/pulls/', '/pull/', 1) 34 | elif notification_type == 'Issue': 35 | pass 36 | elif notification_type == 'Commit': 37 | url = url.replace('/commits/', '/commit/', 1) 38 | else: 39 | log.error('Unknown notification type for emailing: {}'.format( 40 | notification_type)) 41 | return 42 | 43 | body = dedent(u""" 44 | You have been unsubscribed from the {2} with the subject 45 | "{1}". 46 | 47 | Visit {3} to resubscribe. 48 | """).lstrip().format( 49 | address, title, notification_type.lower(), url) 50 | 51 | msg = email.mime.text.MIMEText(body, 'plain', 'utf-8') 52 | msg['Subject'] = u'Unsubscribed from "{}"'.format(title) 53 | msg['From'] = 'Gunsub <{}>'.format(address) 54 | msg['To'] = address 55 | 56 | smtp = smtplib.SMTP('localhost') 57 | smtp.sendmail(address, [address], msg.as_string()) 58 | smtp.quit() 59 | 60 | 61 | def repo_pattern_match(notification, pattern): 62 | name = notification['repository'][ 63 | 'full_name' if '/' in pattern else 'name'] 64 | return fnmatch.fnmatchcase(name, pattern) 65 | 66 | 67 | def repo_list_match(notification, patterns): 68 | return any(repo_pattern_match(notification, p) for p in patterns) 69 | 70 | 71 | def gunsub(github_user, github_password, 72 | github_include_repos=[], github_exclude_repos=[], 73 | since=None, dryrun=False, email=None): 74 | 75 | def req(uri, method='GET', body=None, headers={}): 76 | auth = base64.encodestring('{0}:{1}' 77 | .format(github_user, github_password)) 78 | headers = headers.copy() 79 | headers.update({ 80 | 'Authorization': 'Basic '+auth.strip(), 81 | 'User-Agent': 'gunsub/0.2 (https://github.com/jpetazzo/gunsub)' 82 | }) 83 | c = httplib.HTTPSConnection('api.github.com') 84 | log.debug('{0} {1}'.format(method, uri)) 85 | if body is not None: 86 | body = json.dumps(body) 87 | log.debug('JSONified body: {0!r} ({1} bytes)' 88 | .format(body, len(body))) 89 | c.request(method, uri, body, headers) 90 | r = c.getresponse() 91 | log.debug('x-ratelimit-remaining: {0}' 92 | .format(r.getheader('x-ratelimit-remaining'))) 93 | result = json.loads(r.read()) 94 | return result 95 | 96 | since_qs = '' 97 | if since is not None: 98 | since_qs = '&since=' + time.strftime('%FT%TZ', time.gmtime(since)) 99 | else: 100 | log.info('Scanning all notifications (this could take a while)...') 101 | 102 | count = 0 103 | for page in iterpage(): 104 | notifications = req('/notifications?all=true&page={0}{1}' 105 | .format(page, since_qs)) 106 | if not notifications: 107 | break 108 | for notification in notifications: 109 | # Check inclusion/exclusion rules. 110 | try: 111 | # Releases don't have subscribe or unsubscribe buttons on the 112 | # Github web site, so don't mess with them. 113 | if notification['subject']['type'] == 'Release': 114 | continue 115 | repo_name = notification['repository']['name'] 116 | except TypeError: 117 | # I once got "TypeError: string indices must be integers" from 118 | # the line of code above, which I couldn't debug because the 119 | # resulting log message didn't say what was actually in the 120 | # notification, so logging it here for the next time it 121 | # happens. 122 | log.error('Unexpected notification contents: {}'.format( 123 | notification)) 124 | raise 125 | if github_include_repos and \ 126 | not repo_list_match(notification, github_include_repos): 127 | continue 128 | if repo_list_match(notification, github_exclude_repos): 129 | continue 130 | # If we were initially subscribed because mentioned/created/etc, 131 | # don't touch the subscription information. 132 | if notification['reason'] != 'subscribed': 133 | continue 134 | # Now check if we explicitly subscribed to this thing. 135 | subscription_uri = ('/notifications/threads/{0}/subscription' 136 | .format(notification['id'])) 137 | subscription = req(subscription_uri) 138 | # If no subscription is found, then that subscription was implicit 139 | if 'url' not in subscription: 140 | # ... And we therefore unsubscribe from further notifications 141 | subject_url = notification['subject']['url'] 142 | log.info('Unsubscribing from {0}...'.format(subject_url)) 143 | if not args.dryrun: 144 | result = req(subscription_uri, 'PUT', 145 | dict(subscribed=False, ignored=True)) 146 | if 'subscribed' not in result: 147 | log.warning('When unsubscribing from {0}, I got this: ' 148 | '{1!r} and it does not contain {2!r}.' 149 | .format(subject_url, result, 'subscribed')) 150 | if email: 151 | send_email(email, notification) 152 | count += 1 153 | log.info('Done; had to go through {0} page(s) of notifications, ' 154 | 'and unsubscribed from {1} thread(s).' 155 | .format(page, count)) 156 | 157 | 158 | def wrap_paragraphs(paragraphs): 159 | return '\n\n'.join('\n'.join(wrap(paragraph)) 160 | for paragraph in paragraphs.split('\n')) 161 | 162 | 163 | def parse_args(): 164 | description=wrap_paragraphs( 165 | 'Unsubscribe automatically from Github threads after ' 166 | 'the initial thread notification') 167 | epilog = wrap_paragraphs( 168 | 'Repository include and exclude names can optionally starts with ' 169 | '"owner/" or use shell wildcards.\n' 170 | 'To read more about gunsub, check its ' 171 | 'project page on Github: ' 172 | 'http://github.com/jpetazzo/gunsub.') 173 | parser = argparse.ArgumentParser( 174 | formatter_class=argparse.RawDescriptionHelpFormatter, 175 | description=description, epilog=epilog) 176 | 177 | parser.add_argument('--debug', action='store_true', 178 | help='Enable debug logging') 179 | parser.add_argument('--dryrun', action='store_true', 180 | help='Say what would be done without doing it') 181 | user_default = os.environ.get('GITHUB_USER', None) 182 | parser.add_argument('--user', action='store', default=user_default, 183 | required=not user_default, 184 | help='Github username (or set $GITHUB_USER') 185 | password_default = os.environ.get('GITHUB_PASSWORD', None) 186 | parser.add_argument('--password', action='store', default=password_default, 187 | required=not password_default, 188 | help='Github password (or set $GITHUB_PASSWORD') 189 | parser.add_argument('--interval', action='store', type=int, 190 | default=int(os.environ.get('GITHUB_POLL_INTERVAL', 0)), 191 | help='Poll interval in seconds for continuous ' 192 | 'operation (or set $GITHUB_POLL_INTERVAL)') 193 | include_default = os.environ.get('GITHUB_INCLUDE_REPOS', '').split(',') 194 | parser.add_argument('--include', action='append', default=include_default, 195 | help='List of repositories to include (or set ' 196 | '$GITHUB_INCLUDE_REPOS to comma-separated list)') 197 | exclude_default = os.environ.get('GITHUB_EXCLUDE_REPOS', '').split(',') 198 | parser.add_argument('--exclude', action='append', default=exclude_default, 199 | help='List of repositories to exclude (or set ' 200 | '$GITHUB_EXCLUDE_REPOS to comma-separated list)') 201 | parser.add_argument('--since', metavar='TIME-STRING', action='store', 202 | type=Timestamp, help='Examine notifications starting ' 203 | 'at the specified time') 204 | parser.add_argument('--email', metavar='ADDRESS', action='store', 205 | help='Email address to notify about unsubscribes') 206 | 207 | return parser.parse_args() 208 | 209 | 210 | def main(args): 211 | github_user = args.user 212 | github_password = args.password 213 | github_include_repos = args.include 214 | github_exclude_repos = args.exclude 215 | interval = args.interval 216 | interval = interval and int(interval) 217 | 218 | logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) 219 | 220 | since = None 221 | 222 | state_file = './next-since' 223 | 224 | if args.since: 225 | since = int(args.since.strftime('%s')) 226 | else: 227 | # Read application state 228 | if os.path.isfile(state_file): 229 | with open(state_file) as next_since_file: 230 | since = float(next_since_file.read().split()[0]) 231 | log.info('Parsing events since {0}, {1}'.format( 232 | time.strftime('%FT%TZ', time.gmtime(since)), since)) 233 | 234 | while True: 235 | next_since = time.time() 236 | try: 237 | gunsub(github_user, github_password, 238 | github_include_repos, github_exclude_repos, 239 | since, dryrun=args.dryrun, email=args.email) 240 | if not args.dryrun: 241 | with open(state_file, 'w') as next_since_file: 242 | next_since_file.write(str(next_since)) 243 | since = next_since 244 | except: 245 | log.exception('Error in main loop!') 246 | if not interval: 247 | break 248 | log.debug('Sleeping for {0} seconds.'.format(interval)) 249 | time.sleep(interval) 250 | 251 | 252 | if __name__ == '__main__': 253 | args = parse_args() 254 | main(args) 255 | --------------------------------------------------------------------------------