Show More
@@ -1,1215 +1,1215 b'' | |||
|
1 | 1 | # bugzilla.py - bugzilla integration for mercurial |
|
2 | 2 | # |
|
3 | 3 | # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com> |
|
4 | 4 | # Copyright 2011-4 Jim Hague <jim.hague@acm.org> |
|
5 | 5 | # |
|
6 | 6 | # This software may be used and distributed according to the terms of the |
|
7 | 7 | # GNU General Public License version 2 or any later version. |
|
8 | 8 | |
|
9 | 9 | '''hooks for integrating with the Bugzilla bug tracker |
|
10 | 10 | |
|
11 | 11 | This hook extension adds comments on bugs in Bugzilla when changesets |
|
12 | 12 | that refer to bugs by Bugzilla ID are seen. The comment is formatted using |
|
13 | 13 | the Mercurial template mechanism. |
|
14 | 14 | |
|
15 | 15 | The bug references can optionally include an update for Bugzilla of the |
|
16 | 16 | hours spent working on the bug. Bugs can also be marked fixed. |
|
17 | 17 | |
|
18 | 18 | Four basic modes of access to Bugzilla are provided: |
|
19 | 19 | |
|
20 | 20 | 1. Access via the Bugzilla REST-API. Requires bugzilla 5.0 or later. |
|
21 | 21 | |
|
22 | 22 | 2. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later. |
|
23 | 23 | |
|
24 | 24 | 3. Check data via the Bugzilla XMLRPC interface and submit bug change |
|
25 | 25 | via email to Bugzilla email interface. Requires Bugzilla 3.4 or later. |
|
26 | 26 | |
|
27 | 27 | 4. Writing directly to the Bugzilla database. Only Bugzilla installations |
|
28 | 28 | using MySQL are supported. Requires Python MySQLdb. |
|
29 | 29 | |
|
30 | 30 | Writing directly to the database is susceptible to schema changes, and |
|
31 | 31 | relies on a Bugzilla contrib script to send out bug change |
|
32 | 32 | notification emails. This script runs as the user running Mercurial, |
|
33 | 33 | must be run on the host with the Bugzilla install, and requires |
|
34 | 34 | permission to read Bugzilla configuration details and the necessary |
|
35 | 35 | MySQL user and password to have full access rights to the Bugzilla |
|
36 | 36 | database. For these reasons this access mode is now considered |
|
37 | 37 | deprecated, and will not be updated for new Bugzilla versions going |
|
38 | 38 | forward. Only adding comments is supported in this access mode. |
|
39 | 39 | |
|
40 | 40 | Access via XMLRPC needs a Bugzilla username and password to be specified |
|
41 | 41 | in the configuration. Comments are added under that username. Since the |
|
42 | 42 | configuration must be readable by all Mercurial users, it is recommended |
|
43 | 43 | that the rights of that user are restricted in Bugzilla to the minimum |
|
44 | 44 | necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later. |
|
45 | 45 | |
|
46 | 46 | Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends |
|
47 | 47 | email to the Bugzilla email interface to submit comments to bugs. |
|
48 | 48 | The From: address in the email is set to the email address of the Mercurial |
|
49 | 49 | user, so the comment appears to come from the Mercurial user. In the event |
|
50 | 50 | that the Mercurial user email is not recognized by Bugzilla as a Bugzilla |
|
51 | 51 | user, the email associated with the Bugzilla username used to log into |
|
52 | 52 | Bugzilla is used instead as the source of the comment. Marking bugs fixed |
|
53 | 53 | works on all supported Bugzilla versions. |
|
54 | 54 | |
|
55 | 55 | Access via the REST-API needs either a Bugzilla username and password |
|
56 | 56 | or an apikey specified in the configuration. Comments are made under |
|
57 | 57 | the given username or the user associated with the apikey in Bugzilla. |
|
58 | 58 | |
|
59 | 59 | Configuration items common to all access modes: |
|
60 | 60 | |
|
61 | 61 | bugzilla.version |
|
62 | 62 | The access type to use. Values recognized are: |
|
63 | 63 | |
|
64 | 64 | :``restapi``: Bugzilla REST-API, Bugzilla 5.0 and later. |
|
65 | 65 | :``xmlrpc``: Bugzilla XMLRPC interface. |
|
66 | 66 | :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces. |
|
67 | 67 | :``3.0``: MySQL access, Bugzilla 3.0 and later. |
|
68 | 68 | :``2.18``: MySQL access, Bugzilla 2.18 and up to but not |
|
69 | 69 | including 3.0. |
|
70 | 70 | :``2.16``: MySQL access, Bugzilla 2.16 and up to but not |
|
71 | 71 | including 2.18. |
|
72 | 72 | |
|
73 | 73 | bugzilla.regexp |
|
74 | 74 | Regular expression to match bug IDs for update in changeset commit message. |
|
75 | 75 | It must contain one "()" named group ``<ids>`` containing the bug |
|
76 | 76 | IDs separated by non-digit characters. It may also contain |
|
77 | 77 | a named group ``<hours>`` with a floating-point number giving the |
|
78 | 78 | hours worked on the bug. If no named groups are present, the first |
|
79 | 79 | "()" group is assumed to contain the bug IDs, and work time is not |
|
80 | 80 | updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``, |
|
81 | 81 | ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and |
|
82 | 82 | variations thereof, followed by an hours number prefixed by ``h`` or |
|
83 | 83 | ``hours``, e.g. ``hours 1.5``. Matching is case insensitive. |
|
84 | 84 | |
|
85 | 85 | bugzilla.fixregexp |
|
86 | 86 | Regular expression to match bug IDs for marking fixed in changeset |
|
87 | 87 | commit message. This must contain a "()" named group ``<ids>` containing |
|
88 | 88 | the bug IDs separated by non-digit characters. It may also contain |
|
89 | 89 | a named group ``<hours>`` with a floating-point number giving the |
|
90 | 90 | hours worked on the bug. If no named groups are present, the first |
|
91 | 91 | "()" group is assumed to contain the bug IDs, and work time is not |
|
92 | 92 | updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``, |
|
93 | 93 | ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and |
|
94 | 94 | variations thereof, followed by an hours number prefixed by ``h`` or |
|
95 | 95 | ``hours``, e.g. ``hours 1.5``. Matching is case insensitive. |
|
96 | 96 | |
|
97 | 97 | bugzilla.fixstatus |
|
98 | 98 | The status to set a bug to when marking fixed. Default ``RESOLVED``. |
|
99 | 99 | |
|
100 | 100 | bugzilla.fixresolution |
|
101 | 101 | The resolution to set a bug to when marking fixed. Default ``FIXED``. |
|
102 | 102 | |
|
103 | 103 | bugzilla.style |
|
104 | 104 | The style file to use when formatting comments. |
|
105 | 105 | |
|
106 | 106 | bugzilla.template |
|
107 | 107 | Template to use when formatting comments. Overrides style if |
|
108 | 108 | specified. In addition to the usual Mercurial keywords, the |
|
109 | 109 | extension specifies: |
|
110 | 110 | |
|
111 | 111 | :``{bug}``: The Bugzilla bug ID. |
|
112 | 112 | :``{root}``: The full pathname of the Mercurial repository. |
|
113 | 113 | :``{webroot}``: Stripped pathname of the Mercurial repository. |
|
114 | 114 | :``{hgweb}``: Base URL for browsing Mercurial repositories. |
|
115 | 115 | |
|
116 | 116 | Default ``changeset {node|short} in repo {root} refers to bug |
|
117 | 117 | {bug}.\\ndetails:\\n\\t{desc|tabindent}`` |
|
118 | 118 | |
|
119 | 119 | bugzilla.strip |
|
120 | 120 | The number of path separator characters to strip from the front of |
|
121 | 121 | the Mercurial repository path (``{root}`` in templates) to produce |
|
122 | 122 | ``{webroot}``. For example, a repository with ``{root}`` |
|
123 | 123 | ``/var/local/my-project`` with a strip of 2 gives a value for |
|
124 | 124 | ``{webroot}`` of ``my-project``. Default 0. |
|
125 | 125 | |
|
126 | 126 | web.baseurl |
|
127 | 127 | Base URL for browsing Mercurial repositories. Referenced from |
|
128 | 128 | templates as ``{hgweb}``. |
|
129 | 129 | |
|
130 | 130 | Configuration items common to XMLRPC+email and MySQL access modes: |
|
131 | 131 | |
|
132 | 132 | bugzilla.usermap |
|
133 | 133 | Path of file containing Mercurial committer email to Bugzilla user email |
|
134 | 134 | mappings. If specified, the file should contain one mapping per |
|
135 | 135 | line:: |
|
136 | 136 | |
|
137 | 137 | committer = Bugzilla user |
|
138 | 138 | |
|
139 | 139 | See also the ``[usermap]`` section. |
|
140 | 140 | |
|
141 | 141 | The ``[usermap]`` section is used to specify mappings of Mercurial |
|
142 | 142 | committer email to Bugzilla user email. See also ``bugzilla.usermap``. |
|
143 | 143 | Contains entries of the form ``committer = Bugzilla user``. |
|
144 | 144 | |
|
145 | 145 | XMLRPC and REST-API access mode configuration: |
|
146 | 146 | |
|
147 | 147 | bugzilla.bzurl |
|
148 | 148 | The base URL for the Bugzilla installation. |
|
149 | 149 | Default ``http://localhost/bugzilla``. |
|
150 | 150 | |
|
151 | 151 | bugzilla.user |
|
152 | 152 | The username to use to log into Bugzilla via XMLRPC. Default |
|
153 | 153 | ``bugs``. |
|
154 | 154 | |
|
155 | 155 | bugzilla.password |
|
156 | 156 | The password for Bugzilla login. |
|
157 | 157 | |
|
158 | 158 | REST-API access mode uses the options listed above as well as: |
|
159 | 159 | |
|
160 | 160 | bugzilla.apikey |
|
161 | 161 | An apikey generated on the Bugzilla instance for api access. |
|
162 | 162 | Using an apikey removes the need to store the user and password |
|
163 | 163 | options. |
|
164 | 164 | |
|
165 | 165 | XMLRPC+email access mode uses the XMLRPC access mode configuration items, |
|
166 | 166 | and also: |
|
167 | 167 | |
|
168 | 168 | bugzilla.bzemail |
|
169 | 169 | The Bugzilla email address. |
|
170 | 170 | |
|
171 | 171 | In addition, the Mercurial email settings must be configured. See the |
|
172 | 172 | documentation in hgrc(5), sections ``[email]`` and ``[smtp]``. |
|
173 | 173 | |
|
174 | 174 | MySQL access mode configuration: |
|
175 | 175 | |
|
176 | 176 | bugzilla.host |
|
177 | 177 | Hostname of the MySQL server holding the Bugzilla database. |
|
178 | 178 | Default ``localhost``. |
|
179 | 179 | |
|
180 | 180 | bugzilla.db |
|
181 | 181 | Name of the Bugzilla database in MySQL. Default ``bugs``. |
|
182 | 182 | |
|
183 | 183 | bugzilla.user |
|
184 | 184 | Username to use to access MySQL server. Default ``bugs``. |
|
185 | 185 | |
|
186 | 186 | bugzilla.password |
|
187 | 187 | Password to use to access MySQL server. |
|
188 | 188 | |
|
189 | 189 | bugzilla.timeout |
|
190 | 190 | Database connection timeout (seconds). Default 5. |
|
191 | 191 | |
|
192 | 192 | bugzilla.bzuser |
|
193 | 193 | Fallback Bugzilla user name to record comments with, if changeset |
|
194 | 194 | committer cannot be found as a Bugzilla user. |
|
195 | 195 | |
|
196 | 196 | bugzilla.bzdir |
|
197 | 197 | Bugzilla install directory. Used by default notify. Default |
|
198 | 198 | ``/var/www/html/bugzilla``. |
|
199 | 199 | |
|
200 | 200 | bugzilla.notify |
|
201 | 201 | The command to run to get Bugzilla to send bug change notification |
|
202 | 202 | emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug |
|
203 | 203 | id) and ``user`` (committer bugzilla email). Default depends on |
|
204 | 204 | version; from 2.18 it is "cd %(bzdir)s && perl -T |
|
205 | 205 | contrib/sendbugmail.pl %(id)s %(user)s". |
|
206 | 206 | |
|
207 | 207 | Activating the extension:: |
|
208 | 208 | |
|
209 | 209 | [extensions] |
|
210 | 210 | bugzilla = |
|
211 | 211 | |
|
212 | 212 | [hooks] |
|
213 | 213 | # run bugzilla hook on every change pulled or pushed in here |
|
214 | 214 | incoming.bugzilla = python:hgext.bugzilla.hook |
|
215 | 215 | |
|
216 | 216 | Example configurations: |
|
217 | 217 | |
|
218 | 218 | XMLRPC example configuration. This uses the Bugzilla at |
|
219 | 219 | ``http://my-project.org/bugzilla``, logging in as user |
|
220 | 220 | ``bugmail@my-project.org`` with password ``plugh``. It is used with a |
|
221 | 221 | collection of Mercurial repositories in ``/var/local/hg/repos/``, |
|
222 | 222 | with a web interface at ``http://my-project.org/hg``. :: |
|
223 | 223 | |
|
224 | 224 | [bugzilla] |
|
225 | 225 | bzurl=http://my-project.org/bugzilla |
|
226 | 226 | user=bugmail@my-project.org |
|
227 | 227 | password=plugh |
|
228 | 228 | version=xmlrpc |
|
229 | 229 | template=Changeset {node|short} in {root|basename}. |
|
230 | 230 | {hgweb}/{webroot}/rev/{node|short}\\n |
|
231 | 231 | {desc}\\n |
|
232 | 232 | strip=5 |
|
233 | 233 | |
|
234 | 234 | [web] |
|
235 | 235 | baseurl=http://my-project.org/hg |
|
236 | 236 | |
|
237 | 237 | XMLRPC+email example configuration. This uses the Bugzilla at |
|
238 | 238 | ``http://my-project.org/bugzilla``, logging in as user |
|
239 | 239 | ``bugmail@my-project.org`` with password ``plugh``. It is used with a |
|
240 | 240 | collection of Mercurial repositories in ``/var/local/hg/repos/``, |
|
241 | 241 | with a web interface at ``http://my-project.org/hg``. Bug comments |
|
242 | 242 | are sent to the Bugzilla email address |
|
243 | 243 | ``bugzilla@my-project.org``. :: |
|
244 | 244 | |
|
245 | 245 | [bugzilla] |
|
246 | 246 | bzurl=http://my-project.org/bugzilla |
|
247 | 247 | user=bugmail@my-project.org |
|
248 | 248 | password=plugh |
|
249 | 249 | version=xmlrpc+email |
|
250 | 250 | bzemail=bugzilla@my-project.org |
|
251 | 251 | template=Changeset {node|short} in {root|basename}. |
|
252 | 252 | {hgweb}/{webroot}/rev/{node|short}\\n |
|
253 | 253 | {desc}\\n |
|
254 | 254 | strip=5 |
|
255 | 255 | |
|
256 | 256 | [web] |
|
257 | 257 | baseurl=http://my-project.org/hg |
|
258 | 258 | |
|
259 | 259 | [usermap] |
|
260 | 260 | user@emaildomain.com=user.name@bugzilladomain.com |
|
261 | 261 | |
|
262 | 262 | MySQL example configuration. This has a local Bugzilla 3.2 installation |
|
263 | 263 | in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``, |
|
264 | 264 | the Bugzilla database name is ``bugs`` and MySQL is |
|
265 | 265 | accessed with MySQL username ``bugs`` password ``XYZZY``. It is used |
|
266 | 266 | with a collection of Mercurial repositories in ``/var/local/hg/repos/``, |
|
267 | 267 | with a web interface at ``http://my-project.org/hg``. :: |
|
268 | 268 | |
|
269 | 269 | [bugzilla] |
|
270 | 270 | host=localhost |
|
271 | 271 | password=XYZZY |
|
272 | 272 | version=3.0 |
|
273 | 273 | bzuser=unknown@domain.com |
|
274 | 274 | bzdir=/opt/bugzilla-3.2 |
|
275 | 275 | template=Changeset {node|short} in {root|basename}. |
|
276 | 276 | {hgweb}/{webroot}/rev/{node|short}\\n |
|
277 | 277 | {desc}\\n |
|
278 | 278 | strip=5 |
|
279 | 279 | |
|
280 | 280 | [web] |
|
281 | 281 | baseurl=http://my-project.org/hg |
|
282 | 282 | |
|
283 | 283 | [usermap] |
|
284 | 284 | user@emaildomain.com=user.name@bugzilladomain.com |
|
285 | 285 | |
|
286 | 286 | All the above add a comment to the Bugzilla bug record of the form:: |
|
287 | 287 | |
|
288 | 288 | Changeset 3b16791d6642 in repository-name. |
|
289 | 289 | http://my-project.org/hg/repository-name/rev/3b16791d6642 |
|
290 | 290 | |
|
291 | 291 | Changeset commit comment. Bug 1234. |
|
292 | 292 | ''' |
|
293 | 293 | |
|
294 | 294 | from __future__ import absolute_import |
|
295 | 295 | |
|
296 | 296 | import json |
|
297 | 297 | import re |
|
298 | 298 | import time |
|
299 | 299 | |
|
300 | 300 | from mercurial.i18n import _ |
|
301 | 301 | from mercurial.node import short |
|
302 | 302 | from mercurial import ( |
|
303 | 303 | error, |
|
304 | 304 | logcmdutil, |
|
305 | 305 | mail, |
|
306 | 306 | pycompat, |
|
307 | 307 | registrar, |
|
308 | 308 | url, |
|
309 | 309 | util, |
|
310 | 310 | ) |
|
311 | 311 | from mercurial.utils import ( |
|
312 | 312 | procutil, |
|
313 | 313 | stringutil, |
|
314 | 314 | ) |
|
315 | 315 | |
|
316 | 316 | xmlrpclib = util.xmlrpclib |
|
317 | 317 | |
|
318 | 318 | # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for |
|
319 | 319 | # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should |
|
320 | 320 | # be specifying the version(s) of Mercurial they are tested with, or |
|
321 | 321 | # leave the attribute unspecified. |
|
322 | 322 | testedwith = b'ships-with-hg-core' |
|
323 | 323 | |
|
324 | 324 | configtable = {} |
|
325 | 325 | configitem = registrar.configitem(configtable) |
|
326 | 326 | |
|
327 | 327 | configitem( |
|
328 | 328 | b'bugzilla', b'apikey', default=b'', |
|
329 | 329 | ) |
|
330 | 330 | configitem( |
|
331 | 331 | b'bugzilla', b'bzdir', default=b'/var/www/html/bugzilla', |
|
332 | 332 | ) |
|
333 | 333 | configitem( |
|
334 | 334 | b'bugzilla', b'bzemail', default=None, |
|
335 | 335 | ) |
|
336 | 336 | configitem( |
|
337 | 337 | b'bugzilla', b'bzurl', default=b'http://localhost/bugzilla/', |
|
338 | 338 | ) |
|
339 | 339 | configitem( |
|
340 | 340 | b'bugzilla', b'bzuser', default=None, |
|
341 | 341 | ) |
|
342 | 342 | configitem( |
|
343 | 343 | b'bugzilla', b'db', default=b'bugs', |
|
344 | 344 | ) |
|
345 | 345 | configitem( |
|
346 | 346 | b'bugzilla', |
|
347 | 347 | b'fixregexp', |
|
348 | 348 | default=( |
|
349 | 349 | br'fix(?:es)?\s*(?:bugs?\s*)?,?\s*' |
|
350 | 350 | br'(?:nos?\.?|num(?:ber)?s?)?\s*' |
|
351 | 351 | br'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)' |
|
352 | 352 | br'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?' |
|
353 | 353 | ), |
|
354 | 354 | ) |
|
355 | 355 | configitem( |
|
356 | 356 | b'bugzilla', b'fixresolution', default=b'FIXED', |
|
357 | 357 | ) |
|
358 | 358 | configitem( |
|
359 | 359 | b'bugzilla', b'fixstatus', default=b'RESOLVED', |
|
360 | 360 | ) |
|
361 | 361 | configitem( |
|
362 | 362 | b'bugzilla', b'host', default=b'localhost', |
|
363 | 363 | ) |
|
364 | 364 | configitem( |
|
365 | 365 | b'bugzilla', b'notify', default=configitem.dynamicdefault, |
|
366 | 366 | ) |
|
367 | 367 | configitem( |
|
368 | 368 | b'bugzilla', b'password', default=None, |
|
369 | 369 | ) |
|
370 | 370 | configitem( |
|
371 | 371 | b'bugzilla', |
|
372 | 372 | b'regexp', |
|
373 | 373 | default=( |
|
374 | 374 | br'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*' |
|
375 | 375 | br'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)' |
|
376 | 376 | br'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?' |
|
377 | 377 | ), |
|
378 | 378 | ) |
|
379 | 379 | configitem( |
|
380 | 380 | b'bugzilla', b'strip', default=0, |
|
381 | 381 | ) |
|
382 | 382 | configitem( |
|
383 | 383 | b'bugzilla', b'style', default=None, |
|
384 | 384 | ) |
|
385 | 385 | configitem( |
|
386 | 386 | b'bugzilla', b'template', default=None, |
|
387 | 387 | ) |
|
388 | 388 | configitem( |
|
389 | 389 | b'bugzilla', b'timeout', default=5, |
|
390 | 390 | ) |
|
391 | 391 | configitem( |
|
392 | 392 | b'bugzilla', b'user', default=b'bugs', |
|
393 | 393 | ) |
|
394 | 394 | configitem( |
|
395 | 395 | b'bugzilla', b'usermap', default=None, |
|
396 | 396 | ) |
|
397 | 397 | configitem( |
|
398 | 398 | b'bugzilla', b'version', default=None, |
|
399 | 399 | ) |
|
400 | 400 | |
|
401 | 401 | |
|
402 | 402 | class bzaccess(object): |
|
403 | 403 | '''Base class for access to Bugzilla.''' |
|
404 | 404 | |
|
405 | 405 | def __init__(self, ui): |
|
406 | 406 | self.ui = ui |
|
407 | 407 | usermap = self.ui.config(b'bugzilla', b'usermap') |
|
408 | 408 | if usermap: |
|
409 | 409 | self.ui.readconfig(usermap, sections=[b'usermap']) |
|
410 | 410 | |
|
411 | 411 | def map_committer(self, user): |
|
412 | 412 | '''map name of committer to Bugzilla user name.''' |
|
413 | 413 | for committer, bzuser in self.ui.configitems(b'usermap'): |
|
414 | 414 | if committer.lower() == user.lower(): |
|
415 | 415 | return bzuser |
|
416 | 416 | return user |
|
417 | 417 | |
|
418 | 418 | # Methods to be implemented by access classes. |
|
419 | 419 | # |
|
420 | 420 | # 'bugs' is a dict keyed on bug id, where values are a dict holding |
|
421 | 421 | # updates to bug state. Recognized dict keys are: |
|
422 | 422 | # |
|
423 | 423 | # 'hours': Value, float containing work hours to be updated. |
|
424 | 424 | # 'fix': If key present, bug is to be marked fixed. Value ignored. |
|
425 | 425 | |
|
426 | 426 | def filter_real_bug_ids(self, bugs): |
|
427 | 427 | '''remove bug IDs that do not exist in Bugzilla from bugs.''' |
|
428 | 428 | |
|
429 | 429 | def filter_cset_known_bug_ids(self, node, bugs): |
|
430 | 430 | '''remove bug IDs where node occurs in comment text from bugs.''' |
|
431 | 431 | |
|
432 | 432 | def updatebug(self, bugid, newstate, text, committer): |
|
433 | 433 | '''update the specified bug. Add comment text and set new states. |
|
434 | 434 | |
|
435 | 435 | If possible add the comment as being from the committer of |
|
436 | 436 | the changeset. Otherwise use the default Bugzilla user. |
|
437 | 437 | ''' |
|
438 | 438 | |
|
439 | 439 | def notify(self, bugs, committer): |
|
440 | 440 | '''Force sending of Bugzilla notification emails. |
|
441 | 441 | |
|
442 | 442 | Only required if the access method does not trigger notification |
|
443 | 443 | emails automatically. |
|
444 | 444 | ''' |
|
445 | 445 | |
|
446 | 446 | |
|
447 | 447 | # Bugzilla via direct access to MySQL database. |
|
448 | 448 | class bzmysql(bzaccess): |
|
449 | 449 | '''Support for direct MySQL access to Bugzilla. |
|
450 | 450 | |
|
451 | 451 | The earliest Bugzilla version this is tested with is version 2.16. |
|
452 | 452 | |
|
453 | 453 | If your Bugzilla is version 3.4 or above, you are strongly |
|
454 | 454 | recommended to use the XMLRPC access method instead. |
|
455 | 455 | ''' |
|
456 | 456 | |
|
457 | 457 | @staticmethod |
|
458 | 458 | def sql_buglist(ids): |
|
459 | 459 | '''return SQL-friendly list of bug ids''' |
|
460 | 460 | return b'(' + b','.join(map(str, ids)) + b')' |
|
461 | 461 | |
|
462 | 462 | _MySQLdb = None |
|
463 | 463 | |
|
464 | 464 | def __init__(self, ui): |
|
465 | 465 | try: |
|
466 | 466 | import MySQLdb as mysql |
|
467 | 467 | |
|
468 | 468 | bzmysql._MySQLdb = mysql |
|
469 | 469 | except ImportError as err: |
|
470 | 470 | raise error.Abort( |
|
471 | 471 | _(b'python mysql support not available: %s') % err |
|
472 | 472 | ) |
|
473 | 473 | |
|
474 | 474 | bzaccess.__init__(self, ui) |
|
475 | 475 | |
|
476 | 476 | host = self.ui.config(b'bugzilla', b'host') |
|
477 | 477 | user = self.ui.config(b'bugzilla', b'user') |
|
478 | 478 | passwd = self.ui.config(b'bugzilla', b'password') |
|
479 | 479 | db = self.ui.config(b'bugzilla', b'db') |
|
480 | 480 | timeout = int(self.ui.config(b'bugzilla', b'timeout')) |
|
481 | 481 | self.ui.note( |
|
482 | 482 | _(b'connecting to %s:%s as %s, password %s\n') |
|
483 | 483 | % (host, db, user, b'*' * len(passwd)) |
|
484 | 484 | ) |
|
485 | 485 | self.conn = bzmysql._MySQLdb.connect( |
|
486 | 486 | host=host, user=user, passwd=passwd, db=db, connect_timeout=timeout |
|
487 | 487 | ) |
|
488 | 488 | self.cursor = self.conn.cursor() |
|
489 | 489 | self.longdesc_id = self.get_longdesc_id() |
|
490 | 490 | self.user_ids = {} |
|
491 | 491 | self.default_notify = b"cd %(bzdir)s && ./processmail %(id)s %(user)s" |
|
492 | 492 | |
|
493 | 493 | def run(self, *args, **kwargs): |
|
494 | 494 | '''run a query.''' |
|
495 | 495 | self.ui.note(_(b'query: %s %s\n') % (args, kwargs)) |
|
496 | 496 | try: |
|
497 | 497 | self.cursor.execute(*args, **kwargs) |
|
498 | 498 | except bzmysql._MySQLdb.MySQLError: |
|
499 | 499 | self.ui.note(_(b'failed query: %s %s\n') % (args, kwargs)) |
|
500 | 500 | raise |
|
501 | 501 | |
|
502 | 502 | def get_longdesc_id(self): |
|
503 | 503 | '''get identity of longdesc field''' |
|
504 | 504 | self.run(b'select fieldid from fielddefs where name = "longdesc"') |
|
505 | 505 | ids = self.cursor.fetchall() |
|
506 | 506 | if len(ids) != 1: |
|
507 | 507 | raise error.Abort(_(b'unknown database schema')) |
|
508 | 508 | return ids[0][0] |
|
509 | 509 | |
|
510 | 510 | def filter_real_bug_ids(self, bugs): |
|
511 | 511 | '''filter not-existing bugs from set.''' |
|
512 | 512 | self.run( |
|
513 | 513 | b'select bug_id from bugs where bug_id in %s' |
|
514 | 514 | % bzmysql.sql_buglist(bugs.keys()) |
|
515 | 515 | ) |
|
516 | 516 | existing = [id for (id,) in self.cursor.fetchall()] |
|
517 | 517 | for id in bugs.keys(): |
|
518 | 518 | if id not in existing: |
|
519 | 519 | self.ui.status(_(b'bug %d does not exist\n') % id) |
|
520 | 520 | del bugs[id] |
|
521 | 521 | |
|
522 | 522 | def filter_cset_known_bug_ids(self, node, bugs): |
|
523 | 523 | '''filter bug ids that already refer to this changeset from set.''' |
|
524 | 524 | self.run( |
|
525 | 525 | '''select bug_id from longdescs where |
|
526 | 526 | bug_id in %s and thetext like "%%%s%%"''' |
|
527 | 527 | % (bzmysql.sql_buglist(bugs.keys()), short(node)) |
|
528 | 528 | ) |
|
529 | 529 | for (id,) in self.cursor.fetchall(): |
|
530 | 530 | self.ui.status( |
|
531 | 531 | _(b'bug %d already knows about changeset %s\n') |
|
532 | 532 | % (id, short(node)) |
|
533 | 533 | ) |
|
534 | 534 | del bugs[id] |
|
535 | 535 | |
|
536 | 536 | def notify(self, bugs, committer): |
|
537 | 537 | '''tell bugzilla to send mail.''' |
|
538 | 538 | self.ui.status(_(b'telling bugzilla to send mail:\n')) |
|
539 | 539 | (user, userid) = self.get_bugzilla_user(committer) |
|
540 | 540 | for id in bugs.keys(): |
|
541 | 541 | self.ui.status(_(b' bug %s\n') % id) |
|
542 | 542 | cmdfmt = self.ui.config(b'bugzilla', b'notify', self.default_notify) |
|
543 | 543 | bzdir = self.ui.config(b'bugzilla', b'bzdir') |
|
544 | 544 | try: |
|
545 | 545 | # Backwards-compatible with old notify string, which |
|
546 | 546 | # took one string. This will throw with a new format |
|
547 | 547 | # string. |
|
548 | 548 | cmd = cmdfmt % id |
|
549 | 549 | except TypeError: |
|
550 | 550 | cmd = cmdfmt % {b'bzdir': bzdir, b'id': id, b'user': user} |
|
551 | 551 | self.ui.note(_(b'running notify command %s\n') % cmd) |
|
552 | 552 | fp = procutil.popen(b'(%s) 2>&1' % cmd, b'rb') |
|
553 | 553 | out = util.fromnativeeol(fp.read()) |
|
554 | 554 | ret = fp.close() |
|
555 | 555 | if ret: |
|
556 | 556 | self.ui.warn(out) |
|
557 | 557 | raise error.Abort( |
|
558 | 558 | _(b'bugzilla notify command %s') % procutil.explainexit(ret) |
|
559 | 559 | ) |
|
560 | 560 | self.ui.status(_(b'done\n')) |
|
561 | 561 | |
|
562 | 562 | def get_user_id(self, user): |
|
563 | 563 | '''look up numeric bugzilla user id.''' |
|
564 | 564 | try: |
|
565 | 565 | return self.user_ids[user] |
|
566 | 566 | except KeyError: |
|
567 | 567 | try: |
|
568 | 568 | userid = int(user) |
|
569 | 569 | except ValueError: |
|
570 | 570 | self.ui.note(_(b'looking up user %s\n') % user) |
|
571 | 571 | self.run( |
|
572 | 572 | '''select userid from profiles |
|
573 | 573 | where login_name like %s''', |
|
574 | 574 | user, |
|
575 | 575 | ) |
|
576 | 576 | all = self.cursor.fetchall() |
|
577 | 577 | if len(all) != 1: |
|
578 | 578 | raise KeyError(user) |
|
579 | 579 | userid = int(all[0][0]) |
|
580 | 580 | self.user_ids[user] = userid |
|
581 | 581 | return userid |
|
582 | 582 | |
|
583 | 583 | def get_bugzilla_user(self, committer): |
|
584 | 584 | '''See if committer is a registered bugzilla user. Return |
|
585 | 585 | bugzilla username and userid if so. If not, return default |
|
586 | 586 | bugzilla username and userid.''' |
|
587 | 587 | user = self.map_committer(committer) |
|
588 | 588 | try: |
|
589 | 589 | userid = self.get_user_id(user) |
|
590 | 590 | except KeyError: |
|
591 | 591 | try: |
|
592 | 592 | defaultuser = self.ui.config(b'bugzilla', b'bzuser') |
|
593 | 593 | if not defaultuser: |
|
594 | 594 | raise error.Abort( |
|
595 | 595 | _(b'cannot find bugzilla user id for %s') % user |
|
596 | 596 | ) |
|
597 | 597 | userid = self.get_user_id(defaultuser) |
|
598 | 598 | user = defaultuser |
|
599 | 599 | except KeyError: |
|
600 | 600 | raise error.Abort( |
|
601 | 601 | _(b'cannot find bugzilla user id for %s or %s') |
|
602 | 602 | % (user, defaultuser) |
|
603 | 603 | ) |
|
604 | 604 | return (user, userid) |
|
605 | 605 | |
|
606 | 606 | def updatebug(self, bugid, newstate, text, committer): |
|
607 | 607 | '''update bug state with comment text. |
|
608 | 608 | |
|
609 | 609 | Try adding comment as committer of changeset, otherwise as |
|
610 | 610 | default bugzilla user.''' |
|
611 | 611 | if len(newstate) > 0: |
|
612 | 612 | self.ui.warn(_(b"Bugzilla/MySQL cannot update bug state\n")) |
|
613 | 613 | |
|
614 | 614 | (user, userid) = self.get_bugzilla_user(committer) |
|
615 | 615 | now = time.strftime(r'%Y-%m-%d %H:%M:%S') |
|
616 | 616 | self.run( |
|
617 | 617 | '''insert into longdescs |
|
618 | 618 | (bug_id, who, bug_when, thetext) |
|
619 | 619 | values (%s, %s, %s, %s)''', |
|
620 | 620 | (bugid, userid, now, text), |
|
621 | 621 | ) |
|
622 | 622 | self.run( |
|
623 | 623 | '''insert into bugs_activity (bug_id, who, bug_when, fieldid) |
|
624 | 624 | values (%s, %s, %s, %s)''', |
|
625 | 625 | (bugid, userid, now, self.longdesc_id), |
|
626 | 626 | ) |
|
627 | 627 | self.conn.commit() |
|
628 | 628 | |
|
629 | 629 | |
|
630 | 630 | class bzmysql_2_18(bzmysql): |
|
631 | 631 | '''support for bugzilla 2.18 series.''' |
|
632 | 632 | |
|
633 | 633 | def __init__(self, ui): |
|
634 | 634 | bzmysql.__init__(self, ui) |
|
635 | 635 | self.default_notify = ( |
|
636 | 636 | b"cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s" |
|
637 | 637 | ) |
|
638 | 638 | |
|
639 | 639 | |
|
640 | 640 | class bzmysql_3_0(bzmysql_2_18): |
|
641 | 641 | '''support for bugzilla 3.0 series.''' |
|
642 | 642 | |
|
643 | 643 | def __init__(self, ui): |
|
644 | 644 | bzmysql_2_18.__init__(self, ui) |
|
645 | 645 | |
|
646 | 646 | def get_longdesc_id(self): |
|
647 | 647 | '''get identity of longdesc field''' |
|
648 | 648 | self.run(b'select id from fielddefs where name = "longdesc"') |
|
649 | 649 | ids = self.cursor.fetchall() |
|
650 | 650 | if len(ids) != 1: |
|
651 | 651 | raise error.Abort(_(b'unknown database schema')) |
|
652 | 652 | return ids[0][0] |
|
653 | 653 | |
|
654 | 654 | |
|
655 | 655 | # Bugzilla via XMLRPC interface. |
|
656 | 656 | |
|
657 | 657 | |
|
658 | 658 | class cookietransportrequest(object): |
|
659 | 659 | """A Transport request method that retains cookies over its lifetime. |
|
660 | 660 | |
|
661 | 661 | The regular xmlrpclib transports ignore cookies. Which causes |
|
662 | 662 | a bit of a problem when you need a cookie-based login, as with |
|
663 | 663 | the Bugzilla XMLRPC interface prior to 4.4.3. |
|
664 | 664 | |
|
665 | 665 | So this is a helper for defining a Transport which looks for |
|
666 | 666 | cookies being set in responses and saves them to add to all future |
|
667 | 667 | requests. |
|
668 | 668 | """ |
|
669 | 669 | |
|
670 | 670 | # Inspiration drawn from |
|
671 | 671 | # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html |
|
672 | 672 | # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/ |
|
673 | 673 | |
|
674 | 674 | cookies = [] |
|
675 | 675 | |
|
676 | 676 | def send_cookies(self, connection): |
|
677 | 677 | if self.cookies: |
|
678 | 678 | for cookie in self.cookies: |
|
679 | 679 | connection.putheader(b"Cookie", cookie) |
|
680 | 680 | |
|
681 | 681 | def request(self, host, handler, request_body, verbose=0): |
|
682 | 682 | self.verbose = verbose |
|
683 | 683 | self.accept_gzip_encoding = False |
|
684 | 684 | |
|
685 | 685 | # issue XML-RPC request |
|
686 | 686 | h = self.make_connection(host) |
|
687 | 687 | if verbose: |
|
688 | 688 | h.set_debuglevel(1) |
|
689 | 689 | |
|
690 | 690 | self.send_request(h, handler, request_body) |
|
691 | 691 | self.send_host(h, host) |
|
692 | 692 | self.send_cookies(h) |
|
693 | 693 | self.send_user_agent(h) |
|
694 | 694 | self.send_content(h, request_body) |
|
695 | 695 | |
|
696 | 696 | # Deal with differences between Python 2.6 and 2.7. |
|
697 | 697 | # In the former h is a HTTP(S). In the latter it's a |
|
698 | 698 | # HTTP(S)Connection. Luckily, the 2.6 implementation of |
|
699 | 699 | # HTTP(S) has an underlying HTTP(S)Connection, so extract |
|
700 | 700 | # that and use it. |
|
701 | 701 | try: |
|
702 | 702 | response = h.getresponse() |
|
703 | 703 | except AttributeError: |
|
704 | 704 | response = h._conn.getresponse() |
|
705 | 705 | |
|
706 | 706 | # Add any cookie definitions to our list. |
|
707 | 707 | for header in response.msg.getallmatchingheaders(b"Set-Cookie"): |
|
708 | 708 | val = header.split(b": ", 1)[1] |
|
709 | 709 | cookie = val.split(b";", 1)[0] |
|
710 | 710 | self.cookies.append(cookie) |
|
711 | 711 | |
|
712 | 712 | if response.status != 200: |
|
713 | 713 | raise xmlrpclib.ProtocolError( |
|
714 | 714 | host + handler, |
|
715 | 715 | response.status, |
|
716 | 716 | response.reason, |
|
717 | 717 | response.msg.headers, |
|
718 | 718 | ) |
|
719 | 719 | |
|
720 | 720 | payload = response.read() |
|
721 | 721 | parser, unmarshaller = self.getparser() |
|
722 | 722 | parser.feed(payload) |
|
723 | 723 | parser.close() |
|
724 | 724 | |
|
725 | 725 | return unmarshaller.close() |
|
726 | 726 | |
|
727 | 727 | |
|
728 | 728 | # The explicit calls to the underlying xmlrpclib __init__() methods are |
|
729 | 729 | # necessary. The xmlrpclib.Transport classes are old-style classes, and |
|
730 | 730 | # it turns out their __init__() doesn't get called when doing multiple |
|
731 | 731 | # inheritance with a new-style class. |
|
732 | 732 | class cookietransport(cookietransportrequest, xmlrpclib.Transport): |
|
733 | 733 | def __init__(self, use_datetime=0): |
|
734 | 734 | if util.safehasattr(xmlrpclib.Transport, "__init__"): |
|
735 | 735 | xmlrpclib.Transport.__init__(self, use_datetime) |
|
736 | 736 | |
|
737 | 737 | |
|
738 | 738 | class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport): |
|
739 | 739 | def __init__(self, use_datetime=0): |
|
740 | 740 | if util.safehasattr(xmlrpclib.Transport, "__init__"): |
|
741 | 741 | xmlrpclib.SafeTransport.__init__(self, use_datetime) |
|
742 | 742 | |
|
743 | 743 | |
|
744 | 744 | class bzxmlrpc(bzaccess): |
|
745 | 745 | """Support for access to Bugzilla via the Bugzilla XMLRPC API. |
|
746 | 746 | |
|
747 | 747 | Requires a minimum Bugzilla version 3.4. |
|
748 | 748 | """ |
|
749 | 749 | |
|
750 | 750 | def __init__(self, ui): |
|
751 | 751 | bzaccess.__init__(self, ui) |
|
752 | 752 | |
|
753 | 753 | bzweb = self.ui.config(b'bugzilla', b'bzurl') |
|
754 | 754 | bzweb = bzweb.rstrip(b"/") + b"/xmlrpc.cgi" |
|
755 | 755 | |
|
756 | 756 | user = self.ui.config(b'bugzilla', b'user') |
|
757 | 757 | passwd = self.ui.config(b'bugzilla', b'password') |
|
758 | 758 | |
|
759 | 759 | self.fixstatus = self.ui.config(b'bugzilla', b'fixstatus') |
|
760 | 760 | self.fixresolution = self.ui.config(b'bugzilla', b'fixresolution') |
|
761 | 761 | |
|
762 | 762 | self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb)) |
|
763 | 763 | ver = self.bzproxy.Bugzilla.version()[b'version'].split(b'.') |
|
764 | 764 | self.bzvermajor = int(ver[0]) |
|
765 | 765 | self.bzverminor = int(ver[1]) |
|
766 | 766 | login = self.bzproxy.User.login( |
|
767 | 767 | {b'login': user, b'password': passwd, b'restrict_login': True} |
|
768 | 768 | ) |
|
769 | 769 | self.bztoken = login.get(b'token', b'') |
|
770 | 770 | |
|
771 | 771 | def transport(self, uri): |
|
772 | 772 | if util.urlreq.urlparse(uri, b"http")[0] == b"https": |
|
773 | 773 | return cookiesafetransport() |
|
774 | 774 | else: |
|
775 | 775 | return cookietransport() |
|
776 | 776 | |
|
777 | 777 | def get_bug_comments(self, id): |
|
778 | 778 | """Return a string with all comment text for a bug.""" |
|
779 | 779 | c = self.bzproxy.Bug.comments( |
|
780 | 780 | {b'ids': [id], b'include_fields': [b'text'], b'token': self.bztoken} |
|
781 | 781 | ) |
|
782 | 782 | return b''.join( |
|
783 | 783 | [t[b'text'] for t in c[b'bugs'][b'%d' % id][b'comments']] |
|
784 | 784 | ) |
|
785 | 785 | |
|
786 | 786 | def filter_real_bug_ids(self, bugs): |
|
787 | 787 | probe = self.bzproxy.Bug.get( |
|
788 | 788 | { |
|
789 | 789 | b'ids': sorted(bugs.keys()), |
|
790 | 790 | b'include_fields': [], |
|
791 | 791 | b'permissive': True, |
|
792 | 792 | b'token': self.bztoken, |
|
793 | 793 | } |
|
794 | 794 | ) |
|
795 | 795 | for badbug in probe[b'faults']: |
|
796 | 796 | id = badbug[b'id'] |
|
797 | 797 | self.ui.status(_(b'bug %d does not exist\n') % id) |
|
798 | 798 | del bugs[id] |
|
799 | 799 | |
|
800 | 800 | def filter_cset_known_bug_ids(self, node, bugs): |
|
801 | 801 | for id in sorted(bugs.keys()): |
|
802 | 802 | if self.get_bug_comments(id).find(short(node)) != -1: |
|
803 | 803 | self.ui.status( |
|
804 | 804 | _(b'bug %d already knows about changeset %s\n') |
|
805 | 805 | % (id, short(node)) |
|
806 | 806 | ) |
|
807 | 807 | del bugs[id] |
|
808 | 808 | |
|
809 | 809 | def updatebug(self, bugid, newstate, text, committer): |
|
810 | 810 | args = {} |
|
811 | 811 | if b'hours' in newstate: |
|
812 | 812 | args[b'work_time'] = newstate[b'hours'] |
|
813 | 813 | |
|
814 | 814 | if self.bzvermajor >= 4: |
|
815 | 815 | args[b'ids'] = [bugid] |
|
816 | 816 | args[b'comment'] = {b'body': text} |
|
817 | 817 | if b'fix' in newstate: |
|
818 | 818 | args[b'status'] = self.fixstatus |
|
819 | 819 | args[b'resolution'] = self.fixresolution |
|
820 | 820 | args[b'token'] = self.bztoken |
|
821 | 821 | self.bzproxy.Bug.update(args) |
|
822 | 822 | else: |
|
823 | 823 | if b'fix' in newstate: |
|
824 | 824 | self.ui.warn( |
|
825 | 825 | _( |
|
826 | 826 | b"Bugzilla/XMLRPC needs Bugzilla 4.0 or later " |
|
827 | 827 | b"to mark bugs fixed\n" |
|
828 | 828 | ) |
|
829 | 829 | ) |
|
830 | 830 | args[b'id'] = bugid |
|
831 | 831 | args[b'comment'] = text |
|
832 | 832 | self.bzproxy.Bug.add_comment(args) |
|
833 | 833 | |
|
834 | 834 | |
|
835 | 835 | class bzxmlrpcemail(bzxmlrpc): |
|
836 | 836 | """Read data from Bugzilla via XMLRPC, send updates via email. |
|
837 | 837 | |
|
838 | 838 | Advantages of sending updates via email: |
|
839 | 839 | 1. Comments can be added as any user, not just logged in user. |
|
840 | 840 | 2. Bug statuses or other fields not accessible via XMLRPC can |
|
841 | 841 | potentially be updated. |
|
842 | 842 | |
|
843 | 843 | There is no XMLRPC function to change bug status before Bugzilla |
|
844 | 844 | 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0. |
|
845 | 845 | But bugs can be marked fixed via email from 3.4 onwards. |
|
846 | 846 | """ |
|
847 | 847 | |
|
848 | 848 | # The email interface changes subtly between 3.4 and 3.6. In 3.4, |
|
849 | 849 | # in-email fields are specified as '@<fieldname> = <value>'. In |
|
850 | 850 | # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id |
|
851 | 851 | # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards |
|
852 | 852 | # compatibility, but rather than rely on this use the new format for |
|
853 | 853 | # 4.0 onwards. |
|
854 | 854 | |
|
855 | 855 | def __init__(self, ui): |
|
856 | 856 | bzxmlrpc.__init__(self, ui) |
|
857 | 857 | |
|
858 | 858 | self.bzemail = self.ui.config(b'bugzilla', b'bzemail') |
|
859 | 859 | if not self.bzemail: |
|
860 | 860 | raise error.Abort(_(b"configuration 'bzemail' missing")) |
|
861 | 861 | mail.validateconfig(self.ui) |
|
862 | 862 | |
|
863 | 863 | def makecommandline(self, fieldname, value): |
|
864 | 864 | if self.bzvermajor >= 4: |
|
865 | 865 | return b"@%s %s" % (fieldname, pycompat.bytestr(value)) |
|
866 | 866 | else: |
|
867 | 867 | if fieldname == b"id": |
|
868 | 868 | fieldname = b"bug_id" |
|
869 | 869 | return b"@%s = %s" % (fieldname, pycompat.bytestr(value)) |
|
870 | 870 | |
|
871 | 871 | def send_bug_modify_email(self, bugid, commands, comment, committer): |
|
872 | 872 | '''send modification message to Bugzilla bug via email. |
|
873 | 873 | |
|
874 | 874 | The message format is documented in the Bugzilla email_in.pl |
|
875 | 875 | specification. commands is a list of command lines, comment is the |
|
876 | 876 | comment text. |
|
877 | 877 | |
|
878 | 878 | To stop users from crafting commit comments with |
|
879 | 879 | Bugzilla commands, specify the bug ID via the message body, rather |
|
880 | 880 | than the subject line, and leave a blank line after it. |
|
881 | 881 | ''' |
|
882 | 882 | user = self.map_committer(committer) |
|
883 | 883 | matches = self.bzproxy.User.get( |
|
884 | 884 | {b'match': [user], b'token': self.bztoken} |
|
885 | 885 | ) |
|
886 | 886 | if not matches[b'users']: |
|
887 | 887 | user = self.ui.config(b'bugzilla', b'user') |
|
888 | 888 | matches = self.bzproxy.User.get( |
|
889 | 889 | {b'match': [user], b'token': self.bztoken} |
|
890 | 890 | ) |
|
891 | 891 | if not matches[b'users']: |
|
892 | 892 | raise error.Abort( |
|
893 | 893 | _(b"default bugzilla user %s email not found") % user |
|
894 | 894 | ) |
|
895 | 895 | user = matches[b'users'][0][b'email'] |
|
896 | 896 | commands.append(self.makecommandline(b"id", bugid)) |
|
897 | 897 | |
|
898 | 898 | text = b"\n".join(commands) + b"\n\n" + comment |
|
899 | 899 | |
|
900 | 900 | _charsets = mail._charsets(self.ui) |
|
901 | 901 | user = mail.addressencode(self.ui, user, _charsets) |
|
902 | 902 | bzemail = mail.addressencode(self.ui, self.bzemail, _charsets) |
|
903 | 903 | msg = mail.mimeencode(self.ui, text, _charsets) |
|
904 | 904 | msg[b'From'] = user |
|
905 | 905 | msg[b'To'] = bzemail |
|
906 | 906 | msg[b'Subject'] = mail.headencode( |
|
907 | 907 | self.ui, b"Bug modification", _charsets |
|
908 | 908 | ) |
|
909 | 909 | sendmail = mail.connect(self.ui) |
|
910 | 910 | sendmail(user, bzemail, msg.as_string()) |
|
911 | 911 | |
|
912 | 912 | def updatebug(self, bugid, newstate, text, committer): |
|
913 | 913 | cmds = [] |
|
914 | 914 | if b'hours' in newstate: |
|
915 | 915 | cmds.append(self.makecommandline(b"work_time", newstate[b'hours'])) |
|
916 | 916 | if b'fix' in newstate: |
|
917 | 917 | cmds.append(self.makecommandline(b"bug_status", self.fixstatus)) |
|
918 | 918 | cmds.append(self.makecommandline(b"resolution", self.fixresolution)) |
|
919 | 919 | self.send_bug_modify_email(bugid, cmds, text, committer) |
|
920 | 920 | |
|
921 | 921 | |
|
922 | 922 | class NotFound(LookupError): |
|
923 | 923 | pass |
|
924 | 924 | |
|
925 | 925 | |
|
926 | 926 | class bzrestapi(bzaccess): |
|
927 | 927 | """Read and write bugzilla data using the REST API available since |
|
928 | 928 | Bugzilla 5.0. |
|
929 | 929 | """ |
|
930 | 930 | |
|
931 | 931 | def __init__(self, ui): |
|
932 | 932 | bzaccess.__init__(self, ui) |
|
933 | 933 | bz = self.ui.config(b'bugzilla', b'bzurl') |
|
934 | 934 | self.bzroot = b'/'.join([bz, b'rest']) |
|
935 | 935 | self.apikey = self.ui.config(b'bugzilla', b'apikey') |
|
936 | 936 | self.user = self.ui.config(b'bugzilla', b'user') |
|
937 | 937 | self.passwd = self.ui.config(b'bugzilla', b'password') |
|
938 | 938 | self.fixstatus = self.ui.config(b'bugzilla', b'fixstatus') |
|
939 | 939 | self.fixresolution = self.ui.config(b'bugzilla', b'fixresolution') |
|
940 | 940 | |
|
941 | 941 | def apiurl(self, targets, include_fields=None): |
|
942 | 942 | url = b'/'.join([self.bzroot] + [pycompat.bytestr(t) for t in targets]) |
|
943 | 943 | qv = {} |
|
944 | 944 | if self.apikey: |
|
945 | 945 | qv[b'api_key'] = self.apikey |
|
946 | 946 | elif self.user and self.passwd: |
|
947 | 947 | qv[b'login'] = self.user |
|
948 | 948 | qv[b'password'] = self.passwd |
|
949 | 949 | if include_fields: |
|
950 | 950 | qv[b'include_fields'] = include_fields |
|
951 | 951 | if qv: |
|
952 | 952 | url = b'%s?%s' % (url, util.urlreq.urlencode(qv)) |
|
953 | 953 | return url |
|
954 | 954 | |
|
955 | 955 | def _fetch(self, burl): |
|
956 | 956 | try: |
|
957 | 957 | resp = url.open(self.ui, burl) |
|
958 |
return |
|
|
958 | return pycompat.json_loads(resp.read()) | |
|
959 | 959 | except util.urlerr.httperror as inst: |
|
960 | 960 | if inst.code == 401: |
|
961 | 961 | raise error.Abort(_(b'authorization failed')) |
|
962 | 962 | if inst.code == 404: |
|
963 | 963 | raise NotFound() |
|
964 | 964 | else: |
|
965 | 965 | raise |
|
966 | 966 | |
|
967 | 967 | def _submit(self, burl, data, method=b'POST'): |
|
968 | 968 | data = json.dumps(data) |
|
969 | 969 | if method == b'PUT': |
|
970 | 970 | |
|
971 | 971 | class putrequest(util.urlreq.request): |
|
972 | 972 | def get_method(self): |
|
973 | 973 | return b'PUT' |
|
974 | 974 | |
|
975 | 975 | request_type = putrequest |
|
976 | 976 | else: |
|
977 | 977 | request_type = util.urlreq.request |
|
978 | 978 | req = request_type(burl, data, {b'Content-Type': b'application/json'}) |
|
979 | 979 | try: |
|
980 | 980 | resp = url.opener(self.ui).open(req) |
|
981 |
return |
|
|
981 | return pycompat.json_loads(resp.read()) | |
|
982 | 982 | except util.urlerr.httperror as inst: |
|
983 | 983 | if inst.code == 401: |
|
984 | 984 | raise error.Abort(_(b'authorization failed')) |
|
985 | 985 | if inst.code == 404: |
|
986 | 986 | raise NotFound() |
|
987 | 987 | else: |
|
988 | 988 | raise |
|
989 | 989 | |
|
990 | 990 | def filter_real_bug_ids(self, bugs): |
|
991 | 991 | '''remove bug IDs that do not exist in Bugzilla from bugs.''' |
|
992 | 992 | badbugs = set() |
|
993 | 993 | for bugid in bugs: |
|
994 | 994 | burl = self.apiurl((b'bug', bugid), include_fields=b'status') |
|
995 | 995 | try: |
|
996 | 996 | self._fetch(burl) |
|
997 | 997 | except NotFound: |
|
998 | 998 | badbugs.add(bugid) |
|
999 | 999 | for bugid in badbugs: |
|
1000 | 1000 | del bugs[bugid] |
|
1001 | 1001 | |
|
1002 | 1002 | def filter_cset_known_bug_ids(self, node, bugs): |
|
1003 | 1003 | '''remove bug IDs where node occurs in comment text from bugs.''' |
|
1004 | 1004 | sn = short(node) |
|
1005 | 1005 | for bugid in bugs.keys(): |
|
1006 | 1006 | burl = self.apiurl( |
|
1007 | 1007 | (b'bug', bugid, b'comment'), include_fields=b'text' |
|
1008 | 1008 | ) |
|
1009 | 1009 | result = self._fetch(burl) |
|
1010 | 1010 | comments = result[b'bugs'][pycompat.bytestr(bugid)][b'comments'] |
|
1011 | 1011 | if any(sn in c[b'text'] for c in comments): |
|
1012 | 1012 | self.ui.status( |
|
1013 | 1013 | _(b'bug %d already knows about changeset %s\n') |
|
1014 | 1014 | % (bugid, sn) |
|
1015 | 1015 | ) |
|
1016 | 1016 | del bugs[bugid] |
|
1017 | 1017 | |
|
1018 | 1018 | def updatebug(self, bugid, newstate, text, committer): |
|
1019 | 1019 | '''update the specified bug. Add comment text and set new states. |
|
1020 | 1020 | |
|
1021 | 1021 | If possible add the comment as being from the committer of |
|
1022 | 1022 | the changeset. Otherwise use the default Bugzilla user. |
|
1023 | 1023 | ''' |
|
1024 | 1024 | bugmod = {} |
|
1025 | 1025 | if b'hours' in newstate: |
|
1026 | 1026 | bugmod[b'work_time'] = newstate[b'hours'] |
|
1027 | 1027 | if b'fix' in newstate: |
|
1028 | 1028 | bugmod[b'status'] = self.fixstatus |
|
1029 | 1029 | bugmod[b'resolution'] = self.fixresolution |
|
1030 | 1030 | if bugmod: |
|
1031 | 1031 | # if we have to change the bugs state do it here |
|
1032 | 1032 | bugmod[b'comment'] = { |
|
1033 | 1033 | b'comment': text, |
|
1034 | 1034 | b'is_private': False, |
|
1035 | 1035 | b'is_markdown': False, |
|
1036 | 1036 | } |
|
1037 | 1037 | burl = self.apiurl((b'bug', bugid)) |
|
1038 | 1038 | self._submit(burl, bugmod, method=b'PUT') |
|
1039 | 1039 | self.ui.debug(b'updated bug %s\n' % bugid) |
|
1040 | 1040 | else: |
|
1041 | 1041 | burl = self.apiurl((b'bug', bugid, b'comment')) |
|
1042 | 1042 | self._submit( |
|
1043 | 1043 | burl, |
|
1044 | 1044 | { |
|
1045 | 1045 | b'comment': text, |
|
1046 | 1046 | b'is_private': False, |
|
1047 | 1047 | b'is_markdown': False, |
|
1048 | 1048 | }, |
|
1049 | 1049 | ) |
|
1050 | 1050 | self.ui.debug(b'added comment to bug %s\n' % bugid) |
|
1051 | 1051 | |
|
1052 | 1052 | def notify(self, bugs, committer): |
|
1053 | 1053 | '''Force sending of Bugzilla notification emails. |
|
1054 | 1054 | |
|
1055 | 1055 | Only required if the access method does not trigger notification |
|
1056 | 1056 | emails automatically. |
|
1057 | 1057 | ''' |
|
1058 | 1058 | pass |
|
1059 | 1059 | |
|
1060 | 1060 | |
|
1061 | 1061 | class bugzilla(object): |
|
1062 | 1062 | # supported versions of bugzilla. different versions have |
|
1063 | 1063 | # different schemas. |
|
1064 | 1064 | _versions = { |
|
1065 | 1065 | b'2.16': bzmysql, |
|
1066 | 1066 | b'2.18': bzmysql_2_18, |
|
1067 | 1067 | b'3.0': bzmysql_3_0, |
|
1068 | 1068 | b'xmlrpc': bzxmlrpc, |
|
1069 | 1069 | b'xmlrpc+email': bzxmlrpcemail, |
|
1070 | 1070 | b'restapi': bzrestapi, |
|
1071 | 1071 | } |
|
1072 | 1072 | |
|
1073 | 1073 | def __init__(self, ui, repo): |
|
1074 | 1074 | self.ui = ui |
|
1075 | 1075 | self.repo = repo |
|
1076 | 1076 | |
|
1077 | 1077 | bzversion = self.ui.config(b'bugzilla', b'version') |
|
1078 | 1078 | try: |
|
1079 | 1079 | bzclass = bugzilla._versions[bzversion] |
|
1080 | 1080 | except KeyError: |
|
1081 | 1081 | raise error.Abort( |
|
1082 | 1082 | _(b'bugzilla version %s not supported') % bzversion |
|
1083 | 1083 | ) |
|
1084 | 1084 | self.bzdriver = bzclass(self.ui) |
|
1085 | 1085 | |
|
1086 | 1086 | self.bug_re = re.compile( |
|
1087 | 1087 | self.ui.config(b'bugzilla', b'regexp'), re.IGNORECASE |
|
1088 | 1088 | ) |
|
1089 | 1089 | self.fix_re = re.compile( |
|
1090 | 1090 | self.ui.config(b'bugzilla', b'fixregexp'), re.IGNORECASE |
|
1091 | 1091 | ) |
|
1092 | 1092 | self.split_re = re.compile(br'\D+') |
|
1093 | 1093 | |
|
1094 | 1094 | def find_bugs(self, ctx): |
|
1095 | 1095 | '''return bugs dictionary created from commit comment. |
|
1096 | 1096 | |
|
1097 | 1097 | Extract bug info from changeset comments. Filter out any that are |
|
1098 | 1098 | not known to Bugzilla, and any that already have a reference to |
|
1099 | 1099 | the given changeset in their comments. |
|
1100 | 1100 | ''' |
|
1101 | 1101 | start = 0 |
|
1102 | 1102 | hours = 0.0 |
|
1103 | 1103 | bugs = {} |
|
1104 | 1104 | bugmatch = self.bug_re.search(ctx.description(), start) |
|
1105 | 1105 | fixmatch = self.fix_re.search(ctx.description(), start) |
|
1106 | 1106 | while True: |
|
1107 | 1107 | bugattribs = {} |
|
1108 | 1108 | if not bugmatch and not fixmatch: |
|
1109 | 1109 | break |
|
1110 | 1110 | if not bugmatch: |
|
1111 | 1111 | m = fixmatch |
|
1112 | 1112 | elif not fixmatch: |
|
1113 | 1113 | m = bugmatch |
|
1114 | 1114 | else: |
|
1115 | 1115 | if bugmatch.start() < fixmatch.start(): |
|
1116 | 1116 | m = bugmatch |
|
1117 | 1117 | else: |
|
1118 | 1118 | m = fixmatch |
|
1119 | 1119 | start = m.end() |
|
1120 | 1120 | if m is bugmatch: |
|
1121 | 1121 | bugmatch = self.bug_re.search(ctx.description(), start) |
|
1122 | 1122 | if b'fix' in bugattribs: |
|
1123 | 1123 | del bugattribs[b'fix'] |
|
1124 | 1124 | else: |
|
1125 | 1125 | fixmatch = self.fix_re.search(ctx.description(), start) |
|
1126 | 1126 | bugattribs[b'fix'] = None |
|
1127 | 1127 | |
|
1128 | 1128 | try: |
|
1129 | 1129 | ids = m.group(b'ids') |
|
1130 | 1130 | except IndexError: |
|
1131 | 1131 | ids = m.group(1) |
|
1132 | 1132 | try: |
|
1133 | 1133 | hours = float(m.group(b'hours')) |
|
1134 | 1134 | bugattribs[b'hours'] = hours |
|
1135 | 1135 | except IndexError: |
|
1136 | 1136 | pass |
|
1137 | 1137 | except TypeError: |
|
1138 | 1138 | pass |
|
1139 | 1139 | except ValueError: |
|
1140 | 1140 | self.ui.status(_(b"%s: invalid hours\n") % m.group(b'hours')) |
|
1141 | 1141 | |
|
1142 | 1142 | for id in self.split_re.split(ids): |
|
1143 | 1143 | if not id: |
|
1144 | 1144 | continue |
|
1145 | 1145 | bugs[int(id)] = bugattribs |
|
1146 | 1146 | if bugs: |
|
1147 | 1147 | self.bzdriver.filter_real_bug_ids(bugs) |
|
1148 | 1148 | if bugs: |
|
1149 | 1149 | self.bzdriver.filter_cset_known_bug_ids(ctx.node(), bugs) |
|
1150 | 1150 | return bugs |
|
1151 | 1151 | |
|
1152 | 1152 | def update(self, bugid, newstate, ctx): |
|
1153 | 1153 | '''update bugzilla bug with reference to changeset.''' |
|
1154 | 1154 | |
|
1155 | 1155 | def webroot(root): |
|
1156 | 1156 | '''strip leading prefix of repo root and turn into |
|
1157 | 1157 | url-safe path.''' |
|
1158 | 1158 | count = int(self.ui.config(b'bugzilla', b'strip')) |
|
1159 | 1159 | root = util.pconvert(root) |
|
1160 | 1160 | while count > 0: |
|
1161 | 1161 | c = root.find(b'/') |
|
1162 | 1162 | if c == -1: |
|
1163 | 1163 | break |
|
1164 | 1164 | root = root[c + 1 :] |
|
1165 | 1165 | count -= 1 |
|
1166 | 1166 | return root |
|
1167 | 1167 | |
|
1168 | 1168 | mapfile = None |
|
1169 | 1169 | tmpl = self.ui.config(b'bugzilla', b'template') |
|
1170 | 1170 | if not tmpl: |
|
1171 | 1171 | mapfile = self.ui.config(b'bugzilla', b'style') |
|
1172 | 1172 | if not mapfile and not tmpl: |
|
1173 | 1173 | tmpl = _( |
|
1174 | 1174 | b'changeset {node|short} in repo {root} refers ' |
|
1175 | 1175 | b'to bug {bug}.\ndetails:\n\t{desc|tabindent}' |
|
1176 | 1176 | ) |
|
1177 | 1177 | spec = logcmdutil.templatespec(tmpl, mapfile) |
|
1178 | 1178 | t = logcmdutil.changesettemplater(self.ui, self.repo, spec) |
|
1179 | 1179 | self.ui.pushbuffer() |
|
1180 | 1180 | t.show( |
|
1181 | 1181 | ctx, |
|
1182 | 1182 | changes=ctx.changeset(), |
|
1183 | 1183 | bug=pycompat.bytestr(bugid), |
|
1184 | 1184 | hgweb=self.ui.config(b'web', b'baseurl'), |
|
1185 | 1185 | root=self.repo.root, |
|
1186 | 1186 | webroot=webroot(self.repo.root), |
|
1187 | 1187 | ) |
|
1188 | 1188 | data = self.ui.popbuffer() |
|
1189 | 1189 | self.bzdriver.updatebug( |
|
1190 | 1190 | bugid, newstate, data, stringutil.email(ctx.user()) |
|
1191 | 1191 | ) |
|
1192 | 1192 | |
|
1193 | 1193 | def notify(self, bugs, committer): |
|
1194 | 1194 | '''ensure Bugzilla users are notified of bug change.''' |
|
1195 | 1195 | self.bzdriver.notify(bugs, committer) |
|
1196 | 1196 | |
|
1197 | 1197 | |
|
1198 | 1198 | def hook(ui, repo, hooktype, node=None, **kwargs): |
|
1199 | 1199 | '''add comment to bugzilla for each changeset that refers to a |
|
1200 | 1200 | bugzilla bug id. only add a comment once per bug, so same change |
|
1201 | 1201 | seen multiple times does not fill bug with duplicate data.''' |
|
1202 | 1202 | if node is None: |
|
1203 | 1203 | raise error.Abort( |
|
1204 | 1204 | _(b'hook type %s does not pass a changeset id') % hooktype |
|
1205 | 1205 | ) |
|
1206 | 1206 | try: |
|
1207 | 1207 | bz = bugzilla(ui, repo) |
|
1208 | 1208 | ctx = repo[node] |
|
1209 | 1209 | bugs = bz.find_bugs(ctx) |
|
1210 | 1210 | if bugs: |
|
1211 | 1211 | for bug in bugs: |
|
1212 | 1212 | bz.update(bug, bugs[bug], ctx) |
|
1213 | 1213 | bz.notify(bugs, stringutil.email(ctx.user())) |
|
1214 | 1214 | except Exception as e: |
|
1215 | 1215 | raise error.Abort(_(b'Bugzilla error: %s') % e) |
@@ -1,883 +1,882 b'' | |||
|
1 | 1 | # fix - rewrite file content in changesets and working copy |
|
2 | 2 | # |
|
3 | 3 | # Copyright 2018 Google LLC. |
|
4 | 4 | # |
|
5 | 5 | # This software may be used and distributed according to the terms of the |
|
6 | 6 | # GNU General Public License version 2 or any later version. |
|
7 | 7 | """rewrite file content in changesets or working copy (EXPERIMENTAL) |
|
8 | 8 | |
|
9 | 9 | Provides a command that runs configured tools on the contents of modified files, |
|
10 | 10 | writing back any fixes to the working copy or replacing changesets. |
|
11 | 11 | |
|
12 | 12 | Here is an example configuration that causes :hg:`fix` to apply automatic |
|
13 | 13 | formatting fixes to modified lines in C++ code:: |
|
14 | 14 | |
|
15 | 15 | [fix] |
|
16 | 16 | clang-format:command=clang-format --assume-filename={rootpath} |
|
17 | 17 | clang-format:linerange=--lines={first}:{last} |
|
18 | 18 | clang-format:pattern=set:**.cpp or **.hpp |
|
19 | 19 | |
|
20 | 20 | The :command suboption forms the first part of the shell command that will be |
|
21 | 21 | used to fix a file. The content of the file is passed on standard input, and the |
|
22 | 22 | fixed file content is expected on standard output. Any output on standard error |
|
23 | 23 | will be displayed as a warning. If the exit status is not zero, the file will |
|
24 | 24 | not be affected. A placeholder warning is displayed if there is a non-zero exit |
|
25 | 25 | status but no standard error output. Some values may be substituted into the |
|
26 | 26 | command:: |
|
27 | 27 | |
|
28 | 28 | {rootpath} The path of the file being fixed, relative to the repo root |
|
29 | 29 | {basename} The name of the file being fixed, without the directory path |
|
30 | 30 | |
|
31 | 31 | If the :linerange suboption is set, the tool will only be run if there are |
|
32 | 32 | changed lines in a file. The value of this suboption is appended to the shell |
|
33 | 33 | command once for every range of changed lines in the file. Some values may be |
|
34 | 34 | substituted into the command:: |
|
35 | 35 | |
|
36 | 36 | {first} The 1-based line number of the first line in the modified range |
|
37 | 37 | {last} The 1-based line number of the last line in the modified range |
|
38 | 38 | |
|
39 | 39 | Deleted sections of a file will be ignored by :linerange, because there is no |
|
40 | 40 | corresponding line range in the version being fixed. |
|
41 | 41 | |
|
42 | 42 | By default, tools that set :linerange will only be executed if there is at least |
|
43 | 43 | one changed line range. This is meant to prevent accidents like running a code |
|
44 | 44 | formatter in such a way that it unexpectedly reformats the whole file. If such a |
|
45 | 45 | tool needs to operate on unchanged files, it should set the :skipclean suboption |
|
46 | 46 | to false. |
|
47 | 47 | |
|
48 | 48 | The :pattern suboption determines which files will be passed through each |
|
49 | 49 | configured tool. See :hg:`help patterns` for possible values. However, all |
|
50 | 50 | patterns are relative to the repo root, even if that text says they are relative |
|
51 | 51 | to the current working directory. If there are file arguments to :hg:`fix`, the |
|
52 | 52 | intersection of these patterns is used. |
|
53 | 53 | |
|
54 | 54 | There is also a configurable limit for the maximum size of file that will be |
|
55 | 55 | processed by :hg:`fix`:: |
|
56 | 56 | |
|
57 | 57 | [fix] |
|
58 | 58 | maxfilesize = 2MB |
|
59 | 59 | |
|
60 | 60 | Normally, execution of configured tools will continue after a failure (indicated |
|
61 | 61 | by a non-zero exit status). It can also be configured to abort after the first |
|
62 | 62 | such failure, so that no files will be affected if any tool fails. This abort |
|
63 | 63 | will also cause :hg:`fix` to exit with a non-zero status:: |
|
64 | 64 | |
|
65 | 65 | [fix] |
|
66 | 66 | failure = abort |
|
67 | 67 | |
|
68 | 68 | When multiple tools are configured to affect a file, they execute in an order |
|
69 | 69 | defined by the :priority suboption. The priority suboption has a default value |
|
70 | 70 | of zero for each tool. Tools are executed in order of descending priority. The |
|
71 | 71 | execution order of tools with equal priority is unspecified. For example, you |
|
72 | 72 | could use the 'sort' and 'head' utilities to keep only the 10 smallest numbers |
|
73 | 73 | in a text file by ensuring that 'sort' runs before 'head':: |
|
74 | 74 | |
|
75 | 75 | [fix] |
|
76 | 76 | sort:command = sort -n |
|
77 | 77 | head:command = head -n 10 |
|
78 | 78 | sort:pattern = numbers.txt |
|
79 | 79 | head:pattern = numbers.txt |
|
80 | 80 | sort:priority = 2 |
|
81 | 81 | head:priority = 1 |
|
82 | 82 | |
|
83 | 83 | To account for changes made by each tool, the line numbers used for incremental |
|
84 | 84 | formatting are recomputed before executing the next tool. So, each tool may see |
|
85 | 85 | different values for the arguments added by the :linerange suboption. |
|
86 | 86 | |
|
87 | 87 | Each fixer tool is allowed to return some metadata in addition to the fixed file |
|
88 | 88 | content. The metadata must be placed before the file content on stdout, |
|
89 | 89 | separated from the file content by a zero byte. The metadata is parsed as a JSON |
|
90 | 90 | value (so, it should be UTF-8 encoded and contain no zero bytes). A fixer tool |
|
91 | 91 | is expected to produce this metadata encoding if and only if the :metadata |
|
92 | 92 | suboption is true:: |
|
93 | 93 | |
|
94 | 94 | [fix] |
|
95 | 95 | tool:command = tool --prepend-json-metadata |
|
96 | 96 | tool:metadata = true |
|
97 | 97 | |
|
98 | 98 | The metadata values are passed to hooks, which can be used to print summaries or |
|
99 | 99 | perform other post-fixing work. The supported hooks are:: |
|
100 | 100 | |
|
101 | 101 | "postfixfile" |
|
102 | 102 | Run once for each file in each revision where any fixer tools made changes |
|
103 | 103 | to the file content. Provides "$HG_REV" and "$HG_PATH" to identify the file, |
|
104 | 104 | and "$HG_METADATA" with a map of fixer names to metadata values from fixer |
|
105 | 105 | tools that affected the file. Fixer tools that didn't affect the file have a |
|
106 | 106 | valueof None. Only fixer tools that executed are present in the metadata. |
|
107 | 107 | |
|
108 | 108 | "postfix" |
|
109 | 109 | Run once after all files and revisions have been handled. Provides |
|
110 | 110 | "$HG_REPLACEMENTS" with information about what revisions were created and |
|
111 | 111 | made obsolete. Provides a boolean "$HG_WDIRWRITTEN" to indicate whether any |
|
112 | 112 | files in the working copy were updated. Provides a list "$HG_METADATA" |
|
113 | 113 | mapping fixer tool names to lists of metadata values returned from |
|
114 | 114 | executions that modified a file. This aggregates the same metadata |
|
115 | 115 | previously passed to the "postfixfile" hook. |
|
116 | 116 | |
|
117 | 117 | Fixer tools are run the in repository's root directory. This allows them to read |
|
118 | 118 | configuration files from the working copy, or even write to the working copy. |
|
119 | 119 | The working copy is not updated to match the revision being fixed. In fact, |
|
120 | 120 | several revisions may be fixed in parallel. Writes to the working copy are not |
|
121 | 121 | amended into the revision being fixed; fixer tools should always write fixed |
|
122 | 122 | file content back to stdout as documented above. |
|
123 | 123 | """ |
|
124 | 124 | |
|
125 | 125 | from __future__ import absolute_import |
|
126 | 126 | |
|
127 | 127 | import collections |
|
128 | 128 | import itertools |
|
129 | import json | |
|
130 | 129 | import os |
|
131 | 130 | import re |
|
132 | 131 | import subprocess |
|
133 | 132 | |
|
134 | 133 | from mercurial.i18n import _ |
|
135 | 134 | from mercurial.node import nullrev |
|
136 | 135 | from mercurial.node import wdirrev |
|
137 | 136 | |
|
138 | 137 | from mercurial.utils import procutil |
|
139 | 138 | |
|
140 | 139 | from mercurial import ( |
|
141 | 140 | cmdutil, |
|
142 | 141 | context, |
|
143 | 142 | copies, |
|
144 | 143 | error, |
|
145 | 144 | match as matchmod, |
|
146 | 145 | mdiff, |
|
147 | 146 | merge, |
|
148 | 147 | obsolete, |
|
149 | 148 | pycompat, |
|
150 | 149 | registrar, |
|
151 | 150 | scmutil, |
|
152 | 151 | util, |
|
153 | 152 | worker, |
|
154 | 153 | ) |
|
155 | 154 | |
|
156 | 155 | # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for |
|
157 | 156 | # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should |
|
158 | 157 | # be specifying the version(s) of Mercurial they are tested with, or |
|
159 | 158 | # leave the attribute unspecified. |
|
160 | 159 | testedwith = b'ships-with-hg-core' |
|
161 | 160 | |
|
162 | 161 | cmdtable = {} |
|
163 | 162 | command = registrar.command(cmdtable) |
|
164 | 163 | |
|
165 | 164 | configtable = {} |
|
166 | 165 | configitem = registrar.configitem(configtable) |
|
167 | 166 | |
|
168 | 167 | # Register the suboptions allowed for each configured fixer, and default values. |
|
169 | 168 | FIXER_ATTRS = { |
|
170 | 169 | b'command': None, |
|
171 | 170 | b'linerange': None, |
|
172 | 171 | b'pattern': None, |
|
173 | 172 | b'priority': 0, |
|
174 | 173 | b'metadata': False, |
|
175 | 174 | b'skipclean': True, |
|
176 | 175 | b'enabled': True, |
|
177 | 176 | } |
|
178 | 177 | |
|
179 | 178 | for key, default in FIXER_ATTRS.items(): |
|
180 | 179 | configitem(b'fix', b'.*:%s$' % key, default=default, generic=True) |
|
181 | 180 | |
|
182 | 181 | # A good default size allows most source code files to be fixed, but avoids |
|
183 | 182 | # letting fixer tools choke on huge inputs, which could be surprising to the |
|
184 | 183 | # user. |
|
185 | 184 | configitem(b'fix', b'maxfilesize', default=b'2MB') |
|
186 | 185 | |
|
187 | 186 | # Allow fix commands to exit non-zero if an executed fixer tool exits non-zero. |
|
188 | 187 | # This helps users do shell scripts that stop when a fixer tool signals a |
|
189 | 188 | # problem. |
|
190 | 189 | configitem(b'fix', b'failure', default=b'continue') |
|
191 | 190 | |
|
192 | 191 | |
|
193 | 192 | def checktoolfailureaction(ui, message, hint=None): |
|
194 | 193 | """Abort with 'message' if fix.failure=abort""" |
|
195 | 194 | action = ui.config(b'fix', b'failure') |
|
196 | 195 | if action not in (b'continue', b'abort'): |
|
197 | 196 | raise error.Abort( |
|
198 | 197 | _(b'unknown fix.failure action: %s') % (action,), |
|
199 | 198 | hint=_(b'use "continue" or "abort"'), |
|
200 | 199 | ) |
|
201 | 200 | if action == b'abort': |
|
202 | 201 | raise error.Abort(message, hint=hint) |
|
203 | 202 | |
|
204 | 203 | |
|
205 | 204 | allopt = (b'', b'all', False, _(b'fix all non-public non-obsolete revisions')) |
|
206 | 205 | baseopt = ( |
|
207 | 206 | b'', |
|
208 | 207 | b'base', |
|
209 | 208 | [], |
|
210 | 209 | _( |
|
211 | 210 | b'revisions to diff against (overrides automatic ' |
|
212 | 211 | b'selection, and applies to every revision being ' |
|
213 | 212 | b'fixed)' |
|
214 | 213 | ), |
|
215 | 214 | _(b'REV'), |
|
216 | 215 | ) |
|
217 | 216 | revopt = (b'r', b'rev', [], _(b'revisions to fix'), _(b'REV')) |
|
218 | 217 | wdiropt = (b'w', b'working-dir', False, _(b'fix the working directory')) |
|
219 | 218 | wholeopt = (b'', b'whole', False, _(b'always fix every line of a file')) |
|
220 | 219 | usage = _(b'[OPTION]... [FILE]...') |
|
221 | 220 | |
|
222 | 221 | |
|
223 | 222 | @command( |
|
224 | 223 | b'fix', |
|
225 | 224 | [allopt, baseopt, revopt, wdiropt, wholeopt], |
|
226 | 225 | usage, |
|
227 | 226 | helpcategory=command.CATEGORY_FILE_CONTENTS, |
|
228 | 227 | ) |
|
229 | 228 | def fix(ui, repo, *pats, **opts): |
|
230 | 229 | """rewrite file content in changesets or working directory |
|
231 | 230 | |
|
232 | 231 | Runs any configured tools to fix the content of files. Only affects files |
|
233 | 232 | with changes, unless file arguments are provided. Only affects changed lines |
|
234 | 233 | of files, unless the --whole flag is used. Some tools may always affect the |
|
235 | 234 | whole file regardless of --whole. |
|
236 | 235 | |
|
237 | 236 | If revisions are specified with --rev, those revisions will be checked, and |
|
238 | 237 | they may be replaced with new revisions that have fixed file content. It is |
|
239 | 238 | desirable to specify all descendants of each specified revision, so that the |
|
240 | 239 | fixes propagate to the descendants. If all descendants are fixed at the same |
|
241 | 240 | time, no merging, rebasing, or evolution will be required. |
|
242 | 241 | |
|
243 | 242 | If --working-dir is used, files with uncommitted changes in the working copy |
|
244 | 243 | will be fixed. If the checked-out revision is also fixed, the working |
|
245 | 244 | directory will update to the replacement revision. |
|
246 | 245 | |
|
247 | 246 | When determining what lines of each file to fix at each revision, the whole |
|
248 | 247 | set of revisions being fixed is considered, so that fixes to earlier |
|
249 | 248 | revisions are not forgotten in later ones. The --base flag can be used to |
|
250 | 249 | override this default behavior, though it is not usually desirable to do so. |
|
251 | 250 | """ |
|
252 | 251 | opts = pycompat.byteskwargs(opts) |
|
253 | 252 | if opts[b'all']: |
|
254 | 253 | if opts[b'rev']: |
|
255 | 254 | raise error.Abort(_(b'cannot specify both "--rev" and "--all"')) |
|
256 | 255 | opts[b'rev'] = [b'not public() and not obsolete()'] |
|
257 | 256 | opts[b'working_dir'] = True |
|
258 | 257 | with repo.wlock(), repo.lock(), repo.transaction(b'fix'): |
|
259 | 258 | revstofix = getrevstofix(ui, repo, opts) |
|
260 | 259 | basectxs = getbasectxs(repo, opts, revstofix) |
|
261 | 260 | workqueue, numitems = getworkqueue( |
|
262 | 261 | ui, repo, pats, opts, revstofix, basectxs |
|
263 | 262 | ) |
|
264 | 263 | fixers = getfixers(ui) |
|
265 | 264 | |
|
266 | 265 | # There are no data dependencies between the workers fixing each file |
|
267 | 266 | # revision, so we can use all available parallelism. |
|
268 | 267 | def getfixes(items): |
|
269 | 268 | for rev, path in items: |
|
270 | 269 | ctx = repo[rev] |
|
271 | 270 | olddata = ctx[path].data() |
|
272 | 271 | metadata, newdata = fixfile( |
|
273 | 272 | ui, repo, opts, fixers, ctx, path, basectxs[rev] |
|
274 | 273 | ) |
|
275 | 274 | # Don't waste memory/time passing unchanged content back, but |
|
276 | 275 | # produce one result per item either way. |
|
277 | 276 | yield ( |
|
278 | 277 | rev, |
|
279 | 278 | path, |
|
280 | 279 | metadata, |
|
281 | 280 | newdata if newdata != olddata else None, |
|
282 | 281 | ) |
|
283 | 282 | |
|
284 | 283 | results = worker.worker( |
|
285 | 284 | ui, 1.0, getfixes, tuple(), workqueue, threadsafe=False |
|
286 | 285 | ) |
|
287 | 286 | |
|
288 | 287 | # We have to hold on to the data for each successor revision in memory |
|
289 | 288 | # until all its parents are committed. We ensure this by committing and |
|
290 | 289 | # freeing memory for the revisions in some topological order. This |
|
291 | 290 | # leaves a little bit of memory efficiency on the table, but also makes |
|
292 | 291 | # the tests deterministic. It might also be considered a feature since |
|
293 | 292 | # it makes the results more easily reproducible. |
|
294 | 293 | filedata = collections.defaultdict(dict) |
|
295 | 294 | aggregatemetadata = collections.defaultdict(list) |
|
296 | 295 | replacements = {} |
|
297 | 296 | wdirwritten = False |
|
298 | 297 | commitorder = sorted(revstofix, reverse=True) |
|
299 | 298 | with ui.makeprogress( |
|
300 | 299 | topic=_(b'fixing'), unit=_(b'files'), total=sum(numitems.values()) |
|
301 | 300 | ) as progress: |
|
302 | 301 | for rev, path, filerevmetadata, newdata in results: |
|
303 | 302 | progress.increment(item=path) |
|
304 | 303 | for fixername, fixermetadata in filerevmetadata.items(): |
|
305 | 304 | aggregatemetadata[fixername].append(fixermetadata) |
|
306 | 305 | if newdata is not None: |
|
307 | 306 | filedata[rev][path] = newdata |
|
308 | 307 | hookargs = { |
|
309 | 308 | b'rev': rev, |
|
310 | 309 | b'path': path, |
|
311 | 310 | b'metadata': filerevmetadata, |
|
312 | 311 | } |
|
313 | 312 | repo.hook( |
|
314 | 313 | b'postfixfile', |
|
315 | 314 | throw=False, |
|
316 | 315 | **pycompat.strkwargs(hookargs) |
|
317 | 316 | ) |
|
318 | 317 | numitems[rev] -= 1 |
|
319 | 318 | # Apply the fixes for this and any other revisions that are |
|
320 | 319 | # ready and sitting at the front of the queue. Using a loop here |
|
321 | 320 | # prevents the queue from being blocked by the first revision to |
|
322 | 321 | # be ready out of order. |
|
323 | 322 | while commitorder and not numitems[commitorder[-1]]: |
|
324 | 323 | rev = commitorder.pop() |
|
325 | 324 | ctx = repo[rev] |
|
326 | 325 | if rev == wdirrev: |
|
327 | 326 | writeworkingdir(repo, ctx, filedata[rev], replacements) |
|
328 | 327 | wdirwritten = bool(filedata[rev]) |
|
329 | 328 | else: |
|
330 | 329 | replacerev(ui, repo, ctx, filedata[rev], replacements) |
|
331 | 330 | del filedata[rev] |
|
332 | 331 | |
|
333 | 332 | cleanup(repo, replacements, wdirwritten) |
|
334 | 333 | hookargs = { |
|
335 | 334 | b'replacements': replacements, |
|
336 | 335 | b'wdirwritten': wdirwritten, |
|
337 | 336 | b'metadata': aggregatemetadata, |
|
338 | 337 | } |
|
339 | 338 | repo.hook(b'postfix', throw=True, **pycompat.strkwargs(hookargs)) |
|
340 | 339 | |
|
341 | 340 | |
|
342 | 341 | def cleanup(repo, replacements, wdirwritten): |
|
343 | 342 | """Calls scmutil.cleanupnodes() with the given replacements. |
|
344 | 343 | |
|
345 | 344 | "replacements" is a dict from nodeid to nodeid, with one key and one value |
|
346 | 345 | for every revision that was affected by fixing. This is slightly different |
|
347 | 346 | from cleanupnodes(). |
|
348 | 347 | |
|
349 | 348 | "wdirwritten" is a bool which tells whether the working copy was affected by |
|
350 | 349 | fixing, since it has no entry in "replacements". |
|
351 | 350 | |
|
352 | 351 | Useful as a hook point for extending "hg fix" with output summarizing the |
|
353 | 352 | effects of the command, though we choose not to output anything here. |
|
354 | 353 | """ |
|
355 | 354 | replacements = { |
|
356 | 355 | prec: [succ] for prec, succ in pycompat.iteritems(replacements) |
|
357 | 356 | } |
|
358 | 357 | scmutil.cleanupnodes(repo, replacements, b'fix', fixphase=True) |
|
359 | 358 | |
|
360 | 359 | |
|
361 | 360 | def getworkqueue(ui, repo, pats, opts, revstofix, basectxs): |
|
362 | 361 | """"Constructs the list of files to be fixed at specific revisions |
|
363 | 362 | |
|
364 | 363 | It is up to the caller how to consume the work items, and the only |
|
365 | 364 | dependence between them is that replacement revisions must be committed in |
|
366 | 365 | topological order. Each work item represents a file in the working copy or |
|
367 | 366 | in some revision that should be fixed and written back to the working copy |
|
368 | 367 | or into a replacement revision. |
|
369 | 368 | |
|
370 | 369 | Work items for the same revision are grouped together, so that a worker |
|
371 | 370 | pool starting with the first N items in parallel is likely to finish the |
|
372 | 371 | first revision's work before other revisions. This can allow us to write |
|
373 | 372 | the result to disk and reduce memory footprint. At time of writing, the |
|
374 | 373 | partition strategy in worker.py seems favorable to this. We also sort the |
|
375 | 374 | items by ascending revision number to match the order in which we commit |
|
376 | 375 | the fixes later. |
|
377 | 376 | """ |
|
378 | 377 | workqueue = [] |
|
379 | 378 | numitems = collections.defaultdict(int) |
|
380 | 379 | maxfilesize = ui.configbytes(b'fix', b'maxfilesize') |
|
381 | 380 | for rev in sorted(revstofix): |
|
382 | 381 | fixctx = repo[rev] |
|
383 | 382 | match = scmutil.match(fixctx, pats, opts) |
|
384 | 383 | for path in sorted( |
|
385 | 384 | pathstofix(ui, repo, pats, opts, match, basectxs[rev], fixctx) |
|
386 | 385 | ): |
|
387 | 386 | fctx = fixctx[path] |
|
388 | 387 | if fctx.islink(): |
|
389 | 388 | continue |
|
390 | 389 | if fctx.size() > maxfilesize: |
|
391 | 390 | ui.warn( |
|
392 | 391 | _(b'ignoring file larger than %s: %s\n') |
|
393 | 392 | % (util.bytecount(maxfilesize), path) |
|
394 | 393 | ) |
|
395 | 394 | continue |
|
396 | 395 | workqueue.append((rev, path)) |
|
397 | 396 | numitems[rev] += 1 |
|
398 | 397 | return workqueue, numitems |
|
399 | 398 | |
|
400 | 399 | |
|
401 | 400 | def getrevstofix(ui, repo, opts): |
|
402 | 401 | """Returns the set of revision numbers that should be fixed""" |
|
403 | 402 | revs = set(scmutil.revrange(repo, opts[b'rev'])) |
|
404 | 403 | for rev in revs: |
|
405 | 404 | checkfixablectx(ui, repo, repo[rev]) |
|
406 | 405 | if revs: |
|
407 | 406 | cmdutil.checkunfinished(repo) |
|
408 | 407 | checknodescendants(repo, revs) |
|
409 | 408 | if opts.get(b'working_dir'): |
|
410 | 409 | revs.add(wdirrev) |
|
411 | 410 | if list(merge.mergestate.read(repo).unresolved()): |
|
412 | 411 | raise error.Abort(b'unresolved conflicts', hint=b"use 'hg resolve'") |
|
413 | 412 | if not revs: |
|
414 | 413 | raise error.Abort( |
|
415 | 414 | b'no changesets specified', hint=b'use --rev or --working-dir' |
|
416 | 415 | ) |
|
417 | 416 | return revs |
|
418 | 417 | |
|
419 | 418 | |
|
420 | 419 | def checknodescendants(repo, revs): |
|
421 | 420 | if not obsolete.isenabled(repo, obsolete.allowunstableopt) and repo.revs( |
|
422 | 421 | b'(%ld::) - (%ld)', revs, revs |
|
423 | 422 | ): |
|
424 | 423 | raise error.Abort( |
|
425 | 424 | _(b'can only fix a changeset together with all its descendants') |
|
426 | 425 | ) |
|
427 | 426 | |
|
428 | 427 | |
|
429 | 428 | def checkfixablectx(ui, repo, ctx): |
|
430 | 429 | """Aborts if the revision shouldn't be replaced with a fixed one.""" |
|
431 | 430 | if not ctx.mutable(): |
|
432 | 431 | raise error.Abort( |
|
433 | 432 | b'can\'t fix immutable changeset %s' |
|
434 | 433 | % (scmutil.formatchangeid(ctx),) |
|
435 | 434 | ) |
|
436 | 435 | if ctx.obsolete(): |
|
437 | 436 | # It would be better to actually check if the revision has a successor. |
|
438 | 437 | allowdivergence = ui.configbool( |
|
439 | 438 | b'experimental', b'evolution.allowdivergence' |
|
440 | 439 | ) |
|
441 | 440 | if not allowdivergence: |
|
442 | 441 | raise error.Abort( |
|
443 | 442 | b'fixing obsolete revision could cause divergence' |
|
444 | 443 | ) |
|
445 | 444 | |
|
446 | 445 | |
|
447 | 446 | def pathstofix(ui, repo, pats, opts, match, basectxs, fixctx): |
|
448 | 447 | """Returns the set of files that should be fixed in a context |
|
449 | 448 | |
|
450 | 449 | The result depends on the base contexts; we include any file that has |
|
451 | 450 | changed relative to any of the base contexts. Base contexts should be |
|
452 | 451 | ancestors of the context being fixed. |
|
453 | 452 | """ |
|
454 | 453 | files = set() |
|
455 | 454 | for basectx in basectxs: |
|
456 | 455 | stat = basectx.status( |
|
457 | 456 | fixctx, match=match, listclean=bool(pats), listunknown=bool(pats) |
|
458 | 457 | ) |
|
459 | 458 | files.update( |
|
460 | 459 | set( |
|
461 | 460 | itertools.chain( |
|
462 | 461 | stat.added, stat.modified, stat.clean, stat.unknown |
|
463 | 462 | ) |
|
464 | 463 | ) |
|
465 | 464 | ) |
|
466 | 465 | return files |
|
467 | 466 | |
|
468 | 467 | |
|
469 | 468 | def lineranges(opts, path, basectxs, fixctx, content2): |
|
470 | 469 | """Returns the set of line ranges that should be fixed in a file |
|
471 | 470 | |
|
472 | 471 | Of the form [(10, 20), (30, 40)]. |
|
473 | 472 | |
|
474 | 473 | This depends on the given base contexts; we must consider lines that have |
|
475 | 474 | changed versus any of the base contexts, and whether the file has been |
|
476 | 475 | renamed versus any of them. |
|
477 | 476 | |
|
478 | 477 | Another way to understand this is that we exclude line ranges that are |
|
479 | 478 | common to the file in all base contexts. |
|
480 | 479 | """ |
|
481 | 480 | if opts.get(b'whole'): |
|
482 | 481 | # Return a range containing all lines. Rely on the diff implementation's |
|
483 | 482 | # idea of how many lines are in the file, instead of reimplementing it. |
|
484 | 483 | return difflineranges(b'', content2) |
|
485 | 484 | |
|
486 | 485 | rangeslist = [] |
|
487 | 486 | for basectx in basectxs: |
|
488 | 487 | basepath = copies.pathcopies(basectx, fixctx).get(path, path) |
|
489 | 488 | if basepath in basectx: |
|
490 | 489 | content1 = basectx[basepath].data() |
|
491 | 490 | else: |
|
492 | 491 | content1 = b'' |
|
493 | 492 | rangeslist.extend(difflineranges(content1, content2)) |
|
494 | 493 | return unionranges(rangeslist) |
|
495 | 494 | |
|
496 | 495 | |
|
497 | 496 | def unionranges(rangeslist): |
|
498 | 497 | """Return the union of some closed intervals |
|
499 | 498 | |
|
500 | 499 | >>> unionranges([]) |
|
501 | 500 | [] |
|
502 | 501 | >>> unionranges([(1, 100)]) |
|
503 | 502 | [(1, 100)] |
|
504 | 503 | >>> unionranges([(1, 100), (1, 100)]) |
|
505 | 504 | [(1, 100)] |
|
506 | 505 | >>> unionranges([(1, 100), (2, 100)]) |
|
507 | 506 | [(1, 100)] |
|
508 | 507 | >>> unionranges([(1, 99), (1, 100)]) |
|
509 | 508 | [(1, 100)] |
|
510 | 509 | >>> unionranges([(1, 100), (40, 60)]) |
|
511 | 510 | [(1, 100)] |
|
512 | 511 | >>> unionranges([(1, 49), (50, 100)]) |
|
513 | 512 | [(1, 100)] |
|
514 | 513 | >>> unionranges([(1, 48), (50, 100)]) |
|
515 | 514 | [(1, 48), (50, 100)] |
|
516 | 515 | >>> unionranges([(1, 2), (3, 4), (5, 6)]) |
|
517 | 516 | [(1, 6)] |
|
518 | 517 | """ |
|
519 | 518 | rangeslist = sorted(set(rangeslist)) |
|
520 | 519 | unioned = [] |
|
521 | 520 | if rangeslist: |
|
522 | 521 | unioned, rangeslist = [rangeslist[0]], rangeslist[1:] |
|
523 | 522 | for a, b in rangeslist: |
|
524 | 523 | c, d = unioned[-1] |
|
525 | 524 | if a > d + 1: |
|
526 | 525 | unioned.append((a, b)) |
|
527 | 526 | else: |
|
528 | 527 | unioned[-1] = (c, max(b, d)) |
|
529 | 528 | return unioned |
|
530 | 529 | |
|
531 | 530 | |
|
532 | 531 | def difflineranges(content1, content2): |
|
533 | 532 | """Return list of line number ranges in content2 that differ from content1. |
|
534 | 533 | |
|
535 | 534 | Line numbers are 1-based. The numbers are the first and last line contained |
|
536 | 535 | in the range. Single-line ranges have the same line number for the first and |
|
537 | 536 | last line. Excludes any empty ranges that result from lines that are only |
|
538 | 537 | present in content1. Relies on mdiff's idea of where the line endings are in |
|
539 | 538 | the string. |
|
540 | 539 | |
|
541 | 540 | >>> from mercurial import pycompat |
|
542 | 541 | >>> lines = lambda s: b'\\n'.join([c for c in pycompat.iterbytestr(s)]) |
|
543 | 542 | >>> difflineranges2 = lambda a, b: difflineranges(lines(a), lines(b)) |
|
544 | 543 | >>> difflineranges2(b'', b'') |
|
545 | 544 | [] |
|
546 | 545 | >>> difflineranges2(b'a', b'') |
|
547 | 546 | [] |
|
548 | 547 | >>> difflineranges2(b'', b'A') |
|
549 | 548 | [(1, 1)] |
|
550 | 549 | >>> difflineranges2(b'a', b'a') |
|
551 | 550 | [] |
|
552 | 551 | >>> difflineranges2(b'a', b'A') |
|
553 | 552 | [(1, 1)] |
|
554 | 553 | >>> difflineranges2(b'ab', b'') |
|
555 | 554 | [] |
|
556 | 555 | >>> difflineranges2(b'', b'AB') |
|
557 | 556 | [(1, 2)] |
|
558 | 557 | >>> difflineranges2(b'abc', b'ac') |
|
559 | 558 | [] |
|
560 | 559 | >>> difflineranges2(b'ab', b'aCb') |
|
561 | 560 | [(2, 2)] |
|
562 | 561 | >>> difflineranges2(b'abc', b'aBc') |
|
563 | 562 | [(2, 2)] |
|
564 | 563 | >>> difflineranges2(b'ab', b'AB') |
|
565 | 564 | [(1, 2)] |
|
566 | 565 | >>> difflineranges2(b'abcde', b'aBcDe') |
|
567 | 566 | [(2, 2), (4, 4)] |
|
568 | 567 | >>> difflineranges2(b'abcde', b'aBCDe') |
|
569 | 568 | [(2, 4)] |
|
570 | 569 | """ |
|
571 | 570 | ranges = [] |
|
572 | 571 | for lines, kind in mdiff.allblocks(content1, content2): |
|
573 | 572 | firstline, lastline = lines[2:4] |
|
574 | 573 | if kind == b'!' and firstline != lastline: |
|
575 | 574 | ranges.append((firstline + 1, lastline)) |
|
576 | 575 | return ranges |
|
577 | 576 | |
|
578 | 577 | |
|
579 | 578 | def getbasectxs(repo, opts, revstofix): |
|
580 | 579 | """Returns a map of the base contexts for each revision |
|
581 | 580 | |
|
582 | 581 | The base contexts determine which lines are considered modified when we |
|
583 | 582 | attempt to fix just the modified lines in a file. It also determines which |
|
584 | 583 | files we attempt to fix, so it is important to compute this even when |
|
585 | 584 | --whole is used. |
|
586 | 585 | """ |
|
587 | 586 | # The --base flag overrides the usual logic, and we give every revision |
|
588 | 587 | # exactly the set of baserevs that the user specified. |
|
589 | 588 | if opts.get(b'base'): |
|
590 | 589 | baserevs = set(scmutil.revrange(repo, opts.get(b'base'))) |
|
591 | 590 | if not baserevs: |
|
592 | 591 | baserevs = {nullrev} |
|
593 | 592 | basectxs = {repo[rev] for rev in baserevs} |
|
594 | 593 | return {rev: basectxs for rev in revstofix} |
|
595 | 594 | |
|
596 | 595 | # Proceed in topological order so that we can easily determine each |
|
597 | 596 | # revision's baserevs by looking at its parents and their baserevs. |
|
598 | 597 | basectxs = collections.defaultdict(set) |
|
599 | 598 | for rev in sorted(revstofix): |
|
600 | 599 | ctx = repo[rev] |
|
601 | 600 | for pctx in ctx.parents(): |
|
602 | 601 | if pctx.rev() in basectxs: |
|
603 | 602 | basectxs[rev].update(basectxs[pctx.rev()]) |
|
604 | 603 | else: |
|
605 | 604 | basectxs[rev].add(pctx) |
|
606 | 605 | return basectxs |
|
607 | 606 | |
|
608 | 607 | |
|
609 | 608 | def fixfile(ui, repo, opts, fixers, fixctx, path, basectxs): |
|
610 | 609 | """Run any configured fixers that should affect the file in this context |
|
611 | 610 | |
|
612 | 611 | Returns the file content that results from applying the fixers in some order |
|
613 | 612 | starting with the file's content in the fixctx. Fixers that support line |
|
614 | 613 | ranges will affect lines that have changed relative to any of the basectxs |
|
615 | 614 | (i.e. they will only avoid lines that are common to all basectxs). |
|
616 | 615 | |
|
617 | 616 | A fixer tool's stdout will become the file's new content if and only if it |
|
618 | 617 | exits with code zero. The fixer tool's working directory is the repository's |
|
619 | 618 | root. |
|
620 | 619 | """ |
|
621 | 620 | metadata = {} |
|
622 | 621 | newdata = fixctx[path].data() |
|
623 | 622 | for fixername, fixer in pycompat.iteritems(fixers): |
|
624 | 623 | if fixer.affects(opts, fixctx, path): |
|
625 | 624 | ranges = lineranges(opts, path, basectxs, fixctx, newdata) |
|
626 | 625 | command = fixer.command(ui, path, ranges) |
|
627 | 626 | if command is None: |
|
628 | 627 | continue |
|
629 | 628 | ui.debug(b'subprocess: %s\n' % (command,)) |
|
630 | 629 | proc = subprocess.Popen( |
|
631 | 630 | procutil.tonativestr(command), |
|
632 | 631 | shell=True, |
|
633 | 632 | cwd=procutil.tonativestr(repo.root), |
|
634 | 633 | stdin=subprocess.PIPE, |
|
635 | 634 | stdout=subprocess.PIPE, |
|
636 | 635 | stderr=subprocess.PIPE, |
|
637 | 636 | ) |
|
638 | 637 | stdout, stderr = proc.communicate(newdata) |
|
639 | 638 | if stderr: |
|
640 | 639 | showstderr(ui, fixctx.rev(), fixername, stderr) |
|
641 | 640 | newerdata = stdout |
|
642 | 641 | if fixer.shouldoutputmetadata(): |
|
643 | 642 | try: |
|
644 | 643 | metadatajson, newerdata = stdout.split(b'\0', 1) |
|
645 |
metadata[fixername] = |
|
|
644 | metadata[fixername] = pycompat.json_loads(metadatajson) | |
|
646 | 645 | except ValueError: |
|
647 | 646 | ui.warn( |
|
648 | 647 | _(b'ignored invalid output from fixer tool: %s\n') |
|
649 | 648 | % (fixername,) |
|
650 | 649 | ) |
|
651 | 650 | continue |
|
652 | 651 | else: |
|
653 | 652 | metadata[fixername] = None |
|
654 | 653 | if proc.returncode == 0: |
|
655 | 654 | newdata = newerdata |
|
656 | 655 | else: |
|
657 | 656 | if not stderr: |
|
658 | 657 | message = _(b'exited with status %d\n') % (proc.returncode,) |
|
659 | 658 | showstderr(ui, fixctx.rev(), fixername, message) |
|
660 | 659 | checktoolfailureaction( |
|
661 | 660 | ui, |
|
662 | 661 | _(b'no fixes will be applied'), |
|
663 | 662 | hint=_( |
|
664 | 663 | b'use --config fix.failure=continue to apply any ' |
|
665 | 664 | b'successful fixes anyway' |
|
666 | 665 | ), |
|
667 | 666 | ) |
|
668 | 667 | return metadata, newdata |
|
669 | 668 | |
|
670 | 669 | |
|
671 | 670 | def showstderr(ui, rev, fixername, stderr): |
|
672 | 671 | """Writes the lines of the stderr string as warnings on the ui |
|
673 | 672 | |
|
674 | 673 | Uses the revision number and fixername to give more context to each line of |
|
675 | 674 | the error message. Doesn't include file names, since those take up a lot of |
|
676 | 675 | space and would tend to be included in the error message if they were |
|
677 | 676 | relevant. |
|
678 | 677 | """ |
|
679 | 678 | for line in re.split(b'[\r\n]+', stderr): |
|
680 | 679 | if line: |
|
681 | 680 | ui.warn(b'[') |
|
682 | 681 | if rev is None: |
|
683 | 682 | ui.warn(_(b'wdir'), label=b'evolve.rev') |
|
684 | 683 | else: |
|
685 | 684 | ui.warn((str(rev)), label=b'evolve.rev') |
|
686 | 685 | ui.warn(b'] %s: %s\n' % (fixername, line)) |
|
687 | 686 | |
|
688 | 687 | |
|
689 | 688 | def writeworkingdir(repo, ctx, filedata, replacements): |
|
690 | 689 | """Write new content to the working copy and check out the new p1 if any |
|
691 | 690 | |
|
692 | 691 | We check out a new revision if and only if we fixed something in both the |
|
693 | 692 | working directory and its parent revision. This avoids the need for a full |
|
694 | 693 | update/merge, and means that the working directory simply isn't affected |
|
695 | 694 | unless the --working-dir flag is given. |
|
696 | 695 | |
|
697 | 696 | Directly updates the dirstate for the affected files. |
|
698 | 697 | """ |
|
699 | 698 | for path, data in pycompat.iteritems(filedata): |
|
700 | 699 | fctx = ctx[path] |
|
701 | 700 | fctx.write(data, fctx.flags()) |
|
702 | 701 | if repo.dirstate[path] == b'n': |
|
703 | 702 | repo.dirstate.normallookup(path) |
|
704 | 703 | |
|
705 | 704 | oldparentnodes = repo.dirstate.parents() |
|
706 | 705 | newparentnodes = [replacements.get(n, n) for n in oldparentnodes] |
|
707 | 706 | if newparentnodes != oldparentnodes: |
|
708 | 707 | repo.setparents(*newparentnodes) |
|
709 | 708 | |
|
710 | 709 | |
|
711 | 710 | def replacerev(ui, repo, ctx, filedata, replacements): |
|
712 | 711 | """Commit a new revision like the given one, but with file content changes |
|
713 | 712 | |
|
714 | 713 | "ctx" is the original revision to be replaced by a modified one. |
|
715 | 714 | |
|
716 | 715 | "filedata" is a dict that maps paths to their new file content. All other |
|
717 | 716 | paths will be recreated from the original revision without changes. |
|
718 | 717 | "filedata" may contain paths that didn't exist in the original revision; |
|
719 | 718 | they will be added. |
|
720 | 719 | |
|
721 | 720 | "replacements" is a dict that maps a single node to a single node, and it is |
|
722 | 721 | updated to indicate the original revision is replaced by the newly created |
|
723 | 722 | one. No entry is added if the replacement's node already exists. |
|
724 | 723 | |
|
725 | 724 | The new revision has the same parents as the old one, unless those parents |
|
726 | 725 | have already been replaced, in which case those replacements are the parents |
|
727 | 726 | of this new revision. Thus, if revisions are replaced in topological order, |
|
728 | 727 | there is no need to rebase them into the original topology later. |
|
729 | 728 | """ |
|
730 | 729 | |
|
731 | 730 | p1rev, p2rev = repo.changelog.parentrevs(ctx.rev()) |
|
732 | 731 | p1ctx, p2ctx = repo[p1rev], repo[p2rev] |
|
733 | 732 | newp1node = replacements.get(p1ctx.node(), p1ctx.node()) |
|
734 | 733 | newp2node = replacements.get(p2ctx.node(), p2ctx.node()) |
|
735 | 734 | |
|
736 | 735 | # We don't want to create a revision that has no changes from the original, |
|
737 | 736 | # but we should if the original revision's parent has been replaced. |
|
738 | 737 | # Otherwise, we would produce an orphan that needs no actual human |
|
739 | 738 | # intervention to evolve. We can't rely on commit() to avoid creating the |
|
740 | 739 | # un-needed revision because the extra field added below produces a new hash |
|
741 | 740 | # regardless of file content changes. |
|
742 | 741 | if ( |
|
743 | 742 | not filedata |
|
744 | 743 | and p1ctx.node() not in replacements |
|
745 | 744 | and p2ctx.node() not in replacements |
|
746 | 745 | ): |
|
747 | 746 | return |
|
748 | 747 | |
|
749 | 748 | def filectxfn(repo, memctx, path): |
|
750 | 749 | if path not in ctx: |
|
751 | 750 | return None |
|
752 | 751 | fctx = ctx[path] |
|
753 | 752 | copysource = fctx.copysource() |
|
754 | 753 | return context.memfilectx( |
|
755 | 754 | repo, |
|
756 | 755 | memctx, |
|
757 | 756 | path=fctx.path(), |
|
758 | 757 | data=filedata.get(path, fctx.data()), |
|
759 | 758 | islink=fctx.islink(), |
|
760 | 759 | isexec=fctx.isexec(), |
|
761 | 760 | copysource=copysource, |
|
762 | 761 | ) |
|
763 | 762 | |
|
764 | 763 | extra = ctx.extra().copy() |
|
765 | 764 | extra[b'fix_source'] = ctx.hex() |
|
766 | 765 | |
|
767 | 766 | memctx = context.memctx( |
|
768 | 767 | repo, |
|
769 | 768 | parents=(newp1node, newp2node), |
|
770 | 769 | text=ctx.description(), |
|
771 | 770 | files=set(ctx.files()) | set(filedata.keys()), |
|
772 | 771 | filectxfn=filectxfn, |
|
773 | 772 | user=ctx.user(), |
|
774 | 773 | date=ctx.date(), |
|
775 | 774 | extra=extra, |
|
776 | 775 | branch=ctx.branch(), |
|
777 | 776 | editor=None, |
|
778 | 777 | ) |
|
779 | 778 | sucnode = memctx.commit() |
|
780 | 779 | prenode = ctx.node() |
|
781 | 780 | if prenode == sucnode: |
|
782 | 781 | ui.debug(b'node %s already existed\n' % (ctx.hex())) |
|
783 | 782 | else: |
|
784 | 783 | replacements[ctx.node()] = sucnode |
|
785 | 784 | |
|
786 | 785 | |
|
787 | 786 | def getfixers(ui): |
|
788 | 787 | """Returns a map of configured fixer tools indexed by their names |
|
789 | 788 | |
|
790 | 789 | Each value is a Fixer object with methods that implement the behavior of the |
|
791 | 790 | fixer's config suboptions. Does not validate the config values. |
|
792 | 791 | """ |
|
793 | 792 | fixers = {} |
|
794 | 793 | for name in fixernames(ui): |
|
795 | 794 | enabled = ui.configbool(b'fix', name + b':enabled') |
|
796 | 795 | command = ui.config(b'fix', name + b':command') |
|
797 | 796 | pattern = ui.config(b'fix', name + b':pattern') |
|
798 | 797 | linerange = ui.config(b'fix', name + b':linerange') |
|
799 | 798 | priority = ui.configint(b'fix', name + b':priority') |
|
800 | 799 | metadata = ui.configbool(b'fix', name + b':metadata') |
|
801 | 800 | skipclean = ui.configbool(b'fix', name + b':skipclean') |
|
802 | 801 | # Don't use a fixer if it has no pattern configured. It would be |
|
803 | 802 | # dangerous to let it affect all files. It would be pointless to let it |
|
804 | 803 | # affect no files. There is no reasonable subset of files to use as the |
|
805 | 804 | # default. |
|
806 | 805 | if command is None: |
|
807 | 806 | ui.warn( |
|
808 | 807 | _(b'fixer tool has no command configuration: %s\n') % (name,) |
|
809 | 808 | ) |
|
810 | 809 | elif pattern is None: |
|
811 | 810 | ui.warn( |
|
812 | 811 | _(b'fixer tool has no pattern configuration: %s\n') % (name,) |
|
813 | 812 | ) |
|
814 | 813 | elif not enabled: |
|
815 | 814 | ui.debug(b'ignoring disabled fixer tool: %s\n' % (name,)) |
|
816 | 815 | else: |
|
817 | 816 | fixers[name] = Fixer( |
|
818 | 817 | command, pattern, linerange, priority, metadata, skipclean |
|
819 | 818 | ) |
|
820 | 819 | return collections.OrderedDict( |
|
821 | 820 | sorted(fixers.items(), key=lambda item: item[1]._priority, reverse=True) |
|
822 | 821 | ) |
|
823 | 822 | |
|
824 | 823 | |
|
825 | 824 | def fixernames(ui): |
|
826 | 825 | """Returns the names of [fix] config options that have suboptions""" |
|
827 | 826 | names = set() |
|
828 | 827 | for k, v in ui.configitems(b'fix'): |
|
829 | 828 | if b':' in k: |
|
830 | 829 | names.add(k.split(b':', 1)[0]) |
|
831 | 830 | return names |
|
832 | 831 | |
|
833 | 832 | |
|
834 | 833 | class Fixer(object): |
|
835 | 834 | """Wraps the raw config values for a fixer with methods""" |
|
836 | 835 | |
|
837 | 836 | def __init__( |
|
838 | 837 | self, command, pattern, linerange, priority, metadata, skipclean |
|
839 | 838 | ): |
|
840 | 839 | self._command = command |
|
841 | 840 | self._pattern = pattern |
|
842 | 841 | self._linerange = linerange |
|
843 | 842 | self._priority = priority |
|
844 | 843 | self._metadata = metadata |
|
845 | 844 | self._skipclean = skipclean |
|
846 | 845 | |
|
847 | 846 | def affects(self, opts, fixctx, path): |
|
848 | 847 | """Should this fixer run on the file at the given path and context?""" |
|
849 | 848 | repo = fixctx.repo() |
|
850 | 849 | matcher = matchmod.match( |
|
851 | 850 | repo.root, repo.root, [self._pattern], ctx=fixctx |
|
852 | 851 | ) |
|
853 | 852 | return matcher(path) |
|
854 | 853 | |
|
855 | 854 | def shouldoutputmetadata(self): |
|
856 | 855 | """Should the stdout of this fixer start with JSON and a null byte?""" |
|
857 | 856 | return self._metadata |
|
858 | 857 | |
|
859 | 858 | def command(self, ui, path, ranges): |
|
860 | 859 | """A shell command to use to invoke this fixer on the given file/lines |
|
861 | 860 | |
|
862 | 861 | May return None if there is no appropriate command to run for the given |
|
863 | 862 | parameters. |
|
864 | 863 | """ |
|
865 | 864 | expand = cmdutil.rendercommandtemplate |
|
866 | 865 | parts = [ |
|
867 | 866 | expand( |
|
868 | 867 | ui, |
|
869 | 868 | self._command, |
|
870 | 869 | {b'rootpath': path, b'basename': os.path.basename(path)}, |
|
871 | 870 | ) |
|
872 | 871 | ] |
|
873 | 872 | if self._linerange: |
|
874 | 873 | if self._skipclean and not ranges: |
|
875 | 874 | # No line ranges to fix, so don't run the fixer. |
|
876 | 875 | return None |
|
877 | 876 | for first, last in ranges: |
|
878 | 877 | parts.append( |
|
879 | 878 | expand( |
|
880 | 879 | ui, self._linerange, {b'first': first, b'last': last} |
|
881 | 880 | ) |
|
882 | 881 | ) |
|
883 | 882 | return b' '.join(parts) |
@@ -1,746 +1,746 b'' | |||
|
1 | 1 | # blobstore.py - local and remote (speaking Git-LFS protocol) blob storages |
|
2 | 2 | # |
|
3 | 3 | # Copyright 2017 Facebook, Inc. |
|
4 | 4 | # |
|
5 | 5 | # This software may be used and distributed according to the terms of the |
|
6 | 6 | # GNU General Public License version 2 or any later version. |
|
7 | 7 | |
|
8 | 8 | from __future__ import absolute_import |
|
9 | 9 | |
|
10 | 10 | import contextlib |
|
11 | 11 | import errno |
|
12 | 12 | import hashlib |
|
13 | 13 | import json |
|
14 | 14 | import os |
|
15 | 15 | import re |
|
16 | 16 | import socket |
|
17 | 17 | |
|
18 | 18 | from mercurial.i18n import _ |
|
19 | 19 | from mercurial.pycompat import getattr |
|
20 | 20 | |
|
21 | 21 | from mercurial import ( |
|
22 | 22 | encoding, |
|
23 | 23 | error, |
|
24 | 24 | node, |
|
25 | 25 | pathutil, |
|
26 | 26 | pycompat, |
|
27 | 27 | url as urlmod, |
|
28 | 28 | util, |
|
29 | 29 | vfs as vfsmod, |
|
30 | 30 | worker, |
|
31 | 31 | ) |
|
32 | 32 | |
|
33 | 33 | from mercurial.utils import stringutil |
|
34 | 34 | |
|
35 | 35 | from ..largefiles import lfutil |
|
36 | 36 | |
|
37 | 37 | # 64 bytes for SHA256 |
|
38 | 38 | _lfsre = re.compile(br'\A[a-f0-9]{64}\Z') |
|
39 | 39 | |
|
40 | 40 | |
|
41 | 41 | class lfsvfs(vfsmod.vfs): |
|
42 | 42 | def join(self, path): |
|
43 | 43 | """split the path at first two characters, like: XX/XXXXX...""" |
|
44 | 44 | if not _lfsre.match(path): |
|
45 | 45 | raise error.ProgrammingError(b'unexpected lfs path: %s' % path) |
|
46 | 46 | return super(lfsvfs, self).join(path[0:2], path[2:]) |
|
47 | 47 | |
|
48 | 48 | def walk(self, path=None, onerror=None): |
|
49 | 49 | """Yield (dirpath, [], oids) tuple for blobs under path |
|
50 | 50 | |
|
51 | 51 | Oids only exist in the root of this vfs, so dirpath is always ''. |
|
52 | 52 | """ |
|
53 | 53 | root = os.path.normpath(self.base) |
|
54 | 54 | # when dirpath == root, dirpath[prefixlen:] becomes empty |
|
55 | 55 | # because len(dirpath) < prefixlen. |
|
56 | 56 | prefixlen = len(pathutil.normasprefix(root)) |
|
57 | 57 | oids = [] |
|
58 | 58 | |
|
59 | 59 | for dirpath, dirs, files in os.walk( |
|
60 | 60 | self.reljoin(self.base, path or b''), onerror=onerror |
|
61 | 61 | ): |
|
62 | 62 | dirpath = dirpath[prefixlen:] |
|
63 | 63 | |
|
64 | 64 | # Silently skip unexpected files and directories |
|
65 | 65 | if len(dirpath) == 2: |
|
66 | 66 | oids.extend( |
|
67 | 67 | [dirpath + f for f in files if _lfsre.match(dirpath + f)] |
|
68 | 68 | ) |
|
69 | 69 | |
|
70 | 70 | yield (b'', [], oids) |
|
71 | 71 | |
|
72 | 72 | |
|
73 | 73 | class nullvfs(lfsvfs): |
|
74 | 74 | def __init__(self): |
|
75 | 75 | pass |
|
76 | 76 | |
|
77 | 77 | def exists(self, oid): |
|
78 | 78 | return False |
|
79 | 79 | |
|
80 | 80 | def read(self, oid): |
|
81 | 81 | # store.read() calls into here if the blob doesn't exist in its |
|
82 | 82 | # self.vfs. Raise the same error as a normal vfs when asked to read a |
|
83 | 83 | # file that doesn't exist. The only difference is the full file path |
|
84 | 84 | # isn't available in the error. |
|
85 | 85 | raise IOError( |
|
86 | 86 | errno.ENOENT, |
|
87 | 87 | pycompat.sysstr(b'%s: No such file or directory' % oid), |
|
88 | 88 | ) |
|
89 | 89 | |
|
90 | 90 | def walk(self, path=None, onerror=None): |
|
91 | 91 | return (b'', [], []) |
|
92 | 92 | |
|
93 | 93 | def write(self, oid, data): |
|
94 | 94 | pass |
|
95 | 95 | |
|
96 | 96 | |
|
97 | 97 | class filewithprogress(object): |
|
98 | 98 | """a file-like object that supports __len__ and read. |
|
99 | 99 | |
|
100 | 100 | Useful to provide progress information for how many bytes are read. |
|
101 | 101 | """ |
|
102 | 102 | |
|
103 | 103 | def __init__(self, fp, callback): |
|
104 | 104 | self._fp = fp |
|
105 | 105 | self._callback = callback # func(readsize) |
|
106 | 106 | fp.seek(0, os.SEEK_END) |
|
107 | 107 | self._len = fp.tell() |
|
108 | 108 | fp.seek(0) |
|
109 | 109 | |
|
110 | 110 | def __len__(self): |
|
111 | 111 | return self._len |
|
112 | 112 | |
|
113 | 113 | def read(self, size): |
|
114 | 114 | if self._fp is None: |
|
115 | 115 | return b'' |
|
116 | 116 | data = self._fp.read(size) |
|
117 | 117 | if data: |
|
118 | 118 | if self._callback: |
|
119 | 119 | self._callback(len(data)) |
|
120 | 120 | else: |
|
121 | 121 | self._fp.close() |
|
122 | 122 | self._fp = None |
|
123 | 123 | return data |
|
124 | 124 | |
|
125 | 125 | |
|
126 | 126 | class local(object): |
|
127 | 127 | """Local blobstore for large file contents. |
|
128 | 128 | |
|
129 | 129 | This blobstore is used both as a cache and as a staging area for large blobs |
|
130 | 130 | to be uploaded to the remote blobstore. |
|
131 | 131 | """ |
|
132 | 132 | |
|
133 | 133 | def __init__(self, repo): |
|
134 | 134 | fullpath = repo.svfs.join(b'lfs/objects') |
|
135 | 135 | self.vfs = lfsvfs(fullpath) |
|
136 | 136 | |
|
137 | 137 | if repo.ui.configbool(b'experimental', b'lfs.disableusercache'): |
|
138 | 138 | self.cachevfs = nullvfs() |
|
139 | 139 | else: |
|
140 | 140 | usercache = lfutil._usercachedir(repo.ui, b'lfs') |
|
141 | 141 | self.cachevfs = lfsvfs(usercache) |
|
142 | 142 | self.ui = repo.ui |
|
143 | 143 | |
|
144 | 144 | def open(self, oid): |
|
145 | 145 | """Open a read-only file descriptor to the named blob, in either the |
|
146 | 146 | usercache or the local store.""" |
|
147 | 147 | # The usercache is the most likely place to hold the file. Commit will |
|
148 | 148 | # write to both it and the local store, as will anything that downloads |
|
149 | 149 | # the blobs. However, things like clone without an update won't |
|
150 | 150 | # populate the local store. For an init + push of a local clone, |
|
151 | 151 | # the usercache is the only place it _could_ be. If not present, the |
|
152 | 152 | # missing file msg here will indicate the local repo, not the usercache. |
|
153 | 153 | if self.cachevfs.exists(oid): |
|
154 | 154 | return self.cachevfs(oid, b'rb') |
|
155 | 155 | |
|
156 | 156 | return self.vfs(oid, b'rb') |
|
157 | 157 | |
|
158 | 158 | def download(self, oid, src): |
|
159 | 159 | """Read the blob from the remote source in chunks, verify the content, |
|
160 | 160 | and write to this local blobstore.""" |
|
161 | 161 | sha256 = hashlib.sha256() |
|
162 | 162 | |
|
163 | 163 | with self.vfs(oid, b'wb', atomictemp=True) as fp: |
|
164 | 164 | for chunk in util.filechunkiter(src, size=1048576): |
|
165 | 165 | fp.write(chunk) |
|
166 | 166 | sha256.update(chunk) |
|
167 | 167 | |
|
168 | 168 | realoid = node.hex(sha256.digest()) |
|
169 | 169 | if realoid != oid: |
|
170 | 170 | raise LfsCorruptionError( |
|
171 | 171 | _(b'corrupt remote lfs object: %s') % oid |
|
172 | 172 | ) |
|
173 | 173 | |
|
174 | 174 | self._linktousercache(oid) |
|
175 | 175 | |
|
176 | 176 | def write(self, oid, data): |
|
177 | 177 | """Write blob to local blobstore. |
|
178 | 178 | |
|
179 | 179 | This should only be called from the filelog during a commit or similar. |
|
180 | 180 | As such, there is no need to verify the data. Imports from a remote |
|
181 | 181 | store must use ``download()`` instead.""" |
|
182 | 182 | with self.vfs(oid, b'wb', atomictemp=True) as fp: |
|
183 | 183 | fp.write(data) |
|
184 | 184 | |
|
185 | 185 | self._linktousercache(oid) |
|
186 | 186 | |
|
187 | 187 | def linkfromusercache(self, oid): |
|
188 | 188 | """Link blobs found in the user cache into this store. |
|
189 | 189 | |
|
190 | 190 | The server module needs to do this when it lets the client know not to |
|
191 | 191 | upload the blob, to ensure it is always available in this store. |
|
192 | 192 | Normally this is done implicitly when the client reads or writes the |
|
193 | 193 | blob, but that doesn't happen when the server tells the client that it |
|
194 | 194 | already has the blob. |
|
195 | 195 | """ |
|
196 | 196 | if not isinstance(self.cachevfs, nullvfs) and not self.vfs.exists(oid): |
|
197 | 197 | self.ui.note(_(b'lfs: found %s in the usercache\n') % oid) |
|
198 | 198 | lfutil.link(self.cachevfs.join(oid), self.vfs.join(oid)) |
|
199 | 199 | |
|
200 | 200 | def _linktousercache(self, oid): |
|
201 | 201 | # XXX: should we verify the content of the cache, and hardlink back to |
|
202 | 202 | # the local store on success, but truncate, write and link on failure? |
|
203 | 203 | if not self.cachevfs.exists(oid) and not isinstance( |
|
204 | 204 | self.cachevfs, nullvfs |
|
205 | 205 | ): |
|
206 | 206 | self.ui.note(_(b'lfs: adding %s to the usercache\n') % oid) |
|
207 | 207 | lfutil.link(self.vfs.join(oid), self.cachevfs.join(oid)) |
|
208 | 208 | |
|
209 | 209 | def read(self, oid, verify=True): |
|
210 | 210 | """Read blob from local blobstore.""" |
|
211 | 211 | if not self.vfs.exists(oid): |
|
212 | 212 | blob = self._read(self.cachevfs, oid, verify) |
|
213 | 213 | |
|
214 | 214 | # Even if revlog will verify the content, it needs to be verified |
|
215 | 215 | # now before making the hardlink to avoid propagating corrupt blobs. |
|
216 | 216 | # Don't abort if corruption is detected, because `hg verify` will |
|
217 | 217 | # give more useful info about the corruption- simply don't add the |
|
218 | 218 | # hardlink. |
|
219 | 219 | if verify or node.hex(hashlib.sha256(blob).digest()) == oid: |
|
220 | 220 | self.ui.note(_(b'lfs: found %s in the usercache\n') % oid) |
|
221 | 221 | lfutil.link(self.cachevfs.join(oid), self.vfs.join(oid)) |
|
222 | 222 | else: |
|
223 | 223 | self.ui.note(_(b'lfs: found %s in the local lfs store\n') % oid) |
|
224 | 224 | blob = self._read(self.vfs, oid, verify) |
|
225 | 225 | return blob |
|
226 | 226 | |
|
227 | 227 | def _read(self, vfs, oid, verify): |
|
228 | 228 | """Read blob (after verifying) from the given store""" |
|
229 | 229 | blob = vfs.read(oid) |
|
230 | 230 | if verify: |
|
231 | 231 | _verify(oid, blob) |
|
232 | 232 | return blob |
|
233 | 233 | |
|
234 | 234 | def verify(self, oid): |
|
235 | 235 | """Indicate whether or not the hash of the underlying file matches its |
|
236 | 236 | name.""" |
|
237 | 237 | sha256 = hashlib.sha256() |
|
238 | 238 | |
|
239 | 239 | with self.open(oid) as fp: |
|
240 | 240 | for chunk in util.filechunkiter(fp, size=1048576): |
|
241 | 241 | sha256.update(chunk) |
|
242 | 242 | |
|
243 | 243 | return oid == node.hex(sha256.digest()) |
|
244 | 244 | |
|
245 | 245 | def has(self, oid): |
|
246 | 246 | """Returns True if the local blobstore contains the requested blob, |
|
247 | 247 | False otherwise.""" |
|
248 | 248 | return self.cachevfs.exists(oid) or self.vfs.exists(oid) |
|
249 | 249 | |
|
250 | 250 | |
|
251 | 251 | def _urlerrorreason(urlerror): |
|
252 | 252 | '''Create a friendly message for the given URLError to be used in an |
|
253 | 253 | LfsRemoteError message. |
|
254 | 254 | ''' |
|
255 | 255 | inst = urlerror |
|
256 | 256 | |
|
257 | 257 | if isinstance(urlerror.reason, Exception): |
|
258 | 258 | inst = urlerror.reason |
|
259 | 259 | |
|
260 | 260 | if util.safehasattr(inst, b'reason'): |
|
261 | 261 | try: # usually it is in the form (errno, strerror) |
|
262 | 262 | reason = inst.reason.args[1] |
|
263 | 263 | except (AttributeError, IndexError): |
|
264 | 264 | # it might be anything, for example a string |
|
265 | 265 | reason = inst.reason |
|
266 | 266 | if isinstance(reason, pycompat.unicode): |
|
267 | 267 | # SSLError of Python 2.7.9 contains a unicode |
|
268 | 268 | reason = encoding.unitolocal(reason) |
|
269 | 269 | return reason |
|
270 | 270 | elif getattr(inst, "strerror", None): |
|
271 | 271 | return encoding.strtolocal(inst.strerror) |
|
272 | 272 | else: |
|
273 | 273 | return stringutil.forcebytestr(urlerror) |
|
274 | 274 | |
|
275 | 275 | |
|
276 | 276 | class lfsauthhandler(util.urlreq.basehandler): |
|
277 | 277 | handler_order = 480 # Before HTTPDigestAuthHandler (== 490) |
|
278 | 278 | |
|
279 | 279 | def http_error_401(self, req, fp, code, msg, headers): |
|
280 | 280 | """Enforces that any authentication performed is HTTP Basic |
|
281 | 281 | Authentication. No authentication is also acceptable. |
|
282 | 282 | """ |
|
283 | 283 | authreq = headers.get(r'www-authenticate', None) |
|
284 | 284 | if authreq: |
|
285 | 285 | scheme = authreq.split()[0] |
|
286 | 286 | |
|
287 | 287 | if scheme.lower() != r'basic': |
|
288 | 288 | msg = _(b'the server must support Basic Authentication') |
|
289 | 289 | raise util.urlerr.httperror( |
|
290 | 290 | req.get_full_url(), |
|
291 | 291 | code, |
|
292 | 292 | encoding.strfromlocal(msg), |
|
293 | 293 | headers, |
|
294 | 294 | fp, |
|
295 | 295 | ) |
|
296 | 296 | return None |
|
297 | 297 | |
|
298 | 298 | |
|
299 | 299 | class _gitlfsremote(object): |
|
300 | 300 | def __init__(self, repo, url): |
|
301 | 301 | ui = repo.ui |
|
302 | 302 | self.ui = ui |
|
303 | 303 | baseurl, authinfo = url.authinfo() |
|
304 | 304 | self.baseurl = baseurl.rstrip(b'/') |
|
305 | 305 | useragent = repo.ui.config(b'experimental', b'lfs.user-agent') |
|
306 | 306 | if not useragent: |
|
307 | 307 | useragent = b'git-lfs/2.3.4 (Mercurial %s)' % util.version() |
|
308 | 308 | self.urlopener = urlmod.opener(ui, authinfo, useragent) |
|
309 | 309 | self.urlopener.add_handler(lfsauthhandler()) |
|
310 | 310 | self.retry = ui.configint(b'lfs', b'retry') |
|
311 | 311 | |
|
312 | 312 | def writebatch(self, pointers, fromstore): |
|
313 | 313 | """Batch upload from local to remote blobstore.""" |
|
314 | 314 | self._batch(_deduplicate(pointers), fromstore, b'upload') |
|
315 | 315 | |
|
316 | 316 | def readbatch(self, pointers, tostore): |
|
317 | 317 | """Batch download from remote to local blostore.""" |
|
318 | 318 | self._batch(_deduplicate(pointers), tostore, b'download') |
|
319 | 319 | |
|
320 | 320 | def _batchrequest(self, pointers, action): |
|
321 | 321 | """Get metadata about objects pointed by pointers for given action |
|
322 | 322 | |
|
323 | 323 | Return decoded JSON object like {'objects': [{'oid': '', 'size': 1}]} |
|
324 | 324 | See https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md |
|
325 | 325 | """ |
|
326 | 326 | objects = [ |
|
327 | 327 | {r'oid': pycompat.strurl(p.oid()), r'size': p.size()} |
|
328 | 328 | for p in pointers |
|
329 | 329 | ] |
|
330 | 330 | requestdata = pycompat.bytesurl( |
|
331 | 331 | json.dumps( |
|
332 | 332 | {r'objects': objects, r'operation': pycompat.strurl(action),} |
|
333 | 333 | ) |
|
334 | 334 | ) |
|
335 | 335 | url = b'%s/objects/batch' % self.baseurl |
|
336 | 336 | batchreq = util.urlreq.request(pycompat.strurl(url), data=requestdata) |
|
337 | 337 | batchreq.add_header(r'Accept', r'application/vnd.git-lfs+json') |
|
338 | 338 | batchreq.add_header(r'Content-Type', r'application/vnd.git-lfs+json') |
|
339 | 339 | try: |
|
340 | 340 | with contextlib.closing(self.urlopener.open(batchreq)) as rsp: |
|
341 | 341 | rawjson = rsp.read() |
|
342 | 342 | except util.urlerr.httperror as ex: |
|
343 | 343 | hints = { |
|
344 | 344 | 400: _( |
|
345 | 345 | b'check that lfs serving is enabled on %s and "%s" is ' |
|
346 | 346 | b'supported' |
|
347 | 347 | ) |
|
348 | 348 | % (self.baseurl, action), |
|
349 | 349 | 404: _(b'the "lfs.url" config may be used to override %s') |
|
350 | 350 | % self.baseurl, |
|
351 | 351 | } |
|
352 | 352 | hint = hints.get(ex.code, _(b'api=%s, action=%s') % (url, action)) |
|
353 | 353 | raise LfsRemoteError( |
|
354 | 354 | _(b'LFS HTTP error: %s') % stringutil.forcebytestr(ex), |
|
355 | 355 | hint=hint, |
|
356 | 356 | ) |
|
357 | 357 | except util.urlerr.urlerror as ex: |
|
358 | 358 | hint = ( |
|
359 | 359 | _(b'the "lfs.url" config may be used to override %s') |
|
360 | 360 | % self.baseurl |
|
361 | 361 | ) |
|
362 | 362 | raise LfsRemoteError( |
|
363 | 363 | _(b'LFS error: %s') % _urlerrorreason(ex), hint=hint |
|
364 | 364 | ) |
|
365 | 365 | try: |
|
366 |
response = |
|
|
366 | response = pycompat.json_loads(rawjson) | |
|
367 | 367 | except ValueError: |
|
368 | 368 | raise LfsRemoteError( |
|
369 | 369 | _(b'LFS server returns invalid JSON: %s') |
|
370 | 370 | % rawjson.encode("utf-8") |
|
371 | 371 | ) |
|
372 | 372 | |
|
373 | 373 | if self.ui.debugflag: |
|
374 | 374 | self.ui.debug(b'Status: %d\n' % rsp.status) |
|
375 | 375 | # lfs-test-server and hg serve return headers in different order |
|
376 | 376 | headers = pycompat.bytestr(rsp.info()).strip() |
|
377 | 377 | self.ui.debug(b'%s\n' % b'\n'.join(sorted(headers.splitlines()))) |
|
378 | 378 | |
|
379 | 379 | if r'objects' in response: |
|
380 | 380 | response[r'objects'] = sorted( |
|
381 | 381 | response[r'objects'], key=lambda p: p[r'oid'] |
|
382 | 382 | ) |
|
383 | 383 | self.ui.debug( |
|
384 | 384 | b'%s\n' |
|
385 | 385 | % pycompat.bytesurl( |
|
386 | 386 | json.dumps( |
|
387 | 387 | response, |
|
388 | 388 | indent=2, |
|
389 | 389 | separators=(r'', r': '), |
|
390 | 390 | sort_keys=True, |
|
391 | 391 | ) |
|
392 | 392 | ) |
|
393 | 393 | ) |
|
394 | 394 | |
|
395 | 395 | def encodestr(x): |
|
396 | 396 | if isinstance(x, pycompat.unicode): |
|
397 | 397 | return x.encode('utf-8') |
|
398 | 398 | return x |
|
399 | 399 | |
|
400 | 400 | return pycompat.rapply(encodestr, response) |
|
401 | 401 | |
|
402 | 402 | def _checkforservererror(self, pointers, responses, action): |
|
403 | 403 | """Scans errors from objects |
|
404 | 404 | |
|
405 | 405 | Raises LfsRemoteError if any objects have an error""" |
|
406 | 406 | for response in responses: |
|
407 | 407 | # The server should return 404 when objects cannot be found. Some |
|
408 | 408 | # server implementation (ex. lfs-test-server) does not set "error" |
|
409 | 409 | # but just removes "download" from "actions". Treat that case |
|
410 | 410 | # as the same as 404 error. |
|
411 | 411 | if b'error' not in response: |
|
412 | 412 | if action == b'download' and action not in response.get( |
|
413 | 413 | b'actions', [] |
|
414 | 414 | ): |
|
415 | 415 | code = 404 |
|
416 | 416 | else: |
|
417 | 417 | continue |
|
418 | 418 | else: |
|
419 | 419 | # An error dict without a code doesn't make much sense, so |
|
420 | 420 | # treat as a server error. |
|
421 | 421 | code = response.get(b'error').get(b'code', 500) |
|
422 | 422 | |
|
423 | 423 | ptrmap = {p.oid(): p for p in pointers} |
|
424 | 424 | p = ptrmap.get(response[b'oid'], None) |
|
425 | 425 | if p: |
|
426 | 426 | filename = getattr(p, 'filename', b'unknown') |
|
427 | 427 | errors = { |
|
428 | 428 | 404: b'The object does not exist', |
|
429 | 429 | 410: b'The object was removed by the owner', |
|
430 | 430 | 422: b'Validation error', |
|
431 | 431 | 500: b'Internal server error', |
|
432 | 432 | } |
|
433 | 433 | msg = errors.get(code, b'status code %d' % code) |
|
434 | 434 | raise LfsRemoteError( |
|
435 | 435 | _(b'LFS server error for "%s": %s') % (filename, msg) |
|
436 | 436 | ) |
|
437 | 437 | else: |
|
438 | 438 | raise LfsRemoteError( |
|
439 | 439 | _(b'LFS server error. Unsolicited response for oid %s') |
|
440 | 440 | % response[b'oid'] |
|
441 | 441 | ) |
|
442 | 442 | |
|
443 | 443 | def _extractobjects(self, response, pointers, action): |
|
444 | 444 | """extract objects from response of the batch API |
|
445 | 445 | |
|
446 | 446 | response: parsed JSON object returned by batch API |
|
447 | 447 | return response['objects'] filtered by action |
|
448 | 448 | raise if any object has an error |
|
449 | 449 | """ |
|
450 | 450 | # Scan errors from objects - fail early |
|
451 | 451 | objects = response.get(b'objects', []) |
|
452 | 452 | self._checkforservererror(pointers, objects, action) |
|
453 | 453 | |
|
454 | 454 | # Filter objects with given action. Practically, this skips uploading |
|
455 | 455 | # objects which exist in the server. |
|
456 | 456 | filteredobjects = [ |
|
457 | 457 | o for o in objects if action in o.get(b'actions', []) |
|
458 | 458 | ] |
|
459 | 459 | |
|
460 | 460 | return filteredobjects |
|
461 | 461 | |
|
462 | 462 | def _basictransfer(self, obj, action, localstore): |
|
463 | 463 | """Download or upload a single object using basic transfer protocol |
|
464 | 464 | |
|
465 | 465 | obj: dict, an object description returned by batch API |
|
466 | 466 | action: string, one of ['upload', 'download'] |
|
467 | 467 | localstore: blobstore.local |
|
468 | 468 | |
|
469 | 469 | See https://github.com/git-lfs/git-lfs/blob/master/docs/api/\ |
|
470 | 470 | basic-transfers.md |
|
471 | 471 | """ |
|
472 | 472 | oid = obj[b'oid'] |
|
473 | 473 | href = obj[b'actions'][action].get(b'href') |
|
474 | 474 | headers = obj[b'actions'][action].get(b'header', {}).items() |
|
475 | 475 | |
|
476 | 476 | request = util.urlreq.request(pycompat.strurl(href)) |
|
477 | 477 | if action == b'upload': |
|
478 | 478 | # If uploading blobs, read data from local blobstore. |
|
479 | 479 | if not localstore.verify(oid): |
|
480 | 480 | raise error.Abort( |
|
481 | 481 | _(b'detected corrupt lfs object: %s') % oid, |
|
482 | 482 | hint=_(b'run hg verify'), |
|
483 | 483 | ) |
|
484 | 484 | request.data = filewithprogress(localstore.open(oid), None) |
|
485 | 485 | request.get_method = lambda: r'PUT' |
|
486 | 486 | request.add_header(r'Content-Type', r'application/octet-stream') |
|
487 | 487 | request.add_header(r'Content-Length', len(request.data)) |
|
488 | 488 | |
|
489 | 489 | for k, v in headers: |
|
490 | 490 | request.add_header(pycompat.strurl(k), pycompat.strurl(v)) |
|
491 | 491 | |
|
492 | 492 | response = b'' |
|
493 | 493 | try: |
|
494 | 494 | with contextlib.closing(self.urlopener.open(request)) as req: |
|
495 | 495 | ui = self.ui # Shorten debug lines |
|
496 | 496 | if self.ui.debugflag: |
|
497 | 497 | ui.debug(b'Status: %d\n' % req.status) |
|
498 | 498 | # lfs-test-server and hg serve return headers in different |
|
499 | 499 | # order |
|
500 | 500 | headers = pycompat.bytestr(req.info()).strip() |
|
501 | 501 | ui.debug(b'%s\n' % b'\n'.join(sorted(headers.splitlines()))) |
|
502 | 502 | |
|
503 | 503 | if action == b'download': |
|
504 | 504 | # If downloading blobs, store downloaded data to local |
|
505 | 505 | # blobstore |
|
506 | 506 | localstore.download(oid, req) |
|
507 | 507 | else: |
|
508 | 508 | while True: |
|
509 | 509 | data = req.read(1048576) |
|
510 | 510 | if not data: |
|
511 | 511 | break |
|
512 | 512 | response += data |
|
513 | 513 | if response: |
|
514 | 514 | ui.debug(b'lfs %s response: %s' % (action, response)) |
|
515 | 515 | except util.urlerr.httperror as ex: |
|
516 | 516 | if self.ui.debugflag: |
|
517 | 517 | self.ui.debug( |
|
518 | 518 | b'%s: %s\n' % (oid, ex.read()) |
|
519 | 519 | ) # XXX: also bytes? |
|
520 | 520 | raise LfsRemoteError( |
|
521 | 521 | _(b'LFS HTTP error: %s (oid=%s, action=%s)') |
|
522 | 522 | % (stringutil.forcebytestr(ex), oid, action) |
|
523 | 523 | ) |
|
524 | 524 | except util.urlerr.urlerror as ex: |
|
525 | 525 | hint = _(b'attempted connection to %s') % pycompat.bytesurl( |
|
526 | 526 | util.urllibcompat.getfullurl(request) |
|
527 | 527 | ) |
|
528 | 528 | raise LfsRemoteError( |
|
529 | 529 | _(b'LFS error: %s') % _urlerrorreason(ex), hint=hint |
|
530 | 530 | ) |
|
531 | 531 | |
|
532 | 532 | def _batch(self, pointers, localstore, action): |
|
533 | 533 | if action not in [b'upload', b'download']: |
|
534 | 534 | raise error.ProgrammingError(b'invalid Git-LFS action: %s' % action) |
|
535 | 535 | |
|
536 | 536 | response = self._batchrequest(pointers, action) |
|
537 | 537 | objects = self._extractobjects(response, pointers, action) |
|
538 | 538 | total = sum(x.get(b'size', 0) for x in objects) |
|
539 | 539 | sizes = {} |
|
540 | 540 | for obj in objects: |
|
541 | 541 | sizes[obj.get(b'oid')] = obj.get(b'size', 0) |
|
542 | 542 | topic = { |
|
543 | 543 | b'upload': _(b'lfs uploading'), |
|
544 | 544 | b'download': _(b'lfs downloading'), |
|
545 | 545 | }[action] |
|
546 | 546 | if len(objects) > 1: |
|
547 | 547 | self.ui.note( |
|
548 | 548 | _(b'lfs: need to transfer %d objects (%s)\n') |
|
549 | 549 | % (len(objects), util.bytecount(total)) |
|
550 | 550 | ) |
|
551 | 551 | |
|
552 | 552 | def transfer(chunk): |
|
553 | 553 | for obj in chunk: |
|
554 | 554 | objsize = obj.get(b'size', 0) |
|
555 | 555 | if self.ui.verbose: |
|
556 | 556 | if action == b'download': |
|
557 | 557 | msg = _(b'lfs: downloading %s (%s)\n') |
|
558 | 558 | elif action == b'upload': |
|
559 | 559 | msg = _(b'lfs: uploading %s (%s)\n') |
|
560 | 560 | self.ui.note( |
|
561 | 561 | msg % (obj.get(b'oid'), util.bytecount(objsize)) |
|
562 | 562 | ) |
|
563 | 563 | retry = self.retry |
|
564 | 564 | while True: |
|
565 | 565 | try: |
|
566 | 566 | self._basictransfer(obj, action, localstore) |
|
567 | 567 | yield 1, obj.get(b'oid') |
|
568 | 568 | break |
|
569 | 569 | except socket.error as ex: |
|
570 | 570 | if retry > 0: |
|
571 | 571 | self.ui.note( |
|
572 | 572 | _(b'lfs: failed: %r (remaining retry %d)\n') |
|
573 | 573 | % (stringutil.forcebytestr(ex), retry) |
|
574 | 574 | ) |
|
575 | 575 | retry -= 1 |
|
576 | 576 | continue |
|
577 | 577 | raise |
|
578 | 578 | |
|
579 | 579 | # Until https multiplexing gets sorted out |
|
580 | 580 | if self.ui.configbool(b'experimental', b'lfs.worker-enable'): |
|
581 | 581 | oids = worker.worker( |
|
582 | 582 | self.ui, |
|
583 | 583 | 0.1, |
|
584 | 584 | transfer, |
|
585 | 585 | (), |
|
586 | 586 | sorted(objects, key=lambda o: o.get(b'oid')), |
|
587 | 587 | ) |
|
588 | 588 | else: |
|
589 | 589 | oids = transfer(sorted(objects, key=lambda o: o.get(b'oid'))) |
|
590 | 590 | |
|
591 | 591 | with self.ui.makeprogress(topic, total=total) as progress: |
|
592 | 592 | progress.update(0) |
|
593 | 593 | processed = 0 |
|
594 | 594 | blobs = 0 |
|
595 | 595 | for _one, oid in oids: |
|
596 | 596 | processed += sizes[oid] |
|
597 | 597 | blobs += 1 |
|
598 | 598 | progress.update(processed) |
|
599 | 599 | self.ui.note(_(b'lfs: processed: %s\n') % oid) |
|
600 | 600 | |
|
601 | 601 | if blobs > 0: |
|
602 | 602 | if action == b'upload': |
|
603 | 603 | self.ui.status( |
|
604 | 604 | _(b'lfs: uploaded %d files (%s)\n') |
|
605 | 605 | % (blobs, util.bytecount(processed)) |
|
606 | 606 | ) |
|
607 | 607 | elif action == b'download': |
|
608 | 608 | self.ui.status( |
|
609 | 609 | _(b'lfs: downloaded %d files (%s)\n') |
|
610 | 610 | % (blobs, util.bytecount(processed)) |
|
611 | 611 | ) |
|
612 | 612 | |
|
613 | 613 | def __del__(self): |
|
614 | 614 | # copied from mercurial/httppeer.py |
|
615 | 615 | urlopener = getattr(self, 'urlopener', None) |
|
616 | 616 | if urlopener: |
|
617 | 617 | for h in urlopener.handlers: |
|
618 | 618 | h.close() |
|
619 | 619 | getattr(h, "close_all", lambda: None)() |
|
620 | 620 | |
|
621 | 621 | |
|
622 | 622 | class _dummyremote(object): |
|
623 | 623 | """Dummy store storing blobs to temp directory.""" |
|
624 | 624 | |
|
625 | 625 | def __init__(self, repo, url): |
|
626 | 626 | fullpath = repo.vfs.join(b'lfs', url.path) |
|
627 | 627 | self.vfs = lfsvfs(fullpath) |
|
628 | 628 | |
|
629 | 629 | def writebatch(self, pointers, fromstore): |
|
630 | 630 | for p in _deduplicate(pointers): |
|
631 | 631 | content = fromstore.read(p.oid(), verify=True) |
|
632 | 632 | with self.vfs(p.oid(), b'wb', atomictemp=True) as fp: |
|
633 | 633 | fp.write(content) |
|
634 | 634 | |
|
635 | 635 | def readbatch(self, pointers, tostore): |
|
636 | 636 | for p in _deduplicate(pointers): |
|
637 | 637 | with self.vfs(p.oid(), b'rb') as fp: |
|
638 | 638 | tostore.download(p.oid(), fp) |
|
639 | 639 | |
|
640 | 640 | |
|
641 | 641 | class _nullremote(object): |
|
642 | 642 | """Null store storing blobs to /dev/null.""" |
|
643 | 643 | |
|
644 | 644 | def __init__(self, repo, url): |
|
645 | 645 | pass |
|
646 | 646 | |
|
647 | 647 | def writebatch(self, pointers, fromstore): |
|
648 | 648 | pass |
|
649 | 649 | |
|
650 | 650 | def readbatch(self, pointers, tostore): |
|
651 | 651 | pass |
|
652 | 652 | |
|
653 | 653 | |
|
654 | 654 | class _promptremote(object): |
|
655 | 655 | """Prompt user to set lfs.url when accessed.""" |
|
656 | 656 | |
|
657 | 657 | def __init__(self, repo, url): |
|
658 | 658 | pass |
|
659 | 659 | |
|
660 | 660 | def writebatch(self, pointers, fromstore, ui=None): |
|
661 | 661 | self._prompt() |
|
662 | 662 | |
|
663 | 663 | def readbatch(self, pointers, tostore, ui=None): |
|
664 | 664 | self._prompt() |
|
665 | 665 | |
|
666 | 666 | def _prompt(self): |
|
667 | 667 | raise error.Abort(_(b'lfs.url needs to be configured')) |
|
668 | 668 | |
|
669 | 669 | |
|
670 | 670 | _storemap = { |
|
671 | 671 | b'https': _gitlfsremote, |
|
672 | 672 | b'http': _gitlfsremote, |
|
673 | 673 | b'file': _dummyremote, |
|
674 | 674 | b'null': _nullremote, |
|
675 | 675 | None: _promptremote, |
|
676 | 676 | } |
|
677 | 677 | |
|
678 | 678 | |
|
679 | 679 | def _deduplicate(pointers): |
|
680 | 680 | """Remove any duplicate oids that exist in the list""" |
|
681 | 681 | reduced = util.sortdict() |
|
682 | 682 | for p in pointers: |
|
683 | 683 | reduced[p.oid()] = p |
|
684 | 684 | return reduced.values() |
|
685 | 685 | |
|
686 | 686 | |
|
687 | 687 | def _verify(oid, content): |
|
688 | 688 | realoid = node.hex(hashlib.sha256(content).digest()) |
|
689 | 689 | if realoid != oid: |
|
690 | 690 | raise LfsCorruptionError( |
|
691 | 691 | _(b'detected corrupt lfs object: %s') % oid, |
|
692 | 692 | hint=_(b'run hg verify'), |
|
693 | 693 | ) |
|
694 | 694 | |
|
695 | 695 | |
|
696 | 696 | def remote(repo, remote=None): |
|
697 | 697 | """remotestore factory. return a store in _storemap depending on config |
|
698 | 698 | |
|
699 | 699 | If ``lfs.url`` is specified, use that remote endpoint. Otherwise, try to |
|
700 | 700 | infer the endpoint, based on the remote repository using the same path |
|
701 | 701 | adjustments as git. As an extension, 'http' is supported as well so that |
|
702 | 702 | ``hg serve`` works out of the box. |
|
703 | 703 | |
|
704 | 704 | https://github.com/git-lfs/git-lfs/blob/master/docs/api/server-discovery.md |
|
705 | 705 | """ |
|
706 | 706 | lfsurl = repo.ui.config(b'lfs', b'url') |
|
707 | 707 | url = util.url(lfsurl or b'') |
|
708 | 708 | if lfsurl is None: |
|
709 | 709 | if remote: |
|
710 | 710 | path = remote |
|
711 | 711 | elif util.safehasattr(repo, b'_subtoppath'): |
|
712 | 712 | # The pull command sets this during the optional update phase, which |
|
713 | 713 | # tells exactly where the pull originated, whether 'paths.default' |
|
714 | 714 | # or explicit. |
|
715 | 715 | path = repo._subtoppath |
|
716 | 716 | else: |
|
717 | 717 | # TODO: investigate 'paths.remote:lfsurl' style path customization, |
|
718 | 718 | # and fall back to inferring from 'paths.remote' if unspecified. |
|
719 | 719 | path = repo.ui.config(b'paths', b'default') or b'' |
|
720 | 720 | |
|
721 | 721 | defaulturl = util.url(path) |
|
722 | 722 | |
|
723 | 723 | # TODO: support local paths as well. |
|
724 | 724 | # TODO: consider the ssh -> https transformation that git applies |
|
725 | 725 | if defaulturl.scheme in (b'http', b'https'): |
|
726 | 726 | if defaulturl.path and defaulturl.path[:-1] != b'/': |
|
727 | 727 | defaulturl.path += b'/' |
|
728 | 728 | defaulturl.path = (defaulturl.path or b'') + b'.git/info/lfs' |
|
729 | 729 | |
|
730 | 730 | url = util.url(bytes(defaulturl)) |
|
731 | 731 | repo.ui.note(_(b'lfs: assuming remote store: %s\n') % url) |
|
732 | 732 | |
|
733 | 733 | scheme = url.scheme |
|
734 | 734 | if scheme not in _storemap: |
|
735 | 735 | raise error.Abort(_(b'lfs: unknown url scheme: %s') % scheme) |
|
736 | 736 | return _storemap[scheme](repo, url) |
|
737 | 737 | |
|
738 | 738 | |
|
739 | 739 | class LfsRemoteError(error.StorageError): |
|
740 | 740 | pass |
|
741 | 741 | |
|
742 | 742 | |
|
743 | 743 | class LfsCorruptionError(error.Abort): |
|
744 | 744 | """Raised when a corrupt blob is detected, aborting an operation |
|
745 | 745 | |
|
746 | 746 | It exists to allow specialized handling on the server side.""" |
@@ -1,370 +1,370 b'' | |||
|
1 | 1 | # wireprotolfsserver.py - lfs protocol server side implementation |
|
2 | 2 | # |
|
3 | 3 | # Copyright 2018 Matt Harbison <matt_harbison@yahoo.com> |
|
4 | 4 | # |
|
5 | 5 | # This software may be used and distributed according to the terms of the |
|
6 | 6 | # GNU General Public License version 2 or any later version. |
|
7 | 7 | |
|
8 | 8 | from __future__ import absolute_import |
|
9 | 9 | |
|
10 | 10 | import datetime |
|
11 | 11 | import errno |
|
12 | 12 | import json |
|
13 | 13 | import traceback |
|
14 | 14 | |
|
15 | 15 | from mercurial.hgweb import common as hgwebcommon |
|
16 | 16 | |
|
17 | 17 | from mercurial import ( |
|
18 | 18 | exthelper, |
|
19 | 19 | pycompat, |
|
20 | 20 | util, |
|
21 | 21 | wireprotoserver, |
|
22 | 22 | ) |
|
23 | 23 | |
|
24 | 24 | from . import blobstore |
|
25 | 25 | |
|
26 | 26 | HTTP_OK = hgwebcommon.HTTP_OK |
|
27 | 27 | HTTP_CREATED = hgwebcommon.HTTP_CREATED |
|
28 | 28 | HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST |
|
29 | 29 | HTTP_NOT_FOUND = hgwebcommon.HTTP_NOT_FOUND |
|
30 | 30 | HTTP_METHOD_NOT_ALLOWED = hgwebcommon.HTTP_METHOD_NOT_ALLOWED |
|
31 | 31 | HTTP_NOT_ACCEPTABLE = hgwebcommon.HTTP_NOT_ACCEPTABLE |
|
32 | 32 | HTTP_UNSUPPORTED_MEDIA_TYPE = hgwebcommon.HTTP_UNSUPPORTED_MEDIA_TYPE |
|
33 | 33 | |
|
34 | 34 | eh = exthelper.exthelper() |
|
35 | 35 | |
|
36 | 36 | |
|
37 | 37 | @eh.wrapfunction(wireprotoserver, b'handlewsgirequest') |
|
38 | 38 | def handlewsgirequest(orig, rctx, req, res, checkperm): |
|
39 | 39 | """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS |
|
40 | 40 | request if it is left unprocessed by the wrapped method. |
|
41 | 41 | """ |
|
42 | 42 | if orig(rctx, req, res, checkperm): |
|
43 | 43 | return True |
|
44 | 44 | |
|
45 | 45 | if not rctx.repo.ui.configbool(b'experimental', b'lfs.serve'): |
|
46 | 46 | return False |
|
47 | 47 | |
|
48 | 48 | if not util.safehasattr(rctx.repo.svfs, 'lfslocalblobstore'): |
|
49 | 49 | return False |
|
50 | 50 | |
|
51 | 51 | if not req.dispatchpath: |
|
52 | 52 | return False |
|
53 | 53 | |
|
54 | 54 | try: |
|
55 | 55 | if req.dispatchpath == b'.git/info/lfs/objects/batch': |
|
56 | 56 | checkperm(rctx, req, b'pull') |
|
57 | 57 | return _processbatchrequest(rctx.repo, req, res) |
|
58 | 58 | # TODO: reserve and use a path in the proposed http wireprotocol /api/ |
|
59 | 59 | # namespace? |
|
60 | 60 | elif req.dispatchpath.startswith(b'.hg/lfs/objects'): |
|
61 | 61 | return _processbasictransfer( |
|
62 | 62 | rctx.repo, req, res, lambda perm: checkperm(rctx, req, perm) |
|
63 | 63 | ) |
|
64 | 64 | return False |
|
65 | 65 | except hgwebcommon.ErrorResponse as e: |
|
66 | 66 | # XXX: copied from the handler surrounding wireprotoserver._callhttp() |
|
67 | 67 | # in the wrapped function. Should this be moved back to hgweb to |
|
68 | 68 | # be a common handler? |
|
69 | 69 | for k, v in e.headers: |
|
70 | 70 | res.headers[k] = v |
|
71 | 71 | res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e)) |
|
72 | 72 | res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e)) |
|
73 | 73 | return True |
|
74 | 74 | |
|
75 | 75 | |
|
76 | 76 | def _sethttperror(res, code, message=None): |
|
77 | 77 | res.status = hgwebcommon.statusmessage(code, message=message) |
|
78 | 78 | res.headers[b'Content-Type'] = b'text/plain; charset=utf-8' |
|
79 | 79 | res.setbodybytes(b'') |
|
80 | 80 | |
|
81 | 81 | |
|
82 | 82 | def _logexception(req): |
|
83 | 83 | """Write information about the current exception to wsgi.errors.""" |
|
84 | 84 | tb = pycompat.sysbytes(traceback.format_exc()) |
|
85 | 85 | errorlog = req.rawenv[b'wsgi.errors'] |
|
86 | 86 | |
|
87 | 87 | uri = b'' |
|
88 | 88 | if req.apppath: |
|
89 | 89 | uri += req.apppath |
|
90 | 90 | uri += b'/' + req.dispatchpath |
|
91 | 91 | |
|
92 | 92 | errorlog.write( |
|
93 | 93 | b"Exception happened while processing request '%s':\n%s" % (uri, tb) |
|
94 | 94 | ) |
|
95 | 95 | |
|
96 | 96 | |
|
97 | 97 | def _processbatchrequest(repo, req, res): |
|
98 | 98 | """Handle a request for the Batch API, which is the gateway to granting file |
|
99 | 99 | access. |
|
100 | 100 | |
|
101 | 101 | https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md |
|
102 | 102 | """ |
|
103 | 103 | |
|
104 | 104 | # Mercurial client request: |
|
105 | 105 | # |
|
106 | 106 | # HOST: localhost:$HGPORT |
|
107 | 107 | # ACCEPT: application/vnd.git-lfs+json |
|
108 | 108 | # ACCEPT-ENCODING: identity |
|
109 | 109 | # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316) |
|
110 | 110 | # Content-Length: 125 |
|
111 | 111 | # Content-Type: application/vnd.git-lfs+json |
|
112 | 112 | # |
|
113 | 113 | # { |
|
114 | 114 | # "objects": [ |
|
115 | 115 | # { |
|
116 | 116 | # "oid": "31cf...8e5b" |
|
117 | 117 | # "size": 12 |
|
118 | 118 | # } |
|
119 | 119 | # ] |
|
120 | 120 | # "operation": "upload" |
|
121 | 121 | # } |
|
122 | 122 | |
|
123 | 123 | if req.method != b'POST': |
|
124 | 124 | _sethttperror(res, HTTP_METHOD_NOT_ALLOWED) |
|
125 | 125 | return True |
|
126 | 126 | |
|
127 | 127 | if req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json': |
|
128 | 128 | _sethttperror(res, HTTP_UNSUPPORTED_MEDIA_TYPE) |
|
129 | 129 | return True |
|
130 | 130 | |
|
131 | 131 | if req.headers[b'Accept'] != b'application/vnd.git-lfs+json': |
|
132 | 132 | _sethttperror(res, HTTP_NOT_ACCEPTABLE) |
|
133 | 133 | return True |
|
134 | 134 | |
|
135 | 135 | # XXX: specify an encoding? |
|
136 |
lfsreq = |
|
|
136 | lfsreq = pycompat.json_loads(req.bodyfh.read()) | |
|
137 | 137 | |
|
138 | 138 | # If no transfer handlers are explicitly requested, 'basic' is assumed. |
|
139 | 139 | if r'basic' not in lfsreq.get(r'transfers', [r'basic']): |
|
140 | 140 | _sethttperror( |
|
141 | 141 | res, |
|
142 | 142 | HTTP_BAD_REQUEST, |
|
143 | 143 | b'Only the basic LFS transfer handler is supported', |
|
144 | 144 | ) |
|
145 | 145 | return True |
|
146 | 146 | |
|
147 | 147 | operation = lfsreq.get(r'operation') |
|
148 | 148 | operation = pycompat.bytestr(operation) |
|
149 | 149 | |
|
150 | 150 | if operation not in (b'upload', b'download'): |
|
151 | 151 | _sethttperror( |
|
152 | 152 | res, |
|
153 | 153 | HTTP_BAD_REQUEST, |
|
154 | 154 | b'Unsupported LFS transfer operation: %s' % operation, |
|
155 | 155 | ) |
|
156 | 156 | return True |
|
157 | 157 | |
|
158 | 158 | localstore = repo.svfs.lfslocalblobstore |
|
159 | 159 | |
|
160 | 160 | objects = [ |
|
161 | 161 | p |
|
162 | 162 | for p in _batchresponseobjects( |
|
163 | 163 | req, lfsreq.get(r'objects', []), operation, localstore |
|
164 | 164 | ) |
|
165 | 165 | ] |
|
166 | 166 | |
|
167 | 167 | rsp = { |
|
168 | 168 | r'transfer': r'basic', |
|
169 | 169 | r'objects': objects, |
|
170 | 170 | } |
|
171 | 171 | |
|
172 | 172 | res.status = hgwebcommon.statusmessage(HTTP_OK) |
|
173 | 173 | res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json' |
|
174 | 174 | res.setbodybytes(pycompat.bytestr(json.dumps(rsp))) |
|
175 | 175 | |
|
176 | 176 | return True |
|
177 | 177 | |
|
178 | 178 | |
|
179 | 179 | def _batchresponseobjects(req, objects, action, store): |
|
180 | 180 | """Yield one dictionary of attributes for the Batch API response for each |
|
181 | 181 | object in the list. |
|
182 | 182 | |
|
183 | 183 | req: The parsedrequest for the Batch API request |
|
184 | 184 | objects: The list of objects in the Batch API object request list |
|
185 | 185 | action: 'upload' or 'download' |
|
186 | 186 | store: The local blob store for servicing requests""" |
|
187 | 187 | |
|
188 | 188 | # Successful lfs-test-server response to solict an upload: |
|
189 | 189 | # { |
|
190 | 190 | # u'objects': [{ |
|
191 | 191 | # u'size': 12, |
|
192 | 192 | # u'oid': u'31cf...8e5b', |
|
193 | 193 | # u'actions': { |
|
194 | 194 | # u'upload': { |
|
195 | 195 | # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b', |
|
196 | 196 | # u'expires_at': u'0001-01-01T00:00:00Z', |
|
197 | 197 | # u'header': { |
|
198 | 198 | # u'Accept': u'application/vnd.git-lfs' |
|
199 | 199 | # } |
|
200 | 200 | # } |
|
201 | 201 | # } |
|
202 | 202 | # }] |
|
203 | 203 | # } |
|
204 | 204 | |
|
205 | 205 | # TODO: Sort out the expires_at/expires_in/authenticated keys. |
|
206 | 206 | |
|
207 | 207 | for obj in objects: |
|
208 | 208 | # Convert unicode to ASCII to create a filesystem path |
|
209 | 209 | soid = obj.get(r'oid') |
|
210 | 210 | oid = soid.encode(r'ascii') |
|
211 | 211 | rsp = { |
|
212 | 212 | r'oid': soid, |
|
213 | 213 | r'size': obj.get(r'size'), # XXX: should this check the local size? |
|
214 | 214 | # r'authenticated': True, |
|
215 | 215 | } |
|
216 | 216 | |
|
217 | 217 | exists = True |
|
218 | 218 | verifies = False |
|
219 | 219 | |
|
220 | 220 | # Verify an existing file on the upload request, so that the client is |
|
221 | 221 | # solicited to re-upload if it corrupt locally. Download requests are |
|
222 | 222 | # also verified, so the error can be flagged in the Batch API response. |
|
223 | 223 | # (Maybe we can use this to short circuit the download for `hg verify`, |
|
224 | 224 | # IFF the client can assert that the remote end is an hg server.) |
|
225 | 225 | # Otherwise, it's potentially overkill on download, since it is also |
|
226 | 226 | # verified as the file is streamed to the caller. |
|
227 | 227 | try: |
|
228 | 228 | verifies = store.verify(oid) |
|
229 | 229 | if verifies and action == b'upload': |
|
230 | 230 | # The client will skip this upload, but make sure it remains |
|
231 | 231 | # available locally. |
|
232 | 232 | store.linkfromusercache(oid) |
|
233 | 233 | except IOError as inst: |
|
234 | 234 | if inst.errno != errno.ENOENT: |
|
235 | 235 | _logexception(req) |
|
236 | 236 | |
|
237 | 237 | rsp[r'error'] = { |
|
238 | 238 | r'code': 500, |
|
239 | 239 | r'message': inst.strerror or r'Internal Server Server', |
|
240 | 240 | } |
|
241 | 241 | yield rsp |
|
242 | 242 | continue |
|
243 | 243 | |
|
244 | 244 | exists = False |
|
245 | 245 | |
|
246 | 246 | # Items are always listed for downloads. They are dropped for uploads |
|
247 | 247 | # IFF they already exist locally. |
|
248 | 248 | if action == b'download': |
|
249 | 249 | if not exists: |
|
250 | 250 | rsp[r'error'] = { |
|
251 | 251 | r'code': 404, |
|
252 | 252 | r'message': r"The object does not exist", |
|
253 | 253 | } |
|
254 | 254 | yield rsp |
|
255 | 255 | continue |
|
256 | 256 | |
|
257 | 257 | elif not verifies: |
|
258 | 258 | rsp[r'error'] = { |
|
259 | 259 | r'code': 422, # XXX: is this the right code? |
|
260 | 260 | r'message': r"The object is corrupt", |
|
261 | 261 | } |
|
262 | 262 | yield rsp |
|
263 | 263 | continue |
|
264 | 264 | |
|
265 | 265 | elif verifies: |
|
266 | 266 | yield rsp # Skip 'actions': already uploaded |
|
267 | 267 | continue |
|
268 | 268 | |
|
269 | 269 | expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10) |
|
270 | 270 | |
|
271 | 271 | def _buildheader(): |
|
272 | 272 | # The spec doesn't mention the Accept header here, but avoid |
|
273 | 273 | # a gratuitous deviation from lfs-test-server in the test |
|
274 | 274 | # output. |
|
275 | 275 | hdr = {r'Accept': r'application/vnd.git-lfs'} |
|
276 | 276 | |
|
277 | 277 | auth = req.headers.get(b'Authorization', b'') |
|
278 | 278 | if auth.startswith(b'Basic '): |
|
279 | 279 | hdr[r'Authorization'] = pycompat.strurl(auth) |
|
280 | 280 | |
|
281 | 281 | return hdr |
|
282 | 282 | |
|
283 | 283 | rsp[r'actions'] = { |
|
284 | 284 | r'%s' |
|
285 | 285 | % pycompat.strurl(action): { |
|
286 | 286 | r'href': pycompat.strurl( |
|
287 | 287 | b'%s%s/.hg/lfs/objects/%s' % (req.baseurl, req.apppath, oid) |
|
288 | 288 | ), |
|
289 | 289 | # datetime.isoformat() doesn't include the 'Z' suffix |
|
290 | 290 | r"expires_at": expiresat.strftime(r'%Y-%m-%dT%H:%M:%SZ'), |
|
291 | 291 | r'header': _buildheader(), |
|
292 | 292 | } |
|
293 | 293 | } |
|
294 | 294 | |
|
295 | 295 | yield rsp |
|
296 | 296 | |
|
297 | 297 | |
|
298 | 298 | def _processbasictransfer(repo, req, res, checkperm): |
|
299 | 299 | """Handle a single file upload (PUT) or download (GET) action for the Basic |
|
300 | 300 | Transfer Adapter. |
|
301 | 301 | |
|
302 | 302 | After determining if the request is for an upload or download, the access |
|
303 | 303 | must be checked by calling ``checkperm()`` with either 'pull' or 'upload' |
|
304 | 304 | before accessing the files. |
|
305 | 305 | |
|
306 | 306 | https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md |
|
307 | 307 | """ |
|
308 | 308 | |
|
309 | 309 | method = req.method |
|
310 | 310 | oid = req.dispatchparts[-1] |
|
311 | 311 | localstore = repo.svfs.lfslocalblobstore |
|
312 | 312 | |
|
313 | 313 | if len(req.dispatchparts) != 4: |
|
314 | 314 | _sethttperror(res, HTTP_NOT_FOUND) |
|
315 | 315 | return True |
|
316 | 316 | |
|
317 | 317 | if method == b'PUT': |
|
318 | 318 | checkperm(b'upload') |
|
319 | 319 | |
|
320 | 320 | # TODO: verify Content-Type? |
|
321 | 321 | |
|
322 | 322 | existed = localstore.has(oid) |
|
323 | 323 | |
|
324 | 324 | # TODO: how to handle timeouts? The body proxy handles limiting to |
|
325 | 325 | # Content-Length, but what happens if a client sends less than it |
|
326 | 326 | # says it will? |
|
327 | 327 | |
|
328 | 328 | statusmessage = hgwebcommon.statusmessage |
|
329 | 329 | try: |
|
330 | 330 | localstore.download(oid, req.bodyfh) |
|
331 | 331 | res.status = statusmessage(HTTP_OK if existed else HTTP_CREATED) |
|
332 | 332 | except blobstore.LfsCorruptionError: |
|
333 | 333 | _logexception(req) |
|
334 | 334 | |
|
335 | 335 | # XXX: Is this the right code? |
|
336 | 336 | res.status = statusmessage(422, b'corrupt blob') |
|
337 | 337 | |
|
338 | 338 | # There's no payload here, but this is the header that lfs-test-server |
|
339 | 339 | # sends back. This eliminates some gratuitous test output conditionals. |
|
340 | 340 | res.headers[b'Content-Type'] = b'text/plain; charset=utf-8' |
|
341 | 341 | res.setbodybytes(b'') |
|
342 | 342 | |
|
343 | 343 | return True |
|
344 | 344 | elif method == b'GET': |
|
345 | 345 | checkperm(b'pull') |
|
346 | 346 | |
|
347 | 347 | res.status = hgwebcommon.statusmessage(HTTP_OK) |
|
348 | 348 | res.headers[b'Content-Type'] = b'application/octet-stream' |
|
349 | 349 | |
|
350 | 350 | try: |
|
351 | 351 | # TODO: figure out how to send back the file in chunks, instead of |
|
352 | 352 | # reading the whole thing. (Also figure out how to send back |
|
353 | 353 | # an error status if an IOError occurs after a partial write |
|
354 | 354 | # in that case. Here, everything is read before starting.) |
|
355 | 355 | res.setbodybytes(localstore.read(oid)) |
|
356 | 356 | except blobstore.LfsCorruptionError: |
|
357 | 357 | _logexception(req) |
|
358 | 358 | |
|
359 | 359 | # XXX: Is this the right code? |
|
360 | 360 | res.status = hgwebcommon.statusmessage(422, b'corrupt blob') |
|
361 | 361 | res.setbodybytes(b'') |
|
362 | 362 | |
|
363 | 363 | return True |
|
364 | 364 | else: |
|
365 | 365 | _sethttperror( |
|
366 | 366 | res, |
|
367 | 367 | HTTP_METHOD_NOT_ALLOWED, |
|
368 | 368 | message=b'Unsupported LFS transfer method: %s' % method, |
|
369 | 369 | ) |
|
370 | 370 | return True |
@@ -1,1651 +1,1651 b'' | |||
|
1 | 1 | # phabricator.py - simple Phabricator integration |
|
2 | 2 | # |
|
3 | 3 | # Copyright 2017 Facebook, Inc. |
|
4 | 4 | # |
|
5 | 5 | # This software may be used and distributed according to the terms of the |
|
6 | 6 | # GNU General Public License version 2 or any later version. |
|
7 | 7 | """simple Phabricator integration (EXPERIMENTAL) |
|
8 | 8 | |
|
9 | 9 | This extension provides a ``phabsend`` command which sends a stack of |
|
10 | 10 | changesets to Phabricator, and a ``phabread`` command which prints a stack of |
|
11 | 11 | revisions in a format suitable for :hg:`import`, and a ``phabupdate`` command |
|
12 | 12 | to update statuses in batch. |
|
13 | 13 | |
|
14 | 14 | By default, Phabricator requires ``Test Plan`` which might prevent some |
|
15 | 15 | changeset from being sent. The requirement could be disabled by changing |
|
16 | 16 | ``differential.require-test-plan-field`` config server side. |
|
17 | 17 | |
|
18 | 18 | Config:: |
|
19 | 19 | |
|
20 | 20 | [phabricator] |
|
21 | 21 | # Phabricator URL |
|
22 | 22 | url = https://phab.example.com/ |
|
23 | 23 | |
|
24 | 24 | # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its |
|
25 | 25 | # callsign is "FOO". |
|
26 | 26 | callsign = FOO |
|
27 | 27 | |
|
28 | 28 | # curl command to use. If not set (default), use builtin HTTP library to |
|
29 | 29 | # communicate. If set, use the specified curl command. This could be useful |
|
30 | 30 | # if you need to specify advanced options that is not easily supported by |
|
31 | 31 | # the internal library. |
|
32 | 32 | curlcmd = curl --connect-timeout 2 --retry 3 --silent |
|
33 | 33 | |
|
34 | 34 | [auth] |
|
35 | 35 | example.schemes = https |
|
36 | 36 | example.prefix = phab.example.com |
|
37 | 37 | |
|
38 | 38 | # API token. Get it from https://$HOST/conduit/login/ |
|
39 | 39 | example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx |
|
40 | 40 | """ |
|
41 | 41 | |
|
42 | 42 | from __future__ import absolute_import |
|
43 | 43 | |
|
44 | 44 | import base64 |
|
45 | 45 | import contextlib |
|
46 | 46 | import hashlib |
|
47 | 47 | import itertools |
|
48 | 48 | import json |
|
49 | 49 | import mimetypes |
|
50 | 50 | import operator |
|
51 | 51 | import re |
|
52 | 52 | |
|
53 | 53 | from mercurial.node import bin, nullid |
|
54 | 54 | from mercurial.i18n import _ |
|
55 | 55 | from mercurial.pycompat import getattr |
|
56 | 56 | from mercurial.thirdparty import attr |
|
57 | 57 | from mercurial import ( |
|
58 | 58 | cmdutil, |
|
59 | 59 | context, |
|
60 | 60 | encoding, |
|
61 | 61 | error, |
|
62 | 62 | exthelper, |
|
63 | 63 | httpconnection as httpconnectionmod, |
|
64 | 64 | match, |
|
65 | 65 | mdiff, |
|
66 | 66 | obsutil, |
|
67 | 67 | parser, |
|
68 | 68 | patch, |
|
69 | 69 | phases, |
|
70 | 70 | pycompat, |
|
71 | 71 | scmutil, |
|
72 | 72 | smartset, |
|
73 | 73 | tags, |
|
74 | 74 | templatefilters, |
|
75 | 75 | templateutil, |
|
76 | 76 | url as urlmod, |
|
77 | 77 | util, |
|
78 | 78 | ) |
|
79 | 79 | from mercurial.utils import ( |
|
80 | 80 | procutil, |
|
81 | 81 | stringutil, |
|
82 | 82 | ) |
|
83 | 83 | |
|
84 | 84 | # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for |
|
85 | 85 | # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should |
|
86 | 86 | # be specifying the version(s) of Mercurial they are tested with, or |
|
87 | 87 | # leave the attribute unspecified. |
|
88 | 88 | testedwith = b'ships-with-hg-core' |
|
89 | 89 | |
|
90 | 90 | eh = exthelper.exthelper() |
|
91 | 91 | |
|
92 | 92 | cmdtable = eh.cmdtable |
|
93 | 93 | command = eh.command |
|
94 | 94 | configtable = eh.configtable |
|
95 | 95 | templatekeyword = eh.templatekeyword |
|
96 | 96 | |
|
97 | 97 | # developer config: phabricator.batchsize |
|
98 | 98 | eh.configitem( |
|
99 | 99 | b'phabricator', b'batchsize', default=12, |
|
100 | 100 | ) |
|
101 | 101 | eh.configitem( |
|
102 | 102 | b'phabricator', b'callsign', default=None, |
|
103 | 103 | ) |
|
104 | 104 | eh.configitem( |
|
105 | 105 | b'phabricator', b'curlcmd', default=None, |
|
106 | 106 | ) |
|
107 | 107 | # developer config: phabricator.repophid |
|
108 | 108 | eh.configitem( |
|
109 | 109 | b'phabricator', b'repophid', default=None, |
|
110 | 110 | ) |
|
111 | 111 | eh.configitem( |
|
112 | 112 | b'phabricator', b'url', default=None, |
|
113 | 113 | ) |
|
114 | 114 | eh.configitem( |
|
115 | 115 | b'phabsend', b'confirm', default=False, |
|
116 | 116 | ) |
|
117 | 117 | |
|
118 | 118 | colortable = { |
|
119 | 119 | b'phabricator.action.created': b'green', |
|
120 | 120 | b'phabricator.action.skipped': b'magenta', |
|
121 | 121 | b'phabricator.action.updated': b'magenta', |
|
122 | 122 | b'phabricator.desc': b'', |
|
123 | 123 | b'phabricator.drev': b'bold', |
|
124 | 124 | b'phabricator.node': b'', |
|
125 | 125 | } |
|
126 | 126 | |
|
127 | 127 | _VCR_FLAGS = [ |
|
128 | 128 | ( |
|
129 | 129 | b'', |
|
130 | 130 | b'test-vcr', |
|
131 | 131 | b'', |
|
132 | 132 | _( |
|
133 | 133 | b'Path to a vcr file. If nonexistent, will record a new vcr transcript' |
|
134 | 134 | b', otherwise will mock all http requests using the specified vcr file.' |
|
135 | 135 | b' (ADVANCED)' |
|
136 | 136 | ), |
|
137 | 137 | ), |
|
138 | 138 | ] |
|
139 | 139 | |
|
140 | 140 | |
|
141 | 141 | def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False): |
|
142 | 142 | fullflags = flags + _VCR_FLAGS |
|
143 | 143 | |
|
144 | 144 | def hgmatcher(r1, r2): |
|
145 | 145 | if r1.uri != r2.uri or r1.method != r2.method: |
|
146 | 146 | return False |
|
147 | 147 | r1params = util.urlreq.parseqs(r1.body) |
|
148 | 148 | r2params = util.urlreq.parseqs(r2.body) |
|
149 | 149 | for key in r1params: |
|
150 | 150 | if key not in r2params: |
|
151 | 151 | return False |
|
152 | 152 | value = r1params[key][0] |
|
153 | 153 | # we want to compare json payloads without worrying about ordering |
|
154 | 154 | if value.startswith(b'{') and value.endswith(b'}'): |
|
155 |
r1json = |
|
|
156 |
r2json = |
|
|
155 | r1json = pycompat.json_loads(value) | |
|
156 | r2json = pycompat.json_loads(r2params[key][0]) | |
|
157 | 157 | if r1json != r2json: |
|
158 | 158 | return False |
|
159 | 159 | elif r2params[key][0] != value: |
|
160 | 160 | return False |
|
161 | 161 | return True |
|
162 | 162 | |
|
163 | 163 | def sanitiserequest(request): |
|
164 | 164 | request.body = re.sub( |
|
165 | 165 | br'cli-[a-z0-9]+', br'cli-hahayouwish', request.body |
|
166 | 166 | ) |
|
167 | 167 | return request |
|
168 | 168 | |
|
169 | 169 | def sanitiseresponse(response): |
|
170 | 170 | if r'set-cookie' in response[r'headers']: |
|
171 | 171 | del response[r'headers'][r'set-cookie'] |
|
172 | 172 | return response |
|
173 | 173 | |
|
174 | 174 | def decorate(fn): |
|
175 | 175 | def inner(*args, **kwargs): |
|
176 | 176 | cassette = pycompat.fsdecode(kwargs.pop(r'test_vcr', None)) |
|
177 | 177 | if cassette: |
|
178 | 178 | import hgdemandimport |
|
179 | 179 | |
|
180 | 180 | with hgdemandimport.deactivated(): |
|
181 | 181 | import vcr as vcrmod |
|
182 | 182 | import vcr.stubs as stubs |
|
183 | 183 | |
|
184 | 184 | vcr = vcrmod.VCR( |
|
185 | 185 | serializer=r'json', |
|
186 | 186 | before_record_request=sanitiserequest, |
|
187 | 187 | before_record_response=sanitiseresponse, |
|
188 | 188 | custom_patches=[ |
|
189 | 189 | ( |
|
190 | 190 | urlmod, |
|
191 | 191 | r'httpconnection', |
|
192 | 192 | stubs.VCRHTTPConnection, |
|
193 | 193 | ), |
|
194 | 194 | ( |
|
195 | 195 | urlmod, |
|
196 | 196 | r'httpsconnection', |
|
197 | 197 | stubs.VCRHTTPSConnection, |
|
198 | 198 | ), |
|
199 | 199 | ], |
|
200 | 200 | ) |
|
201 | 201 | vcr.register_matcher(r'hgmatcher', hgmatcher) |
|
202 | 202 | with vcr.use_cassette(cassette, match_on=[r'hgmatcher']): |
|
203 | 203 | return fn(*args, **kwargs) |
|
204 | 204 | return fn(*args, **kwargs) |
|
205 | 205 | |
|
206 | 206 | inner.__name__ = fn.__name__ |
|
207 | 207 | inner.__doc__ = fn.__doc__ |
|
208 | 208 | return command( |
|
209 | 209 | name, |
|
210 | 210 | fullflags, |
|
211 | 211 | spec, |
|
212 | 212 | helpcategory=helpcategory, |
|
213 | 213 | optionalrepo=optionalrepo, |
|
214 | 214 | )(inner) |
|
215 | 215 | |
|
216 | 216 | return decorate |
|
217 | 217 | |
|
218 | 218 | |
|
219 | 219 | def urlencodenested(params): |
|
220 | 220 | """like urlencode, but works with nested parameters. |
|
221 | 221 | |
|
222 | 222 | For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be |
|
223 | 223 | flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to |
|
224 | 224 | urlencode. Note: the encoding is consistent with PHP's http_build_query. |
|
225 | 225 | """ |
|
226 | 226 | flatparams = util.sortdict() |
|
227 | 227 | |
|
228 | 228 | def process(prefix, obj): |
|
229 | 229 | if isinstance(obj, bool): |
|
230 | 230 | obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form |
|
231 | 231 | lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)] |
|
232 | 232 | items = {list: lister, dict: lambda x: x.items()}.get(type(obj)) |
|
233 | 233 | if items is None: |
|
234 | 234 | flatparams[prefix] = obj |
|
235 | 235 | else: |
|
236 | 236 | for k, v in items(obj): |
|
237 | 237 | if prefix: |
|
238 | 238 | process(b'%s[%s]' % (prefix, k), v) |
|
239 | 239 | else: |
|
240 | 240 | process(k, v) |
|
241 | 241 | |
|
242 | 242 | process(b'', params) |
|
243 | 243 | return util.urlreq.urlencode(flatparams) |
|
244 | 244 | |
|
245 | 245 | |
|
246 | 246 | def readurltoken(ui): |
|
247 | 247 | """return conduit url, token and make sure they exist |
|
248 | 248 | |
|
249 | 249 | Currently read from [auth] config section. In the future, it might |
|
250 | 250 | make sense to read from .arcconfig and .arcrc as well. |
|
251 | 251 | """ |
|
252 | 252 | url = ui.config(b'phabricator', b'url') |
|
253 | 253 | if not url: |
|
254 | 254 | raise error.Abort( |
|
255 | 255 | _(b'config %s.%s is required') % (b'phabricator', b'url') |
|
256 | 256 | ) |
|
257 | 257 | |
|
258 | 258 | res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user) |
|
259 | 259 | token = None |
|
260 | 260 | |
|
261 | 261 | if res: |
|
262 | 262 | group, auth = res |
|
263 | 263 | |
|
264 | 264 | ui.debug(b"using auth.%s.* for authentication\n" % group) |
|
265 | 265 | |
|
266 | 266 | token = auth.get(b'phabtoken') |
|
267 | 267 | |
|
268 | 268 | if not token: |
|
269 | 269 | raise error.Abort( |
|
270 | 270 | _(b'Can\'t find conduit token associated to %s') % (url,) |
|
271 | 271 | ) |
|
272 | 272 | |
|
273 | 273 | return url, token |
|
274 | 274 | |
|
275 | 275 | |
|
276 | 276 | def callconduit(ui, name, params): |
|
277 | 277 | """call Conduit API, params is a dict. return json.loads result, or None""" |
|
278 | 278 | host, token = readurltoken(ui) |
|
279 | 279 | url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo() |
|
280 | 280 | ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params))) |
|
281 | 281 | params = params.copy() |
|
282 | 282 | params[b'__conduit__'] = { |
|
283 | 283 | b'token': token, |
|
284 | 284 | } |
|
285 | 285 | rawdata = { |
|
286 | 286 | b'params': templatefilters.json(params), |
|
287 | 287 | b'output': b'json', |
|
288 | 288 | b'__conduit__': 1, |
|
289 | 289 | } |
|
290 | 290 | data = urlencodenested(rawdata) |
|
291 | 291 | curlcmd = ui.config(b'phabricator', b'curlcmd') |
|
292 | 292 | if curlcmd: |
|
293 | 293 | sin, sout = procutil.popen2( |
|
294 | 294 | b'%s -d @- %s' % (curlcmd, procutil.shellquote(url)) |
|
295 | 295 | ) |
|
296 | 296 | sin.write(data) |
|
297 | 297 | sin.close() |
|
298 | 298 | body = sout.read() |
|
299 | 299 | else: |
|
300 | 300 | urlopener = urlmod.opener(ui, authinfo) |
|
301 | 301 | request = util.urlreq.request(pycompat.strurl(url), data=data) |
|
302 | 302 | with contextlib.closing(urlopener.open(request)) as rsp: |
|
303 | 303 | body = rsp.read() |
|
304 | 304 | ui.debug(b'Conduit Response: %s\n' % body) |
|
305 | 305 | parsed = pycompat.rapply( |
|
306 | 306 | lambda x: encoding.unitolocal(x) |
|
307 | 307 | if isinstance(x, pycompat.unicode) |
|
308 | 308 | else x, |
|
309 | 309 | # json.loads only accepts bytes from py3.6+ |
|
310 |
|
|
|
310 | pycompat.json_loads(encoding.unifromlocal(body)), | |
|
311 | 311 | ) |
|
312 | 312 | if parsed.get(b'error_code'): |
|
313 | 313 | msg = _(b'Conduit Error (%s): %s') % ( |
|
314 | 314 | parsed[b'error_code'], |
|
315 | 315 | parsed[b'error_info'], |
|
316 | 316 | ) |
|
317 | 317 | raise error.Abort(msg) |
|
318 | 318 | return parsed[b'result'] |
|
319 | 319 | |
|
320 | 320 | |
|
321 | 321 | @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True) |
|
322 | 322 | def debugcallconduit(ui, repo, name): |
|
323 | 323 | """call Conduit API |
|
324 | 324 | |
|
325 | 325 | Call parameters are read from stdin as a JSON blob. Result will be written |
|
326 | 326 | to stdout as a JSON blob. |
|
327 | 327 | """ |
|
328 | 328 | # json.loads only accepts bytes from 3.6+ |
|
329 | 329 | rawparams = encoding.unifromlocal(ui.fin.read()) |
|
330 | 330 | # json.loads only returns unicode strings |
|
331 | 331 | params = pycompat.rapply( |
|
332 | 332 | lambda x: encoding.unitolocal(x) |
|
333 | 333 | if isinstance(x, pycompat.unicode) |
|
334 | 334 | else x, |
|
335 |
|
|
|
335 | pycompat.json_loads(rawparams), | |
|
336 | 336 | ) |
|
337 | 337 | # json.dumps only accepts unicode strings |
|
338 | 338 | result = pycompat.rapply( |
|
339 | 339 | lambda x: encoding.unifromlocal(x) if isinstance(x, bytes) else x, |
|
340 | 340 | callconduit(ui, name, params), |
|
341 | 341 | ) |
|
342 | 342 | s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': ')) |
|
343 | 343 | ui.write(b'%s\n' % encoding.unitolocal(s)) |
|
344 | 344 | |
|
345 | 345 | |
|
346 | 346 | def getrepophid(repo): |
|
347 | 347 | """given callsign, return repository PHID or None""" |
|
348 | 348 | # developer config: phabricator.repophid |
|
349 | 349 | repophid = repo.ui.config(b'phabricator', b'repophid') |
|
350 | 350 | if repophid: |
|
351 | 351 | return repophid |
|
352 | 352 | callsign = repo.ui.config(b'phabricator', b'callsign') |
|
353 | 353 | if not callsign: |
|
354 | 354 | return None |
|
355 | 355 | query = callconduit( |
|
356 | 356 | repo.ui, |
|
357 | 357 | b'diffusion.repository.search', |
|
358 | 358 | {b'constraints': {b'callsigns': [callsign]}}, |
|
359 | 359 | ) |
|
360 | 360 | if len(query[b'data']) == 0: |
|
361 | 361 | return None |
|
362 | 362 | repophid = query[b'data'][0][b'phid'] |
|
363 | 363 | repo.ui.setconfig(b'phabricator', b'repophid', repophid) |
|
364 | 364 | return repophid |
|
365 | 365 | |
|
366 | 366 | |
|
367 | 367 | _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z') |
|
368 | 368 | _differentialrevisiondescre = re.compile( |
|
369 | 369 | br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M |
|
370 | 370 | ) |
|
371 | 371 | |
|
372 | 372 | |
|
373 | 373 | def getoldnodedrevmap(repo, nodelist): |
|
374 | 374 | """find previous nodes that has been sent to Phabricator |
|
375 | 375 | |
|
376 | 376 | return {node: (oldnode, Differential diff, Differential Revision ID)} |
|
377 | 377 | for node in nodelist with known previous sent versions, or associated |
|
378 | 378 | Differential Revision IDs. ``oldnode`` and ``Differential diff`` could |
|
379 | 379 | be ``None``. |
|
380 | 380 | |
|
381 | 381 | Examines commit messages like "Differential Revision:" to get the |
|
382 | 382 | association information. |
|
383 | 383 | |
|
384 | 384 | If such commit message line is not found, examines all precursors and their |
|
385 | 385 | tags. Tags with format like "D1234" are considered a match and the node |
|
386 | 386 | with that tag, and the number after "D" (ex. 1234) will be returned. |
|
387 | 387 | |
|
388 | 388 | The ``old node``, if not None, is guaranteed to be the last diff of |
|
389 | 389 | corresponding Differential Revision, and exist in the repo. |
|
390 | 390 | """ |
|
391 | 391 | unfi = repo.unfiltered() |
|
392 | 392 | nodemap = unfi.changelog.nodemap |
|
393 | 393 | |
|
394 | 394 | result = {} # {node: (oldnode?, lastdiff?, drev)} |
|
395 | 395 | toconfirm = {} # {node: (force, {precnode}, drev)} |
|
396 | 396 | for node in nodelist: |
|
397 | 397 | ctx = unfi[node] |
|
398 | 398 | # For tags like "D123", put them into "toconfirm" to verify later |
|
399 | 399 | precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node])) |
|
400 | 400 | for n in precnodes: |
|
401 | 401 | if n in nodemap: |
|
402 | 402 | for tag in unfi.nodetags(n): |
|
403 | 403 | m = _differentialrevisiontagre.match(tag) |
|
404 | 404 | if m: |
|
405 | 405 | toconfirm[node] = (0, set(precnodes), int(m.group(1))) |
|
406 | 406 | continue |
|
407 | 407 | |
|
408 | 408 | # Check commit message |
|
409 | 409 | m = _differentialrevisiondescre.search(ctx.description()) |
|
410 | 410 | if m: |
|
411 | 411 | toconfirm[node] = (1, set(precnodes), int(m.group(r'id'))) |
|
412 | 412 | |
|
413 | 413 | # Double check if tags are genuine by collecting all old nodes from |
|
414 | 414 | # Phabricator, and expect precursors overlap with it. |
|
415 | 415 | if toconfirm: |
|
416 | 416 | drevs = [drev for force, precs, drev in toconfirm.values()] |
|
417 | 417 | alldiffs = callconduit( |
|
418 | 418 | unfi.ui, b'differential.querydiffs', {b'revisionIDs': drevs} |
|
419 | 419 | ) |
|
420 | 420 | getnode = lambda d: bin(getdiffmeta(d).get(b'node', b'')) or None |
|
421 | 421 | for newnode, (force, precset, drev) in toconfirm.items(): |
|
422 | 422 | diffs = [ |
|
423 | 423 | d for d in alldiffs.values() if int(d[b'revisionID']) == drev |
|
424 | 424 | ] |
|
425 | 425 | |
|
426 | 426 | # "precursors" as known by Phabricator |
|
427 | 427 | phprecset = set(getnode(d) for d in diffs) |
|
428 | 428 | |
|
429 | 429 | # Ignore if precursors (Phabricator and local repo) do not overlap, |
|
430 | 430 | # and force is not set (when commit message says nothing) |
|
431 | 431 | if not force and not bool(phprecset & precset): |
|
432 | 432 | tagname = b'D%d' % drev |
|
433 | 433 | tags.tag( |
|
434 | 434 | repo, |
|
435 | 435 | tagname, |
|
436 | 436 | nullid, |
|
437 | 437 | message=None, |
|
438 | 438 | user=None, |
|
439 | 439 | date=None, |
|
440 | 440 | local=True, |
|
441 | 441 | ) |
|
442 | 442 | unfi.ui.warn( |
|
443 | 443 | _( |
|
444 | 444 | b'D%s: local tag removed - does not match ' |
|
445 | 445 | b'Differential history\n' |
|
446 | 446 | ) |
|
447 | 447 | % drev |
|
448 | 448 | ) |
|
449 | 449 | continue |
|
450 | 450 | |
|
451 | 451 | # Find the last node using Phabricator metadata, and make sure it |
|
452 | 452 | # exists in the repo |
|
453 | 453 | oldnode = lastdiff = None |
|
454 | 454 | if diffs: |
|
455 | 455 | lastdiff = max(diffs, key=lambda d: int(d[b'id'])) |
|
456 | 456 | oldnode = getnode(lastdiff) |
|
457 | 457 | if oldnode and oldnode not in nodemap: |
|
458 | 458 | oldnode = None |
|
459 | 459 | |
|
460 | 460 | result[newnode] = (oldnode, lastdiff, drev) |
|
461 | 461 | |
|
462 | 462 | return result |
|
463 | 463 | |
|
464 | 464 | |
|
465 | 465 | def getdiff(ctx, diffopts): |
|
466 | 466 | """plain-text diff without header (user, commit message, etc)""" |
|
467 | 467 | output = util.stringio() |
|
468 | 468 | for chunk, _label in patch.diffui( |
|
469 | 469 | ctx.repo(), ctx.p1().node(), ctx.node(), None, opts=diffopts |
|
470 | 470 | ): |
|
471 | 471 | output.write(chunk) |
|
472 | 472 | return output.getvalue() |
|
473 | 473 | |
|
474 | 474 | |
|
475 | 475 | class DiffChangeType(object): |
|
476 | 476 | ADD = 1 |
|
477 | 477 | CHANGE = 2 |
|
478 | 478 | DELETE = 3 |
|
479 | 479 | MOVE_AWAY = 4 |
|
480 | 480 | COPY_AWAY = 5 |
|
481 | 481 | MOVE_HERE = 6 |
|
482 | 482 | COPY_HERE = 7 |
|
483 | 483 | MULTICOPY = 8 |
|
484 | 484 | |
|
485 | 485 | |
|
486 | 486 | class DiffFileType(object): |
|
487 | 487 | TEXT = 1 |
|
488 | 488 | IMAGE = 2 |
|
489 | 489 | BINARY = 3 |
|
490 | 490 | |
|
491 | 491 | |
|
492 | 492 | @attr.s |
|
493 | 493 | class phabhunk(dict): |
|
494 | 494 | """Represents a Differential hunk, which is owned by a Differential change |
|
495 | 495 | """ |
|
496 | 496 | |
|
497 | 497 | oldOffset = attr.ib(default=0) # camelcase-required |
|
498 | 498 | oldLength = attr.ib(default=0) # camelcase-required |
|
499 | 499 | newOffset = attr.ib(default=0) # camelcase-required |
|
500 | 500 | newLength = attr.ib(default=0) # camelcase-required |
|
501 | 501 | corpus = attr.ib(default='') |
|
502 | 502 | # These get added to the phabchange's equivalents |
|
503 | 503 | addLines = attr.ib(default=0) # camelcase-required |
|
504 | 504 | delLines = attr.ib(default=0) # camelcase-required |
|
505 | 505 | |
|
506 | 506 | |
|
507 | 507 | @attr.s |
|
508 | 508 | class phabchange(object): |
|
509 | 509 | """Represents a Differential change, owns Differential hunks and owned by a |
|
510 | 510 | Differential diff. Each one represents one file in a diff. |
|
511 | 511 | """ |
|
512 | 512 | |
|
513 | 513 | currentPath = attr.ib(default=None) # camelcase-required |
|
514 | 514 | oldPath = attr.ib(default=None) # camelcase-required |
|
515 | 515 | awayPaths = attr.ib(default=attr.Factory(list)) # camelcase-required |
|
516 | 516 | metadata = attr.ib(default=attr.Factory(dict)) |
|
517 | 517 | oldProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required |
|
518 | 518 | newProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required |
|
519 | 519 | type = attr.ib(default=DiffChangeType.CHANGE) |
|
520 | 520 | fileType = attr.ib(default=DiffFileType.TEXT) # camelcase-required |
|
521 | 521 | commitHash = attr.ib(default=None) # camelcase-required |
|
522 | 522 | addLines = attr.ib(default=0) # camelcase-required |
|
523 | 523 | delLines = attr.ib(default=0) # camelcase-required |
|
524 | 524 | hunks = attr.ib(default=attr.Factory(list)) |
|
525 | 525 | |
|
526 | 526 | def copynewmetadatatoold(self): |
|
527 | 527 | for key in list(self.metadata.keys()): |
|
528 | 528 | newkey = key.replace(b'new:', b'old:') |
|
529 | 529 | self.metadata[newkey] = self.metadata[key] |
|
530 | 530 | |
|
531 | 531 | def addoldmode(self, value): |
|
532 | 532 | self.oldProperties[b'unix:filemode'] = value |
|
533 | 533 | |
|
534 | 534 | def addnewmode(self, value): |
|
535 | 535 | self.newProperties[b'unix:filemode'] = value |
|
536 | 536 | |
|
537 | 537 | def addhunk(self, hunk): |
|
538 | 538 | if not isinstance(hunk, phabhunk): |
|
539 | 539 | raise error.Abort(b'phabchange.addhunk only takes phabhunks') |
|
540 | 540 | self.hunks.append(pycompat.byteskwargs(attr.asdict(hunk))) |
|
541 | 541 | # It's useful to include these stats since the Phab web UI shows them, |
|
542 | 542 | # and uses them to estimate how large a change a Revision is. Also used |
|
543 | 543 | # in email subjects for the [+++--] bit. |
|
544 | 544 | self.addLines += hunk.addLines |
|
545 | 545 | self.delLines += hunk.delLines |
|
546 | 546 | |
|
547 | 547 | |
|
548 | 548 | @attr.s |
|
549 | 549 | class phabdiff(object): |
|
550 | 550 | """Represents a Differential diff, owns Differential changes. Corresponds |
|
551 | 551 | to a commit. |
|
552 | 552 | """ |
|
553 | 553 | |
|
554 | 554 | # Doesn't seem to be any reason to send this (output of uname -n) |
|
555 | 555 | sourceMachine = attr.ib(default=b'') # camelcase-required |
|
556 | 556 | sourcePath = attr.ib(default=b'/') # camelcase-required |
|
557 | 557 | sourceControlBaseRevision = attr.ib(default=b'0' * 40) # camelcase-required |
|
558 | 558 | sourceControlPath = attr.ib(default=b'/') # camelcase-required |
|
559 | 559 | sourceControlSystem = attr.ib(default=b'hg') # camelcase-required |
|
560 | 560 | branch = attr.ib(default=b'default') |
|
561 | 561 | bookmark = attr.ib(default=None) |
|
562 | 562 | creationMethod = attr.ib(default=b'phabsend') # camelcase-required |
|
563 | 563 | lintStatus = attr.ib(default=b'none') # camelcase-required |
|
564 | 564 | unitStatus = attr.ib(default=b'none') # camelcase-required |
|
565 | 565 | changes = attr.ib(default=attr.Factory(dict)) |
|
566 | 566 | repositoryPHID = attr.ib(default=None) # camelcase-required |
|
567 | 567 | |
|
568 | 568 | def addchange(self, change): |
|
569 | 569 | if not isinstance(change, phabchange): |
|
570 | 570 | raise error.Abort(b'phabdiff.addchange only takes phabchanges') |
|
571 | 571 | self.changes[change.currentPath] = pycompat.byteskwargs( |
|
572 | 572 | attr.asdict(change) |
|
573 | 573 | ) |
|
574 | 574 | |
|
575 | 575 | |
|
576 | 576 | def maketext(pchange, ctx, fname): |
|
577 | 577 | """populate the phabchange for a text file""" |
|
578 | 578 | repo = ctx.repo() |
|
579 | 579 | fmatcher = match.exact([fname]) |
|
580 | 580 | diffopts = mdiff.diffopts(git=True, context=32767) |
|
581 | 581 | _pfctx, _fctx, header, fhunks = next( |
|
582 | 582 | patch.diffhunks(repo, ctx.p1(), ctx, fmatcher, opts=diffopts) |
|
583 | 583 | ) |
|
584 | 584 | |
|
585 | 585 | for fhunk in fhunks: |
|
586 | 586 | (oldOffset, oldLength, newOffset, newLength), lines = fhunk |
|
587 | 587 | corpus = b''.join(lines[1:]) |
|
588 | 588 | shunk = list(header) |
|
589 | 589 | shunk.extend(lines) |
|
590 | 590 | _mf, _mt, addLines, delLines, _hb = patch.diffstatsum( |
|
591 | 591 | patch.diffstatdata(util.iterlines(shunk)) |
|
592 | 592 | ) |
|
593 | 593 | pchange.addhunk( |
|
594 | 594 | phabhunk( |
|
595 | 595 | oldOffset, |
|
596 | 596 | oldLength, |
|
597 | 597 | newOffset, |
|
598 | 598 | newLength, |
|
599 | 599 | corpus, |
|
600 | 600 | addLines, |
|
601 | 601 | delLines, |
|
602 | 602 | ) |
|
603 | 603 | ) |
|
604 | 604 | |
|
605 | 605 | |
|
606 | 606 | def uploadchunks(fctx, fphid): |
|
607 | 607 | """upload large binary files as separate chunks. |
|
608 | 608 | Phab requests chunking over 8MiB, and splits into 4MiB chunks |
|
609 | 609 | """ |
|
610 | 610 | ui = fctx.repo().ui |
|
611 | 611 | chunks = callconduit(ui, b'file.querychunks', {b'filePHID': fphid}) |
|
612 | 612 | progress = ui.makeprogress( |
|
613 | 613 | _(b'uploading file chunks'), unit=_(b'chunks'), total=len(chunks) |
|
614 | 614 | ) |
|
615 | 615 | for chunk in chunks: |
|
616 | 616 | progress.increment() |
|
617 | 617 | if chunk[b'complete']: |
|
618 | 618 | continue |
|
619 | 619 | bstart = int(chunk[b'byteStart']) |
|
620 | 620 | bend = int(chunk[b'byteEnd']) |
|
621 | 621 | callconduit( |
|
622 | 622 | ui, |
|
623 | 623 | b'file.uploadchunk', |
|
624 | 624 | { |
|
625 | 625 | b'filePHID': fphid, |
|
626 | 626 | b'byteStart': bstart, |
|
627 | 627 | b'data': base64.b64encode(fctx.data()[bstart:bend]), |
|
628 | 628 | b'dataEncoding': b'base64', |
|
629 | 629 | }, |
|
630 | 630 | ) |
|
631 | 631 | progress.complete() |
|
632 | 632 | |
|
633 | 633 | |
|
634 | 634 | def uploadfile(fctx): |
|
635 | 635 | """upload binary files to Phabricator""" |
|
636 | 636 | repo = fctx.repo() |
|
637 | 637 | ui = repo.ui |
|
638 | 638 | fname = fctx.path() |
|
639 | 639 | size = fctx.size() |
|
640 | 640 | fhash = pycompat.bytestr(hashlib.sha256(fctx.data()).hexdigest()) |
|
641 | 641 | |
|
642 | 642 | # an allocate call is required first to see if an upload is even required |
|
643 | 643 | # (Phab might already have it) and to determine if chunking is needed |
|
644 | 644 | allocateparams = { |
|
645 | 645 | b'name': fname, |
|
646 | 646 | b'contentLength': size, |
|
647 | 647 | b'contentHash': fhash, |
|
648 | 648 | } |
|
649 | 649 | filealloc = callconduit(ui, b'file.allocate', allocateparams) |
|
650 | 650 | fphid = filealloc[b'filePHID'] |
|
651 | 651 | |
|
652 | 652 | if filealloc[b'upload']: |
|
653 | 653 | ui.write(_(b'uploading %s\n') % bytes(fctx)) |
|
654 | 654 | if not fphid: |
|
655 | 655 | uploadparams = { |
|
656 | 656 | b'name': fname, |
|
657 | 657 | b'data_base64': base64.b64encode(fctx.data()), |
|
658 | 658 | } |
|
659 | 659 | fphid = callconduit(ui, b'file.upload', uploadparams) |
|
660 | 660 | else: |
|
661 | 661 | uploadchunks(fctx, fphid) |
|
662 | 662 | else: |
|
663 | 663 | ui.debug(b'server already has %s\n' % bytes(fctx)) |
|
664 | 664 | |
|
665 | 665 | if not fphid: |
|
666 | 666 | raise error.Abort(b'Upload of %s failed.' % bytes(fctx)) |
|
667 | 667 | |
|
668 | 668 | return fphid |
|
669 | 669 | |
|
670 | 670 | |
|
671 | 671 | def addoldbinary(pchange, fctx, originalfname): |
|
672 | 672 | """add the metadata for the previous version of a binary file to the |
|
673 | 673 | phabchange for the new version |
|
674 | 674 | """ |
|
675 | 675 | oldfctx = fctx.p1()[originalfname] |
|
676 | 676 | if fctx.cmp(oldfctx): |
|
677 | 677 | # Files differ, add the old one |
|
678 | 678 | pchange.metadata[b'old:file:size'] = oldfctx.size() |
|
679 | 679 | mimeguess, _enc = mimetypes.guess_type( |
|
680 | 680 | encoding.unifromlocal(oldfctx.path()) |
|
681 | 681 | ) |
|
682 | 682 | if mimeguess: |
|
683 | 683 | pchange.metadata[b'old:file:mime-type'] = pycompat.bytestr( |
|
684 | 684 | mimeguess |
|
685 | 685 | ) |
|
686 | 686 | fphid = uploadfile(oldfctx) |
|
687 | 687 | pchange.metadata[b'old:binary-phid'] = fphid |
|
688 | 688 | else: |
|
689 | 689 | # If it's left as IMAGE/BINARY web UI might try to display it |
|
690 | 690 | pchange.fileType = DiffFileType.TEXT |
|
691 | 691 | pchange.copynewmetadatatoold() |
|
692 | 692 | |
|
693 | 693 | |
|
694 | 694 | def makebinary(pchange, fctx): |
|
695 | 695 | """populate the phabchange for a binary file""" |
|
696 | 696 | pchange.fileType = DiffFileType.BINARY |
|
697 | 697 | fphid = uploadfile(fctx) |
|
698 | 698 | pchange.metadata[b'new:binary-phid'] = fphid |
|
699 | 699 | pchange.metadata[b'new:file:size'] = fctx.size() |
|
700 | 700 | mimeguess, _enc = mimetypes.guess_type(encoding.unifromlocal(fctx.path())) |
|
701 | 701 | if mimeguess: |
|
702 | 702 | mimeguess = pycompat.bytestr(mimeguess) |
|
703 | 703 | pchange.metadata[b'new:file:mime-type'] = mimeguess |
|
704 | 704 | if mimeguess.startswith(b'image/'): |
|
705 | 705 | pchange.fileType = DiffFileType.IMAGE |
|
706 | 706 | |
|
707 | 707 | |
|
708 | 708 | # Copied from mercurial/patch.py |
|
709 | 709 | gitmode = {b'l': b'120000', b'x': b'100755', b'': b'100644'} |
|
710 | 710 | |
|
711 | 711 | |
|
712 | 712 | def notutf8(fctx): |
|
713 | 713 | """detect non-UTF-8 text files since Phabricator requires them to be marked |
|
714 | 714 | as binary |
|
715 | 715 | """ |
|
716 | 716 | try: |
|
717 | 717 | fctx.data().decode('utf-8') |
|
718 | 718 | if fctx.parents(): |
|
719 | 719 | fctx.p1().data().decode('utf-8') |
|
720 | 720 | return False |
|
721 | 721 | except UnicodeDecodeError: |
|
722 | 722 | fctx.repo().ui.write( |
|
723 | 723 | _(b'file %s detected as non-UTF-8, marked as binary\n') |
|
724 | 724 | % fctx.path() |
|
725 | 725 | ) |
|
726 | 726 | return True |
|
727 | 727 | |
|
728 | 728 | |
|
729 | 729 | def addremoved(pdiff, ctx, removed): |
|
730 | 730 | """add removed files to the phabdiff. Shouldn't include moves""" |
|
731 | 731 | for fname in removed: |
|
732 | 732 | pchange = phabchange( |
|
733 | 733 | currentPath=fname, oldPath=fname, type=DiffChangeType.DELETE |
|
734 | 734 | ) |
|
735 | 735 | pchange.addoldmode(gitmode[ctx.p1()[fname].flags()]) |
|
736 | 736 | fctx = ctx.p1()[fname] |
|
737 | 737 | if not (fctx.isbinary() or notutf8(fctx)): |
|
738 | 738 | maketext(pchange, ctx, fname) |
|
739 | 739 | |
|
740 | 740 | pdiff.addchange(pchange) |
|
741 | 741 | |
|
742 | 742 | |
|
743 | 743 | def addmodified(pdiff, ctx, modified): |
|
744 | 744 | """add modified files to the phabdiff""" |
|
745 | 745 | for fname in modified: |
|
746 | 746 | fctx = ctx[fname] |
|
747 | 747 | pchange = phabchange(currentPath=fname, oldPath=fname) |
|
748 | 748 | filemode = gitmode[ctx[fname].flags()] |
|
749 | 749 | originalmode = gitmode[ctx.p1()[fname].flags()] |
|
750 | 750 | if filemode != originalmode: |
|
751 | 751 | pchange.addoldmode(originalmode) |
|
752 | 752 | pchange.addnewmode(filemode) |
|
753 | 753 | |
|
754 | 754 | if fctx.isbinary() or notutf8(fctx): |
|
755 | 755 | makebinary(pchange, fctx) |
|
756 | 756 | addoldbinary(pchange, fctx, fname) |
|
757 | 757 | else: |
|
758 | 758 | maketext(pchange, ctx, fname) |
|
759 | 759 | |
|
760 | 760 | pdiff.addchange(pchange) |
|
761 | 761 | |
|
762 | 762 | |
|
763 | 763 | def addadded(pdiff, ctx, added, removed): |
|
764 | 764 | """add file adds to the phabdiff, both new files and copies/moves""" |
|
765 | 765 | # Keep track of files that've been recorded as moved/copied, so if there are |
|
766 | 766 | # additional copies we can mark them (moves get removed from removed) |
|
767 | 767 | copiedchanges = {} |
|
768 | 768 | movedchanges = {} |
|
769 | 769 | for fname in added: |
|
770 | 770 | fctx = ctx[fname] |
|
771 | 771 | pchange = phabchange(currentPath=fname) |
|
772 | 772 | |
|
773 | 773 | filemode = gitmode[ctx[fname].flags()] |
|
774 | 774 | renamed = fctx.renamed() |
|
775 | 775 | |
|
776 | 776 | if renamed: |
|
777 | 777 | originalfname = renamed[0] |
|
778 | 778 | originalmode = gitmode[ctx.p1()[originalfname].flags()] |
|
779 | 779 | pchange.oldPath = originalfname |
|
780 | 780 | |
|
781 | 781 | if originalfname in removed: |
|
782 | 782 | origpchange = phabchange( |
|
783 | 783 | currentPath=originalfname, |
|
784 | 784 | oldPath=originalfname, |
|
785 | 785 | type=DiffChangeType.MOVE_AWAY, |
|
786 | 786 | awayPaths=[fname], |
|
787 | 787 | ) |
|
788 | 788 | movedchanges[originalfname] = origpchange |
|
789 | 789 | removed.remove(originalfname) |
|
790 | 790 | pchange.type = DiffChangeType.MOVE_HERE |
|
791 | 791 | elif originalfname in movedchanges: |
|
792 | 792 | movedchanges[originalfname].type = DiffChangeType.MULTICOPY |
|
793 | 793 | movedchanges[originalfname].awayPaths.append(fname) |
|
794 | 794 | pchange.type = DiffChangeType.COPY_HERE |
|
795 | 795 | else: # pure copy |
|
796 | 796 | if originalfname not in copiedchanges: |
|
797 | 797 | origpchange = phabchange( |
|
798 | 798 | currentPath=originalfname, type=DiffChangeType.COPY_AWAY |
|
799 | 799 | ) |
|
800 | 800 | copiedchanges[originalfname] = origpchange |
|
801 | 801 | else: |
|
802 | 802 | origpchange = copiedchanges[originalfname] |
|
803 | 803 | origpchange.awayPaths.append(fname) |
|
804 | 804 | pchange.type = DiffChangeType.COPY_HERE |
|
805 | 805 | |
|
806 | 806 | if filemode != originalmode: |
|
807 | 807 | pchange.addoldmode(originalmode) |
|
808 | 808 | pchange.addnewmode(filemode) |
|
809 | 809 | else: # Brand-new file |
|
810 | 810 | pchange.addnewmode(gitmode[fctx.flags()]) |
|
811 | 811 | pchange.type = DiffChangeType.ADD |
|
812 | 812 | |
|
813 | 813 | if fctx.isbinary() or notutf8(fctx): |
|
814 | 814 | makebinary(pchange, fctx) |
|
815 | 815 | if renamed: |
|
816 | 816 | addoldbinary(pchange, fctx, originalfname) |
|
817 | 817 | else: |
|
818 | 818 | maketext(pchange, ctx, fname) |
|
819 | 819 | |
|
820 | 820 | pdiff.addchange(pchange) |
|
821 | 821 | |
|
822 | 822 | for _path, copiedchange in copiedchanges.items(): |
|
823 | 823 | pdiff.addchange(copiedchange) |
|
824 | 824 | for _path, movedchange in movedchanges.items(): |
|
825 | 825 | pdiff.addchange(movedchange) |
|
826 | 826 | |
|
827 | 827 | |
|
828 | 828 | def creatediff(ctx): |
|
829 | 829 | """create a Differential Diff""" |
|
830 | 830 | repo = ctx.repo() |
|
831 | 831 | repophid = getrepophid(repo) |
|
832 | 832 | # Create a "Differential Diff" via "differential.creatediff" API |
|
833 | 833 | pdiff = phabdiff( |
|
834 | 834 | sourceControlBaseRevision=b'%s' % ctx.p1().hex(), |
|
835 | 835 | branch=b'%s' % ctx.branch(), |
|
836 | 836 | ) |
|
837 | 837 | modified, added, removed, _d, _u, _i, _c = ctx.p1().status(ctx) |
|
838 | 838 | # addadded will remove moved files from removed, so addremoved won't get |
|
839 | 839 | # them |
|
840 | 840 | addadded(pdiff, ctx, added, removed) |
|
841 | 841 | addmodified(pdiff, ctx, modified) |
|
842 | 842 | addremoved(pdiff, ctx, removed) |
|
843 | 843 | if repophid: |
|
844 | 844 | pdiff.repositoryPHID = repophid |
|
845 | 845 | diff = callconduit( |
|
846 | 846 | repo.ui, |
|
847 | 847 | b'differential.creatediff', |
|
848 | 848 | pycompat.byteskwargs(attr.asdict(pdiff)), |
|
849 | 849 | ) |
|
850 | 850 | if not diff: |
|
851 | 851 | raise error.Abort(_(b'cannot create diff for %s') % ctx) |
|
852 | 852 | return diff |
|
853 | 853 | |
|
854 | 854 | |
|
855 | 855 | def writediffproperties(ctx, diff): |
|
856 | 856 | """write metadata to diff so patches could be applied losslessly""" |
|
857 | 857 | # creatediff returns with a diffid but query returns with an id |
|
858 | 858 | diffid = diff.get(b'diffid', diff.get(b'id')) |
|
859 | 859 | params = { |
|
860 | 860 | b'diff_id': diffid, |
|
861 | 861 | b'name': b'hg:meta', |
|
862 | 862 | b'data': templatefilters.json( |
|
863 | 863 | { |
|
864 | 864 | b'user': ctx.user(), |
|
865 | 865 | b'date': b'%d %d' % ctx.date(), |
|
866 | 866 | b'branch': ctx.branch(), |
|
867 | 867 | b'node': ctx.hex(), |
|
868 | 868 | b'parent': ctx.p1().hex(), |
|
869 | 869 | } |
|
870 | 870 | ), |
|
871 | 871 | } |
|
872 | 872 | callconduit(ctx.repo().ui, b'differential.setdiffproperty', params) |
|
873 | 873 | |
|
874 | 874 | params = { |
|
875 | 875 | b'diff_id': diffid, |
|
876 | 876 | b'name': b'local:commits', |
|
877 | 877 | b'data': templatefilters.json( |
|
878 | 878 | { |
|
879 | 879 | ctx.hex(): { |
|
880 | 880 | b'author': stringutil.person(ctx.user()), |
|
881 | 881 | b'authorEmail': stringutil.email(ctx.user()), |
|
882 | 882 | b'time': int(ctx.date()[0]), |
|
883 | 883 | b'commit': ctx.hex(), |
|
884 | 884 | b'parents': [ctx.p1().hex()], |
|
885 | 885 | b'branch': ctx.branch(), |
|
886 | 886 | }, |
|
887 | 887 | } |
|
888 | 888 | ), |
|
889 | 889 | } |
|
890 | 890 | callconduit(ctx.repo().ui, b'differential.setdiffproperty', params) |
|
891 | 891 | |
|
892 | 892 | |
|
893 | 893 | def createdifferentialrevision( |
|
894 | 894 | ctx, |
|
895 | 895 | revid=None, |
|
896 | 896 | parentrevphid=None, |
|
897 | 897 | oldnode=None, |
|
898 | 898 | olddiff=None, |
|
899 | 899 | actions=None, |
|
900 | 900 | comment=None, |
|
901 | 901 | ): |
|
902 | 902 | """create or update a Differential Revision |
|
903 | 903 | |
|
904 | 904 | If revid is None, create a new Differential Revision, otherwise update |
|
905 | 905 | revid. If parentrevphid is not None, set it as a dependency. |
|
906 | 906 | |
|
907 | 907 | If oldnode is not None, check if the patch content (without commit message |
|
908 | 908 | and metadata) has changed before creating another diff. |
|
909 | 909 | |
|
910 | 910 | If actions is not None, they will be appended to the transaction. |
|
911 | 911 | """ |
|
912 | 912 | repo = ctx.repo() |
|
913 | 913 | if oldnode: |
|
914 | 914 | diffopts = mdiff.diffopts(git=True, context=32767) |
|
915 | 915 | oldctx = repo.unfiltered()[oldnode] |
|
916 | 916 | neednewdiff = getdiff(ctx, diffopts) != getdiff(oldctx, diffopts) |
|
917 | 917 | else: |
|
918 | 918 | neednewdiff = True |
|
919 | 919 | |
|
920 | 920 | transactions = [] |
|
921 | 921 | if neednewdiff: |
|
922 | 922 | diff = creatediff(ctx) |
|
923 | 923 | transactions.append({b'type': b'update', b'value': diff[b'phid']}) |
|
924 | 924 | if comment: |
|
925 | 925 | transactions.append({b'type': b'comment', b'value': comment}) |
|
926 | 926 | else: |
|
927 | 927 | # Even if we don't need to upload a new diff because the patch content |
|
928 | 928 | # does not change. We might still need to update its metadata so |
|
929 | 929 | # pushers could know the correct node metadata. |
|
930 | 930 | assert olddiff |
|
931 | 931 | diff = olddiff |
|
932 | 932 | writediffproperties(ctx, diff) |
|
933 | 933 | |
|
934 | 934 | # Set the parent Revision every time, so commit re-ordering is picked-up |
|
935 | 935 | if parentrevphid: |
|
936 | 936 | transactions.append( |
|
937 | 937 | {b'type': b'parents.set', b'value': [parentrevphid]} |
|
938 | 938 | ) |
|
939 | 939 | |
|
940 | 940 | if actions: |
|
941 | 941 | transactions += actions |
|
942 | 942 | |
|
943 | 943 | # Parse commit message and update related fields. |
|
944 | 944 | desc = ctx.description() |
|
945 | 945 | info = callconduit( |
|
946 | 946 | repo.ui, b'differential.parsecommitmessage', {b'corpus': desc} |
|
947 | 947 | ) |
|
948 | 948 | for k, v in info[b'fields'].items(): |
|
949 | 949 | if k in [b'title', b'summary', b'testPlan']: |
|
950 | 950 | transactions.append({b'type': k, b'value': v}) |
|
951 | 951 | |
|
952 | 952 | params = {b'transactions': transactions} |
|
953 | 953 | if revid is not None: |
|
954 | 954 | # Update an existing Differential Revision |
|
955 | 955 | params[b'objectIdentifier'] = revid |
|
956 | 956 | |
|
957 | 957 | revision = callconduit(repo.ui, b'differential.revision.edit', params) |
|
958 | 958 | if not revision: |
|
959 | 959 | raise error.Abort(_(b'cannot create revision for %s') % ctx) |
|
960 | 960 | |
|
961 | 961 | return revision, diff |
|
962 | 962 | |
|
963 | 963 | |
|
964 | 964 | def userphids(repo, names): |
|
965 | 965 | """convert user names to PHIDs""" |
|
966 | 966 | names = [name.lower() for name in names] |
|
967 | 967 | query = {b'constraints': {b'usernames': names}} |
|
968 | 968 | result = callconduit(repo.ui, b'user.search', query) |
|
969 | 969 | # username not found is not an error of the API. So check if we have missed |
|
970 | 970 | # some names here. |
|
971 | 971 | data = result[b'data'] |
|
972 | 972 | resolved = set(entry[b'fields'][b'username'].lower() for entry in data) |
|
973 | 973 | unresolved = set(names) - resolved |
|
974 | 974 | if unresolved: |
|
975 | 975 | raise error.Abort( |
|
976 | 976 | _(b'unknown username: %s') % b' '.join(sorted(unresolved)) |
|
977 | 977 | ) |
|
978 | 978 | return [entry[b'phid'] for entry in data] |
|
979 | 979 | |
|
980 | 980 | |
|
981 | 981 | @vcrcommand( |
|
982 | 982 | b'phabsend', |
|
983 | 983 | [ |
|
984 | 984 | (b'r', b'rev', [], _(b'revisions to send'), _(b'REV')), |
|
985 | 985 | (b'', b'amend', True, _(b'update commit messages')), |
|
986 | 986 | (b'', b'reviewer', [], _(b'specify reviewers')), |
|
987 | 987 | (b'', b'blocker', [], _(b'specify blocking reviewers')), |
|
988 | 988 | ( |
|
989 | 989 | b'm', |
|
990 | 990 | b'comment', |
|
991 | 991 | b'', |
|
992 | 992 | _(b'add a comment to Revisions with new/updated Diffs'), |
|
993 | 993 | ), |
|
994 | 994 | (b'', b'confirm', None, _(b'ask for confirmation before sending')), |
|
995 | 995 | ], |
|
996 | 996 | _(b'REV [OPTIONS]'), |
|
997 | 997 | helpcategory=command.CATEGORY_IMPORT_EXPORT, |
|
998 | 998 | ) |
|
999 | 999 | def phabsend(ui, repo, *revs, **opts): |
|
1000 | 1000 | """upload changesets to Phabricator |
|
1001 | 1001 | |
|
1002 | 1002 | If there are multiple revisions specified, they will be send as a stack |
|
1003 | 1003 | with a linear dependencies relationship using the order specified by the |
|
1004 | 1004 | revset. |
|
1005 | 1005 | |
|
1006 | 1006 | For the first time uploading changesets, local tags will be created to |
|
1007 | 1007 | maintain the association. After the first time, phabsend will check |
|
1008 | 1008 | obsstore and tags information so it can figure out whether to update an |
|
1009 | 1009 | existing Differential Revision, or create a new one. |
|
1010 | 1010 | |
|
1011 | 1011 | If --amend is set, update commit messages so they have the |
|
1012 | 1012 | ``Differential Revision`` URL, remove related tags. This is similar to what |
|
1013 | 1013 | arcanist will do, and is more desired in author-push workflows. Otherwise, |
|
1014 | 1014 | use local tags to record the ``Differential Revision`` association. |
|
1015 | 1015 | |
|
1016 | 1016 | The --confirm option lets you confirm changesets before sending them. You |
|
1017 | 1017 | can also add following to your configuration file to make it default |
|
1018 | 1018 | behaviour:: |
|
1019 | 1019 | |
|
1020 | 1020 | [phabsend] |
|
1021 | 1021 | confirm = true |
|
1022 | 1022 | |
|
1023 | 1023 | phabsend will check obsstore and the above association to decide whether to |
|
1024 | 1024 | update an existing Differential Revision, or create a new one. |
|
1025 | 1025 | """ |
|
1026 | 1026 | opts = pycompat.byteskwargs(opts) |
|
1027 | 1027 | revs = list(revs) + opts.get(b'rev', []) |
|
1028 | 1028 | revs = scmutil.revrange(repo, revs) |
|
1029 | 1029 | |
|
1030 | 1030 | if not revs: |
|
1031 | 1031 | raise error.Abort(_(b'phabsend requires at least one changeset')) |
|
1032 | 1032 | if opts.get(b'amend'): |
|
1033 | 1033 | cmdutil.checkunfinished(repo) |
|
1034 | 1034 | |
|
1035 | 1035 | # {newnode: (oldnode, olddiff, olddrev} |
|
1036 | 1036 | oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs]) |
|
1037 | 1037 | |
|
1038 | 1038 | confirm = ui.configbool(b'phabsend', b'confirm') |
|
1039 | 1039 | confirm |= bool(opts.get(b'confirm')) |
|
1040 | 1040 | if confirm: |
|
1041 | 1041 | confirmed = _confirmbeforesend(repo, revs, oldmap) |
|
1042 | 1042 | if not confirmed: |
|
1043 | 1043 | raise error.Abort(_(b'phabsend cancelled')) |
|
1044 | 1044 | |
|
1045 | 1045 | actions = [] |
|
1046 | 1046 | reviewers = opts.get(b'reviewer', []) |
|
1047 | 1047 | blockers = opts.get(b'blocker', []) |
|
1048 | 1048 | phids = [] |
|
1049 | 1049 | if reviewers: |
|
1050 | 1050 | phids.extend(userphids(repo, reviewers)) |
|
1051 | 1051 | if blockers: |
|
1052 | 1052 | phids.extend( |
|
1053 | 1053 | map(lambda phid: b'blocking(%s)' % phid, userphids(repo, blockers)) |
|
1054 | 1054 | ) |
|
1055 | 1055 | if phids: |
|
1056 | 1056 | actions.append({b'type': b'reviewers.add', b'value': phids}) |
|
1057 | 1057 | |
|
1058 | 1058 | drevids = [] # [int] |
|
1059 | 1059 | diffmap = {} # {newnode: diff} |
|
1060 | 1060 | |
|
1061 | 1061 | # Send patches one by one so we know their Differential Revision PHIDs and |
|
1062 | 1062 | # can provide dependency relationship |
|
1063 | 1063 | lastrevphid = None |
|
1064 | 1064 | for rev in revs: |
|
1065 | 1065 | ui.debug(b'sending rev %d\n' % rev) |
|
1066 | 1066 | ctx = repo[rev] |
|
1067 | 1067 | |
|
1068 | 1068 | # Get Differential Revision ID |
|
1069 | 1069 | oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None)) |
|
1070 | 1070 | if oldnode != ctx.node() or opts.get(b'amend'): |
|
1071 | 1071 | # Create or update Differential Revision |
|
1072 | 1072 | revision, diff = createdifferentialrevision( |
|
1073 | 1073 | ctx, |
|
1074 | 1074 | revid, |
|
1075 | 1075 | lastrevphid, |
|
1076 | 1076 | oldnode, |
|
1077 | 1077 | olddiff, |
|
1078 | 1078 | actions, |
|
1079 | 1079 | opts.get(b'comment'), |
|
1080 | 1080 | ) |
|
1081 | 1081 | diffmap[ctx.node()] = diff |
|
1082 | 1082 | newrevid = int(revision[b'object'][b'id']) |
|
1083 | 1083 | newrevphid = revision[b'object'][b'phid'] |
|
1084 | 1084 | if revid: |
|
1085 | 1085 | action = b'updated' |
|
1086 | 1086 | else: |
|
1087 | 1087 | action = b'created' |
|
1088 | 1088 | |
|
1089 | 1089 | # Create a local tag to note the association, if commit message |
|
1090 | 1090 | # does not have it already |
|
1091 | 1091 | m = _differentialrevisiondescre.search(ctx.description()) |
|
1092 | 1092 | if not m or int(m.group(r'id')) != newrevid: |
|
1093 | 1093 | tagname = b'D%d' % newrevid |
|
1094 | 1094 | tags.tag( |
|
1095 | 1095 | repo, |
|
1096 | 1096 | tagname, |
|
1097 | 1097 | ctx.node(), |
|
1098 | 1098 | message=None, |
|
1099 | 1099 | user=None, |
|
1100 | 1100 | date=None, |
|
1101 | 1101 | local=True, |
|
1102 | 1102 | ) |
|
1103 | 1103 | else: |
|
1104 | 1104 | # Nothing changed. But still set "newrevphid" so the next revision |
|
1105 | 1105 | # could depend on this one and "newrevid" for the summary line. |
|
1106 | 1106 | newrevphid = querydrev(repo, b'%d' % revid)[0][b'phid'] |
|
1107 | 1107 | newrevid = revid |
|
1108 | 1108 | action = b'skipped' |
|
1109 | 1109 | |
|
1110 | 1110 | actiondesc = ui.label( |
|
1111 | 1111 | { |
|
1112 | 1112 | b'created': _(b'created'), |
|
1113 | 1113 | b'skipped': _(b'skipped'), |
|
1114 | 1114 | b'updated': _(b'updated'), |
|
1115 | 1115 | }[action], |
|
1116 | 1116 | b'phabricator.action.%s' % action, |
|
1117 | 1117 | ) |
|
1118 | 1118 | drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev') |
|
1119 | 1119 | nodedesc = ui.label(bytes(ctx), b'phabricator.node') |
|
1120 | 1120 | desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc') |
|
1121 | 1121 | ui.write( |
|
1122 | 1122 | _(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc, desc) |
|
1123 | 1123 | ) |
|
1124 | 1124 | drevids.append(newrevid) |
|
1125 | 1125 | lastrevphid = newrevphid |
|
1126 | 1126 | |
|
1127 | 1127 | # Update commit messages and remove tags |
|
1128 | 1128 | if opts.get(b'amend'): |
|
1129 | 1129 | unfi = repo.unfiltered() |
|
1130 | 1130 | drevs = callconduit(ui, b'differential.query', {b'ids': drevids}) |
|
1131 | 1131 | with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'): |
|
1132 | 1132 | wnode = unfi[b'.'].node() |
|
1133 | 1133 | mapping = {} # {oldnode: [newnode]} |
|
1134 | 1134 | for i, rev in enumerate(revs): |
|
1135 | 1135 | old = unfi[rev] |
|
1136 | 1136 | drevid = drevids[i] |
|
1137 | 1137 | drev = [d for d in drevs if int(d[b'id']) == drevid][0] |
|
1138 | 1138 | newdesc = getdescfromdrev(drev) |
|
1139 | 1139 | # Make sure commit message contain "Differential Revision" |
|
1140 | 1140 | if old.description() != newdesc: |
|
1141 | 1141 | if old.phase() == phases.public: |
|
1142 | 1142 | ui.warn( |
|
1143 | 1143 | _(b"warning: not updating public commit %s\n") |
|
1144 | 1144 | % scmutil.formatchangeid(old) |
|
1145 | 1145 | ) |
|
1146 | 1146 | continue |
|
1147 | 1147 | parents = [ |
|
1148 | 1148 | mapping.get(old.p1().node(), (old.p1(),))[0], |
|
1149 | 1149 | mapping.get(old.p2().node(), (old.p2(),))[0], |
|
1150 | 1150 | ] |
|
1151 | 1151 | new = context.metadataonlyctx( |
|
1152 | 1152 | repo, |
|
1153 | 1153 | old, |
|
1154 | 1154 | parents=parents, |
|
1155 | 1155 | text=newdesc, |
|
1156 | 1156 | user=old.user(), |
|
1157 | 1157 | date=old.date(), |
|
1158 | 1158 | extra=old.extra(), |
|
1159 | 1159 | ) |
|
1160 | 1160 | |
|
1161 | 1161 | newnode = new.commit() |
|
1162 | 1162 | |
|
1163 | 1163 | mapping[old.node()] = [newnode] |
|
1164 | 1164 | # Update diff property |
|
1165 | 1165 | # If it fails just warn and keep going, otherwise the DREV |
|
1166 | 1166 | # associations will be lost |
|
1167 | 1167 | try: |
|
1168 | 1168 | writediffproperties(unfi[newnode], diffmap[old.node()]) |
|
1169 | 1169 | except util.urlerr.urlerror: |
|
1170 | 1170 | ui.warnnoi18n( |
|
1171 | 1171 | b'Failed to update metadata for D%d\n' % drevid |
|
1172 | 1172 | ) |
|
1173 | 1173 | # Remove local tags since it's no longer necessary |
|
1174 | 1174 | tagname = b'D%d' % drevid |
|
1175 | 1175 | if tagname in repo.tags(): |
|
1176 | 1176 | tags.tag( |
|
1177 | 1177 | repo, |
|
1178 | 1178 | tagname, |
|
1179 | 1179 | nullid, |
|
1180 | 1180 | message=None, |
|
1181 | 1181 | user=None, |
|
1182 | 1182 | date=None, |
|
1183 | 1183 | local=True, |
|
1184 | 1184 | ) |
|
1185 | 1185 | scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True) |
|
1186 | 1186 | if wnode in mapping: |
|
1187 | 1187 | unfi.setparents(mapping[wnode][0]) |
|
1188 | 1188 | |
|
1189 | 1189 | |
|
1190 | 1190 | # Map from "hg:meta" keys to header understood by "hg import". The order is |
|
1191 | 1191 | # consistent with "hg export" output. |
|
1192 | 1192 | _metanamemap = util.sortdict( |
|
1193 | 1193 | [ |
|
1194 | 1194 | (b'user', b'User'), |
|
1195 | 1195 | (b'date', b'Date'), |
|
1196 | 1196 | (b'branch', b'Branch'), |
|
1197 | 1197 | (b'node', b'Node ID'), |
|
1198 | 1198 | (b'parent', b'Parent '), |
|
1199 | 1199 | ] |
|
1200 | 1200 | ) |
|
1201 | 1201 | |
|
1202 | 1202 | |
|
1203 | 1203 | def _confirmbeforesend(repo, revs, oldmap): |
|
1204 | 1204 | url, token = readurltoken(repo.ui) |
|
1205 | 1205 | ui = repo.ui |
|
1206 | 1206 | for rev in revs: |
|
1207 | 1207 | ctx = repo[rev] |
|
1208 | 1208 | desc = ctx.description().splitlines()[0] |
|
1209 | 1209 | oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None)) |
|
1210 | 1210 | if drevid: |
|
1211 | 1211 | drevdesc = ui.label(b'D%d' % drevid, b'phabricator.drev') |
|
1212 | 1212 | else: |
|
1213 | 1213 | drevdesc = ui.label(_(b'NEW'), b'phabricator.drev') |
|
1214 | 1214 | |
|
1215 | 1215 | ui.write( |
|
1216 | 1216 | _(b'%s - %s: %s\n') |
|
1217 | 1217 | % ( |
|
1218 | 1218 | drevdesc, |
|
1219 | 1219 | ui.label(bytes(ctx), b'phabricator.node'), |
|
1220 | 1220 | ui.label(desc, b'phabricator.desc'), |
|
1221 | 1221 | ) |
|
1222 | 1222 | ) |
|
1223 | 1223 | |
|
1224 | 1224 | if ui.promptchoice( |
|
1225 | 1225 | _(b'Send the above changes to %s (yn)?$$ &Yes $$ &No') % url |
|
1226 | 1226 | ): |
|
1227 | 1227 | return False |
|
1228 | 1228 | |
|
1229 | 1229 | return True |
|
1230 | 1230 | |
|
1231 | 1231 | |
|
1232 | 1232 | _knownstatusnames = { |
|
1233 | 1233 | b'accepted', |
|
1234 | 1234 | b'needsreview', |
|
1235 | 1235 | b'needsrevision', |
|
1236 | 1236 | b'closed', |
|
1237 | 1237 | b'abandoned', |
|
1238 | 1238 | } |
|
1239 | 1239 | |
|
1240 | 1240 | |
|
1241 | 1241 | def _getstatusname(drev): |
|
1242 | 1242 | """get normalized status name from a Differential Revision""" |
|
1243 | 1243 | return drev[b'statusName'].replace(b' ', b'').lower() |
|
1244 | 1244 | |
|
1245 | 1245 | |
|
1246 | 1246 | # Small language to specify differential revisions. Support symbols: (), :X, |
|
1247 | 1247 | # +, and -. |
|
1248 | 1248 | |
|
1249 | 1249 | _elements = { |
|
1250 | 1250 | # token-type: binding-strength, primary, prefix, infix, suffix |
|
1251 | 1251 | b'(': (12, None, (b'group', 1, b')'), None, None), |
|
1252 | 1252 | b':': (8, None, (b'ancestors', 8), None, None), |
|
1253 | 1253 | b'&': (5, None, None, (b'and_', 5), None), |
|
1254 | 1254 | b'+': (4, None, None, (b'add', 4), None), |
|
1255 | 1255 | b'-': (4, None, None, (b'sub', 4), None), |
|
1256 | 1256 | b')': (0, None, None, None, None), |
|
1257 | 1257 | b'symbol': (0, b'symbol', None, None, None), |
|
1258 | 1258 | b'end': (0, None, None, None, None), |
|
1259 | 1259 | } |
|
1260 | 1260 | |
|
1261 | 1261 | |
|
1262 | 1262 | def _tokenize(text): |
|
1263 | 1263 | view = memoryview(text) # zero-copy slice |
|
1264 | 1264 | special = b'():+-& ' |
|
1265 | 1265 | pos = 0 |
|
1266 | 1266 | length = len(text) |
|
1267 | 1267 | while pos < length: |
|
1268 | 1268 | symbol = b''.join( |
|
1269 | 1269 | itertools.takewhile( |
|
1270 | 1270 | lambda ch: ch not in special, pycompat.iterbytestr(view[pos:]) |
|
1271 | 1271 | ) |
|
1272 | 1272 | ) |
|
1273 | 1273 | if symbol: |
|
1274 | 1274 | yield (b'symbol', symbol, pos) |
|
1275 | 1275 | pos += len(symbol) |
|
1276 | 1276 | else: # special char, ignore space |
|
1277 | 1277 | if text[pos : pos + 1] != b' ': |
|
1278 | 1278 | yield (text[pos : pos + 1], None, pos) |
|
1279 | 1279 | pos += 1 |
|
1280 | 1280 | yield (b'end', None, pos) |
|
1281 | 1281 | |
|
1282 | 1282 | |
|
1283 | 1283 | def _parse(text): |
|
1284 | 1284 | tree, pos = parser.parser(_elements).parse(_tokenize(text)) |
|
1285 | 1285 | if pos != len(text): |
|
1286 | 1286 | raise error.ParseError(b'invalid token', pos) |
|
1287 | 1287 | return tree |
|
1288 | 1288 | |
|
1289 | 1289 | |
|
1290 | 1290 | def _parsedrev(symbol): |
|
1291 | 1291 | """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None""" |
|
1292 | 1292 | if symbol.startswith(b'D') and symbol[1:].isdigit(): |
|
1293 | 1293 | return int(symbol[1:]) |
|
1294 | 1294 | if symbol.isdigit(): |
|
1295 | 1295 | return int(symbol) |
|
1296 | 1296 | |
|
1297 | 1297 | |
|
1298 | 1298 | def _prefetchdrevs(tree): |
|
1299 | 1299 | """return ({single-drev-id}, {ancestor-drev-id}) to prefetch""" |
|
1300 | 1300 | drevs = set() |
|
1301 | 1301 | ancestordrevs = set() |
|
1302 | 1302 | op = tree[0] |
|
1303 | 1303 | if op == b'symbol': |
|
1304 | 1304 | r = _parsedrev(tree[1]) |
|
1305 | 1305 | if r: |
|
1306 | 1306 | drevs.add(r) |
|
1307 | 1307 | elif op == b'ancestors': |
|
1308 | 1308 | r, a = _prefetchdrevs(tree[1]) |
|
1309 | 1309 | drevs.update(r) |
|
1310 | 1310 | ancestordrevs.update(r) |
|
1311 | 1311 | ancestordrevs.update(a) |
|
1312 | 1312 | else: |
|
1313 | 1313 | for t in tree[1:]: |
|
1314 | 1314 | r, a = _prefetchdrevs(t) |
|
1315 | 1315 | drevs.update(r) |
|
1316 | 1316 | ancestordrevs.update(a) |
|
1317 | 1317 | return drevs, ancestordrevs |
|
1318 | 1318 | |
|
1319 | 1319 | |
|
1320 | 1320 | def querydrev(repo, spec): |
|
1321 | 1321 | """return a list of "Differential Revision" dicts |
|
1322 | 1322 | |
|
1323 | 1323 | spec is a string using a simple query language, see docstring in phabread |
|
1324 | 1324 | for details. |
|
1325 | 1325 | |
|
1326 | 1326 | A "Differential Revision dict" looks like: |
|
1327 | 1327 | |
|
1328 | 1328 | { |
|
1329 | 1329 | "id": "2", |
|
1330 | 1330 | "phid": "PHID-DREV-672qvysjcczopag46qty", |
|
1331 | 1331 | "title": "example", |
|
1332 | 1332 | "uri": "https://phab.example.com/D2", |
|
1333 | 1333 | "dateCreated": "1499181406", |
|
1334 | 1334 | "dateModified": "1499182103", |
|
1335 | 1335 | "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye", |
|
1336 | 1336 | "status": "0", |
|
1337 | 1337 | "statusName": "Needs Review", |
|
1338 | 1338 | "properties": [], |
|
1339 | 1339 | "branch": null, |
|
1340 | 1340 | "summary": "", |
|
1341 | 1341 | "testPlan": "", |
|
1342 | 1342 | "lineCount": "2", |
|
1343 | 1343 | "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72", |
|
1344 | 1344 | "diffs": [ |
|
1345 | 1345 | "3", |
|
1346 | 1346 | "4", |
|
1347 | 1347 | ], |
|
1348 | 1348 | "commits": [], |
|
1349 | 1349 | "reviewers": [], |
|
1350 | 1350 | "ccs": [], |
|
1351 | 1351 | "hashes": [], |
|
1352 | 1352 | "auxiliary": { |
|
1353 | 1353 | "phabricator:projects": [], |
|
1354 | 1354 | "phabricator:depends-on": [ |
|
1355 | 1355 | "PHID-DREV-gbapp366kutjebt7agcd" |
|
1356 | 1356 | ] |
|
1357 | 1357 | }, |
|
1358 | 1358 | "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv", |
|
1359 | 1359 | "sourcePath": null |
|
1360 | 1360 | } |
|
1361 | 1361 | """ |
|
1362 | 1362 | |
|
1363 | 1363 | def fetch(params): |
|
1364 | 1364 | """params -> single drev or None""" |
|
1365 | 1365 | key = (params.get(b'ids') or params.get(b'phids') or [None])[0] |
|
1366 | 1366 | if key in prefetched: |
|
1367 | 1367 | return prefetched[key] |
|
1368 | 1368 | drevs = callconduit(repo.ui, b'differential.query', params) |
|
1369 | 1369 | # Fill prefetched with the result |
|
1370 | 1370 | for drev in drevs: |
|
1371 | 1371 | prefetched[drev[b'phid']] = drev |
|
1372 | 1372 | prefetched[int(drev[b'id'])] = drev |
|
1373 | 1373 | if key not in prefetched: |
|
1374 | 1374 | raise error.Abort( |
|
1375 | 1375 | _(b'cannot get Differential Revision %r') % params |
|
1376 | 1376 | ) |
|
1377 | 1377 | return prefetched[key] |
|
1378 | 1378 | |
|
1379 | 1379 | def getstack(topdrevids): |
|
1380 | 1380 | """given a top, get a stack from the bottom, [id] -> [id]""" |
|
1381 | 1381 | visited = set() |
|
1382 | 1382 | result = [] |
|
1383 | 1383 | queue = [{b'ids': [i]} for i in topdrevids] |
|
1384 | 1384 | while queue: |
|
1385 | 1385 | params = queue.pop() |
|
1386 | 1386 | drev = fetch(params) |
|
1387 | 1387 | if drev[b'id'] in visited: |
|
1388 | 1388 | continue |
|
1389 | 1389 | visited.add(drev[b'id']) |
|
1390 | 1390 | result.append(int(drev[b'id'])) |
|
1391 | 1391 | auxiliary = drev.get(b'auxiliary', {}) |
|
1392 | 1392 | depends = auxiliary.get(b'phabricator:depends-on', []) |
|
1393 | 1393 | for phid in depends: |
|
1394 | 1394 | queue.append({b'phids': [phid]}) |
|
1395 | 1395 | result.reverse() |
|
1396 | 1396 | return smartset.baseset(result) |
|
1397 | 1397 | |
|
1398 | 1398 | # Initialize prefetch cache |
|
1399 | 1399 | prefetched = {} # {id or phid: drev} |
|
1400 | 1400 | |
|
1401 | 1401 | tree = _parse(spec) |
|
1402 | 1402 | drevs, ancestordrevs = _prefetchdrevs(tree) |
|
1403 | 1403 | |
|
1404 | 1404 | # developer config: phabricator.batchsize |
|
1405 | 1405 | batchsize = repo.ui.configint(b'phabricator', b'batchsize') |
|
1406 | 1406 | |
|
1407 | 1407 | # Prefetch Differential Revisions in batch |
|
1408 | 1408 | tofetch = set(drevs) |
|
1409 | 1409 | for r in ancestordrevs: |
|
1410 | 1410 | tofetch.update(range(max(1, r - batchsize), r + 1)) |
|
1411 | 1411 | if drevs: |
|
1412 | 1412 | fetch({b'ids': list(tofetch)}) |
|
1413 | 1413 | validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs)) |
|
1414 | 1414 | |
|
1415 | 1415 | # Walk through the tree, return smartsets |
|
1416 | 1416 | def walk(tree): |
|
1417 | 1417 | op = tree[0] |
|
1418 | 1418 | if op == b'symbol': |
|
1419 | 1419 | drev = _parsedrev(tree[1]) |
|
1420 | 1420 | if drev: |
|
1421 | 1421 | return smartset.baseset([drev]) |
|
1422 | 1422 | elif tree[1] in _knownstatusnames: |
|
1423 | 1423 | drevs = [ |
|
1424 | 1424 | r |
|
1425 | 1425 | for r in validids |
|
1426 | 1426 | if _getstatusname(prefetched[r]) == tree[1] |
|
1427 | 1427 | ] |
|
1428 | 1428 | return smartset.baseset(drevs) |
|
1429 | 1429 | else: |
|
1430 | 1430 | raise error.Abort(_(b'unknown symbol: %s') % tree[1]) |
|
1431 | 1431 | elif op in {b'and_', b'add', b'sub'}: |
|
1432 | 1432 | assert len(tree) == 3 |
|
1433 | 1433 | return getattr(operator, op)(walk(tree[1]), walk(tree[2])) |
|
1434 | 1434 | elif op == b'group': |
|
1435 | 1435 | return walk(tree[1]) |
|
1436 | 1436 | elif op == b'ancestors': |
|
1437 | 1437 | return getstack(walk(tree[1])) |
|
1438 | 1438 | else: |
|
1439 | 1439 | raise error.ProgrammingError(b'illegal tree: %r' % tree) |
|
1440 | 1440 | |
|
1441 | 1441 | return [prefetched[r] for r in walk(tree)] |
|
1442 | 1442 | |
|
1443 | 1443 | |
|
1444 | 1444 | def getdescfromdrev(drev): |
|
1445 | 1445 | """get description (commit message) from "Differential Revision" |
|
1446 | 1446 | |
|
1447 | 1447 | This is similar to differential.getcommitmessage API. But we only care |
|
1448 | 1448 | about limited fields: title, summary, test plan, and URL. |
|
1449 | 1449 | """ |
|
1450 | 1450 | title = drev[b'title'] |
|
1451 | 1451 | summary = drev[b'summary'].rstrip() |
|
1452 | 1452 | testplan = drev[b'testPlan'].rstrip() |
|
1453 | 1453 | if testplan: |
|
1454 | 1454 | testplan = b'Test Plan:\n%s' % testplan |
|
1455 | 1455 | uri = b'Differential Revision: %s' % drev[b'uri'] |
|
1456 | 1456 | return b'\n\n'.join(filter(None, [title, summary, testplan, uri])) |
|
1457 | 1457 | |
|
1458 | 1458 | |
|
1459 | 1459 | def getdiffmeta(diff): |
|
1460 | 1460 | """get commit metadata (date, node, user, p1) from a diff object |
|
1461 | 1461 | |
|
1462 | 1462 | The metadata could be "hg:meta", sent by phabsend, like: |
|
1463 | 1463 | |
|
1464 | 1464 | "properties": { |
|
1465 | 1465 | "hg:meta": { |
|
1466 | 1466 | "date": "1499571514 25200", |
|
1467 | 1467 | "node": "98c08acae292b2faf60a279b4189beb6cff1414d", |
|
1468 | 1468 | "user": "Foo Bar <foo@example.com>", |
|
1469 | 1469 | "parent": "6d0abad76b30e4724a37ab8721d630394070fe16" |
|
1470 | 1470 | } |
|
1471 | 1471 | } |
|
1472 | 1472 | |
|
1473 | 1473 | Or converted from "local:commits", sent by "arc", like: |
|
1474 | 1474 | |
|
1475 | 1475 | "properties": { |
|
1476 | 1476 | "local:commits": { |
|
1477 | 1477 | "98c08acae292b2faf60a279b4189beb6cff1414d": { |
|
1478 | 1478 | "author": "Foo Bar", |
|
1479 | 1479 | "time": 1499546314, |
|
1480 | 1480 | "branch": "default", |
|
1481 | 1481 | "tag": "", |
|
1482 | 1482 | "commit": "98c08acae292b2faf60a279b4189beb6cff1414d", |
|
1483 | 1483 | "rev": "98c08acae292b2faf60a279b4189beb6cff1414d", |
|
1484 | 1484 | "local": "1000", |
|
1485 | 1485 | "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"], |
|
1486 | 1486 | "summary": "...", |
|
1487 | 1487 | "message": "...", |
|
1488 | 1488 | "authorEmail": "foo@example.com" |
|
1489 | 1489 | } |
|
1490 | 1490 | } |
|
1491 | 1491 | } |
|
1492 | 1492 | |
|
1493 | 1493 | Note: metadata extracted from "local:commits" will lose time zone |
|
1494 | 1494 | information. |
|
1495 | 1495 | """ |
|
1496 | 1496 | props = diff.get(b'properties') or {} |
|
1497 | 1497 | meta = props.get(b'hg:meta') |
|
1498 | 1498 | if not meta: |
|
1499 | 1499 | if props.get(b'local:commits'): |
|
1500 | 1500 | commit = sorted(props[b'local:commits'].values())[0] |
|
1501 | 1501 | meta = {} |
|
1502 | 1502 | if b'author' in commit and b'authorEmail' in commit: |
|
1503 | 1503 | meta[b'user'] = b'%s <%s>' % ( |
|
1504 | 1504 | commit[b'author'], |
|
1505 | 1505 | commit[b'authorEmail'], |
|
1506 | 1506 | ) |
|
1507 | 1507 | if b'time' in commit: |
|
1508 | 1508 | meta[b'date'] = b'%d 0' % int(commit[b'time']) |
|
1509 | 1509 | if b'branch' in commit: |
|
1510 | 1510 | meta[b'branch'] = commit[b'branch'] |
|
1511 | 1511 | node = commit.get(b'commit', commit.get(b'rev')) |
|
1512 | 1512 | if node: |
|
1513 | 1513 | meta[b'node'] = node |
|
1514 | 1514 | if len(commit.get(b'parents', ())) >= 1: |
|
1515 | 1515 | meta[b'parent'] = commit[b'parents'][0] |
|
1516 | 1516 | else: |
|
1517 | 1517 | meta = {} |
|
1518 | 1518 | if b'date' not in meta and b'dateCreated' in diff: |
|
1519 | 1519 | meta[b'date'] = b'%s 0' % diff[b'dateCreated'] |
|
1520 | 1520 | if b'branch' not in meta and diff.get(b'branch'): |
|
1521 | 1521 | meta[b'branch'] = diff[b'branch'] |
|
1522 | 1522 | if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'): |
|
1523 | 1523 | meta[b'parent'] = diff[b'sourceControlBaseRevision'] |
|
1524 | 1524 | return meta |
|
1525 | 1525 | |
|
1526 | 1526 | |
|
1527 | 1527 | def readpatch(repo, drevs, write): |
|
1528 | 1528 | """generate plain-text patch readable by 'hg import' |
|
1529 | 1529 | |
|
1530 | 1530 | write is usually ui.write. drevs is what "querydrev" returns, results of |
|
1531 | 1531 | "differential.query". |
|
1532 | 1532 | """ |
|
1533 | 1533 | # Prefetch hg:meta property for all diffs |
|
1534 | 1534 | diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs)) |
|
1535 | 1535 | diffs = callconduit(repo.ui, b'differential.querydiffs', {b'ids': diffids}) |
|
1536 | 1536 | |
|
1537 | 1537 | # Generate patch for each drev |
|
1538 | 1538 | for drev in drevs: |
|
1539 | 1539 | repo.ui.note(_(b'reading D%s\n') % drev[b'id']) |
|
1540 | 1540 | |
|
1541 | 1541 | diffid = max(int(v) for v in drev[b'diffs']) |
|
1542 | 1542 | body = callconduit( |
|
1543 | 1543 | repo.ui, b'differential.getrawdiff', {b'diffID': diffid} |
|
1544 | 1544 | ) |
|
1545 | 1545 | desc = getdescfromdrev(drev) |
|
1546 | 1546 | header = b'# HG changeset patch\n' |
|
1547 | 1547 | |
|
1548 | 1548 | # Try to preserve metadata from hg:meta property. Write hg patch |
|
1549 | 1549 | # headers that can be read by the "import" command. See patchheadermap |
|
1550 | 1550 | # and extract in mercurial/patch.py for supported headers. |
|
1551 | 1551 | meta = getdiffmeta(diffs[b'%d' % diffid]) |
|
1552 | 1552 | for k in _metanamemap.keys(): |
|
1553 | 1553 | if k in meta: |
|
1554 | 1554 | header += b'# %s %s\n' % (_metanamemap[k], meta[k]) |
|
1555 | 1555 | |
|
1556 | 1556 | content = b'%s%s\n%s' % (header, desc, body) |
|
1557 | 1557 | write(content) |
|
1558 | 1558 | |
|
1559 | 1559 | |
|
1560 | 1560 | @vcrcommand( |
|
1561 | 1561 | b'phabread', |
|
1562 | 1562 | [(b'', b'stack', False, _(b'read dependencies'))], |
|
1563 | 1563 | _(b'DREVSPEC [OPTIONS]'), |
|
1564 | 1564 | helpcategory=command.CATEGORY_IMPORT_EXPORT, |
|
1565 | 1565 | ) |
|
1566 | 1566 | def phabread(ui, repo, spec, **opts): |
|
1567 | 1567 | """print patches from Phabricator suitable for importing |
|
1568 | 1568 | |
|
1569 | 1569 | DREVSPEC could be a Differential Revision identity, like ``D123``, or just |
|
1570 | 1570 | the number ``123``. It could also have common operators like ``+``, ``-``, |
|
1571 | 1571 | ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to |
|
1572 | 1572 | select a stack. |
|
1573 | 1573 | |
|
1574 | 1574 | ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision`` |
|
1575 | 1575 | could be used to filter patches by status. For performance reason, they |
|
1576 | 1576 | only represent a subset of non-status selections and cannot be used alone. |
|
1577 | 1577 | |
|
1578 | 1578 | For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude |
|
1579 | 1579 | D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a |
|
1580 | 1580 | stack up to D9. |
|
1581 | 1581 | |
|
1582 | 1582 | If --stack is given, follow dependencies information and read all patches. |
|
1583 | 1583 | It is equivalent to the ``:`` operator. |
|
1584 | 1584 | """ |
|
1585 | 1585 | opts = pycompat.byteskwargs(opts) |
|
1586 | 1586 | if opts.get(b'stack'): |
|
1587 | 1587 | spec = b':(%s)' % spec |
|
1588 | 1588 | drevs = querydrev(repo, spec) |
|
1589 | 1589 | readpatch(repo, drevs, ui.write) |
|
1590 | 1590 | |
|
1591 | 1591 | |
|
1592 | 1592 | @vcrcommand( |
|
1593 | 1593 | b'phabupdate', |
|
1594 | 1594 | [ |
|
1595 | 1595 | (b'', b'accept', False, _(b'accept revisions')), |
|
1596 | 1596 | (b'', b'reject', False, _(b'reject revisions')), |
|
1597 | 1597 | (b'', b'abandon', False, _(b'abandon revisions')), |
|
1598 | 1598 | (b'', b'reclaim', False, _(b'reclaim revisions')), |
|
1599 | 1599 | (b'm', b'comment', b'', _(b'comment on the last revision')), |
|
1600 | 1600 | ], |
|
1601 | 1601 | _(b'DREVSPEC [OPTIONS]'), |
|
1602 | 1602 | helpcategory=command.CATEGORY_IMPORT_EXPORT, |
|
1603 | 1603 | ) |
|
1604 | 1604 | def phabupdate(ui, repo, spec, **opts): |
|
1605 | 1605 | """update Differential Revision in batch |
|
1606 | 1606 | |
|
1607 | 1607 | DREVSPEC selects revisions. See :hg:`help phabread` for its usage. |
|
1608 | 1608 | """ |
|
1609 | 1609 | opts = pycompat.byteskwargs(opts) |
|
1610 | 1610 | flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)] |
|
1611 | 1611 | if len(flags) > 1: |
|
1612 | 1612 | raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags)) |
|
1613 | 1613 | |
|
1614 | 1614 | actions = [] |
|
1615 | 1615 | for f in flags: |
|
1616 | 1616 | actions.append({b'type': f, b'value': True}) |
|
1617 | 1617 | |
|
1618 | 1618 | drevs = querydrev(repo, spec) |
|
1619 | 1619 | for i, drev in enumerate(drevs): |
|
1620 | 1620 | if i + 1 == len(drevs) and opts.get(b'comment'): |
|
1621 | 1621 | actions.append({b'type': b'comment', b'value': opts[b'comment']}) |
|
1622 | 1622 | if actions: |
|
1623 | 1623 | params = { |
|
1624 | 1624 | b'objectIdentifier': drev[b'phid'], |
|
1625 | 1625 | b'transactions': actions, |
|
1626 | 1626 | } |
|
1627 | 1627 | callconduit(ui, b'differential.revision.edit', params) |
|
1628 | 1628 | |
|
1629 | 1629 | |
|
1630 | 1630 | @eh.templatekeyword(b'phabreview', requires={b'ctx'}) |
|
1631 | 1631 | def template_review(context, mapping): |
|
1632 | 1632 | """:phabreview: Object describing the review for this changeset. |
|
1633 | 1633 | Has attributes `url` and `id`. |
|
1634 | 1634 | """ |
|
1635 | 1635 | ctx = context.resource(mapping, b'ctx') |
|
1636 | 1636 | m = _differentialrevisiondescre.search(ctx.description()) |
|
1637 | 1637 | if m: |
|
1638 | 1638 | return templateutil.hybriddict( |
|
1639 | 1639 | {b'url': m.group(r'url'), b'id': b"D%s" % m.group(r'id'),} |
|
1640 | 1640 | ) |
|
1641 | 1641 | else: |
|
1642 | 1642 | tags = ctx.repo().nodetags(ctx.node()) |
|
1643 | 1643 | for t in tags: |
|
1644 | 1644 | if _differentialrevisiontagre.match(t): |
|
1645 | 1645 | url = ctx.repo().ui.config(b'phabricator', b'url') |
|
1646 | 1646 | if not url.endswith(b'/'): |
|
1647 | 1647 | url += b'/' |
|
1648 | 1648 | url += t |
|
1649 | 1649 | |
|
1650 | 1650 | return templateutil.hybriddict({b'url': url, b'id': t,}) |
|
1651 | 1651 | return None |
@@ -1,454 +1,499 b'' | |||
|
1 | 1 | # pycompat.py - portability shim for python 3 |
|
2 | 2 | # |
|
3 | 3 | # This software may be used and distributed according to the terms of the |
|
4 | 4 | # GNU General Public License version 2 or any later version. |
|
5 | 5 | |
|
6 | 6 | """Mercurial portability shim for python 3. |
|
7 | 7 | |
|
8 | 8 | This contains aliases to hide python version-specific details from the core. |
|
9 | 9 | """ |
|
10 | 10 | |
|
11 | 11 | from __future__ import absolute_import |
|
12 | 12 | |
|
13 | 13 | import getopt |
|
14 | 14 | import inspect |
|
15 | import json | |
|
15 | 16 | import os |
|
16 | 17 | import shlex |
|
17 | 18 | import sys |
|
18 | 19 | import tempfile |
|
19 | 20 | |
|
20 | 21 | ispy3 = sys.version_info[0] >= 3 |
|
21 | 22 | ispypy = r'__pypy__' in sys.builtin_module_names |
|
22 | 23 | |
|
23 | 24 | if not ispy3: |
|
24 | 25 | import cookielib |
|
25 | 26 | import cPickle as pickle |
|
26 | 27 | import httplib |
|
27 | 28 | import Queue as queue |
|
28 | 29 | import SocketServer as socketserver |
|
29 | 30 | import xmlrpclib |
|
30 | 31 | |
|
31 | 32 | from .thirdparty.concurrent import futures |
|
32 | 33 | |
|
33 | 34 | def future_set_exception_info(f, exc_info): |
|
34 | 35 | f.set_exception_info(*exc_info) |
|
35 | 36 | |
|
36 | 37 | |
|
37 | 38 | else: |
|
38 | 39 | import concurrent.futures as futures |
|
39 | 40 | import http.cookiejar as cookielib |
|
40 | 41 | import http.client as httplib |
|
41 | 42 | import pickle |
|
42 | 43 | import queue as queue |
|
43 | 44 | import socketserver |
|
44 | 45 | import xmlrpc.client as xmlrpclib |
|
45 | 46 | |
|
46 | 47 | def future_set_exception_info(f, exc_info): |
|
47 | 48 | f.set_exception(exc_info[0]) |
|
48 | 49 | |
|
49 | 50 | |
|
50 | 51 | def identity(a): |
|
51 | 52 | return a |
|
52 | 53 | |
|
53 | 54 | |
|
54 | 55 | def _rapply(f, xs): |
|
55 | 56 | if xs is None: |
|
56 | 57 | # assume None means non-value of optional data |
|
57 | 58 | return xs |
|
58 | 59 | if isinstance(xs, (list, set, tuple)): |
|
59 | 60 | return type(xs)(_rapply(f, x) for x in xs) |
|
60 | 61 | if isinstance(xs, dict): |
|
61 | 62 | return type(xs)((_rapply(f, k), _rapply(f, v)) for k, v in xs.items()) |
|
62 | 63 | return f(xs) |
|
63 | 64 | |
|
64 | 65 | |
|
65 | 66 | def rapply(f, xs): |
|
66 | 67 | """Apply function recursively to every item preserving the data structure |
|
67 | 68 | |
|
68 | 69 | >>> def f(x): |
|
69 | 70 | ... return 'f(%s)' % x |
|
70 | 71 | >>> rapply(f, None) is None |
|
71 | 72 | True |
|
72 | 73 | >>> rapply(f, 'a') |
|
73 | 74 | 'f(a)' |
|
74 | 75 | >>> rapply(f, {'a'}) == {'f(a)'} |
|
75 | 76 | True |
|
76 | 77 | >>> rapply(f, ['a', 'b', None, {'c': 'd'}, []]) |
|
77 | 78 | ['f(a)', 'f(b)', None, {'f(c)': 'f(d)'}, []] |
|
78 | 79 | |
|
79 | 80 | >>> xs = [object()] |
|
80 | 81 | >>> rapply(identity, xs) is xs |
|
81 | 82 | True |
|
82 | 83 | """ |
|
83 | 84 | if f is identity: |
|
84 | 85 | # fast path mainly for py2 |
|
85 | 86 | return xs |
|
86 | 87 | return _rapply(f, xs) |
|
87 | 88 | |
|
88 | 89 | |
|
89 | 90 | if ispy3: |
|
90 | 91 | import builtins |
|
92 | import codecs | |
|
91 | 93 | import functools |
|
92 | 94 | import io |
|
93 | 95 | import struct |
|
94 | 96 | |
|
95 | 97 | fsencode = os.fsencode |
|
96 | 98 | fsdecode = os.fsdecode |
|
97 | 99 | oscurdir = os.curdir.encode('ascii') |
|
98 | 100 | oslinesep = os.linesep.encode('ascii') |
|
99 | 101 | osname = os.name.encode('ascii') |
|
100 | 102 | ospathsep = os.pathsep.encode('ascii') |
|
101 | 103 | ospardir = os.pardir.encode('ascii') |
|
102 | 104 | ossep = os.sep.encode('ascii') |
|
103 | 105 | osaltsep = os.altsep |
|
104 | 106 | if osaltsep: |
|
105 | 107 | osaltsep = osaltsep.encode('ascii') |
|
106 | 108 | |
|
107 | 109 | sysplatform = sys.platform.encode('ascii') |
|
108 | 110 | sysexecutable = sys.executable |
|
109 | 111 | if sysexecutable: |
|
110 | 112 | sysexecutable = os.fsencode(sysexecutable) |
|
111 | 113 | bytesio = io.BytesIO |
|
112 | 114 | # TODO deprecate stringio name, as it is a lie on Python 3. |
|
113 | 115 | stringio = bytesio |
|
114 | 116 | |
|
115 | 117 | def maplist(*args): |
|
116 | 118 | return list(map(*args)) |
|
117 | 119 | |
|
118 | 120 | def rangelist(*args): |
|
119 | 121 | return list(range(*args)) |
|
120 | 122 | |
|
121 | 123 | def ziplist(*args): |
|
122 | 124 | return list(zip(*args)) |
|
123 | 125 | |
|
124 | 126 | rawinput = input |
|
125 | 127 | getargspec = inspect.getfullargspec |
|
126 | 128 | |
|
127 | 129 | long = int |
|
128 | 130 | |
|
129 | 131 | # TODO: .buffer might not exist if std streams were replaced; we'll need |
|
130 | 132 | # a silly wrapper to make a bytes stream backed by a unicode one. |
|
131 | 133 | stdin = sys.stdin.buffer |
|
132 | 134 | stdout = sys.stdout.buffer |
|
133 | 135 | stderr = sys.stderr.buffer |
|
134 | 136 | |
|
135 | 137 | # Since Python 3 converts argv to wchar_t type by Py_DecodeLocale() on Unix, |
|
136 | 138 | # we can use os.fsencode() to get back bytes argv. |
|
137 | 139 | # |
|
138 | 140 | # https://hg.python.org/cpython/file/v3.5.1/Programs/python.c#l55 |
|
139 | 141 | # |
|
140 | 142 | # TODO: On Windows, the native argv is wchar_t, so we'll need a different |
|
141 | 143 | # workaround to simulate the Python 2 (i.e. ANSI Win32 API) behavior. |
|
142 | 144 | if getattr(sys, 'argv', None) is not None: |
|
143 | 145 | sysargv = list(map(os.fsencode, sys.argv)) |
|
144 | 146 | |
|
145 | 147 | bytechr = struct.Struct(r'>B').pack |
|
146 | 148 | byterepr = b'%r'.__mod__ |
|
147 | 149 | |
|
148 | 150 | class bytestr(bytes): |
|
149 | 151 | """A bytes which mostly acts as a Python 2 str |
|
150 | 152 | |
|
151 | 153 | >>> bytestr(), bytestr(bytearray(b'foo')), bytestr(u'ascii'), bytestr(1) |
|
152 | 154 | ('', 'foo', 'ascii', '1') |
|
153 | 155 | >>> s = bytestr(b'foo') |
|
154 | 156 | >>> assert s is bytestr(s) |
|
155 | 157 | |
|
156 | 158 | __bytes__() should be called if provided: |
|
157 | 159 | |
|
158 | 160 | >>> class bytesable(object): |
|
159 | 161 | ... def __bytes__(self): |
|
160 | 162 | ... return b'bytes' |
|
161 | 163 | >>> bytestr(bytesable()) |
|
162 | 164 | 'bytes' |
|
163 | 165 | |
|
164 | 166 | There's no implicit conversion from non-ascii str as its encoding is |
|
165 | 167 | unknown: |
|
166 | 168 | |
|
167 | 169 | >>> bytestr(chr(0x80)) # doctest: +ELLIPSIS |
|
168 | 170 | Traceback (most recent call last): |
|
169 | 171 | ... |
|
170 | 172 | UnicodeEncodeError: ... |
|
171 | 173 | |
|
172 | 174 | Comparison between bytestr and bytes should work: |
|
173 | 175 | |
|
174 | 176 | >>> assert bytestr(b'foo') == b'foo' |
|
175 | 177 | >>> assert b'foo' == bytestr(b'foo') |
|
176 | 178 | >>> assert b'f' in bytestr(b'foo') |
|
177 | 179 | >>> assert bytestr(b'f') in b'foo' |
|
178 | 180 | |
|
179 | 181 | Sliced elements should be bytes, not integer: |
|
180 | 182 | |
|
181 | 183 | >>> s[1], s[:2] |
|
182 | 184 | (b'o', b'fo') |
|
183 | 185 | >>> list(s), list(reversed(s)) |
|
184 | 186 | ([b'f', b'o', b'o'], [b'o', b'o', b'f']) |
|
185 | 187 | |
|
186 | 188 | As bytestr type isn't propagated across operations, you need to cast |
|
187 | 189 | bytes to bytestr explicitly: |
|
188 | 190 | |
|
189 | 191 | >>> s = bytestr(b'foo').upper() |
|
190 | 192 | >>> t = bytestr(s) |
|
191 | 193 | >>> s[0], t[0] |
|
192 | 194 | (70, b'F') |
|
193 | 195 | |
|
194 | 196 | Be careful to not pass a bytestr object to a function which expects |
|
195 | 197 | bytearray-like behavior. |
|
196 | 198 | |
|
197 | 199 | >>> t = bytes(t) # cast to bytes |
|
198 | 200 | >>> assert type(t) is bytes |
|
199 | 201 | """ |
|
200 | 202 | |
|
201 | 203 | def __new__(cls, s=b''): |
|
202 | 204 | if isinstance(s, bytestr): |
|
203 | 205 | return s |
|
204 | 206 | if not isinstance( |
|
205 | 207 | s, (bytes, bytearray) |
|
206 | 208 | ) and not hasattr( # hasattr-py3-only |
|
207 | 209 | s, u'__bytes__' |
|
208 | 210 | ): |
|
209 | 211 | s = str(s).encode('ascii') |
|
210 | 212 | return bytes.__new__(cls, s) |
|
211 | 213 | |
|
212 | 214 | def __getitem__(self, key): |
|
213 | 215 | s = bytes.__getitem__(self, key) |
|
214 | 216 | if not isinstance(s, bytes): |
|
215 | 217 | s = bytechr(s) |
|
216 | 218 | return s |
|
217 | 219 | |
|
218 | 220 | def __iter__(self): |
|
219 | 221 | return iterbytestr(bytes.__iter__(self)) |
|
220 | 222 | |
|
221 | 223 | def __repr__(self): |
|
222 | 224 | return bytes.__repr__(self)[1:] # drop b'' |
|
223 | 225 | |
|
224 | 226 | def iterbytestr(s): |
|
225 | 227 | """Iterate bytes as if it were a str object of Python 2""" |
|
226 | 228 | return map(bytechr, s) |
|
227 | 229 | |
|
228 | 230 | def maybebytestr(s): |
|
229 | 231 | """Promote bytes to bytestr""" |
|
230 | 232 | if isinstance(s, bytes): |
|
231 | 233 | return bytestr(s) |
|
232 | 234 | return s |
|
233 | 235 | |
|
234 | 236 | def sysbytes(s): |
|
235 | 237 | """Convert an internal str (e.g. keyword, __doc__) back to bytes |
|
236 | 238 | |
|
237 | 239 | This never raises UnicodeEncodeError, but only ASCII characters |
|
238 | 240 | can be round-trip by sysstr(sysbytes(s)). |
|
239 | 241 | """ |
|
240 | 242 | return s.encode('utf-8') |
|
241 | 243 | |
|
242 | 244 | def sysstr(s): |
|
243 | 245 | """Return a keyword str to be passed to Python functions such as |
|
244 | 246 | getattr() and str.encode() |
|
245 | 247 | |
|
246 | 248 | This never raises UnicodeDecodeError. Non-ascii characters are |
|
247 | 249 | considered invalid and mapped to arbitrary but unique code points |
|
248 | 250 | such that 'sysstr(a) != sysstr(b)' for all 'a != b'. |
|
249 | 251 | """ |
|
250 | 252 | if isinstance(s, builtins.str): |
|
251 | 253 | return s |
|
252 | 254 | return s.decode('latin-1') |
|
253 | 255 | |
|
254 | 256 | def strurl(url): |
|
255 | 257 | """Converts a bytes url back to str""" |
|
256 | 258 | if isinstance(url, bytes): |
|
257 | 259 | return url.decode('ascii') |
|
258 | 260 | return url |
|
259 | 261 | |
|
260 | 262 | def bytesurl(url): |
|
261 | 263 | """Converts a str url to bytes by encoding in ascii""" |
|
262 | 264 | if isinstance(url, str): |
|
263 | 265 | return url.encode('ascii') |
|
264 | 266 | return url |
|
265 | 267 | |
|
266 | 268 | def raisewithtb(exc, tb): |
|
267 | 269 | """Raise exception with the given traceback""" |
|
268 | 270 | raise exc.with_traceback(tb) |
|
269 | 271 | |
|
270 | 272 | def getdoc(obj): |
|
271 | 273 | """Get docstring as bytes; may be None so gettext() won't confuse it |
|
272 | 274 | with _('')""" |
|
273 | 275 | doc = getattr(obj, '__doc__', None) |
|
274 | 276 | if doc is None: |
|
275 | 277 | return doc |
|
276 | 278 | return sysbytes(doc) |
|
277 | 279 | |
|
278 | 280 | def _wrapattrfunc(f): |
|
279 | 281 | @functools.wraps(f) |
|
280 | 282 | def w(object, name, *args): |
|
281 | 283 | return f(object, sysstr(name), *args) |
|
282 | 284 | |
|
283 | 285 | return w |
|
284 | 286 | |
|
285 | 287 | # these wrappers are automagically imported by hgloader |
|
286 | 288 | delattr = _wrapattrfunc(builtins.delattr) |
|
287 | 289 | getattr = _wrapattrfunc(builtins.getattr) |
|
288 | 290 | hasattr = _wrapattrfunc(builtins.hasattr) |
|
289 | 291 | setattr = _wrapattrfunc(builtins.setattr) |
|
290 | 292 | xrange = builtins.range |
|
291 | 293 | unicode = str |
|
292 | 294 | |
|
293 | 295 | def open(name, mode=b'r', buffering=-1, encoding=None): |
|
294 | 296 | return builtins.open(name, sysstr(mode), buffering, encoding) |
|
295 | 297 | |
|
296 | 298 | safehasattr = _wrapattrfunc(builtins.hasattr) |
|
297 | 299 | |
|
298 | 300 | def _getoptbwrapper(orig, args, shortlist, namelist): |
|
299 | 301 | """ |
|
300 | 302 | Takes bytes arguments, converts them to unicode, pass them to |
|
301 | 303 | getopt.getopt(), convert the returned values back to bytes and then |
|
302 | 304 | return them for Python 3 compatibility as getopt.getopt() don't accepts |
|
303 | 305 | bytes on Python 3. |
|
304 | 306 | """ |
|
305 | 307 | args = [a.decode('latin-1') for a in args] |
|
306 | 308 | shortlist = shortlist.decode('latin-1') |
|
307 | 309 | namelist = [a.decode('latin-1') for a in namelist] |
|
308 | 310 | opts, args = orig(args, shortlist, namelist) |
|
309 | 311 | opts = [(a[0].encode('latin-1'), a[1].encode('latin-1')) for a in opts] |
|
310 | 312 | args = [a.encode('latin-1') for a in args] |
|
311 | 313 | return opts, args |
|
312 | 314 | |
|
313 | 315 | def strkwargs(dic): |
|
314 | 316 | """ |
|
315 | 317 | Converts the keys of a python dictonary to str i.e. unicodes so that |
|
316 | 318 | they can be passed as keyword arguments as dictonaries with bytes keys |
|
317 | 319 | can't be passed as keyword arguments to functions on Python 3. |
|
318 | 320 | """ |
|
319 | 321 | dic = dict((k.decode('latin-1'), v) for k, v in dic.items()) |
|
320 | 322 | return dic |
|
321 | 323 | |
|
322 | 324 | def byteskwargs(dic): |
|
323 | 325 | """ |
|
324 | 326 | Converts keys of python dictonaries to bytes as they were converted to |
|
325 | 327 | str to pass that dictonary as a keyword argument on Python 3. |
|
326 | 328 | """ |
|
327 | 329 | dic = dict((k.encode('latin-1'), v) for k, v in dic.items()) |
|
328 | 330 | return dic |
|
329 | 331 | |
|
330 | 332 | # TODO: handle shlex.shlex(). |
|
331 | 333 | def shlexsplit(s, comments=False, posix=True): |
|
332 | 334 | """ |
|
333 | 335 | Takes bytes argument, convert it to str i.e. unicodes, pass that into |
|
334 | 336 | shlex.split(), convert the returned value to bytes and return that for |
|
335 | 337 | Python 3 compatibility as shelx.split() don't accept bytes on Python 3. |
|
336 | 338 | """ |
|
337 | 339 | ret = shlex.split(s.decode('latin-1'), comments, posix) |
|
338 | 340 | return [a.encode('latin-1') for a in ret] |
|
339 | 341 | |
|
340 | 342 | iteritems = lambda x: x.items() |
|
341 | 343 | itervalues = lambda x: x.values() |
|
342 | 344 | |
|
345 | # Python 3.5's json.load and json.loads require str. We polyfill its | |
|
346 | # code for detecting encoding from bytes. | |
|
347 | if sys.version_info[0:2] < (3, 6): | |
|
348 | ||
|
349 | def _detect_encoding(b): | |
|
350 | bstartswith = b.startswith | |
|
351 | if bstartswith((codecs.BOM_UTF32_BE, codecs.BOM_UTF32_LE)): | |
|
352 | return 'utf-32' | |
|
353 | if bstartswith((codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE)): | |
|
354 | return 'utf-16' | |
|
355 | if bstartswith(codecs.BOM_UTF8): | |
|
356 | return 'utf-8-sig' | |
|
357 | ||
|
358 | if len(b) >= 4: | |
|
359 | if not b[0]: | |
|
360 | # 00 00 -- -- - utf-32-be | |
|
361 | # 00 XX -- -- - utf-16-be | |
|
362 | return 'utf-16-be' if b[1] else 'utf-32-be' | |
|
363 | if not b[1]: | |
|
364 | # XX 00 00 00 - utf-32-le | |
|
365 | # XX 00 00 XX - utf-16-le | |
|
366 | # XX 00 XX -- - utf-16-le | |
|
367 | return 'utf-16-le' if b[2] or b[3] else 'utf-32-le' | |
|
368 | elif len(b) == 2: | |
|
369 | if not b[0]: | |
|
370 | # 00 XX - utf-16-be | |
|
371 | return 'utf-16-be' | |
|
372 | if not b[1]: | |
|
373 | # XX 00 - utf-16-le | |
|
374 | return 'utf-16-le' | |
|
375 | # default | |
|
376 | return 'utf-8' | |
|
377 | ||
|
378 | def json_loads(s, *args, **kwargs): | |
|
379 | if isinstance(s, (bytes, bytearray)): | |
|
380 | s = s.decode(_detect_encoding(s), 'surrogatepass') | |
|
381 | ||
|
382 | return json.loads(s, *args, **kwargs) | |
|
383 | ||
|
384 | else: | |
|
385 | json_loads = json.loads | |
|
386 | ||
|
343 | 387 | else: |
|
344 | 388 | import cStringIO |
|
345 | 389 | |
|
346 | 390 | xrange = xrange |
|
347 | 391 | unicode = unicode |
|
348 | 392 | bytechr = chr |
|
349 | 393 | byterepr = repr |
|
350 | 394 | bytestr = str |
|
351 | 395 | iterbytestr = iter |
|
352 | 396 | maybebytestr = identity |
|
353 | 397 | sysbytes = identity |
|
354 | 398 | sysstr = identity |
|
355 | 399 | strurl = identity |
|
356 | 400 | bytesurl = identity |
|
357 | 401 | open = open |
|
358 | 402 | delattr = delattr |
|
359 | 403 | getattr = getattr |
|
360 | 404 | hasattr = hasattr |
|
361 | 405 | setattr = setattr |
|
362 | 406 | |
|
363 | 407 | # this can't be parsed on Python 3 |
|
364 | 408 | exec(b'def raisewithtb(exc, tb):\n raise exc, None, tb\n') |
|
365 | 409 | |
|
366 | 410 | def fsencode(filename): |
|
367 | 411 | """ |
|
368 | 412 | Partial backport from os.py in Python 3, which only accepts bytes. |
|
369 | 413 | In Python 2, our paths should only ever be bytes, a unicode path |
|
370 | 414 | indicates a bug. |
|
371 | 415 | """ |
|
372 | 416 | if isinstance(filename, str): |
|
373 | 417 | return filename |
|
374 | 418 | else: |
|
375 | 419 | raise TypeError(r"expect str, not %s" % type(filename).__name__) |
|
376 | 420 | |
|
377 | 421 | # In Python 2, fsdecode() has a very chance to receive bytes. So it's |
|
378 | 422 | # better not to touch Python 2 part as it's already working fine. |
|
379 | 423 | fsdecode = identity |
|
380 | 424 | |
|
381 | 425 | def getdoc(obj): |
|
382 | 426 | return getattr(obj, '__doc__', None) |
|
383 | 427 | |
|
384 | 428 | _notset = object() |
|
385 | 429 | |
|
386 | 430 | def safehasattr(thing, attr): |
|
387 | 431 | return getattr(thing, attr, _notset) is not _notset |
|
388 | 432 | |
|
389 | 433 | def _getoptbwrapper(orig, args, shortlist, namelist): |
|
390 | 434 | return orig(args, shortlist, namelist) |
|
391 | 435 | |
|
392 | 436 | strkwargs = identity |
|
393 | 437 | byteskwargs = identity |
|
394 | 438 | |
|
395 | 439 | oscurdir = os.curdir |
|
396 | 440 | oslinesep = os.linesep |
|
397 | 441 | osname = os.name |
|
398 | 442 | ospathsep = os.pathsep |
|
399 | 443 | ospardir = os.pardir |
|
400 | 444 | ossep = os.sep |
|
401 | 445 | osaltsep = os.altsep |
|
402 | 446 | long = long |
|
403 | 447 | stdin = sys.stdin |
|
404 | 448 | stdout = sys.stdout |
|
405 | 449 | stderr = sys.stderr |
|
406 | 450 | if getattr(sys, 'argv', None) is not None: |
|
407 | 451 | sysargv = sys.argv |
|
408 | 452 | sysplatform = sys.platform |
|
409 | 453 | sysexecutable = sys.executable |
|
410 | 454 | shlexsplit = shlex.split |
|
411 | 455 | bytesio = cStringIO.StringIO |
|
412 | 456 | stringio = bytesio |
|
413 | 457 | maplist = map |
|
414 | 458 | rangelist = range |
|
415 | 459 | ziplist = zip |
|
416 | 460 | rawinput = raw_input |
|
417 | 461 | getargspec = inspect.getargspec |
|
418 | 462 | iteritems = lambda x: x.iteritems() |
|
419 | 463 | itervalues = lambda x: x.itervalues() |
|
464 | json_loads = json.loads | |
|
420 | 465 | |
|
421 | 466 | isjython = sysplatform.startswith(b'java') |
|
422 | 467 | |
|
423 | 468 | isdarwin = sysplatform.startswith(b'darwin') |
|
424 | 469 | islinux = sysplatform.startswith(b'linux') |
|
425 | 470 | isposix = osname == b'posix' |
|
426 | 471 | iswindows = osname == b'nt' |
|
427 | 472 | |
|
428 | 473 | |
|
429 | 474 | def getoptb(args, shortlist, namelist): |
|
430 | 475 | return _getoptbwrapper(getopt.getopt, args, shortlist, namelist) |
|
431 | 476 | |
|
432 | 477 | |
|
433 | 478 | def gnugetoptb(args, shortlist, namelist): |
|
434 | 479 | return _getoptbwrapper(getopt.gnu_getopt, args, shortlist, namelist) |
|
435 | 480 | |
|
436 | 481 | |
|
437 | 482 | def mkdtemp(suffix=b'', prefix=b'tmp', dir=None): |
|
438 | 483 | return tempfile.mkdtemp(suffix, prefix, dir) |
|
439 | 484 | |
|
440 | 485 | |
|
441 | 486 | # text=True is not supported; use util.from/tonativeeol() instead |
|
442 | 487 | def mkstemp(suffix=b'', prefix=b'tmp', dir=None): |
|
443 | 488 | return tempfile.mkstemp(suffix, prefix, dir) |
|
444 | 489 | |
|
445 | 490 | |
|
446 | 491 | # mode must include 'b'ytes as encoding= is not supported |
|
447 | 492 | def namedtempfile( |
|
448 | 493 | mode=b'w+b', bufsize=-1, suffix=b'', prefix=b'tmp', dir=None, delete=True |
|
449 | 494 | ): |
|
450 | 495 | mode = sysstr(mode) |
|
451 | 496 | assert r'b' in mode |
|
452 | 497 | return tempfile.NamedTemporaryFile( |
|
453 | 498 | mode, bufsize, suffix=suffix, prefix=prefix, dir=dir, delete=delete |
|
454 | 499 | ) |
@@ -1,124 +1,124 b'' | |||
|
1 | 1 | #!/usr/bin/env python |
|
2 | 2 | |
|
3 | 3 | """This does HTTP GET requests given a host:port and path and returns |
|
4 | 4 | a subset of the headers plus the body of the result.""" |
|
5 | 5 | |
|
6 | 6 | from __future__ import absolute_import |
|
7 | 7 | |
|
8 | 8 | import argparse |
|
9 | 9 | import json |
|
10 | 10 | import os |
|
11 | 11 | import sys |
|
12 | 12 | |
|
13 | 13 | from mercurial import ( |
|
14 | 14 | pycompat, |
|
15 | 15 | util, |
|
16 | 16 | ) |
|
17 | 17 | |
|
18 | 18 | httplib = util.httplib |
|
19 | 19 | |
|
20 | 20 | try: |
|
21 | 21 | import msvcrt |
|
22 | 22 | |
|
23 | 23 | msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) |
|
24 | 24 | msvcrt.setmode(sys.stderr.fileno(), os.O_BINARY) |
|
25 | 25 | except ImportError: |
|
26 | 26 | pass |
|
27 | 27 | |
|
28 | 28 | stdout = getattr(sys.stdout, 'buffer', sys.stdout) |
|
29 | 29 | |
|
30 | 30 | parser = argparse.ArgumentParser() |
|
31 | 31 | parser.add_argument('--twice', action='store_true') |
|
32 | 32 | parser.add_argument('--headeronly', action='store_true') |
|
33 | 33 | parser.add_argument('--json', action='store_true') |
|
34 | 34 | parser.add_argument('--hgproto') |
|
35 | 35 | parser.add_argument( |
|
36 | 36 | '--requestheader', |
|
37 | 37 | nargs='*', |
|
38 | 38 | default=[], |
|
39 | 39 | help='Send an additional HTTP request header. Argument ' |
|
40 | 40 | 'value is <header>=<value>', |
|
41 | 41 | ) |
|
42 | 42 | parser.add_argument('--bodyfile', help='Write HTTP response body to a file') |
|
43 | 43 | parser.add_argument('host') |
|
44 | 44 | parser.add_argument('path') |
|
45 | 45 | parser.add_argument('show', nargs='*') |
|
46 | 46 | |
|
47 | 47 | args = parser.parse_args() |
|
48 | 48 | |
|
49 | 49 | twice = args.twice |
|
50 | 50 | headeronly = args.headeronly |
|
51 | 51 | formatjson = args.json |
|
52 | 52 | hgproto = args.hgproto |
|
53 | 53 | requestheaders = args.requestheader |
|
54 | 54 | |
|
55 | 55 | tag = None |
|
56 | 56 | |
|
57 | 57 | |
|
58 | 58 | def request(host, path, show): |
|
59 | 59 | assert not path.startswith('/'), path |
|
60 | 60 | global tag |
|
61 | 61 | headers = {} |
|
62 | 62 | if tag: |
|
63 | 63 | headers['If-None-Match'] = tag |
|
64 | 64 | if hgproto: |
|
65 | 65 | headers['X-HgProto-1'] = hgproto |
|
66 | 66 | |
|
67 | 67 | for header in requestheaders: |
|
68 | 68 | key, value = header.split('=', 1) |
|
69 | 69 | headers[key] = value |
|
70 | 70 | |
|
71 | 71 | conn = httplib.HTTPConnection(host) |
|
72 | 72 | conn.request("GET", '/' + path, None, headers) |
|
73 | 73 | response = conn.getresponse() |
|
74 | 74 | stdout.write( |
|
75 | 75 | b'%d %s\n' % (response.status, response.reason.encode('ascii')) |
|
76 | 76 | ) |
|
77 | 77 | if show[:1] == ['-']: |
|
78 | 78 | show = sorted( |
|
79 | 79 | h for h, v in response.getheaders() if h.lower() not in show |
|
80 | 80 | ) |
|
81 | 81 | for h in [h.lower() for h in show]: |
|
82 | 82 | if response.getheader(h, None) is not None: |
|
83 | 83 | stdout.write( |
|
84 | 84 | b"%s: %s\n" |
|
85 | 85 | % (h.encode('ascii'), response.getheader(h).encode('ascii')) |
|
86 | 86 | ) |
|
87 | 87 | if not headeronly: |
|
88 | 88 | stdout.write(b'\n') |
|
89 | 89 | data = response.read() |
|
90 | 90 | |
|
91 | 91 | if args.bodyfile: |
|
92 | 92 | bodyfh = open(args.bodyfile, 'wb') |
|
93 | 93 | else: |
|
94 | 94 | bodyfh = stdout |
|
95 | 95 | |
|
96 | 96 | # Pretty print JSON. This also has the beneficial side-effect |
|
97 | 97 | # of verifying emitted JSON is well-formed. |
|
98 | 98 | if formatjson: |
|
99 | 99 | # json.dumps() will print trailing newlines. Eliminate them |
|
100 | 100 | # to make tests easier to write. |
|
101 |
data = |
|
|
101 | data = pycompat.json_loads(data) | |
|
102 | 102 | lines = json.dumps(data, sort_keys=True, indent=2).splitlines() |
|
103 | 103 | for line in lines: |
|
104 | 104 | bodyfh.write(pycompat.sysbytes(line.rstrip())) |
|
105 | 105 | bodyfh.write(b'\n') |
|
106 | 106 | else: |
|
107 | 107 | bodyfh.write(data) |
|
108 | 108 | |
|
109 | 109 | if args.bodyfile: |
|
110 | 110 | bodyfh.close() |
|
111 | 111 | |
|
112 | 112 | if twice and response.getheader('ETag', None): |
|
113 | 113 | tag = response.getheader('ETag') |
|
114 | 114 | |
|
115 | 115 | return response.status |
|
116 | 116 | |
|
117 | 117 | |
|
118 | 118 | status = request(args.host, args.path, args.show) |
|
119 | 119 | if twice: |
|
120 | 120 | status = request(args.host, args.path, args.show) |
|
121 | 121 | |
|
122 | 122 | if 200 <= status <= 305: |
|
123 | 123 | sys.exit(0) |
|
124 | 124 | sys.exit(1) |
General Comments 0
You need to be logged in to leave comments.
Login now