Merge lp:~savoirfairelinux-openerp/lp-community-utils/openerp_review into lp:lp-community-utils

Status: Work in progress
Proposed branch: lp:~savoirfairelinux-openerp/lp-community-utils/openerp_review
Merge into: lp:lp-community-utils
Prerequisite: lp:~savoirfairelinux-openerp/lp-community-utils/nag_refactor
Diff against target: 515 lines (+496/-0) (has conflicts)
2 files modified
lp.py (+38/-0)
openerp_review (+458/-0)
Text conflict in openerp-nag
To merge this branch: bzr merge lp:~savoirfairelinux-openerp/lp-community-utils/openerp_review
Reviewer Review Type Date Requested Status
OpenERP Community Reviewer/Maintainer Pending
Review via email: mp+214447@code.launchpad.net

Description of the change

The intent for this script is to be like Travis CI for github.

When run, this script will use some of openerp-nag's functions to identify OCA addon MPs.

To these, it will comment on the MP providing information about the and review according to http://pad.openerp.com/p/community-review

Here's a description of the behaviour expected

Gather information about an OpenERP addon MP
* Has this MP been reviewed before by an automated script?
  * Does it contain message identifying as automated?
  * Has there been a new commit since last automated message?
* Does it merge cleanly?
* Is this a new module?
  * Add of a directory and an __openerp__.py file
  * AGPL-3 license check
  * Does it include tests?
  * Does it include demo data?
  * Does it include security files?
  * Does the module version have 2 digits (1.0)
  * Has the .pot file been generated
  * Run flake8 on the entire module
  * Check for deprecated code
  * Check for TODO and FIXME comments
  * Check for debugging breakpoints (ipdb, pdb, set_trace())
  * Test install the module on its own, run tests
  * Does it follow all OpenERP conventions
* Is this a modification of an existing module
  * Which modules are involved?
  * What kind of changes are made?
    * Code behaviour
    * Model changes
      * Has the version been bumped?
      * Any migration scripts included?
    * View changes
    * Tests
      * New tests
      * Added tests
      * Removed tests
    * Demo data
    * Data
    * Translation
    * TODO, FIXME comments
      * Added comments
      * Removed comments
    * Check for debugging breakpoints (ipdb, pdb, set_trace())
  * Evaluate if changes create a net improvement
    * Run flake8 before and after merge
    * Check for deprecated code before and after merge
    * Run tests before and after
      * Are they fixed?
      * Are they broken?
      * Are they unchanged?
      * Which tests results have changed? Are they new/removed?
    * Check conventions before and after merge
* Is this a mixture of both?
  * Separate code of new and modified
* Is this a removal?
* Provide link to http://pad.openerp.com/p/community-review
  * Identify points of concern from the pad
* Vote on MP (or don't)

To post a comment you must log in.
31. By Sandy Carter (http://www.savoirfairelinux.com)

[IMP] added stub functions

32. By Sandy Carter (http://www.savoirfairelinux.com)

Function to list contents of local or remote bzr repository

Given a path, return a list of tracked files in bzr using code for `bzr ls`
Give the list of files without checking out. Useful for scanning launchpad
repos.

Example usgae:
    >>>> lp.list_bzr_repo('lp-community-utils')
    ['lp:lp-community-utils/README.rst',
     'lp:lp-community-utils/checkout-flake8.sh',
     'lp:lp-community-utils/clone_mp_to_community.py',
     'lp:lp-community-utils/merge_mp.py',
     'lp:lp-community-utils/openerp-nag',
     'lp:lp-community-utils/projects',
     'lp:lp-community-utils/replay_missing.py']

May be expanded to support revnos and other flags. The cmd_ls.run() function
signature has the following arguments:
    revision
    verbose
    recursive
    from_root
    unknown
    versioned
    ignored
    null
    kind
    show_ids
    path
    directory
Currently, only path is used.

33. By Sandy Carter (http://www.savoirfairelinux.com)

Check target for openerp addons manifest files

Parse options to target project merge proposals

34. By Sandy Carter (http://www.savoirfairelinux.com)

[IMP] Review project and series implemented

Add logging
Add more options to ls_bzr_repo(), notably the missing recursive option
Separate review_project() into review_series()
Implement skip based on non-addon repo, empty MP repo

35. By Sandy Carter (http://www.savoirfairelinux.com)

Implement been_reviewed() check

Check if there exists a comment older than the last change of the MP which
matches the automated message

36. By Sandy Carter (http://www.savoirfairelinux.com)

Implement checking pep8 in review

Unmerged revisions

36. By Sandy Carter (http://www.savoirfairelinux.com)

Implement checking pep8 in review

35. By Sandy Carter (http://www.savoirfairelinux.com)

Implement been_reviewed() check

Check if there exists a comment older than the last change of the MP which
matches the automated message

34. By Sandy Carter (http://www.savoirfairelinux.com)

[IMP] Review project and series implemented

Add logging
Add more options to ls_bzr_repo(), notably the missing recursive option
Separate review_project() into review_series()
Implement skip based on non-addon repo, empty MP repo

33. By Sandy Carter (http://www.savoirfairelinux.com)

Check target for openerp addons manifest files

Parse options to target project merge proposals

32. By Sandy Carter (http://www.savoirfairelinux.com)

Function to list contents of local or remote bzr repository

Given a path, return a list of tracked files in bzr using code for `bzr ls`
Give the list of files without checking out. Useful for scanning launchpad
repos.

Example usgae:
    >>>> lp.list_bzr_repo('lp-community-utils')
    ['lp:lp-community-utils/README.rst',
     'lp:lp-community-utils/checkout-flake8.sh',
     'lp:lp-community-utils/clone_mp_to_community.py',
     'lp:lp-community-utils/merge_mp.py',
     'lp:lp-community-utils/openerp-nag',
     'lp:lp-community-utils/projects',
     'lp:lp-community-utils/replay_missing.py']

May be expanded to support revnos and other flags. The cmd_ls.run() function
signature has the following arguments:
    revision
    verbose
    recursive
    from_root
    unknown
    versioned
    ignored
    null
    kind
    show_ids
    path
    directory
Currently, only path is used.

31. By Sandy Carter (http://www.savoirfairelinux.com)

[IMP] added stub functions

30. By Sandy Carter (http://www.savoirfairelinux.com)

[ADD] stub script for openerp_review along with details in description

29. By Sandy Carter (http://www.savoirfairelinux.com)

Moved common code out of openerp-nag into lp.py so it can be used by other scripts

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lp.py'
2--- lp.py 2014-05-07 22:28:20 +0000
3+++ lp.py 2014-05-07 22:28:20 +0000
4@@ -29,6 +29,8 @@
5 ProgressBar = None
6
7 from launchpadlib.launchpad import Launchpad
8+from bzrlib import commands as bzr_commands, builtins as bzr_builtins
9+from cStringIO import StringIO
10
11
12 # Nag:
13@@ -250,3 +252,39 @@
14 return Launchpad.login_anonymously(consumer_name, service_root)
15 else:
16 return Launchpad.login_with(consumer_name, service_root)
17+
18+
19+def list_bzr_repo(repo, recursive=False, *args, **kwargs):
20+ """
21+ Given a path, return a list of tracked files in bzr using code for `bzr ls`
22+ Return the list of files without checking out. Useful for scanning
23+ launchpad repos.
24+
25+ Example usage:
26+ >>>> lp.list_bzr_repo('lp-community-utils')
27+ ['lp:lp-community-utils/README.rst',
28+ 'lp:lp-community-utils/checkout-flake8.sh',
29+ 'lp:lp-community-utils/clone_mp_to_community.py',
30+ 'lp:lp-community-utils/merge_mp.py',
31+ 'lp:lp-community-utils/openerp-nag',
32+ 'lp:lp-community-utils/projects',
33+ 'lp:lp-community-utils/replay_missing.py']
34+ """
35+ bzr_commands.load_plugins()
36+ cmd_obj = bzr_builtins.cmd_ls()
37+ cmd_obj.outf = StringIO()
38+ cmd_obj.run(path=repo, recursive=recursive, *args, **kwargs)
39+ return cmd_obj.outf.getvalue().splitlines()
40+
41+
42+def branch_bzr_repo(from_location, to_location, *args, **kwargs):
43+ """
44+ Given a remote location and a local destination, branch a bzr repo using
45+ code for `bzr branch`
46+
47+ Example usage:
48+ >>>> lp.list_bzr_repo('lp-community-utils', '/tmp')
49+ """
50+ bzr_commands.load_plugins()
51+ cmd_obj = bzr_builtins.cmd_branch()
52+ cmd_obj.run(from_location, to_location, *args, **kwargs)
53
54=== added file 'openerp_review'
55--- openerp_review 1970-01-01 00:00:00 +0000
56+++ openerp_review 2014-05-07 22:28:20 +0000
57@@ -0,0 +1,458 @@
58+#!/usr/bin/env python2
59+# -*- encoding: utf-8 -*-
60+###############################################################################
61+#
62+# OpenERP, Open Source Management Solution
63+# This module copyright (C) 2010 - 2014 Savoir-faire Linux
64+# (<http://www.savoirfairelinux.com>).
65+#
66+# This program is free software: you can redistribute it and/or modify
67+# it under the terms of the GNU Affero General Public License as
68+# published by the Free Software Foundation, either version 3 of the
69+# License, or (at your option) any later version.
70+#
71+# This program is distributed in the hope that it will be useful,
72+# but WITHOUT ANY WARRANTY; without even the implied warranty of
73+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
74+# GNU Affero General Public License for more details.
75+#
76+# You should have received a copy of the GNU Affero General Public License
77+# along with this program. If not, see <http://www.gnu.org/licenses/>.
78+#
79+###############################################################################
80+
81+"""
82+Gather information about an OpenERP addon MP
83+* Has this MP been reviewed before by an automated script?
84+ * Does it contain message identifying as automated?
85+ * Has there been a new commit since last automated message?
86+* Does it merge cleanly?
87+* Is this a new module?
88+ * Add of a directory and an __openerp__.py file
89+ * AGPL-3 license check
90+ * Does it include tests?
91+ * Does it include demo data?
92+ * Does it include security files?
93+ * Does the module version have 2 digits (1.0)
94+ * Has the .pot file been generated
95+ * Run flake8 on the entire module
96+ * Check for deprecated code
97+ * Check for TODO and FIXME comments
98+ * Check for debugging breakpoints (ipdb, pdb, set_trace())
99+ * Test install the module on its own, run tests
100+ * Does it follow all OpenERP conventions
101+* Is this a modification of an existing module
102+ * Which modules are involved?
103+ * What kind of changes are made?
104+ * Code behaviour
105+ * Model changes
106+ * Has the version been bumped?
107+ * Any migration scripts included?
108+ * View changes
109+ * Tests
110+ * New tests
111+ * Added tests
112+ * Removed tests
113+ * Demo data
114+ * Data
115+ * Translation
116+ * TODO, FIXME comments
117+ * Added comments
118+ * Removed comments
119+ * Check for debugging breakpoints (ipdb, pdb, set_trace())
120+ * Evaluate if changes create a net improvement
121+ * Run flake8 before and after merge
122+ * Check for deprecated code before and after merge
123+ * Run tests before and after
124+ * Are they fixed?
125+ * Are they broken?
126+ * Are they unchanged?
127+ * Which tests results have changed? Are they new/removed?
128+ * Check conventions before and after merge
129+* Is this a mixture of both?
130+ * Separate code of new and modified
131+* Is this a removal?
132+* Provide link to http://pad.openerp.com/p/community-review
133+ * Identify points of concern from the pad
134+* Vote on MP (or don't)
135+
136+"""
137+
138+
139+from __future__ import (
140+ unicode_literals,
141+ print_function,
142+ absolute_import,
143+ division,
144+)
145+
146+import logging
147+import os
148+import re
149+import tempfile
150+import shutil
151+from pep8 import parse_udiff, StandardReport
152+from flake8 import engine
153+from argparse import ArgumentParser
154+from lp import (
155+ launchpad_login,
156+ list_bzr_repo,
157+ branch_bzr_repo,
158+)
159+
160+consumer_name = u'OpenERP Community Automatic Reviewer Scripts'
161+MANIF = u'__openerp__.py'
162+AUTOMATED_MESSAGE = u"This is an automated provided by the %s" % consumer_name
163+ALL_GOOD_MESSAGE = u"All tests came back clean"
164+AUTOMATED_MESSAGE_REGEX = re.compile(AUTOMATED_MESSAGE)
165+MESSAGE_CONTAINS_CONFLICTS = u"""\
166+Merge proposal contains conflicts on the following files
167+ * %s
168+Please, rebase or merge with target branch.
169+"""
170+FLAKE8_IGNORE = u"E501"
171+FLAKE8_INIT_IGNORE = u"E501,F401"
172+FLAKE8_MANIFEST_IGNORE = u"E501"
173+
174+
175+logging.basicConfig(format=u'%(levelname)s:%(message)s', level=logging.INFO)
176+
177+
178+class NoPrintingPep8Report(StandardReport):
179+ def __init__(self, options):
180+ super(NoPrintingPep8Report, self).__init__(options=options)
181+ self.issues = []
182+
183+ def get_file_results(self):
184+ """Return the result instead of printing it."""
185+ self._deferred_print.sort()
186+ for line_number, offset, code, text, doc in self._deferred_print:
187+ self.issues.append(self._fmt % {
188+ 'path': self.filename,
189+ 'row': self.line_offset + line_number, 'col': offset + 1,
190+ 'code': code, 'text': text,
191+ })
192+ if self._show_source:
193+ if line_number > len(self.lines):
194+ line = ''
195+ else:
196+ line = self.lines[line_number - 1]
197+ self.issues.append(line.rstrip())
198+ self.issues.append(re.sub(r'\S', ' ', line[:offset]) + '^')
199+ if self._show_pep8 and doc:
200+ self.issues.append(' ' + doc.strip())
201+ return self.file_errors
202+
203+
204+def review_series(series, mps):
205+ """
206+ Given series, conduct review on all open openERP addon MPs
207+ Check series if not in format of directory/__openerp__.py or empty
208+ Skip MPs which have already been merged
209+ """
210+ branch = series.branch
211+ if not is_openerp_addon_branch(branch):
212+ logging.info(u"%s does not seem to be an OpenERP addon repo, "
213+ "skipping..." % branch.bzr_identity)
214+ return
215+ filtered_mps = filter(lambda m: m.target_branch == branch, mps)
216+ if not filtered_mps:
217+ logging.info(u"%s does not seem to have any active Merge Proposals, "
218+ "skipping..." % branch.bzr_identity)
219+ return
220+ for mp in filtered_mps:
221+ if been_reviewed(mp):
222+ logging.info(
223+ u"%s has already been reviewed by this script, "
224+ "skipping..." % branch.bzr_identity)
225+ return
226+ else:
227+ issues = review_mp(mp)
228+ review = compose_review(mp, issues)
229+ post_message_on_launchpad(mp, review)
230+ return
231+
232+
233+def review_project(project):
234+ """Given repo, conduct review on all series"""
235+ mps = project.getMergeProposals()
236+ for s in project.series:
237+ review_series(s, mps)
238+
239+
240+def is_openerp_addon_branch(branch):
241+ """
242+ Check if the branch is for OpenERP addons
243+ If not, it probably shouldn't be reviewed with this script
244+ Repo should either be empty or be of the format module_name/__openerp__.py
245+ """
246+ files = list_bzr_repo(branch.bzr_identity, recursive=True)
247+ files = map(os.path.split, files)
248+ manif_files = filter(lambda x: x[1] == MANIF, files)
249+ return not files or bool(manif_files)
250+
251+
252+def review_mp(mp):
253+ """Perform series of reviews on launchpad merge proposal"""
254+ def perform_checks(checks, mp, branch_dir):
255+ for check in checks:
256+ res = check(mp, branch_dir)
257+ if res:
258+ yield res
259+ checks = [
260+ contains_flake8_errors_on_diff,
261+ ]
262+ conflicts = contains_conflicts(mp)
263+ if conflicts:
264+ return MESSAGE_CONTAINS_CONFLICTS % '\n * '.join(conflicts)
265+ repo_dir = branch_to_tmp(mp)
266+ res = next(perform_checks(checks, mp, repo_dir), ALL_GOOD_MESSAGE)
267+ shutil.rmtree(repo_dir)
268+ return res
269+
270+
271+def been_reviewed(mp):
272+ """
273+ Has this MP been reviewed before by an automated script?
274+ * Does it contain message identifying as automated?
275+ * Has there been a new commit since last automated message?
276+ Return boolean of whether or not to skip this MP
277+ """
278+ last_change = mp.source_branch.date_last_modified
279+ return any(c for c in mp.all_comments
280+ if c.date_created > last_change
281+ and AUTOMATED_MESSAGE_REGEX.match(c.message_body))
282+
283+
284+def branch_to_tmp(mp):
285+ """Branch the source branch to a temporary directory"""
286+ tmp_dir = os.path.join(tempfile.mkdtemp(prefix='openerp_review'),
287+ mp.source_branch.name)
288+ branch_bzr_repo(mp.source_branch.bzr_identity, tmp_dir)
289+ return tmp_dir
290+
291+
292+def contains_conflicts(mp):
293+ """
294+ Does this MP merge cleanly?
295+ * If it contains conflicts, suggest rebasing or merging with upstream
296+ * Show commands needed to do it
297+ Return boolean of whether or not to continue reviewing this MP
298+ """
299+ return mp.preview_diff.conflicts
300+
301+
302+def contains_flake8_errors_on_diff(mp, branch_dir):
303+ """
304+ Does this MP introduce pep8 violations?
305+
306+ Run flake8 against diff
307+ Separate __init__.py, __openerp__.py and other files as they have different
308+ exceptions.
309+ """
310+ pushd = os.getcwd()
311+ os.chdir(branch_dir)
312+ diff = mp.preview_diff.diff_text.open().read()
313+ selected_lines = parse_udiff(diff)
314+ init_files = []
315+ manifest_files = []
316+ other_files = []
317+ for f in filter(lambda k: k.endswith('.py'), selected_lines.keys()):
318+ if os.path.basename(f) == '__init__.py':
319+ init_files.append(f)
320+ elif os.path.basename(f) == '__openerp__.py':
321+ manifest_files.append(f)
322+ else:
323+ other_files.append(f)
324+ init_style = engine.get_style_guide(selected_lines=selected_lines,
325+ reporter=NoPrintingPep8Report,
326+ ignore=FLAKE8_INIT_IGNORE,
327+ quiet=True)
328+ init_report = {f: init_style.check_files(f).issues
329+ for f in init_files}
330+ manifest_style = engine.get_style_guide(selected_lines=selected_lines,
331+ reporter=NoPrintingPep8Report,
332+ ignore=FLAKE8_MANIFEST_IGNORE,
333+ quiet=True)
334+ manifest_report = {f: manifest_style.check_files(f).issues
335+ for f in manifest_files}
336+ other_style = engine.get_style_guide(selected_lines=selected_lines,
337+ reporter=NoPrintingPep8Report,
338+ ignore=FLAKE8_IGNORE,
339+ quiet=True)
340+ other_report = {f: other_style.check_files(f).issues
341+ for f in other_files}
342+ issues = dict(
343+ filter(
344+ lambda (key, val): val,
345+ init_report.items() +
346+ manifest_report.items() +
347+ other_report.items()
348+ )
349+ )
350+ os.chdir(pushd)
351+ return issues
352+
353+
354+def find_modified_modules(mp):
355+ """
356+ From diff, find which directories modification happened in.
357+ Of these directories, those containing __openerp__.py are considered to be
358+ modules.
359+ Return a list of paths to modules which have had modifications
360+ """
361+ # TODO
362+ raise NotImplementedError(find_modified_modules.__doc__)
363+
364+
365+def get_repo_version(mp):
366+ """
367+ Identify if this test is being run on a 6.0, 6.1, 7.0, 8.0 or other
368+ repo/module.
369+ Return version, this should change the behaviour of the tests.
370+ """
371+ # TODO
372+ raise NotImplementedError(get_repo_version.__doc__)
373+
374+
375+def get_modification_type(mp, module_path):
376+ """
377+ From diff and path, find out if a module has been added, modified, renamed
378+ or removed.
379+ If a directory and an __openerp__.py file have been added, then it is an
380+ added module.
381+ """
382+ # TODO
383+ raise NotImplementedError(get_modification_type.__doc__)
384+
385+
386+def review_new_module(mp, version, module_path):
387+ """
388+ Review the path according to new_module rules for the version in question.
389+ All checks from http://pad.openerp.com/p/community-review should be
390+ considered and comments on launchpad should be valid since these are new
391+ files. Review should be safe too.
392+ * AGPL-3 license check
393+ * Does it include tests?
394+ * Does it include demo data?
395+ * Does it include security files?
396+ * Does the module version have 2 digits (1.0)
397+ * Has the .pot file been generated
398+ * Run flake8 on the entire module
399+ * Check for deprecated code
400+ * Check for TODO and FIXME comments
401+ * Check for debugging breakpoints (ipdb, pdb, set_trace())
402+ * Test install the module on its own, run tests
403+ * Does it follow all OpenERP conventions
404+ """
405+ # TODO
406+ raise NotImplementedError(review_new_module.__doc__)
407+
408+
409+def identify_changes_to_module(mp, module_path):
410+ """
411+ Try to breakdown changes made to module to allow review to focus on only
412+ that and avoid unrelated comments.
413+ * Code behaviour
414+ * Model changes
415+ * View changes
416+ * Tests
417+ * New tests
418+ * Added tests
419+ * Removed tests
420+ * Demo data
421+ * Data
422+ * Translation
423+ * TODO, FIXME comments
424+ * Added comments
425+ * Removed comments
426+ * Check for debugging breakpoints (ipdb, pdb, set_trace())
427+ """
428+ # TODO
429+ raise NotImplementedError(identify_changes_to_module.__doc__)
430+
431+
432+def review_modified_module(mp, version, module_path):
433+ """
434+ See if module passes review process before MP.
435+ If it does, do full review, comment on each issue and problem with merge.
436+ If it didn't, mention it, then try to calculate net improvement. If changes
437+ add or remove problems, mention it. Try to avoid unrelated messages in
438+ review (i.e. problems which existed before and have not been touched by MP)
439+ * What kind of changes are made?
440+ * Evaluate if changes create a net improvement
441+ * Run flake8 before and after merge
442+ * Perform flake8 on diff generated by git's diff on functions
443+ * bzr diff --using git --diff-options "diff -W -p" > diff_file
444+ * flake8-python2 --diff diff_file
445+ * Check for deprecated code before and after merge
446+ * Run tests before and after
447+ * Are they fixed?
448+ * Are they broken?
449+ * Are they unchanged?
450+ * Which tests results have changed? Are they new/removed?
451+ * Check conventions before and after merge
452+ """
453+ changes = identify_changes_to_module(mp, module_path)
454+ # TODO
455+ raise NotImplementedError(review_modified_module.__doc__)
456+
457+
458+def review_renamed_module(mp, version, module_path):
459+ """
460+ Should do the same review as modified module, but keep in mind that files
461+ are in a different place.
462+ If there are no changes besides the name of the module path, skip modified
463+ module review
464+ """
465+ # TODO
466+ raise NotImplementedError(review_renamed_module.__doc__)
467+
468+
469+def review_removed_module(mp, version, module_path):
470+ """
471+ Evaluate what was lost, see if any other modules in repo depend on it.
472+ If module was non-installable prior, there shouldn't be any issue.
473+ """
474+ # TODO
475+ raise NotImplementedError(review_removed_module.__doc__)
476+
477+
478+def compose_review(mp, issues):
479+ """
480+ From issues raised by review_* functions, stitch together a nice message to
481+ be posted on launchpad
482+ """
483+ message = AUTOMATED_MESSAGE
484+ # TODO
485+ logging.warning("TODO: " + compose_review.__doc__)
486+ return message
487+
488+
489+def post_message_on_launchpad(mp, message):
490+ """Given a message to post on launchpad, post it on the MP."""
491+ # TODO
492+ logging.warning("TODO: " + post_message_on_launchpad.__doc__)
493+
494+
495+def parse_args():
496+ """Define and parse command line arguments for script."""
497+ # TODO
498+ parser = ArgumentParser()
499+ group = parser.add_argument_group()
500+ group.add_argument(u'-p', u'--project', metavar="project",
501+ required=True, help=u'Launchpad repository identifier.')
502+ return parser.parse_args()
503+
504+
505+def main():
506+ """Main Function"""
507+ args = parse_args()
508+ lp = launchpad_login(False, consumer_name, 'production')
509+ project = lp.projects[args.project]
510+ review = review_project(project)
511+ # TODO
512+ raise NotImplementedError(main.__doc__)
513+
514+if __name__ == "__main__":
515+ exit(main())

Subscribers

People subscribed via source and target branches