Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
560dba1
feat: workflow to contribute patches back to source
kelly-sovacool Jun 22, 2026
7819a8b
feat: contribute CLI command
kelly-sovacool Jun 23, 2026
83e81a6
fix: fallback to gh auth token if github token isn't set
kelly-sovacool Jun 23, 2026
83cb208
Merge branch 'main' into contribute
kelly-sovacool Jun 23, 2026
d0b6ded
fix: support ssh git URLs
kelly-sovacool Jun 25, 2026
fce50e8
fix: require patch file to exist as file
kelly-sovacool Jun 25, 2026
743b8cd
feat: mark patch status
kelly-sovacool Jun 25, 2026
2f3eafe
chore: Merge branch 'contribute' of github.com:CCBR/syncweaver into c…
kelly-sovacool Jun 25, 2026
70aeb5d
fix(security): do not pass github token in url
kelly-sovacool Jun 25, 2026
3880d36
chore: cleanup unneeded annotate-rejected subcmd
kelly-sovacool Jun 25, 2026
a11eaa1
fix: resolved_patch_path must remain in host repo
kelly-sovacool Jun 25, 2026
5cc971b
docs: update raises section
kelly-sovacool Jun 25, 2026
46a8370
fix: handle newlines for github output
kelly-sovacool Jun 25, 2026
0d4f19c
fix: sanitize branch stub
kelly-sovacool Jun 25, 2026
8457bf0
fix: stay within host repo for patch resolution
kelly-sovacool Jun 25, 2026
3b51429
test: remove duplicate line
kelly-sovacool Jun 25, 2026
099d5df
style: enforce py instructions
kelly-sovacool Jun 25, 2026
07cf838
chore: Merge branch 'contribute' of github.com:CCBR/syncweaver into c…
kelly-sovacool Jun 25, 2026
bd79f6b
fix: missing docstring close
kelly-sovacool Jun 25, 2026
a0673f6
chore: Merge branch 'main' into contribute
kelly-sovacool Jun 25, 2026
7cad376
fix: make sure patch exists before opening PR
kelly-sovacool Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/syncweaver/cli/contribute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""CLI command for contributing host patches back to source repositories."""

from __future__ import annotations

import json
import pathlib

import click

from syncweaver.contribute_patch import (
contribute_patch,
resolve_contribute_patch_metadata,
)
from syncweaver.git import resolve_github_token
from syncweaver.lockfile import load_existing_lockfile
from syncweaver.patch import mark_patch_status


@click.command("contribute")
@click.option(
"--path",
"source_path",
default="",
show_default=False,
help=(
"Tracked source path in the host repository, e.g. code/package1. "
"Resolved automatically from lockfile when not provided."
),
)
@click.option(
"--repo-url",
default="",
show_default=False,
help=(
"Source repository URL or OWNER/REPO shorthand. "
"Used to disambiguate when multiple sources are tracked."
),
)
@click.option(
"--source-repository",
default="",
show_default=False,
help=(
"Source repository in OWNER/REPO format. "
"Derived from lockfile repo_url when not provided."
),
)
@click.option(
"--patch",
"patch_path",
default="",
show_default=False,
type=click.Path(),
help=(
"Path to the patch file to contribute. "
"Resolved from lockfile patch entry when not provided."
),
)
@click.option(
"--base-ref",
"source_base_ref",
default="",
show_default=False,
help=(
"Base branch or ref in the source repository to target. "
"Defaults to lockfile ref when not provided."
),
)
@click.option(
"--lockfile",
default=".syncweaver-lock.json",
show_default=True,
type=click.Path(path_type=pathlib.Path),
help="Path to .syncweaver-lock.json in the host repository.",
)
@click.option(
"--token",
envvar="GITHUB_TOKEN",
default="",
show_default=False,
help=(
"GitHub token with push access to the source repository. "
"May also be set via the GITHUB_TOKEN environment variable or resolved from `gh auth token` "
"when not provided."
),
)
@click.option(
"--run-id",
default="",
show_default=False,
help="Optional identifier appended to the branch name for uniqueness.",
)
@click.option(
"--debug",
is_flag=True,
default=False,
help="Print resolved metadata and verbose git output.",
)
def contribute_cmd(
source_path: str,
repo_url: str,
source_repository: str,
patch_path: str,
source_base_ref: str,
lockfile: pathlib.Path,
token: str,
run_id: str,
debug: bool,
) -> None:
"""Contribute a tracked host patch back to the source repository.

Clones the source repository, applies the patch to a new branch, pushes
it, and opens a pull request. Runs from the host repository directory.
"""
cwd = pathlib.Path.cwd()
resolved_lockfile = cwd / lockfile
try:
resolved = resolve_contribute_patch_metadata(
lockfile=resolved_lockfile,
host_cwd=cwd,
source_path=source_path,
repo_url=repo_url,
source_repository=source_repository,
patch_path=patch_path,
source_base_ref=source_base_ref,
)
except (
FileNotFoundError,
KeyError,
ValueError,
json.JSONDecodeError,
OSError,
) as exc:
raise click.ClickException(str(exc)) from exc

if debug:
click.echo("Resolved metadata:")
click.echo(f" source_path: {resolved['source_path']}")
click.echo(f" repo_url: {resolved['repo_url']}")
click.echo(f" source_repository: {resolved['source_repository']}")
click.echo(f" patch_path: {resolved['patch_path']}")
click.echo(f" source_base_ref: {resolved['source_base_ref']}")

try:
resolved_token = resolve_github_token(token)
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc

try:
lock_data = load_existing_lockfile(resolved_lockfile)
patch_key = resolved["patch_path"]
patch_found = False
for source_entry in lock_data.get("sources", {}).values():
if source_entry.get("patch") == patch_key:
patch_found = True
break
if not patch_found:
raise KeyError(f"Patch path is not tracked in lockfile: {patch_key}")
except (FileNotFoundError, KeyError, json.JSONDecodeError, OSError) as exc:
raise click.ClickException(str(exc)) from exc

try:
pr_url = contribute_patch(
resolved=resolved,
host_cwd=cwd,
github_token=resolved_token,
run_id=run_id,
debug=debug,
)
mark_patch_status(
patch_path=pathlib.Path(resolved["patch_path"]),
status="open",
pr_url=pr_url,
lockfile_path=resolved_lockfile,
)
Comment thread
kelly-sovacool marked this conversation as resolved.
except (FileNotFoundError, KeyError, ValueError, RuntimeError, OSError) as exc:
raise click.ClickException(str(exc)) from exc

click.echo(f"Pull request opened: {pr_url}")
2 changes: 2 additions & 0 deletions src/syncweaver/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import click

from syncweaver.cli.add import add_cmd
from syncweaver.cli.contribute import contribute_cmd
from syncweaver.cli.deps import deps_group
from syncweaver.cli.patch import patch_group
from syncweaver.cli.remove import remove_cmd
Expand All @@ -29,6 +30,7 @@ def cli():

cli.add_command(templates_group)
cli.add_command(add_cmd)
cli.add_command(contribute_cmd)
cli.add_command(update_cmd)
cli.add_command(remove_cmd)
cli.add_command(patch_group)
Comment thread
kelly-sovacool marked this conversation as resolved.
Expand Down
45 changes: 33 additions & 12 deletions src/syncweaver/cli/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@

import click

from syncweaver.patch import annotate_rejected_patch, create_patch, list_patches
from syncweaver.patch import (
PATCH_STATUSES,
create_patch,
list_patches,
mark_patch_status,
)


@click.group("patch")
def patch_group() -> None:
"""Create, annotate, and list source patch artifacts."""
"""Create, mark, and list source patch artifacts."""


@patch_group.command("create")
Expand Down Expand Up @@ -68,7 +73,7 @@ def create_cmd(
click.echo("No source changes detected; patch file removed or unchanged.")


@patch_group.command("annotate-rejected")
@patch_group.command("mark-status")
@click.option(
"--patch",
"patch_path",
Expand All @@ -77,14 +82,22 @@ def create_cmd(
help="Relative patch path, e.g. code/package1/.syncweaver/package1.diff.",
)
@click.option(
"--pr-url",
"--status",
required=True,
help="URL of the upstream pull request associated with this patch.",
type=click.Choice(PATCH_STATUSES, case_sensitive=False),
help="Patch lifecycle status to record in the lockfile.",
)
@click.option(
"--pr-url",
default="",
show_default=False,
help="Optional upstream pull request URL associated with this patch.",
)
@click.option(
"--reason",
required=True,
help="Free-text reason for rejection.",
default="",
show_default=False,
help="Optional free-text note, required for rejected patches.",
)
@click.option(
"--lockfile",
Expand All @@ -93,24 +106,32 @@ def create_cmd(
type=click.Path(path_type=pathlib.Path),
help="Path to .syncweaver-lock.json in the host repository.",
)
def annotate_rejected_cmd(
def mark_status_cmd(
patch_path: pathlib.Path,
status: str,
pr_url: str,
reason: str,
lockfile: pathlib.Path,
) -> None:
"""Record rejected patch metadata in lockfile extension fields."""
"""Record patch lifecycle status metadata in lockfile extension fields."""
try:
patch_key, lockfile_written = annotate_rejected_patch(
patch_key, lockfile_written = mark_patch_status(
patch_path=patch_path,
status=status,
pr_url=pr_url,
reason=reason,
lockfile_path=lockfile,
)
except (FileNotFoundError, KeyError, json.JSONDecodeError, OSError) as exc:
except (
FileNotFoundError,
KeyError,
ValueError,
json.JSONDecodeError,
OSError,
) as exc:
raise click.ClickException(str(exc)) from exc

click.echo(f"Annotated rejected patch {patch_key} in {lockfile_written}")
click.echo(f"Marked patch {patch_key} as {status.lower()} in {lockfile_written}")


@patch_group.command("list")
Expand Down
Loading
Loading