Patchwork D6825: hgext: start building a library for simple hooks

login
register
mail settings
Submitter phabricator
Date Feb. 29, 2020, 9:38 p.m.
Message ID <248531ecab483cd700d369db3a5390ce@localhost.localdomain>
Download mbox | patch
Permalink /patch/45413/
State Not Applicable
Headers show

Comments

phabricator - Feb. 29, 2020, 9:38 p.m.
joerg.sonnenberger updated this revision to Diff 20408.

REPOSITORY
  rHG Mercurial

CHANGES SINCE LAST UPDATE
  https://phab.mercurial-scm.org/D6825?vs=20371&id=20408

BRANCH
  default

CHANGES SINCE LAST ACTION
  https://phab.mercurial-scm.org/D6825/new/

REVISION DETAIL
  https://phab.mercurial-scm.org/D6825

AFFECTED FILES
  contrib/import-checker.py
  hgext/hooklib/__init__.py
  hgext/hooklib/changeset_obsoleted.py
  hgext/hooklib/changeset_published.py
  hgext/hooklib/enforce_draft_commits.py
  hgext/hooklib/reject_merge_commits.py
  hgext/hooklib/reject_new_heads.py
  setup.py
  tests/test-hooklib-changeset_obsoleted.t
  tests/test-hooklib-changeset_published.t
  tests/test-hooklib-enforce_draft_commits.t
  tests/test-hooklib-reject_merge_commits.t
  tests/test-hooklib-reject_new_heads.t

CHANGE DETAILS




To: joerg.sonnenberger, #hg-reviewers, baymax, durin42
Cc: martinvonz, yuja, marmoute, mjpieters, durin42, indygreg, pulkit, mercurial-devel

Patch

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 <<EOF >> $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 <<EOF >> 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)
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 <<EOF >> $HGRCPATH
+  > [extensions]
+  > hooklib =
+  > 
+  > [phases]
+  > publish = False
+  > EOF
+  $ hg init a
+  $ hg --cwd a debugbuilddag '.:parent.:childa*parent/childa<parent@otherbranch./childa'
+  $ hg --cwd a log -G
+  o    changeset:   4:a9fb040caedd
+  |\   branch:      otherbranch
+  | |  tag:         tip
+  | |  parent:      3:af739dfc49b4
+  | |  parent:      1:66f7d451a68b
+  | |  user:        debugbuilddag
+  | |  date:        Thu Jan 01 00:00:04 1970 +0000
+  | |  summary:     r4
+  | |
+  | o  changeset:   3:af739dfc49b4
+  | |  branch:      otherbranch
+  | |  parent:      0:1ea73414a91b
+  | |  user:        debugbuilddag
+  | |  date:        Thu Jan 01 00:00:03 1970 +0000
+  | |  summary:     r3
+  | |
+  +---o  changeset:   2:a6b287721c3b
+  | |/   parent:      0:1ea73414a91b
+  | |    parent:      1:66f7d451a68b
+  | |    user:        debugbuilddag
+  | |    date:        Thu Jan 01 00:00:02 1970 +0000
+  | |    summary:     r2
+  | |
+  o |  changeset:   1:66f7d451a68b
+  |/   tag:         childa
+  |    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 <<EOF >> 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-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 <<EOF >> $HGRCPATH
+  > [extensions]
+  > hooklib =
+  > 
+  > [phases]
+  > publish = False
+  > EOF
+  $ hg init a
+  $ hg --cwd a debugbuilddag .
+  $ hg --cwd a phase --public 0
+  $ hg init b
+  $ cat <<EOF >> 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-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 <<EOF >> $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 <<EOF >> 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: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  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: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  Message-Id: <hg.2ec19bbddee5b542442bf5e1aed97bf706afff6aa765629883fbd1f4edd6fcb0@example.com>
+  Date: * (glob)
+  From: test@example.com
+  To: baz@example.com
+  
+  This changeset has been published.
+  $ hg --cwd b phase --force --draft 0
+  $ cat <<EOF >> 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: <hg.e3381dc41c051215e50b1c166a72949d0fff99609eb373420bcb763af80ef230@alt.example.com>
+  Message-Id: <hg.c927f3d324e645a4245bfed20b0efb5b9582999d6be9bef45a37e7ec21208b24@alt.example.com>
+  Date: * (glob)
+  To: baz@example.com
+  
+  This draft changeset has been published.
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 <<EOF >> $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 <<EOF >> 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: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  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: <hg.364d03da7dc13829eb779a805be7e37f54f572e9afcea7d2626856a794d3e8f3@example.com>
+  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: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  Message-Id: <hg.d6329e9481594f0f3c8a84362b3511318bfbce50748ab1123f909eb6fbcab018@example.com>
+  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/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/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,38 @@ 
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# 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 encoding, 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/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 <joerg@bec.de>
+#
+# 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/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,47 @@ 
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# 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 (
+    encoding,
+    error,
+    phases,
+    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/changeset_published.py b/hgext/hooklib/changeset_published.py
new file mode 100644
--- /dev/null
+++ b/hgext/hooklib/changeset_published.py
@@ -0,0 +1,132 @@ 
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# 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,
+    phases,
+    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:
+        self.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/changeset_obsoleted.py b/hgext/hooklib/changeset_obsoleted.py
new file mode 100644
--- /dev/null
+++ b/hgext/hooklib/changeset_obsoleted.py
@@ -0,0 +1,132 @@ 
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# 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,
+    phases,
+    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:
+        self.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/__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/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