22# License, v. 2.0. If a copy of the MPL was not distributed with this
33# file, You can obtain one at http://mozilla.org/MPL/2.0/.
44import copy
5- from contextlib import contextmanager
65import configparser
76import logging
87import os
9- from pathlib import Path
108import shlex
119import shutil
10+ import subprocess
1211import tempfile
1312import uuid
1413
14+ from contextlib import contextmanager
15+ from pathlib import Path
1516from typing import List , Optional
1617
1718import hglib
@@ -101,21 +102,21 @@ class PatchConflict(PatchApplicationFailure):
101102 )
102103
103104
104- def check_fix_output_for_replacements (fix_output : List [bytes ]) -> Optional [List [str ]]:
105- """Parses `hg fix` output.
105+ class AutoformattingException (Exception ):
106+ """Exception when autoformatting fails to format a patch stack."""
107+
108+ pass
106109
107- Returns:
108- A list of changeset hashes, or None if no changesets are changed.
109- """
110- for line in fix_output :
111- if not line .startswith (b"REPLACEMENTS: " ):
112- continue
113110
114- replacements_list = line .split (b"REPLACEMENTS: " , maxsplit = 1 )[- 1 ].split (b"," )
111+ AUTOFORMAT_COMMIT_MESSAGE = """
112+ No bug: apply code formatting via Lando
115113
116- return [ element . decode ( "latin-1" ) for element in replacements_list ]
114+ # ignore-this-changeset
117115
118- return None
116+ Output from `mach lint`:
117+
118+ {output}
119+ """ .strip ()
119120
120121
121122class HgRepo :
@@ -136,10 +137,6 @@ class HgRepo:
136137 "extensions.strip" : "" ,
137138 "extensions.rebase" : "" ,
138139 "extensions.set_landing_system" : "/app/hgext/set_landing_system.py" ,
139- # Turn on fix extension for autoformatting, set to abort on failure
140- "extensions.fix" : "" ,
141- "fix.failure" : "abort" ,
142- "hooks.postfix" : "python:/app/hgext/postfix_hook.py:postfix_hook" ,
143140 }
144141
145142 def __init__ (self , path , config = None ):
@@ -152,6 +149,13 @@ def __init__(self, path, config=None):
152149 if config :
153150 self .config .update (config )
154151
152+ @property
153+ def mach_path (self ) -> Optional [Path ]:
154+ """Return the `Path` to `mach`, if it exists."""
155+ mach_path = Path (self .path ) / "mach"
156+ if mach_path .exists ():
157+ return mach_path
158+
155159 def _config_to_list (self ):
156160 return ["{}={}" .format (k , v ) for k , v in self .config .items () if v is not None ]
157161
@@ -280,7 +284,7 @@ def clean_repo(self, *, strip_non_public_commits=True):
280284 except hglib .error .CommandError :
281285 pass
282286 try :
283- self .run_hg (["purge" , "--all" ])
287+ self .run_hg (["purge" ])
284288 except hglib .error .CommandError :
285289 pass
286290
@@ -368,56 +372,159 @@ def apply_patch(self, patch_io_buf):
368372 + ["--logfile" , f_msg .name ]
369373 )
370374
371- def format (self ) -> Optional [List [str ]]:
372- """Run `hg fix` to format the currently checked-out stack, reading
373- fileset patterns for each formatter from the `.lando.ini` file in-tree."""
374- # Avoid attempting to autoformat without `.lando.ini` in-tree.
375- lando_config_path = Path (self .path ) / ".lando.ini"
376- if not lando_config_path .exists ():
375+ def read_lando_config (self ) -> Optional [configparser .ConfigParser ]:
376+ """Attempt to read the `.lando.ini` file."""
377+ try :
378+ lando_ini_contents = self .read_checkout_file (".lando.ini" )
379+ except ValueError :
377380 return None
378381
379382 # ConfigParser will use `:` as a delimeter unless told otherwise.
380383 # We set our keys as `formatter:pattern` so specify `=` as the delimiters.
381384 parser = configparser .ConfigParser (delimiters = "=" )
382- with lando_config_path .open () as f :
383- parser .read_file (f )
385+ parser .read_string (lando_ini_contents )
384386
385- # If the file doesn't have a `fix` section, exit early.
386- if not parser .has_section ("fix" ):
387- return None
387+ return parser
388388
389- fix_hg_command = []
390- for key , value in parser .items ("fix" ):
391- if not key .endswith (":pattern" ):
392- continue
389+ def run_code_formatters (self ) -> str :
390+ """Run automated code formatters, returning the output of the process.
393391
394- fix_hg_command += ["--config" , f"fix.{ key } ={ value } " ]
392+ Changes made by code formatters are applied to the working directory and
393+ are not committed into version control.
394+ """
395+ # Run linters.
396+ return self .run_mach_command (["lint" , "--fix" , "--outgoing" ])
395397
396- # Exit if we didn't find any patterns.
397- if not fix_hg_command :
398- return None
398+ def run_mach_bootstrap (self ) -> str :
399+ """Run `mach bootstrap` to configure the system for code formatting."""
400+ return self .run_mach_command (
401+ [
402+ "bootstrap" ,
403+ "--no-system-changes" ,
404+ "--application-choice" ,
405+ "browser" ,
406+ ]
407+ )
399408
400- # Run the formatters.
401- fix_hg_command += ["fix" , "-r" , "stack()" ]
402- fix_output = self .run_hg (fix_hg_command ).splitlines ()
409+ def run_mach_command (self , args : List [str ]) -> str :
410+ """Run a command using the local `mach`, raising if it is missing."""
411+ if not self .mach_path :
412+ raise Exception ("No `mach` found in local repo!" )
413+
414+ # Convert to `str` here so we can log the mach path.
415+ command_args = [str (self .mach_path )] + args
416+
417+ try :
418+ logger .info ("running mach command" , extra = {"command" : command_args })
419+
420+ output = subprocess .run (
421+ command_args ,
422+ capture_output = True ,
423+ check = True ,
424+ cwd = self .path ,
425+ encoding = "utf-8" ,
426+ universal_newlines = True ,
427+ )
428+
429+ logger .info (
430+ "output from mach command" ,
431+ extra = {
432+ "output" : output .stdout ,
433+ },
434+ )
435+
436+ return output .stdout
437+
438+ except subprocess .CalledProcessError as exc :
439+ logger .exception (
440+ "Failed to run mach command" ,
441+ extra = {
442+ "command" : command_args ,
443+ "err" : exc .stderr ,
444+ "output" : exc .stdout ,
445+ },
446+ )
403447
404- # Update the working directory to the latest change.
405- self .run_hg (["update" , "-C" , "-r" , "tip" ])
448+ raise exc
406449
407- # Exit if no revisions were reformatted.
408- pre_formatting_hashes = check_fix_output_for_replacements (fix_output )
409- if not pre_formatting_hashes :
450+ def format_stack_amend (self ) -> Optional [List [str ]]:
451+ """Amend the top commit in the patch stack with changes from formatting."""
452+ try :
453+ # Create a new commit, using `--no-edit` to keep the existing commit message.
454+ self .run_hg (["commit" , "--amend" , "--no-edit" , "--landing_system" , "lando" ])
455+
456+ return [self .get_current_node ().decode ("utf-8" )]
457+ except hglib .error .CommandError as exc :
458+ if exc .out .strip () == b"nothing changed" :
459+ # If nothing changed after formatting we can just return.
460+ return None
461+
462+ raise exc
463+
464+ def format_stack_tip (self , autoformat_output : str ) -> Optional [List [str ]]:
465+ """Add an autoformat commit to the top of the patch stack.
466+
467+ Return the commit hash of the autoformat commit as a `str`,
468+ or return `None` if autoformatting made no changes.
469+ """
470+ try :
471+ # Create a new commit.
472+ self .run_hg (
473+ ["commit" ]
474+ + [
475+ "--message" ,
476+ AUTOFORMAT_COMMIT_MESSAGE .format (output = autoformat_output ),
477+ ]
478+ + ["--landing_system" , "lando" ]
479+ )
480+
481+ return [self .get_current_node ().decode ("utf-8" )]
482+
483+ except hglib .error .CommandError as exc :
484+ if exc .out .strip () == b"nothing changed" :
485+ # If nothing changed after formatting we can just return.
486+ return None
487+
488+ raise exc
489+
490+ def format_stack (self , stack_size : int ) -> Optional [List [str ]]:
491+ """Format the patch stack for landing.
492+
493+ Return a list of `str` commit hashes where autoformatting was applied,
494+ or `None` if autoformatting was skipped. Raise `AutoformattingException`
495+ if autoformatting failed for the current job.
496+ """
497+ # Disable autoformatting if `.lando.ini` is missing or not enabled.
498+ landoini_config = self .read_lando_config ()
499+ if (
500+ not landoini_config
501+ or not landoini_config .has_section ("autoformat" )
502+ or not landoini_config .getboolean ("autoformat" , "enabled" )
503+ ):
410504 return None
411505
412- post_formatting_hashes = (
413- self .run_hg (["log" , "-r" , "stack()" , "-T" , "{node}\n " ])
414- .decode ("utf-8" )
415- .splitlines ()[len (pre_formatting_hashes ) - 1 :]
416- )
506+ # If `mach` is not at the root of the repo, we can't autoformat.
507+ if not self .mach_path :
508+ logger .info ("No `./mach` in the repo - skipping autoformat." )
509+ return None
510+
511+ try :
512+ output = self .run_code_formatters ()
417513
418- logger .info (f"revisions were reformatted: { ', ' .join (post_formatting_hashes )} " )
514+ # When the stack is just a single commit, amend changes into it.
515+ if stack_size == 1 :
516+ return self .format_stack_amend ()
419517
420- return post_formatting_hashes
518+ # If the stack is more than a single commit, create an autoformat commit.
519+ return self .format_stack_tip (output )
520+
521+ except (HgException , subprocess .CalledProcessError ) as exc :
522+ logger .warning ("Failed to create an autoformat commit." )
523+ logger .exception (exc )
524+
525+ raise AutoformattingException (
526+ "Failed to enforce code style guidelines."
527+ ) from exc
421528
422529 def push (self , target , bookmark = None ):
423530 if not os .getenv (REQUEST_USER_ENV_VAR ):
@@ -472,6 +579,10 @@ def get_remote_head(self, source: str) -> bytes:
472579 assert len (cset ) == 12 , cset
473580 return cset
474581
582+ def get_current_node (self ) -> bytes :
583+ """Return the currently checked out node."""
584+ return self .run_hg (["identify" , "-r" , "." , "-i" ])
585+
475586 def update_from_upstream (self , source , remote_rev ):
476587 # Pull and update to remote tip.
477588 cmds = [
0 commit comments