OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/python |
| 2 # Copyright (c) 2009 The Chromium Authors. All rights reserved. |
| 3 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. |
| 5 """Client-side script to send a try job to the try server. It communicates to |
| 6 the try server by either writting to a svn repository or by directly connecting |
| 7 to the server by HTTP. |
| 8 """ |
| 9 |
| 10 |
| 11 import datetime |
| 12 import getpass |
| 13 import logging |
| 14 import optparse |
| 15 import os |
| 16 import shutil |
| 17 import sys |
| 18 import tempfile |
| 19 import traceback |
| 20 import urllib |
| 21 |
| 22 import gcl |
| 23 |
| 24 __version__ = '1.1' |
| 25 |
| 26 |
| 27 # Constants |
| 28 HELP_STRING = "Sorry, Tryserver is not available." |
| 29 SCRIPT_PATH = os.path.join('tools', 'tryserver', 'tryserver.py') |
| 30 USAGE = r"""%prog [options] |
| 31 |
| 32 Client-side script to send a try job to the try server. It communicates to |
| 33 the try server by either writting to a svn repository or by directly connecting |
| 34 to the server by HTTP. |
| 35 |
| 36 |
| 37 Examples: |
| 38 A git patch off a web site (git inserts a/ and b/) and fix the base dir: |
| 39 %prog --url http://url/to/patch.diff --patchlevel 1 --root src |
| 40 |
| 41 Use svn to store the try job, specify an alternate email address and use a |
| 42 premade diff file on the local drive: |
| 43 %prog --email user@example.com |
| 44 --svn_repo svn://svn.chromium.org/chrome-try/try --diff foo.diff |
| 45 |
| 46 Running only on a 'mac' slave with revision src@123 and clobber first; specify |
| 47 manually the 3 source files to use for the try job: |
| 48 %prog --bot mac --revision src@123 --clobber -f src/a.cc -f src/a.h |
| 49 -f include/b.h |
| 50 |
| 51 """ |
| 52 |
| 53 class InvalidScript(Exception): |
| 54 def __str__(self): |
| 55 return self.args[0] + '\n' + HELP_STRING |
| 56 |
| 57 |
| 58 class NoTryServerAccess(Exception): |
| 59 def __str__(self): |
| 60 return self.args[0] + '\n' + HELP_STRING |
| 61 |
| 62 |
| 63 def PathDifference(root, subpath): |
| 64 """Returns the difference subpath minus root.""" |
| 65 if subpath.find(root) != 0: |
| 66 return None |
| 67 # The + 1 is for the trailing / or \. |
| 68 return subpath[len(root) + len(os.sep):] |
| 69 |
| 70 |
| 71 def GetSourceRoot(): |
| 72 """Returns the absolute directory one level up from the repository root.""" |
| 73 return os.path.abspath(os.path.join(gcl.GetRepositoryRoot(), '..')) |
| 74 |
| 75 |
| 76 def ExecuteTryServerScript(): |
| 77 """Locates the tryserver script, executes it and returns its dictionary. |
| 78 |
| 79 The try server script contains the repository-specific try server commands.""" |
| 80 script_locals = {} |
| 81 try: |
| 82 # gcl.GetRepositoryRoot() may throw an exception. |
| 83 script_path = os.path.join(gcl.GetRepositoryRoot(), SCRIPT_PATH) |
| 84 except Exception: |
| 85 return script_locals |
| 86 if os.path.exists(script_path): |
| 87 try: |
| 88 exec(gcl.ReadFile(script_path), script_locals) |
| 89 except Exception, e: |
| 90 # TODO(maruel): Need to specialize the exception trapper. |
| 91 traceback.print_exc() |
| 92 raise InvalidScript('%s is invalid.' % script_path) |
| 93 return script_locals |
| 94 |
| 95 |
| 96 def EscapeDot(name): |
| 97 return name.replace('.', '-') |
| 98 |
| 99 |
| 100 def RunCommand(command): |
| 101 output, retcode = gcl.RunShellWithReturnCode(command) |
| 102 if retcode: |
| 103 raise NoTryServerAccess(' '.join(command) + '\nOuput:\n' + output) |
| 104 return output |
| 105 |
| 106 |
| 107 class SCM(object): |
| 108 """Simplistic base class to implement one function: ProcessOptions.""" |
| 109 def __init__(self, options): |
| 110 self.options = options |
| 111 |
| 112 def ProcessOptions(self): |
| 113 raise Unimplemented |
| 114 |
| 115 |
| 116 class SVN(SCM): |
| 117 """Gathers the options and diff for a subversion checkout.""" |
| 118 def GenerateDiff(self, files, root): |
| 119 """Returns a string containing the diff for the given file list. |
| 120 |
| 121 The files in the list should either be absolute paths or relative to the |
| 122 given root. If no root directory is provided, the repository root will be |
| 123 used. |
| 124 """ |
| 125 previous_cwd = os.getcwd() |
| 126 if root is None: |
| 127 os.chdir(gcl.GetRepositoryRoot()) |
| 128 else: |
| 129 os.chdir(root) |
| 130 |
| 131 diff = [] |
| 132 for file in files: |
| 133 # Use svn info output instead of os.path.isdir because the latter fails |
| 134 # when the file is deleted. |
| 135 if gcl.GetSVNFileInfo(file).get("Node Kind") == "directory": |
| 136 continue |
| 137 # If the user specified a custom diff command in their svn config file, |
| 138 # then it'll be used when we do svn diff, which we don't want to happen |
| 139 # since we want the unified diff. Using --diff-cmd=diff doesn't always |
| 140 # work, since they can have another diff executable in their path that |
| 141 # gives different line endings. So we use a bogus temp directory as the |
| 142 # config directory, which gets around these problems. |
| 143 if sys.platform.startswith("win"): |
| 144 parent_dir = tempfile.gettempdir() |
| 145 else: |
| 146 parent_dir = sys.path[0] # tempdir is not secure. |
| 147 bogus_dir = os.path.join(parent_dir, "temp_svn_config") |
| 148 if not os.path.exists(bogus_dir): |
| 149 os.mkdir(bogus_dir) |
| 150 # Grabs the diff data. |
| 151 data = gcl.RunShell(["svn", "diff", "--config-dir", bogus_dir, file]) |
| 152 |
| 153 # We know the diff will be incorrectly formatted. Fix it. |
| 154 if gcl.IsSVNMoved(file): |
| 155 # The file is "new" in the patch sense. Generate a homebrew diff. |
| 156 # We can't use ReadFile() since it's not using binary mode. |
| 157 file_handle = open(file, 'rb') |
| 158 file_content = file_handle.read() |
| 159 file_handle.close() |
| 160 # Prepend '+ ' to every lines. |
| 161 file_content = ['+ ' + i for i in file_content.splitlines(True)] |
| 162 nb_lines = len(file_content) |
| 163 # We need to use / since patch on unix will fail otherwise. |
| 164 file = file.replace('\\', '/') |
| 165 data = "Index: %s\n" % file |
| 166 data += ("=============================================================" |
| 167 "======\n") |
| 168 # Note: Should we use /dev/null instead? |
| 169 data += "--- %s\n" % file |
| 170 data += "+++ %s\n" % file |
| 171 data += "@@ -0,0 +1,%d @@\n" % nb_lines |
| 172 data += ''.join(file_content) |
| 173 diff.append(data) |
| 174 os.chdir(previous_cwd) |
| 175 return "".join(diff) |
| 176 |
| 177 def ProcessOptions(self): |
| 178 if not self.options.diff: |
| 179 # Generate the diff with svn and write it to the submit queue path. The |
| 180 # files are relative to the repository root, but we need patches relative |
| 181 # to one level up from there (i.e., 'src'), so adjust both the file |
| 182 # paths and the root of the diff. |
| 183 source_root = GetSourceRoot() |
| 184 prefix = PathDifference(source_root, gcl.GetRepositoryRoot()) |
| 185 adjusted_paths = [os.path.join(prefix, x) for x in self.options.files] |
| 186 self.options.diff = self.GenerateDiff(adjusted_paths, root=source_root) |
| 187 |
| 188 |
| 189 class GIT(SCM): |
| 190 """Gathers the options and diff for a git checkout.""" |
| 191 def GenerateDiff(self): |
| 192 """Get the diff we'll send to the try server. We ignore the files list.""" |
| 193 branch = upload.RunShell(['git', 'cl', 'upstream']).strip() |
| 194 diff = upload.RunShell(['git', 'diff-tree', '-p', '--no-prefix', |
| 195 branch, 'HEAD']).splitlines(True) |
| 196 for i in range(len(diff)): |
| 197 # In the case of added files, replace /dev/null with the path to the |
| 198 # file being added. |
| 199 if diff[i].startswith('--- /dev/null'): |
| 200 diff[i] = '--- %s' % diff[i+1][4:] |
| 201 return ''.join(diff) |
| 202 |
| 203 def GetEmail(self): |
| 204 # TODO: check for errors here? |
| 205 return upload.RunShell(['git', 'config', 'user.email']).strip() |
| 206 |
| 207 def GetPatchName(self): |
| 208 """Construct a name for this patch.""" |
| 209 # TODO: perhaps include the hash of the current commit, to distinguish |
| 210 # patches? |
| 211 branch = upload.RunShell(['git', 'symbolic-ref', 'HEAD']).strip() |
| 212 if not branch.startswith('refs/heads/'): |
| 213 raise "Couldn't figure out branch name" |
| 214 branch = branch[len('refs/heads/'):] |
| 215 return branch |
| 216 |
| 217 def ProcessOptions(self): |
| 218 if not self.options.diff: |
| 219 self.options.diff = self.GenerateDiff() |
| 220 if not self.options.name: |
| 221 self.options.name = self.GetPatchName() |
| 222 if not self.options.email: |
| 223 self.options.email = self.GetEmail() |
| 224 |
| 225 |
| 226 def _ParseSendChangeOptions(options): |
| 227 """Parse common options passed to _SendChangeHTTP and _SendChangeSVN.""" |
| 228 values = {} |
| 229 if options.email: |
| 230 values['email'] = options.email |
| 231 values['user'] = options.user |
| 232 values['name'] = options.name |
| 233 if options.bot: |
| 234 values['bot'] = ','.join(options.bot) |
| 235 if options.revision: |
| 236 values['revision'] = options.revision |
| 237 if options.clobber: |
| 238 values['clobber'] = 'true' |
| 239 if options.tests: |
| 240 values['tests'] = ','.join(options.tests) |
| 241 if options.root: |
| 242 values['root'] = options.root |
| 243 if options.patchlevel: |
| 244 values['patchlevel'] = options.patchlevel |
| 245 if options.issue: |
| 246 values['issue'] = options.issue |
| 247 if options.patchset: |
| 248 values['patchset'] = options.patchset |
| 249 return values |
| 250 |
| 251 |
| 252 def _SendChangeHTTP(options): |
| 253 """Send a change to the try server using the HTTP protocol.""" |
| 254 script_locals = ExecuteTryServerScript() |
| 255 |
| 256 if not options.host: |
| 257 options.host = script_locals.get('try_server_http_host', None) |
| 258 if not options.host: |
| 259 raise NoTryServerAccess('Please use the --host option to specify the try ' |
| 260 'server host to connect to.') |
| 261 if not options.port: |
| 262 options.port = script_locals.get('try_server_http_port', None) |
| 263 if not options.port: |
| 264 raise NoTryServerAccess('Please use the --port option to specify the try ' |
| 265 'server port to connect to.') |
| 266 |
| 267 values = _ParseSendChangeOptions(options) |
| 268 values['patch'] = options.diff |
| 269 |
| 270 url = 'http://%s:%s/send_try_patch' % (options.host, options.port) |
| 271 proxies = None |
| 272 if options.proxy: |
| 273 if options.proxy.lower() == 'none': |
| 274 # Effectively disable HTTP_PROXY or Internet settings proxy setup. |
| 275 proxies = {} |
| 276 else: |
| 277 proxies = {'http': options.proxy, 'https': options.proxy} |
| 278 try: |
| 279 connection = urllib.urlopen(url, urllib.urlencode(values), proxies=proxies) |
| 280 except IOError, e: |
| 281 # TODO(thestig) this probably isn't quite right. |
| 282 if values.get('bot') and e[2] == 'got a bad status line': |
| 283 raise NoTryServerAccess('%s is unaccessible. Bad --bot argument?' % url) |
| 284 else: |
| 285 raise NoTryServerAccess('%s is unaccessible.' % url) |
| 286 if not connection: |
| 287 raise NoTryServerAccess('%s is unaccessible.' % url) |
| 288 if connection.read() != 'OK': |
| 289 raise NoTryServerAccess('%s is unaccessible.' % url) |
| 290 return options.name |
| 291 |
| 292 |
| 293 def _SendChangeSVN(options): |
| 294 """Send a change to the try server by committing a diff file on a subversion |
| 295 server.""" |
| 296 script_locals = ExecuteTryServerScript() |
| 297 if not options.svn_repo: |
| 298 options.svn_repo = script_locals.get('try_server_svn', None) |
| 299 if not options.svn_repo: |
| 300 raise NoTryServerAccess('Please use the --svn_repo option to specify the' |
| 301 ' try server svn repository to connect to.') |
| 302 |
| 303 values = _ParseSendChangeOptions(options) |
| 304 description = '' |
| 305 for (k,v) in values.iteritems(): |
| 306 description += "%s=%s\n" % (k,v) |
| 307 |
| 308 # Do an empty checkout. |
| 309 temp_dir = tempfile.mkdtemp() |
| 310 temp_file = tempfile.NamedTemporaryFile() |
| 311 temp_file_name = temp_file.name |
| 312 try: |
| 313 RunCommand(['svn', 'checkout', '--depth', 'empty', '--non-interactive', |
| 314 options.svn_repo, temp_dir]) |
| 315 # TODO(maruel): Use a subdirectory per user? |
| 316 current_time = str(datetime.datetime.now()).replace(':', '.') |
| 317 file_name = (EscapeDot(options.user) + '.' + EscapeDot(options.name) + |
| 318 '.%s.diff' % current_time) |
| 319 full_path = os.path.join(temp_dir, file_name) |
| 320 full_url = options.svn_repo + '/' + file_name |
| 321 file_found = False |
| 322 try: |
| 323 RunCommand(['svn', 'ls', '--non-interactive', full_url]) |
| 324 file_found = True |
| 325 except NoTryServerAccess: |
| 326 pass |
| 327 if file_found: |
| 328 # The file already exists in the repo. Note that commiting a file is a |
| 329 # no-op if the file's content (the diff) is not modified. This is why the |
| 330 # file name contains the date and time. |
| 331 RunCommand(['svn', 'update', '--non-interactive', full_path]) |
| 332 file = open(full_path, 'wb') |
| 333 file.write(options.diff) |
| 334 file.close() |
| 335 else: |
| 336 # Add the file to the repo |
| 337 file = open(full_path, 'wb') |
| 338 file.write(options.diff) |
| 339 file.close() |
| 340 RunCommand(["svn", "add", '--non-interactive', full_path]) |
| 341 temp_file.write(description) |
| 342 temp_file.flush() |
| 343 RunCommand(["svn", "commit", '--non-interactive', full_path, '--file', |
| 344 temp_file_name]) |
| 345 finally: |
| 346 temp_file.close() |
| 347 shutil.rmtree(temp_dir, True) |
| 348 return options.name |
| 349 |
| 350 |
| 351 def GuessVCS(options): |
| 352 """Helper to guess the version control system. |
| 353 |
| 354 NOTE: Very similar to upload.GuessVCS. Doesn't look for hg since we don't |
| 355 support it yet. |
| 356 |
| 357 This examines the current directory, guesses which SCM we're using, and |
| 358 returns an instance of the appropriate class. Exit with an error if we can't |
| 359 figure it out. |
| 360 |
| 361 Returns: |
| 362 A SCM instance. Exits if the SCM can't be guessed. |
| 363 """ |
| 364 # Subversion has a .svn in all working directories. |
| 365 if os.path.isdir('.svn'): |
| 366 logging.info("Guessed VCS = Subversion") |
| 367 return SVN(options) |
| 368 |
| 369 # Git has a command to test if you're in a git tree. |
| 370 # Try running it, but don't die if we don't have git installed. |
| 371 try: |
| 372 out, returncode = gcl.RunShellWithReturnCode(["git", "rev-parse", |
| 373 "--is-inside-work-tree"]) |
| 374 if returncode == 0: |
| 375 logging.info("Guessed VCS = Git") |
| 376 return GIT(options) |
| 377 except OSError, (errno, message): |
| 378 if errno != 2: # ENOENT -- they don't have git installed. |
| 379 raise |
| 380 |
| 381 raise NoTryServerAccess("Could not guess version control system. " |
| 382 "Are you in a working copy directory?") |
| 383 |
| 384 |
| 385 def TryChange(argv, |
| 386 file_list, |
| 387 swallow_exception, |
| 388 prog=None): |
| 389 # Parse argv |
| 390 parser = optparse.OptionParser(usage=USAGE, |
| 391 version=__version__, |
| 392 prog=prog) |
| 393 |
| 394 group = optparse.OptionGroup(parser, "Result and status") |
| 395 group.add_option("-u", "--user", default=getpass.getuser(), |
| 396 help="Owner user name [default: %default]") |
| 397 group.add_option("-e", "--email", default=os.environ.get('EMAIL_ADDRESS'), |
| 398 help="Email address where to send the results. Use the " |
| 399 "EMAIL_ADDRESS environment variable to set the default " |
| 400 "email address [default: %default]") |
| 401 group.add_option("-n", "--name", default='Unnamed', |
| 402 help="Descriptive name of the try job") |
| 403 group.add_option("--issue", type='int', |
| 404 help="Update rietveld issue try job status") |
| 405 group.add_option("--patchset", type='int', |
| 406 help="Update rietveld issue try job status") |
| 407 parser.add_option_group(group) |
| 408 |
| 409 group = optparse.OptionGroup(parser, "Try job options") |
| 410 group.add_option("-b", "--bot", action="append", |
| 411 help="Only use specifics build slaves, ex: '--bot win' to " |
| 412 "run the try job only on the 'win' slave; see the try " |
| 413 "server watefall for the slave's name") |
| 414 group.add_option("-r", "--revision", |
| 415 help="Revision to use for the try job; default: the " |
| 416 "revision will be determined by the try server; see " |
| 417 "its waterfall for more info") |
| 418 group.add_option("-c", "--clobber", action="store_true", |
| 419 help="Force a clobber before building; e.g. don't do an " |
| 420 "incremental build") |
| 421 # Override the list of tests to run, use multiple times to list many tests |
| 422 # (or comma separated) |
| 423 group.add_option("-t", "--tests", action="append", |
| 424 help=optparse.SUPPRESS_HELP) |
| 425 parser.add_option_group(group) |
| 426 |
| 427 group = optparse.OptionGroup(parser, "Patch to run") |
| 428 group.add_option("-f", "--file", default=file_list, dest="files", |
| 429 metavar="FILE", action="append", |
| 430 help="Use many times to list the files to include in the " |
| 431 "try, relative to the repository root") |
| 432 group.add_option("--diff", |
| 433 help="File containing the diff to try") |
| 434 group.add_option("--url", |
| 435 help="Url where to grab a patch") |
| 436 group.add_option("--root", |
| 437 help="Root to use for the patch; base subdirectory for " |
| 438 "patch created in a subdirectory") |
| 439 group.add_option("--patchlevel", type='int', metavar="LEVEL", |
| 440 help="Used as -pN parameter to patch") |
| 441 parser.add_option_group(group) |
| 442 |
| 443 group = optparse.OptionGroup(parser, "Access the try server by HTTP") |
| 444 group.add_option("--use_http", action="store_const", const=_SendChangeHTTP, |
| 445 dest="send_patch", default=_SendChangeHTTP, |
| 446 help="Use HTTP to talk to the try server [default]") |
| 447 group.add_option("--host", |
| 448 help="Host address") |
| 449 group.add_option("--port", |
| 450 help="HTTP port") |
| 451 group.add_option("--proxy", |
| 452 help="HTTP proxy") |
| 453 parser.add_option_group(group) |
| 454 |
| 455 group = optparse.OptionGroup(parser, "Access the try server with SVN") |
| 456 group.add_option("--use_svn", action="store_const", const=_SendChangeSVN, |
| 457 dest="send_patch", |
| 458 help="Use SVN to talk to the try server") |
| 459 group.add_option("--svn_repo", metavar="SVN_URL", |
| 460 help="SVN url to use to write the changes in; --use_svn is " |
| 461 "implied when using --svn_repo") |
| 462 parser.add_option_group(group) |
| 463 |
| 464 options, args = parser.parse_args(argv) |
| 465 # Switch the default accordingly. |
| 466 if options.svn_repo: |
| 467 options.send_patch = _SendChangeSVN |
| 468 |
| 469 if len(args) == 1 and args[0] == 'help': |
| 470 parser.print_help() |
| 471 if (not options.files and (not options.issue and options.patchset) and |
| 472 not options.diff and not options.url): |
| 473 # TODO(maruel): It should just try the modified files showing up in a |
| 474 # svn status. |
| 475 print "Nothing to try, changelist is empty." |
| 476 return |
| 477 |
| 478 try: |
| 479 # Convert options.diff into the content of the diff. |
| 480 if options.url: |
| 481 options.diff = urllib.urlopen(options.url).read() |
| 482 elif options.diff: |
| 483 options.diff = gcl.ReadFile(options.diff) |
| 484 # Process the VCS in any case at least to retrieve the email address. |
| 485 try: |
| 486 options.scm = GuessVCS(options) |
| 487 options.scm.ProcessOptions() |
| 488 except NoTryServerAccess, e: |
| 489 # If we got the diff, we don't care. |
| 490 if not options.diff: |
| 491 raise |
| 492 |
| 493 # Send the patch. |
| 494 patch_name = options.send_patch(options) |
| 495 print 'Patch \'%s\' sent to try server.' % patch_name |
| 496 if patch_name == 'Unnamed': |
| 497 print "Note: use --name NAME to change the try's name." |
| 498 except (InvalidScript, NoTryServerAccess), e: |
| 499 if swallow_exception: |
| 500 return |
| 501 print e |
| 502 |
| 503 |
| 504 if __name__ == "__main__": |
| 505 TryChange(None, None, False) |
OLD | NEW |