Created
January 17, 2026 02:23
-
-
Save rofinn/a8bbec128a77b6725eecf05e62ea7b4d to your computer and use it in GitHub Desktop.
Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts app format.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # /// script | |
| # requires-python = ">=3.11" | |
| # dependencies = [ | |
| # "pandas", | |
| # "openpyxl", | |
| # "click", | |
| # ] | |
| # /// | |
| """ | |
| nippard2workouts.py | |
| Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts app format. | |
| DESCRIPTION | |
| Parses Jeff Nippard workout program spreadsheets and converts them to format compatible with MacroFactor Workouts. | |
| Despite the advertising, only some of the Jeff Nippard programs are supported out of the box (link below). | |
| It handles exercise name mapping, week/workout organization, and generates properly formatted output files. | |
| Currently, tested with the "Fundamentals Hypertrophy Program" (.xlsx file) which places all workouts in a single block/cycle | |
| due to limitations of the Workouts app. | |
| REQUIREMENTS | |
| - Python 3.11+ | |
| - [uv](https://docs.astral.sh/uv/getting-started/installation/) | |
| USAGE | |
| Help: | |
| ```sh | |
| > uv run nippard2workouts.py --help | |
| Installed 9 packages in 116ms | |
| Usage: nippard2workouts.py [OPTIONS] INPUT_FILE | |
| Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts format. | |
| INPUT_FILE: Path to the input Excel file (ex | |
| './Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx') | |
| Options: | |
| -o, --output PATH Output file path. Defaults to <program>.xlsx in | |
| the local directory (ex | |
| './Fundamentals__Full_Body.xlsx') | |
| -m, --mapping PATH JSON mapping file to add or override the default | |
| mapping. Contains a single object where keys = | |
| program names and values = app names. | |
| -l, --location TEXT Gym/location name for workout labels | |
| -p, --program TEXT Name used within the output file and also the | |
| default for the output file path. (ex | |
| 'Fundamentals: Full Body') | |
| -s, --sheet TEXT Sheet name to use or prompts if not provided (ex | |
| 'Full Body') | |
| --ordering TEXT Weekly workout ordering where 0=rest, 1=workout. | |
| (ex 01010100 for 3 workouts/week) | |
| --naming [full|condensed] Naming convention: 'full' (Week 1: Name) or | |
| 'condensed' (W1: Name) | |
| --help Show this message and exit. | |
| ``` | |
| Interactive: | |
| ```sh | |
| > uv run nippard2workouts.py ~/Downloads/Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx | |
| Gym/location name [Gym]: | |
| Available programs: | |
| 1. Body Part Split | |
| 2. Upper Lower | |
| 3. Full Body | |
| Select program number: 3 | |
| Parsing workout data from 'Full Body'... | |
| Found 168 exercises across 8 weeks | |
| Found 3 workout types per week: | |
| - Full Body #1 | |
| - Full Body #3 | |
| - Full Body #2 | |
| Program name [Full Body]: | |
| Using program name: Full Body | |
| Output file path [/Users/rory/Downloads/Full_Body.xlsx]: | |
| Output written to: /Users/rory/Downloads/Full_Body.xlsx | |
| Conversion complete! | |
| ``` | |
| Non-interactive: | |
| > uv run nippard2workouts.py ~/Downloads/Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx \ | |
| --location "My Gym" \ | |
| --sheet "Full Body" \ | |
| --program "Fundamentals: Full Body" \ | |
| --naming condensed \ | |
| --ordering 0101001 \ | |
| --mapping overrides.json \ | |
| --output ./fundamentals-full-body.xlsx | |
| Parsing workout data from 'Full Body'... | |
| Found 168 exercises across 8 weeks | |
| Found 3 workout types per week: | |
| - Full Body #3 | |
| - Full Body #2 | |
| - Full Body #1 | |
| Loaded 1 mapping overrides from overrides.json | |
| Using program name: Fundamentals: Full Body | |
| Output written to: fundamentals-full-body.xlsx | |
| Conversion complete! | |
| ``` | |
| LINKS | |
| - https://jeffnippard.com/products/fundamentals-hypertrophy-program | |
| - https://help.macrofactorapp.com/en/collections/20-macrofactor-workouts | |
| #heading-4-troubleshooting-import-issues | |
| - https://help.macrofactorapp.com/en/articles/287-how-to-import-a-workout-or-program | |
| """ | |
| import json | |
| import re | |
| from pathlib import Path | |
| import click | |
| import pandas as pd | |
| from openpyxl import Workbook | |
| from openpyxl.styles import Alignment, Font | |
| # Default exercise name mapping (input name -> app name) | |
| DEFAULT_EXERCISE_MAPPING = { | |
| "BARBELL BENCH PRESS": "Barbell Bench Press", | |
| "DUMBBELL INCLINE PRESS": "45° Incline Dumbbell Press", | |
| "CABLE FLYE": "Horizontal Cable Fly", | |
| "ASSISTED DIP": "Pin-Loaded Machine Assisted Dip", | |
| "DUMBBELL SKULL CRUSHER": "Lying Dumbbell Triceps Extension", | |
| "BACK SQUAT": "Barbell Back Squat", | |
| "ROMANIAN DEADLIFT": "Barbell Romanian Deadlift", | |
| "BARBELL HIP THRUST": "Plate-Loaded Machine Hip Thrust (Starting From The Top)", | |
| "LEG EXTENSION": "Pin-Loaded Machine Leg Extension", | |
| "LEG CURL": "Seated Pin-Loaded Machine Hamstring Curl", | |
| "STANDING CALF RAISE": "Standing Pin-Loaded Machine Calf Raise", | |
| "CRUNCH": "Weighted Crunch (Holding Weight Overhead)", | |
| "REVERSE GRIP LAT PULLDOWN": "Underhand Grip Cable Lat Pulldown", | |
| "CABLE SEATED ROW": "Seated Neutral Grip Cable Row", | |
| "CHEST-SUPPORTED T-BAR ROW": "Chest-Supported Neutral Grip T-Bar Row", | |
| "SEATED FACE PULL": "Seated Cable Face Pull", | |
| "DUMBBELL SUPINATED CURL": "Seated Dumbbell Biceps Curl", | |
| "DEADLIFT": "Conventional Deadlift", | |
| "DUMBBELL WALKING LUNGE": "Dumbbell Walking Lunge", | |
| "SINGLE-LEG LEG EXTENSION": "Single Leg Pin-Loaded Machine Leg Extension", | |
| "SINGLE-LEG LYING LEG CURL": "Lying Single Leg Pin-Loaded Machine Hamstring Curl", | |
| "MACHINE SEATED HIP ABDUCTION": "Seated Pin-Loaded Machine Hip Abduction", | |
| "PLANK": "Plank", | |
| "OVERHEAD PRESS": "Seated Dumbbell Overhead Press", | |
| "DUMBBELL LATERAL RAISE": "Standing Dumbbell Lateral Raise", | |
| "CABLE REVERSE FLYE": "Neutral Grip Cable Reverse Fly", | |
| "SINGLE-ARM ROPE TRICEP EXTENSION": "Single Arm Neutral Grip Cable Triceps Pushdown", | |
| "SINGLE-ARM CABLE CURL": "Single Arm Bayesian Curl", | |
| "MACHINE INCLINE CHEST PRESS": "Seated Pin-Loaded Machine Incline Press", | |
| "PEC DECK": "Neutral Grip Machine Fly", | |
| "CABLE TRICEP KICKBACK": "Neutral Grip Cable Triceps Kickback", | |
| "GOBLET SQUAT": "Goblet Squat", | |
| "DUMBBELL SINGLE-LEG HIP THRUST": "Single Leg Dumbbell Hip Thrust", | |
| "LEG PRESS": "Plate-Loaded Leg Press", | |
| "LYING LEG CURL": "Lying Pin-Loaded Machine Hamstring Curl", | |
| "BICYCLE CRUNCH": "Bicycle Crunch", | |
| "LAT PULLDOWN": "Overhand Grip Cable Lat Pulldown", | |
| "DUMBBELL ROW": "Single Arm Elbow-In Dumbbell Row", | |
| "BARBELL BENT OVER ROW": "Bent-Over Overhand Grip Barbell Row", | |
| "REVERSE PEC DECK": "Neutral Grip Machine Reverse Fly", | |
| "EZ BAR CURL": "Ez Bar Biceps Curl", | |
| "SEATED LEG CURL": "Seated Pin-Loaded Machine Hamstring Curl", | |
| "HANGING LEG RAISE": "Hanging Straight Knee Leg Raise", | |
| "DUMBBELL SEATED SHOULDER PRESS": "Seated Dumbbell Overhead Press", | |
| "CABLE LATERAL RAISE": "Dual Cable Lateral Raise", | |
| "BENT OVER REVERSE DUMBBELL FLYE": "Bent-Over Neutral Grip Dumbbell Reverse Fly", | |
| "DUMBBELL FLOOR PRESS": "Dumbbell Floor Press", | |
| "HAMMER CURL": "Standing Dumbbell Hammer Curl", | |
| "SINGLE-ARM PULLDOWN": "Half-kneeling Single Arm Cable Lat Pulldown", | |
| "CLOSE-GRIP BENCH PRESS": "Close Grip Bench Press", | |
| "NEUTRAL-GRIP PULLDOWN": "Overhand Grip Cable Lat Pulldown", | |
| } | |
| def load_input(ctx, param, value): | |
| """Load the Excel file and store in context for other callbacks.""" | |
| xlsx = pd.ExcelFile(value) | |
| ctx.ensure_object(dict) | |
| ctx.obj["xlsx"] = xlsx | |
| ctx.obj["input_file"] = value | |
| return value | |
| def get_sheet(ctx, param, value): | |
| """Extract a sole/requested sheet if it exists or prompt user to select one.""" | |
| xlsx = ctx.obj["xlsx"] | |
| sheets = xlsx.sheet_names | |
| if value is not None: | |
| if value not in sheets: | |
| raise click.BadParameter( | |
| f"'{value}' not found. Available: {sheets}" | |
| ) | |
| return value | |
| # Auto-select if only one sheet | |
| if len(sheets) == 1: | |
| click.echo(f"Using sheet: {sheets[0]}") | |
| return sheets[0] | |
| # Prompt user to select | |
| click.echo("\nAvailable programs:") | |
| for i, sheet in enumerate(sheets, 1): | |
| click.echo(f" {i}. {sheet}") | |
| while True: | |
| choice = click.prompt("\nSelect program number", type=int) | |
| if 1 <= choice <= len(sheets): | |
| return sheets[choice - 1] | |
| click.echo("Invalid selection. Please enter a valid number.") | |
| def extract_workouts(df: pd.DataFrame) -> list[dict]: | |
| """Extract the input dataframe workouts into a standard list of dicts.""" | |
| assert df.shape[1] == 10 | |
| week = None | |
| workout = None | |
| results = [] | |
| # Iterating row-wise isn't very efficient, but we aren't processing a lot of rows. | |
| for row in df.itertuples(index=False, name=None): | |
| # If we hit a header then just update the week | |
| if row[2:10] == ('Workout', 'Exercise', 'Working Sets', 'Reps / Duration', 'Load (kg)', 'RPE', 'Rest', 'Notes'): | |
| _week = str(row[1]) | |
| assert _week.startswith("Week") | |
| week = _week | |
| continue | |
| # If the workout column is not nan then update our current workout | |
| if row[2] and isinstance(row[2], str): | |
| workout = row[2] | |
| exercise = row[3] if row[3] and isinstance(row[3], str) else None | |
| # Finally process exercise rows if we have a set week, workout and exercise | |
| if week and workout and exercise: | |
| entry = { | |
| "week": week, | |
| "workout": workout, | |
| "exercise": exercise, | |
| "sets": row[4] if row[4] and isinstance(row[4], int) else None, | |
| "reps": row[5] if row[5] and isinstance(row[5], int) else None, | |
| "rpe": row[7] if row[7] and isinstance(row[7], int) else None, | |
| "rest": row[8] if row[8] and isinstance(row[8], str) else None, | |
| "notes": row[9] if row[9] and isinstance(row[9], str) else None, | |
| } | |
| # Add entry to results if sets and reps exist | |
| if entry['sets'] and entry['reps']: | |
| results.append(entry) | |
| return results | |
| def parse_rest_range(rest_str: str | None) -> int | None: | |
| """Parse rest range string and return lowest value in seconds.""" | |
| if rest_str: | |
| for match in (re.match(r, rest_str) for r in [r"(\d+)-(\d+)\s*min", r"(\d+)\s*min"]): | |
| if match: | |
| return int(match.group(1)) * 60 | |
| return None | |
| def validate_ordering(ctx, param, value): | |
| """Validate the ordering string is 7 digits of 0s and 1s.""" | |
| if value and (len(value) != 7 or not all(c in "01" for c in value)): | |
| raise click.BadParameter( | |
| "Must be exactly 7 digits of 0s and 1s (e.g. 0101010)" | |
| ) | |
| return value | |
| def build_header_rows(program_name: str) -> list[list]: | |
| """Build the program, block, and cycle header rows.""" | |
| return [ | |
| [ | |
| f"Program: {program_name}", | |
| "Cycles: 1", | |
| "Deload: None", | |
| "Color: Yellow", | |
| "Icon: Extension", | |
| ] + [None] * 10, | |
| ["Block 1"] + [None] * 14, | |
| [ | |
| "Cycle 1", | |
| "Exercise", | |
| "Notes", | |
| "Set 1 Type", | |
| "Set 1 Rep Range", | |
| "Set 1 RIR", | |
| "Set 1 Rest", | |
| "Set 2 Type", | |
| "Set 2 Rep Range", | |
| "Set 2 RIR", | |
| "Set 2 Rest", | |
| "Set 3 Type", | |
| "Set 3 Rep Range", | |
| "Set 3 RIR", | |
| "Set 3 Rest", | |
| ], | |
| ] | |
| def build_workout_rows( | |
| week_num: int, | |
| workout_name: str, | |
| exercises: list[dict], | |
| mapping: dict[str, str], | |
| location: str, | |
| naming: str, | |
| ) -> list[list]: | |
| """Build output rows for a single workout.""" | |
| week_str = f"W{week_num}" if naming == "condensed" else f"Week {week_num}" | |
| full_name = f"{week_str}: {workout_name} @ {location}" | |
| rows = [] | |
| for i, ex in enumerate(exercises): | |
| mapped_exercise = mapping.get(ex["exercise"], ex["exercise"]) | |
| rir = (10 - ex["rpe"]) if ex["rpe"] else None | |
| rest_seconds = parse_rest_range(ex["rest"]) | |
| reps = ex["reps"] | |
| if not reps: | |
| rep_range = "" | |
| elif "sec" in str(reps).lower(): | |
| rep_range = reps | |
| else: | |
| rep_range = f"{reps} - {reps}" | |
| # Initialize set data with None, then fill in actual sets | |
| set_data = [None] * 12 | |
| for n in range(min(ex["sets"] or 0, 3)): | |
| j = n * 4 | |
| set_data[j:j + 4] = ["Standard Set", rep_range, rir, rest_seconds] | |
| row = [full_name if i == 0 else None, mapped_exercise, None] + set_data | |
| rows.append(row) | |
| return rows | |
| def build_output_data( | |
| workouts: list[dict], | |
| mapping: dict[str, str], | |
| program_name: str, | |
| ordering: str | None, | |
| location: str, | |
| naming: str, | |
| ) -> list[list]: | |
| """Build the output data structure for the Excel file.""" | |
| # Filter out exercises without mappings | |
| exercises = set(w["exercise"] for w in workouts) | |
| dropped = exercises.difference(mapping) | |
| if dropped: | |
| click.echo( | |
| click.style( | |
| f"\nWarning: Dropping {len(dropped)} unmapped exercises:", | |
| fg="yellow", | |
| ) | |
| ) | |
| for ex in sorted(dropped): | |
| click.echo(click.style(f" - {ex}", fg="yellow")) | |
| workouts = [w for w in workouts if w["exercise"] not in dropped] | |
| weeks = list(dict.fromkeys(w["week"] for w in workouts)) # preserve order | |
| rows = build_header_rows(program_name) | |
| for week_idx, week in enumerate(weeks): | |
| week_num = week_idx + 1 | |
| week_workouts = [w for w in workouts if w["week"] == week] | |
| # Group exercises by workout name (preserving order) | |
| workout_groups: dict[str, list[dict]] = {} | |
| for w in week_workouts: | |
| workout_groups.setdefault(w["workout"], []).append(w) | |
| workout_names = list(workout_groups.keys()) | |
| week_ordering = ordering if ordering else "1" * len(workout_names) | |
| workout_idx = 0 | |
| for day in week_ordering: | |
| if day == "0": | |
| rows.append(["Rest"] + [None] * 14) | |
| elif workout_idx < len(workout_names): | |
| name = workout_names[workout_idx] | |
| rows.extend(build_workout_rows( | |
| week_num, name, workout_groups[name], mapping, location, naming | |
| )) | |
| workout_idx += 1 | |
| else: | |
| rows.append(["Rest"] + [None] * 14) | |
| return rows | |
| def write_output(rows: list[list], output_path: Path) -> None: | |
| """Write the output Excel file with formatting.""" | |
| wb = Workbook() | |
| ws = wb.active | |
| ws.title = "Training Programs" | |
| top_align = Alignment(vertical="top") | |
| merge_start = None | |
| def is_workout_name(value) -> bool: | |
| """Check if value is a workout name (Week X: ... or WX: ...).""" | |
| return isinstance(value, str) and ":" in value and ( | |
| value.startswith("Week ") or value.startswith("W") | |
| ) | |
| def merge_block(start: int, end: int) -> None: | |
| """Merge cells in column A from start to end row.""" | |
| if end > start: | |
| ws.merge_cells(start_row=start, start_column=1, | |
| end_row=end, end_column=1) | |
| ws.cell(start, 1).alignment = top_align | |
| for row_idx, row in enumerate(rows, 1): | |
| # Write row values | |
| for col_idx, value in enumerate(row, 1): | |
| cell = ws.cell(row=row_idx, column=col_idx) | |
| if isinstance(value, (int, float)) and not isinstance(value, bool): | |
| cell.value = float(value) | |
| else: | |
| cell.value = value | |
| # Track merge blocks for column A | |
| col_a = row[0] if row else None | |
| if is_workout_name(col_a): | |
| if merge_start: | |
| merge_block(merge_start, row_idx - 1) | |
| merge_start = row_idx | |
| elif merge_start and col_a is not None: | |
| merge_block(merge_start, row_idx - 1) | |
| merge_start = None | |
| # Merge final block | |
| if merge_start: | |
| merge_block(merge_start, len(rows)) | |
| # Bold header row | |
| bold_font = Font(bold=True) | |
| for cell in ws[1]: | |
| cell.font = bold_font | |
| wb.save(output_path) | |
| click.echo(click.style(f"\nOutput written to: {output_path}", fg="green")) | |
| @click.command() | |
| @click.argument( | |
| "input_file", | |
| type=click.Path(exists=True, path_type=Path), | |
| callback=load_input, | |
| is_eager=True, | |
| ) | |
| @click.option( | |
| "-o", | |
| "--output", | |
| type=click.Path(path_type=Path), | |
| help="Output file path. Defaults to <inputdir>/<program>.xlsx (ex './Fundamentals__Full_Body.xlsx')", | |
| ) | |
| @click.option( | |
| "-m", | |
| "--mapping", | |
| type=click.Path(exists=True, path_type=Path), | |
| help="JSON mapping file to add or override the default mapping. Contains a single object where keys = program names and values = app names.", | |
| ) | |
| @click.option( | |
| "-l", | |
| "--location", | |
| prompt="Gym/location name", | |
| default="Gym", | |
| help="Gym/location name for workout labels", | |
| ) | |
| @click.option( | |
| "-p", | |
| "--program", | |
| help="Name used within the output file and also the default for the output file path. (ex 'Fundamentals: Full Body')", | |
| ) | |
| @click.option( | |
| "-s", | |
| "--sheet", | |
| callback=get_sheet, | |
| expose_value=True, | |
| help="Sheet name to use or prompts if not provided (ex 'Full Body')", | |
| ) | |
| @click.option( | |
| "--ordering", | |
| default=None, | |
| callback=validate_ordering, | |
| help="Weekly workout ordering where 0=rest, 1=workout. (ex 01010100 for 3 workouts/week)", | |
| ) | |
| @click.option( | |
| "--naming", | |
| type=click.Choice(["full", "condensed"], case_sensitive=False), | |
| default="full", | |
| help="Naming convention: 'full' (Week 1: Name) or 'condensed' (W1: Name)", | |
| ) | |
| @click.pass_context | |
| def main( | |
| ctx: click.Context, | |
| input_file: Path, | |
| output: Path | None, | |
| mapping: Path | None, | |
| location: str, | |
| program: str | None, | |
| sheet: str, | |
| ordering: str | None, | |
| naming: str, | |
| ) -> None: | |
| """ | |
| Convert Jeff Nippard workout spreadsheets to MacroFactor Workouts format. | |
| INPUT_FILE: Path to the input Excel file (ex './Fundamentals_Hypertrophy_Program_Excel_Tracking_Sheet.xlsx') | |
| """ | |
| xlsx = ctx.obj["xlsx"] | |
| # Read the selected sheet | |
| df = pd.read_excel(xlsx, sheet_name=sheet) | |
| # Parse workout data | |
| click.echo(f"\nParsing workout data from '{sheet}'...") | |
| workouts = extract_workouts(df) | |
| weeks = list(set(w["week"] for w in workouts)) | |
| click.echo(f"Found {len(workouts)} exercises across {len(weeks)} weeks") | |
| # Show workout types found | |
| workout_names = list(set(w["workout"] for w in workouts)) | |
| click.echo(f"\nFound {len(workout_names)} workout types per week:") | |
| for name in workout_names: | |
| click.echo(f" - {name}") | |
| # Warn if ordering doesn't have enough workout days | |
| num_workouts_per_week = len(workout_names) | |
| if ordering is not None: | |
| num_workout_days = ordering.count("1") | |
| if num_workout_days < num_workouts_per_week: | |
| click.echo( | |
| click.style( | |
| f"Warning: Ordering has {num_workout_days} workout days but you have " | |
| f"{num_workouts_per_week} workouts per week. Some workouts will be skipped.", | |
| fg="yellow", | |
| ) | |
| ) | |
| # Get exercise mapping (start with default, merge in overrides from file) | |
| exercises = sorted(set(w['exercise'] for w in workouts)) | |
| exercise_mapping = DEFAULT_EXERCISE_MAPPING.copy() | |
| if mapping: | |
| try: | |
| with open(mapping, 'r') as fobj: | |
| overrides = json.load(fobj) | |
| exercise_mapping.update(overrides) | |
| click.echo( | |
| f"Loaded {len(overrides)} mapping overrides from {mapping}") | |
| except Exception as e: | |
| click.echo(click.style(f"Error loading mapping: {e}", fg="red")) | |
| raise SystemExit(1) | |
| # Check for missing or empty mappings | |
| missing = [ex for ex in exercises if not exercise_mapping.get(ex, "")] | |
| if missing: | |
| click.echo( | |
| click.style( | |
| f"\nNote: {len(missing)} exercises will be dropped (no mapping):", | |
| fg="yellow", | |
| ) | |
| ) | |
| for ex in missing: | |
| click.echo(click.style(f" - {ex}", fg="yellow")) | |
| # Get program name | |
| default_program = sheet | |
| if program is None: | |
| program = click.prompt("\nProgram name", default=default_program) | |
| click.echo(f"Using program name: {program}") | |
| # Build output | |
| rows = build_output_data( | |
| workouts, exercise_mapping, program, ordering, location, naming | |
| ) | |
| # Determine output path | |
| if output is None: | |
| # Create safe filename from program name | |
| safe_name = "".join( | |
| c if c.isalnum() or c in " -_" else "_" for c in program) | |
| safe_name = safe_name.strip().replace(" ", "_") | |
| default_output = input_file.parent / f"{safe_name}.xlsx" | |
| output_str = click.prompt( | |
| "\nOutput file path", default=str(default_output)) | |
| output = Path(output_str) | |
| # Write output | |
| write_output(rows, output) | |
| click.echo(click.style("\nConversion complete!", fg="green", bold=True)) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment