diff --git a/contrib/import-checker.py b/contrib/import-checker.py --- a/contrib/import-checker.py +++ b/contrib/import-checker.py @@ -392,9 +392,10 @@ modnotfound = True continue yield found[1] - if modnotfound: + if modnotfound and dottedpath != modulename: # "dottedpath" is a package, but imported because of non-module # lookup + # specifically allow "from . import foo" from __init__.py yield dottedpath diff --git a/hgext/hooklib/__init__.py b/hgext/hooklib/__init__.py new file mode 100644 --- /dev/null +++ b/hgext/hooklib/__init__.py @@ -0,0 +1,26 @@ +"""collection of simple hooks for common tasks (EXPERIMENTAL) + +This extension provides a number of simple hooks to handle issues +commonly found in repositories with many contributors: +- email notification when changesets move from draft to public phase +- email notification when changesets are obsoleted +- enforcement of draft phase for all incoming changesets +- enforcement of a no-branch-merge policy +- enforcement of a no-multiple-heads policy + +The implementation of the hooks is subject to change, e.g. whether to +implement them as individual hooks or merge them into the notify +extension as option. The functionality itself is planned to be supported +long-term. +""" +from __future__ import absolute_import +from . import ( + changeset_obsoleted, + changeset_published, +) + +# configtable is only picked up from the "top-level" module of the extension, +# so expand it here to ensure all items are properly loaded +configtable = {} +configtable.update(changeset_published.configtable) +configtable.update(changeset_obsoleted.configtable) diff --git a/hgext/hooklib/changeset_obsoleted.py b/hgext/hooklib/changeset_obsoleted.py new file mode 100644 --- /dev/null +++ b/hgext/hooklib/changeset_obsoleted.py @@ -0,0 +1,131 @@ +# Copyright 2020 Joerg Sonnenberger +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +"""changeset_obsoleted is a hook to send a mail when an +existing draft changeset is obsoleted by an obsmarker without successor. + +Correct message threading requires the same messageidseed to be used for both +the original notification and the new mail. + +Usage: + [notify] + messageidseed = myseed + + [hooks] + pretxnclose.changeset_obsoleted = \ + python:hgext.hooklib.changeset_obsoleted.hook +""" + +from __future__ import absolute_import + +import email.errors as emailerrors +import email.utils as emailutils + +from mercurial.i18n import _ +from mercurial import ( + encoding, + error, + logcmdutil, + mail, + obsutil, + pycompat, + registrar, +) +from mercurial.utils import dateutil +from .. import notify + +configtable = {} +configitem = registrar.configitem(configtable) + +configitem( + b'notify_obsoleted', b'domain', default=None, +) +configitem( + b'notify_obsoleted', b'messageidseed', default=None, +) +configitem( + b'notify_obsoleted', + b'template', + default=b'''Subject: changeset abandoned + +This changeset has been abandoned. +''', +) + + +def _report_commit(ui, repo, ctx): + domain = ui.config(b'notify_obsoleted', b'domain') or ui.config( + b'notify', b'domain' + ) + messageidseed = ui.config( + b'notify_obsoleted', b'messageidseed' + ) or ui.config(b'notify', b'messageidseed') + template = ui.config(b'notify_obsoleted', b'template') + spec = logcmdutil.templatespec(template, None) + templater = logcmdutil.changesettemplater(ui, repo, spec) + ui.pushbuffer() + n = notify.notifier(ui, repo, b'incoming') + + subs = set() + for sub, spec in n.subs: + if spec is None: + subs.add(sub) + continue + revs = repo.revs(b'%r and %d:', spec, ctx.rev()) + if len(revs): + subs.add(sub) + continue + if len(subs) == 0: + ui.debug( + b'notify_obsoleted: no subscribers to selected repo and revset\n' + ) + return + + templater.show( + ctx, + changes=ctx.changeset(), + baseurl=ui.config(b'web', b'baseurl'), + root=repo.root, + webroot=n.root, + ) + data = ui.popbuffer() + + try: + msg = mail.parsebytes(data) + except emailerrors.MessageParseError as inst: + raise error.Abort(inst) + + msg['In-reply-to'] = notify.messageid(ctx, domain, messageidseed) + msg['Message-Id'] = notify.messageid( + ctx, domain, messageidseed + b'-obsoleted' + ) + msg['Date'] = encoding.strfromlocal( + dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2") + ) + if not msg['From']: + sender = ui.config(b'email', b'from') or ui.username() + if b'@' not in sender or b'@localhost' in sender: + sender = n.fixmail(sender) + msg['From'] = mail.addressencode(ui, sender, n.charsets, n.test) + msg['To'] = ', '.join(sorted(subs)) + + msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string() + if ui.configbool(b'notify', b'test'): + ui.write(msgtext) + if not msgtext.endswith(b'\n'): + ui.write(b'\n') + else: + ui.status(_(b'notify_obsoleted: sending mail for %d\n') % ctx.rev()) + mail.sendmail( + ui, emailutils.parseaddr(msg['From'])[1], subs, msgtext, mbox=n.mbox + ) + + +def hook(ui, repo, hooktype, node=None, **kwargs): + if hooktype != b"pretxnclose": + raise error.Abort( + _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype) + ) + for rev in obsutil.getobsoleted(repo, repo.currenttransaction()): + _report_commit(ui, repo, repo.unfiltered()[rev]) diff --git a/hgext/hooklib/changeset_published.py b/hgext/hooklib/changeset_published.py new file mode 100644 --- /dev/null +++ b/hgext/hooklib/changeset_published.py @@ -0,0 +1,131 @@ +# Copyright 2020 Joerg Sonnenberger +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +"""changeset_published is a hook to send a mail when an +existing draft changeset is moved to the public phase. + +Correct message threading requires the same messageidseed to be used for both +the original notification and the new mail. + +Usage: + [notify] + messageidseed = myseed + + [hooks] + txnclose-phase.changeset_published = \ + python:hgext.hooklib.changeset_published.hook +""" + +from __future__ import absolute_import + +import email.errors as emailerrors +import email.utils as emailutils + +from mercurial.i18n import _ +from mercurial import ( + encoding, + error, + logcmdutil, + mail, + pycompat, + registrar, +) +from mercurial.utils import dateutil +from .. import notify + +configtable = {} +configitem = registrar.configitem(configtable) + +configitem( + b'notify_published', b'domain', default=None, +) +configitem( + b'notify_published', b'messageidseed', default=None, +) +configitem( + b'notify_published', + b'template', + default=b'''Subject: changeset published + +This changeset has been published. +''', +) + + +def _report_commit(ui, repo, ctx): + domain = ui.config(b'notify_published', b'domain') or ui.config( + b'notify', b'domain' + ) + messageidseed = ui.config( + b'notify_published', b'messageidseed' + ) or ui.config(b'notify', b'messageidseed') + template = ui.config(b'notify_published', b'template') + spec = logcmdutil.templatespec(template, None) + templater = logcmdutil.changesettemplater(ui, repo, spec) + ui.pushbuffer() + n = notify.notifier(ui, repo, b'incoming') + + subs = set() + for sub, spec in n.subs: + if spec is None: + subs.add(sub) + continue + revs = repo.revs(b'%r and %d:', spec, ctx.rev()) + if len(revs): + subs.add(sub) + continue + if len(subs) == 0: + ui.debug( + b'notify_published: no subscribers to selected repo and revset\n' + ) + return + + templater.show( + ctx, + changes=ctx.changeset(), + baseurl=ui.config(b'web', b'baseurl'), + root=repo.root, + webroot=n.root, + ) + data = ui.popbuffer() + + try: + msg = mail.parsebytes(data) + except emailerrors.MessageParseError as inst: + raise error.Abort(inst) + + msg['In-reply-to'] = notify.messageid(ctx, domain, messageidseed) + msg['Message-Id'] = notify.messageid( + ctx, domain, messageidseed + b'-published' + ) + msg['Date'] = encoding.strfromlocal( + dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2") + ) + if not msg['From']: + sender = ui.config(b'email', b'from') or ui.username() + if b'@' not in sender or b'@localhost' in sender: + sender = n.fixmail(sender) + msg['From'] = mail.addressencode(ui, sender, n.charsets, n.test) + msg['To'] = ', '.join(sorted(subs)) + + msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string() + if ui.configbool(b'notify', b'test'): + ui.write(msgtext) + if not msgtext.endswith(b'\n'): + ui.write(b'\n') + else: + ui.status(_(b'notify_published: sending mail for %d\n') % ctx.rev()) + mail.sendmail( + ui, emailutils.parseaddr(msg['From'])[1], subs, msgtext, mbox=n.mbox + ) + + +def hook(ui, repo, hooktype, node=None, **kwargs): + if hooktype != b"txnclose-phase": + raise error.Abort( + _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype) + ) + ctx = repo.unfiltered()[node] + if kwargs['oldphase'] == b'draft' and kwargs['phase'] == b'public': + _report_commit(ui, repo, ctx) diff --git a/hgext/hooklib/enforce_draft_commits.py b/hgext/hooklib/enforce_draft_commits.py new file mode 100644 --- /dev/null +++ b/hgext/hooklib/enforce_draft_commits.py @@ -0,0 +1,45 @@ +# Copyright 2020 Joerg Sonnenberger +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. + +"""enforce_draft_commits us a hook to ensure that all new changesets are +in the draft phase. This allows enforcing policies for work-in-progress +changes in overlay repositories, i.e. a shared hidden repositories with +different views for work-in-progress code and public history. + +Usage: + [hooks] + pretxnclose-phase.enforce_draft_commits = \ + python:hgext.hooklib.enforce_draft_commits.hook +""" + +from __future__ import absolute_import + +from mercurial.i18n import _ +from mercurial import ( + error, + pycompat, +) + + +def hook(ui, repo, hooktype, node=None, **kwargs): + if hooktype != b"pretxnclose-phase": + raise error.Abort( + _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype) + ) + ctx = repo.unfiltered()[node] + if kwargs['oldphase']: + raise error.Abort( + _(b'Phase change from %r to %r for %s rejected') + % ( + pycompat.bytestr(kwargs['oldphase']), + pycompat.bytestr(kwargs['phase']), + ctx, + ) + ) + elif kwargs['phase'] != b'draft': + raise error.Abort( + _(b'New changeset %s in phase %r rejected') + % (ctx, pycompat.bytestr(kwargs['phase'])) + ) diff --git a/hgext/hooklib/reject_merge_commits.py b/hgext/hooklib/reject_merge_commits.py new file mode 100644 --- /dev/null +++ b/hgext/hooklib/reject_merge_commits.py @@ -0,0 +1,45 @@ +# Copyright 2020 Joerg Sonnenberger +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. + +"""reject_merge_commits is a hook to check new changesets for merge commits. +Merge commits are allowed only between different branches, i.e. merging +a feature branch into the main development branch. This can be used to +enforce policies for linear commit histories. + +Usage: + [hooks] + pretxnchangegroup.reject_merge_commits = \ + python:hgext.hooklib.reject_merge_commits.hook +""" + +from __future__ import absolute_import + +from mercurial.i18n import _ +from mercurial import ( + error, + pycompat, +) + + +def hook(ui, repo, hooktype, node=None, **kwargs): + if hooktype != b"pretxnchangegroup": + raise error.Abort( + _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype) + ) + + ctx = repo.unfiltered()[node] + for rev in repo.changelog.revs(start=ctx.rev()): + rev = repo[rev] + parents = rev.parents() + if len(parents) < 2: + continue + if all(repo[p].branch() == rev.branch() for p in parents): + raise error.Abort( + _( + b'%s rejected as merge on the same branch. ' + b'Please consider rebase.' + ) + % rev + ) diff --git a/hgext/hooklib/reject_new_heads.py b/hgext/hooklib/reject_new_heads.py new file mode 100644 --- /dev/null +++ b/hgext/hooklib/reject_new_heads.py @@ -0,0 +1,41 @@ +# Copyright 2020 Joerg Sonnenberger +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. + +"""reject_new_heads is a hook to check that branches touched by new changesets +have at most one open head. It can be used to enforce policies for +merge-before-push or rebase-before-push. It does not handle pre-existing +hydras. + +Usage: + [hooks] + pretxnclose.reject_new_heads = \ + python:hgext.hooklib.reject_new_heads.hook +""" + +from __future__ import absolute_import + +from mercurial.i18n import _ +from mercurial import ( + error, + pycompat, +) + + +def hook(ui, repo, hooktype, node=None, **kwargs): + if hooktype != b"pretxnclose": + raise error.Abort( + _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype) + ) + ctx = repo.unfiltered()[node] + branches = set() + for rev in repo.changelog.revs(start=ctx.rev()): + rev = repo[rev] + branches.add(rev.branch()) + for branch in branches: + if len(repo.revs("head() and not closed() and branch(%s)", branch)) > 1: + raise error.Abort( + _(b'Changes on branch %r resulted in multiple heads') + % pycompat.bytestr(branch) + ) diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -1210,6 +1210,7 @@ 'hgext.fastannotate', 'hgext.fsmonitor.pywatchman', 'hgext.highlight', + 'hgext.hooklib', 'hgext.infinitepush', 'hgext.largefiles', 'hgext.lfs', diff --git a/tests/test-hooklib-changeset_obsoleted.t b/tests/test-hooklib-changeset_obsoleted.t new file mode 100644 --- /dev/null +++ b/tests/test-hooklib-changeset_obsoleted.t @@ -0,0 +1,84 @@ + $ cat <> $HGRCPATH + > [experimental] + > evolution = true + > + > [extensions] + > notify = + > hooklib = + > + > [phases] + > publish = False + > + > [notify] + > sources = pull + > diffstat = False + > messageidseed = example + > domain = example.com + > + > [reposubs] + > * = baz + > EOF + $ hg init a + $ hg --cwd a debugbuilddag +2 + $ hg init b + $ cat <> b/.hg/hgrc + > [hooks] + > incoming.notify = python:hgext.notify.hook + > pretxnclose.changeset_obsoleted = python:hgext.hooklib.changeset_obsoleted.hook + > EOF + $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py + pulling from ../a + requesting all changes + adding changesets + adding manifests + adding file changes + added 2 changesets with 0 changes to 0 files + new changesets 1ea73414a91b:66f7d451a68b (2 drafts) + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Date: * (glob) + Subject: changeset in * (glob) + From: debugbuilddag@example.com + X-Hg-Notification: changeset 1ea73414a91b + Message-Id: + To: baz@example.com + + changeset 1ea73414a91b in $TESTTMP/b + details: $TESTTMP/b?cmd=changeset;node=1ea73414a91b + description: + r0 + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Date: * (glob) + Subject: changeset in * (glob) + From: debugbuilddag@example.com + X-Hg-Notification: changeset 66f7d451a68b + Message-Id: + To: baz@example.com + + changeset 66f7d451a68b in $TESTTMP/b + details: $TESTTMP/b?cmd=changeset;node=66f7d451a68b + description: + r1 + (run 'hg update' to get a working copy) + $ hg --cwd a debugobsolete 1ea73414a91b0920940797d8fc6a11e447f8ea1e + 1 new obsolescence markers + obsoleted 1 changesets + 1 new orphan changesets + $ hg --cwd a push ../b --hidden | "$PYTHON" $TESTDIR/unwrap-message-id.py + 1 new orphan changesets + pushing to ../b + searching for changes + no changes found + Subject: changeset abandoned + In-reply-to: + Message-Id: + Date: * (glob) + From: test@example.com + To: baz@example.com + + This changeset has been abandoned. + 1 new obsolescence markers + obsoleted 1 changesets diff --git a/tests/test-hooklib-changeset_published.t b/tests/test-hooklib-changeset_published.t new file mode 100644 --- /dev/null +++ b/tests/test-hooklib-changeset_published.t @@ -0,0 +1,84 @@ + $ cat <> $HGRCPATH + > [extensions] + > notify = + > hooklib = + > + > [phases] + > publish = False + > + > [notify] + > sources = pull + > diffstat = False + > messageidseed = example + > domain = example.com + > + > [reposubs] + > * = baz + > EOF + $ hg init a + $ hg --cwd a debugbuilddag . + $ hg init b + $ cat <> b/.hg/hgrc + > [hooks] + > incoming.notify = python:hgext.notify.hook + > txnclose-phase.changeset_published = python:hgext.hooklib.changeset_published.hook + > EOF + $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py + pulling from ../a + requesting all changes + adding changesets + adding manifests + adding file changes + added 1 changesets with 0 changes to 0 files + new changesets 1ea73414a91b (1 drafts) + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Date: * (glob) + Subject: changeset in * (glob) + From: debugbuilddag@example.com + X-Hg-Notification: changeset 1ea73414a91b + Message-Id: + To: baz@example.com + + changeset 1ea73414a91b in $TESTTMP/b + details: $TESTTMP/b?cmd=changeset;node=1ea73414a91b + description: + r0 + (run 'hg update' to get a working copy) + $ hg --cwd a phase --public 0 + $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py + pulling from ../a + searching for changes + no changes found + 1 local changesets published + Subject: changeset published + In-reply-to: + Message-Id: + Date: * (glob) + From: test@example.com + To: baz@example.com + + This changeset has been published. + $ hg --cwd b phase --force --draft 0 + $ cat <> b/.hg/hgrc + > [notify_published] + > messageidseed = example2 + > domain = alt.example.com + > template = Subject: changeset published + > From: hg@example.com\n + > This draft changeset has been published.\n + > EOF + $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py + pulling from ../a + searching for changes + no changes found + 1 local changesets published + Subject: changeset published + From: hg@example.com + In-reply-to: + Message-Id: + Date: * (glob) + To: baz@example.com + + This draft changeset has been published. diff --git a/tests/test-hooklib-enforce_draft_commits.t b/tests/test-hooklib-enforce_draft_commits.t new file mode 100644 --- /dev/null +++ b/tests/test-hooklib-enforce_draft_commits.t @@ -0,0 +1,45 @@ + $ cat <> $HGRCPATH + > [extensions] + > hooklib = + > + > [phases] + > publish = False + > EOF + $ hg init a + $ hg --cwd a debugbuilddag . + $ hg --cwd a phase --public 0 + $ hg init b + $ cat <> b/.hg/hgrc + > [hooks] + > pretxnclose-phase.enforce_draft_commits = \ + > python:hgext.hooklib.enforce_draft_commits.hook + > EOF + $ hg --cwd b pull ../a + pulling from ../a + requesting all changes + adding changesets + adding manifests + adding file changes + error: pretxnclose-phase.enforce_draft_commits hook failed: New changeset 1ea73414a91b in phase 'public' rejected + transaction abort! + rollback completed + abort: New changeset 1ea73414a91b in phase 'public' rejected + [255] + $ hg --cwd a phase --force --draft 0 + $ hg --cwd b pull ../a + pulling from ../a + requesting all changes + adding changesets + adding manifests + adding file changes + added 1 changesets with 0 changes to 0 files + new changesets 1ea73414a91b (1 drafts) + (run 'hg update' to get a working copy) + $ hg --cwd a phase --public 0 + $ hg --cwd b pull ../a + pulling from ../a + searching for changes + no changes found + error: pretxnclose-phase.enforce_draft_commits hook failed: Phase change from 'draft' to 'public' for 1ea73414a91b rejected + abort: Phase change from 'draft' to 'public' for 1ea73414a91b rejected + [255] diff --git a/tests/test-hooklib-reject_merge_commits.t b/tests/test-hooklib-reject_merge_commits.t new file mode 100644 --- /dev/null +++ b/tests/test-hooklib-reject_merge_commits.t @@ -0,0 +1,78 @@ + $ cat <> $HGRCPATH + > [extensions] + > hooklib = + > + > [phases] + > publish = False + > EOF + $ hg init a + $ hg --cwd a debugbuilddag '.:parent.:childa*parent/childa> b/.hg/hgrc + > [hooks] + > pretxnchangegroup.reject_merge_commits = \ + > python:hgext.hooklib.reject_merge_commits.hook + > EOF + $ hg --cwd b pull ../a -r a6b287721c3b + pulling from ../a + adding changesets + adding manifests + adding file changes + error: pretxnchangegroup.reject_merge_commits hook failed: a6b287721c3b rejected as merge on the same branch. Please consider rebase. + transaction abort! + rollback completed + abort: a6b287721c3b rejected as merge on the same branch. Please consider rebase. + [255] + $ hg --cwd b pull ../a -r 1ea73414a91b + pulling from ../a + adding changesets + adding manifests + adding file changes + added 1 changesets with 0 changes to 0 files + new changesets 1ea73414a91b (1 drafts) + (run 'hg update' to get a working copy) + $ hg --cwd b pull ../a -r a9fb040caedd + pulling from ../a + searching for changes + adding changesets + adding manifests + adding file changes + added 3 changesets with 0 changes to 0 files + new changesets 66f7d451a68b:a9fb040caedd (3 drafts) + (run 'hg update' to get a working copy) diff --git a/tests/test-hooklib-reject_new_heads.t b/tests/test-hooklib-reject_new_heads.t new file mode 100644 --- /dev/null +++ b/tests/test-hooklib-reject_new_heads.t @@ -0,0 +1,53 @@ + $ cat <> $HGRCPATH + > [extensions] + > hooklib = + > + > [phases] + > publish = False + > EOF + $ hg init a + $ hg --cwd a debugbuilddag '.:parent.*parent' + $ hg --cwd a log -G + o changeset: 2:fa942426a6fd + | tag: tip + | parent: 0:1ea73414a91b + | user: debugbuilddag + | date: Thu Jan 01 00:00:02 1970 +0000 + | summary: r2 + | + | o changeset: 1:66f7d451a68b + |/ user: debugbuilddag + | date: Thu Jan 01 00:00:01 1970 +0000 + | summary: r1 + | + o changeset: 0:1ea73414a91b + tag: parent + user: debugbuilddag + date: Thu Jan 01 00:00:00 1970 +0000 + summary: r0 + + $ hg init b + $ cat <> b/.hg/hgrc + > [hooks] + > pretxnclose.reject_new_heads = \ + > python:hgext.hooklib.reject_new_heads.hook + > EOF + $ hg --cwd b pull ../a + pulling from ../a + requesting all changes + adding changesets + adding manifests + adding file changes + error: pretxnclose.reject_new_heads hook failed: Changes on branch 'default' resulted in multiple heads + transaction abort! + rollback completed + abort: Changes on branch 'default' resulted in multiple heads + [255] + $ hg --cwd b pull ../a -r 1ea73414a91b + pulling from ../a + adding changesets + adding manifests + adding file changes + added 1 changesets with 0 changes to 0 files + new changesets 1ea73414a91b (1 drafts) + (run 'hg update' to get a working copy)