|
1 # notify.py - email notifications for mercurial |
|
2 # |
|
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com> |
|
4 # |
|
5 # This software may be used and distributed according to the terms |
|
6 # of the GNU General Public License, incorporated herein by reference. |
|
7 # |
|
8 # hook extension to email notifications to people when changesets are |
|
9 # committed to a repo they subscribe to. |
|
10 # |
|
11 # default mode is to print messages to stdout, for testing and |
|
12 # configuring. |
|
13 # |
|
14 # to use, configure notify extension and enable in hgrc like this: |
|
15 # |
|
16 # [extensions] |
|
17 # hgext.notify = |
|
18 # |
|
19 # [hooks] |
|
20 # # one email for each incoming changeset |
|
21 # incoming.notify = python:hgext.notify.hook |
|
22 # # batch emails when many changesets incoming at one time |
|
23 # changegroup.notify = python:hgext.notify.hook |
|
24 # |
|
25 # [notify] |
|
26 # # config items go in here |
|
27 # |
|
28 # config items: |
|
29 # |
|
30 # REQUIRED: |
|
31 # config = /path/to/file # file containing subscriptions |
|
32 # |
|
33 # OPTIONAL: |
|
34 # test = True # print messages to stdout for testing |
|
35 # strip = 3 # number of slashes to strip for url paths |
|
36 # domain = example.com # domain to use if committer missing domain |
|
37 # style = ... # style file to use when formatting email |
|
38 # template = ... # template to use when formatting email |
|
39 # incoming = ... # template to use when run as incoming hook |
|
40 # changegroup = ... # template when run as changegroup hook |
|
41 # maxdiff = 300 # max lines of diffs to include (0=none, -1=all) |
|
42 # maxsubject = 67 # truncate subject line longer than this |
|
43 # [email] |
|
44 # from = user@host.com # email address to send as if none given |
|
45 # [web] |
|
46 # baseurl = http://hgserver/... # root of hg web site for browsing commits |
|
47 # |
|
48 # notify config file has same format as regular hgrc. it has two |
|
49 # sections so you can express subscriptions in whatever way is handier |
|
50 # for you. |
|
51 # |
|
52 # [usersubs] |
|
53 # # key is subscriber email, value is ","-separated list of glob patterns |
|
54 # user@host = pattern |
|
55 # |
|
56 # [reposubs] |
|
57 # # key is glob pattern, value is ","-separated list of subscriber emails |
|
58 # pattern = user@host |
|
59 # |
|
60 # glob patterns are matched against path to repo root. |
|
61 # |
|
62 # if you like, you can put notify config file in repo that users can |
|
63 # push changes to, they can manage their own subscriptions. |
|
64 |
1 from mercurial.demandload import * |
65 from mercurial.demandload import * |
2 from mercurial.i18n import gettext as _ |
66 from mercurial.i18n import gettext as _ |
3 from mercurial.node import * |
67 from mercurial.node import * |
4 demandload(globals(), 'email.MIMEText mercurial:templater,util fnmatch socket') |
68 demandload(globals(), 'email.Parser mercurial:commands,templater,util') |
5 demandload(globals(), 'time') |
69 demandload(globals(), 'fnmatch socket time') |
|
70 |
|
71 # template for single changeset can include email headers. |
|
72 single_template = ''' |
|
73 Subject: changeset in {webroot}: {desc|firstline|strip} |
|
74 From: {author} |
|
75 |
|
76 changeset {node|short} in {root} |
|
77 details: {baseurl}{webroot}?cmd=changeset;node={node|short} |
|
78 description: |
|
79 \t{desc|tabindent|strip} |
|
80 '''.lstrip() |
|
81 |
|
82 # template for multiple changesets should not contain email headers, |
|
83 # because only first set of headers will be used and result will look |
|
84 # strange. |
|
85 multiple_template = ''' |
|
86 changeset {node|short} in {root} |
|
87 details: {baseurl}{webroot}?cmd=changeset;node={node|short} |
|
88 summary: {desc|firstline} |
|
89 ''' |
|
90 |
|
91 deftemplates = { |
|
92 'changegroup': multiple_template, |
|
93 } |
6 |
94 |
7 class notifier(object): |
95 class notifier(object): |
8 def __init__(self, ui, repo): |
96 '''email notification class.''' |
|
97 |
|
98 def __init__(self, ui, repo, hooktype): |
9 self.ui = ui |
99 self.ui = ui |
10 self.ui.readconfig(self.ui.config('notify', 'config')) |
100 self.ui.readconfig(self.ui.config('notify', 'config')) |
11 self.repo = repo |
101 self.repo = repo |
12 self.stripcount = self.ui.config('notify', 'strip') |
102 self.stripcount = int(self.ui.config('notify', 'strip', 0)) |
13 self.root = self.strip(self.repo.root) |
103 self.root = self.strip(self.repo.root) |
|
104 self.domain = self.ui.config('notify', 'domain') |
|
105 self.sio = templater.stringio() |
|
106 self.subs = self.subscribers() |
|
107 |
|
108 mapfile = self.ui.config('notify', 'style') |
|
109 template = (self.ui.config('notify', hooktype) or |
|
110 self.ui.config('notify', 'template')) |
|
111 self.t = templater.changeset_templater(self.ui, self.repo, mapfile, |
|
112 self.sio) |
|
113 if not mapfile and not template: |
|
114 template = deftemplates.get(hooktype) or single_template |
|
115 if template: |
|
116 template = templater.parsestring(template, quoted=False) |
|
117 self.t.use_template(template) |
14 |
118 |
15 def strip(self, path): |
119 def strip(self, path): |
|
120 '''strip leading slashes from local path, turn into web-safe path.''' |
|
121 |
16 path = util.pconvert(path) |
122 path = util.pconvert(path) |
17 count = self.stripcount |
123 count = self.stripcount |
18 while path and count >= 0: |
124 while path and count >= 0: |
19 c = path.find('/') |
125 c = path.find('/') |
20 if c == -1: |
126 if c == -1: |
21 break |
127 break |
22 path = path[c+1:] |
128 path = path[c+1:] |
23 count -= 1 |
129 count -= 1 |
24 return path |
130 return path |
25 |
131 |
|
132 def fixmail(self, addr): |
|
133 '''try to clean up email addresses.''' |
|
134 |
|
135 addr = templater.email(addr.strip()) |
|
136 a = addr.find('@localhost') |
|
137 if a != -1: |
|
138 addr = addr[:a] |
|
139 if '@' not in addr: |
|
140 return addr + '@' + self.domain |
|
141 return addr |
|
142 |
26 def subscribers(self): |
143 def subscribers(self): |
27 subs = [] |
144 '''return list of email addresses of subscribers to this repo.''' |
28 for user, pat in self.ui.configitems('usersubs'): |
145 |
29 if fnmatch.fnmatch(self.root, pat): |
146 subs = {} |
30 subs.append(user) |
147 for user, pats in self.ui.configitems('usersubs'): |
|
148 for pat in pats.split(','): |
|
149 if fnmatch.fnmatch(self.repo.root, pat.strip()): |
|
150 subs[self.fixmail(user)] = 1 |
31 for pat, users in self.ui.configitems('reposubs'): |
151 for pat, users in self.ui.configitems('reposubs'): |
32 if fnmatch.fnmatch(self.root, pat): |
152 if fnmatch.fnmatch(self.repo.root, pat): |
33 subs.extend([u.strip() for u in users.split(',')]) |
153 for user in users.split(','): |
|
154 subs[self.fixmail(user)] = 1 |
|
155 subs = subs.keys() |
34 subs.sort() |
156 subs.sort() |
35 return subs |
157 return subs |
36 |
158 |
37 def seen(self, node): |
|
38 pass |
|
39 |
|
40 def url(self, path=None): |
159 def url(self, path=None): |
41 return self.ui.config('web', 'baseurl') + (path or self.root) |
160 return self.ui.config('web', 'baseurl') + (path or self.root) |
42 |
161 |
43 def message(self, node, changes): |
162 def node(self, node): |
44 sio = templater.stringio() |
163 '''format one changeset.''' |
45 seen = self.seen(node) |
164 |
46 if seen: |
165 self.t.show(changenode=node, changes=self.repo.changelog.read(node), |
47 seen = self.strip(seen) |
166 baseurl=self.ui.config('web', 'baseurl'), |
48 sio.write('Changeset %s merged to %s\n' % |
167 root=self.repo.root, |
49 (short(node), self.url())) |
168 webroot=self.root) |
50 sio.write('First seen in %s\n' % self.url(seen)) |
169 |
|
170 def send(self, node, count): |
|
171 '''send message.''' |
|
172 |
|
173 p = email.Parser.Parser() |
|
174 self.sio.seek(0) |
|
175 msg = p.parse(self.sio) |
|
176 |
|
177 def fix_subject(): |
|
178 '''try to make subject line exist and be useful.''' |
|
179 |
|
180 subject = msg['Subject'] |
|
181 if not subject: |
|
182 if count > 1: |
|
183 subject = _('%s: %d new changesets') % (self.root, count) |
|
184 else: |
|
185 changes = self.repo.changelog.read(node) |
|
186 s = changes[4].lstrip().split('\n', 1)[0].rstrip() |
|
187 subject = '%s: %s' % (self.root, s) |
|
188 maxsubject = int(self.ui.config('notify', 'maxsubject', 67)) |
|
189 if maxsubject and len(subject) > maxsubject: |
|
190 subject = subject[:maxsubject-3] + '...' |
|
191 del msg['Subject'] |
|
192 msg['Subject'] = subject |
|
193 |
|
194 def fix_sender(): |
|
195 '''try to make message have proper sender.''' |
|
196 |
|
197 sender = msg['From'] |
|
198 if not sender: |
|
199 sender = self.ui.config('email', 'from') or self.ui.username() |
|
200 if '@' not in sender or '@localhost' in sender: |
|
201 sender = self.fixmail(sender) |
|
202 del msg['From'] |
|
203 msg['From'] = sender |
|
204 |
|
205 fix_subject() |
|
206 fix_sender() |
|
207 |
|
208 msg['X-Hg-Notification'] = 'changeset ' + short(node) |
|
209 if not msg['Message-Id']: |
|
210 msg['Message-Id'] = ('<hg.%s.%s.%s@%s>' % |
|
211 (short(node), int(time.time()), |
|
212 hash(self.repo.root), socket.getfqdn())) |
|
213 |
|
214 msgtext = msg.as_string(0) |
|
215 if self.ui.configbool('notify', 'test', True): |
|
216 self.ui.write(msgtext) |
|
217 if not msgtext.endswith('\n'): |
|
218 self.ui.write('\n') |
51 else: |
219 else: |
52 sio.write('Changeset %s new to %s\n' % (short(node), self.url())) |
220 mail = self.ui.sendmail() |
53 sio.write('Committed by %s at %s\n' % |
221 mail.sendmail(templater.email(msg['From']), self.subs, msgtext) |
54 (changes[1], templater.isodate(changes[2]))) |
222 |
55 sio.write('See %s?cmd=changeset;node=%s for full details\n' % |
223 def diff(self, node): |
56 (self.url(), short(node))) |
224 maxdiff = int(self.ui.config('notify', 'maxdiff', 300)) |
57 sio.write('\nDescription:\n') |
225 if maxdiff == 0: |
58 sio.write(templater.indent(changes[4], ' ')) |
226 return |
59 msg = email.MIMEText.MIMEText(sio.getvalue(), 'plain') |
227 fp = templater.stringio() |
60 firstline = changes[4].lstrip().split('\n', 1)[0].rstrip() |
228 commands.dodiff(fp, self.ui, self.repo, node, |
61 subject = '%s %s: %s' % (self.root, self.repo.rev(node), firstline) |
229 self.repo.changelog.tip()) |
62 if seen: |
230 difflines = fp.getvalue().splitlines(1) |
63 subject = '[merge] ' + subject |
231 if maxdiff > 0 and len(difflines) > maxdiff: |
64 if subject.endswith('.'): |
232 self.sio.write(_('\ndiffs (truncated from %d to %d lines):\n\n') % |
65 subject = subject[:-1] |
233 (len(difflines), maxdiff)) |
66 if len(subject) > 67: |
234 difflines = difflines[:maxdiff] |
67 subject = subject[:64] + '...' |
235 elif difflines: |
68 msg['Subject'] = subject |
236 self.sio.write(_('\ndiffs (%d lines):\n\n') % len(difflines)) |
69 msg['X-Hg-Repo'] = self.root |
237 self.sio.write(*difflines) |
70 if '@' in changes[1]: |
|
71 msg['From'] = changes[1] |
|
72 else: |
|
73 msg['From'] = self.ui.config('email', 'from') |
|
74 msg['Message-Id'] = '<hg.%s.%s.%s@%s>' % (hex(node), |
|
75 int(time.time()), |
|
76 hash(self.repo.root), |
|
77 socket.getfqdn()) |
|
78 return msg |
|
79 |
|
80 def node(self, node): |
|
81 mail = self.ui.sendmail() |
|
82 changes = self.repo.changelog.read(node) |
|
83 fromaddr = self.ui.config('email', 'from', changes[1]) |
|
84 msg = self.message(node, changes) |
|
85 subs = self.subscribers() |
|
86 msg['To'] = ', '.join(subs) |
|
87 msgtext = msg.as_string(0) |
|
88 mail.sendmail(templater.email(fromaddr), |
|
89 [templater.email(s) for s in subs], |
|
90 msgtext) |
|
91 |
|
92 |
238 |
93 def hook(ui, repo, hooktype, node=None, **kwargs): |
239 def hook(ui, repo, hooktype, node=None, **kwargs): |
94 n = notifier(ui, repo) |
240 '''send email notifications to interested subscribers. |
95 n.node(bin(node)) |
241 |
|
242 if used as changegroup hook, send one email for all changesets in |
|
243 changegroup. else send one email per changeset.''' |
|
244 n = notifier(ui, repo, hooktype) |
|
245 if not n.subs: return True |
|
246 node = bin(node) |
|
247 if hooktype == 'changegroup': |
|
248 start = repo.changelog.rev(node) |
|
249 end = repo.changelog.count() |
|
250 count = end - start |
|
251 for rev in xrange(start, end): |
|
252 n.node(repo.changelog.node(rev)) |
|
253 else: |
|
254 count = 1 |
|
255 n.node(node) |
|
256 n.diff(node) |
|
257 n.send(node, count) |
|
258 return True |