Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
111 changes: 111 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,117 @@ If UPDATE fails to find existing change:
To create a new change, trigger the 'opened' workflow action.
```

## PR Comment Commands

GitHub2Gerrit supports an extensible set of directives issued through
pull request comments. Add a comment containing `@github2gerrit`
followed by a command phrase and the tool will act on it during
the next workflow run.

### Command Format

<!-- markdownlint-disable MD013 -->

```text
@github2gerrit <command>
```

<!-- markdownlint-enable MD013 -->

- Commands are **case-insensitive** — `@github2gerrit Create Missing Change`
works the same as `@github2gerrit create missing change`.
- Only the **latest** occurrence of each command takes effect when the same
command appears in more than one comment.
- The tool logs unrecognised directives at debug level and ignores them.

### Available Commands

<!-- markdownlint-disable MD013 MD060 -->

| Command | Aliases | Description |
| --- | --- | --- |
| `create missing change` | `create-missing`, `create missing` | Create a Gerrit change when an UPDATE operation cannot find an existing one |

<!-- markdownlint-enable MD013 MD060 -->

### Create Missing Change

When a PR `synchronize` event fires, GitHub2Gerrit treats it as an
**UPDATE** operation and expects a Gerrit change to exist. If the
original `opened` event failed (for example due to a bug or transient
error), no Gerrit change exists and every following update fails with:

```text
❌ UPDATE FAILED: Cannot update non-existent Gerrit change
```

The **create missing change** command resolves this without manual
intervention in Gerrit. Two mechanisms trigger it:

#### 1. PR Comment Directive

Add a comment on the stuck pull request:

```text
@github2gerrit create missing change
```

Then re-trigger the workflow (push a trivial change or re-run the
workflow manually). GitHub2Gerrit detects the directive, switches
from UPDATE to CREATE mode, and pushes a new Gerrit change.

#### 2. CLI Flag

Outside GitHub Actions you can pass the flag directly:

```shell
github2gerrit \
--create-missing \
https://github.com/MyOrg/my-repo/pull/42
```

Or set the environment variable:

```shell
export CREATE_MISSING=true
github2gerrit https://github.com/MyOrg/my-repo/pull/42
```

#### What Happens During Fallback

1. The tool attempts the normal UPDATE flow and finds no existing
Gerrit change.
2. It checks for `--create-missing` **or** scans PR comments for the
`@github2gerrit create missing change` directive.
3. If authorised, the operation mode switches from UPDATE to CREATE.
4. The tool posts a notice on the PR:

```text
🔄 GitHub2Gerrit: No existing Gerrit change found for this PR.
Creating a new Gerrit change (fallback from UPDATE operation).
```

5. The pipeline continues as a normal CREATE — preparing commits,
pushing to Gerrit, posting the change URL back on the PR.

#### GitHub Actions Workflow Example

<!-- markdownlint-disable MD013 -->

```yaml
- name: Submit PR to Gerrit
uses: lfreleng-actions/github2gerrit-action@main
with:
GERRIT_SSH_PRIVKEY_G2G: ${{ secrets.GERRIT_SSH_PRIVKEY_G2G }}
CREATE_MISSING: "true" # always allow fallback
```

<!-- markdownlint-enable MD013 -->

> **Tip:** Setting `CREATE_MISSING` to `true` in your workflow means
> stuck PRs self-heal on the next `synchronize` event without requiring
> a comment directive.

## Close Merged PRs Feature

GitHub2Gerrit now includes **automatic PR closure** when Gerrit merges changes
Expand Down
5 changes: 5 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ inputs:
description: "Abandon Gerrit changes when their GitHub PRs are closed"
required: false
default: "true"
CREATE_MISSING:
description: "Create a Gerrit change when an UPDATE operation cannot find an existing one"
required: false
default: "false"

outputs:
gerrit_change_request_url:
Expand Down Expand Up @@ -300,6 +304,7 @@ runs:
AUTOMATION_ONLY: ${{ inputs.AUTOMATION_ONLY }}
CLEANUP_ABANDONED: ${{ inputs.CLEANUP_ABANDONED }}
CLEANUP_GERRIT: ${{ inputs.CLEANUP_GERRIT }}
CREATE_MISSING: ${{ inputs.CREATE_MISSING }}

# Optional Gerrit overrides (when .gitreview is missing)
GERRIT_SERVER: ${{ inputs.GERRIT_SERVER }}
Expand Down
4 changes: 2 additions & 2 deletions scripts/test_rich_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def demo_progress_tracking():
# Show final summary
summary = progress_tracker.get_summary()
console.print("\n✅ Operation completed!")
console.print(f"⏱️ Total time: {summary.get('elapsed_time', 'unknown')}")
console.print(f" Total time: {summary.get('elapsed_time', 'unknown')}")
console.print(f"📊 PRs processed: {summary['prs_processed']}")
console.print(f"🔄 Changes submitted: {summary['changes_submitted']}")

Expand Down Expand Up @@ -135,7 +135,7 @@ def demo_bulk_processing():
# Show final summary
summary = progress_tracker.get_summary()
console.print("\n⚠️ Processing completed with some issues!")
console.print(f"⏱️ Total time: {summary.get('elapsed_time', 'unknown')}")
console.print(f" Total time: {summary.get('elapsed_time', 'unknown')}")
console.print(f"📊 PRs processed: {summary['prs_processed']}")
console.print(f"✅ Changes submitted: {summary['changes_submitted']}")
console.print(f"⏭️ Duplicates skipped: {summary['duplicates_skipped']}")
Expand Down
130 changes: 80 additions & 50 deletions src/github2gerrit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ def main(
),
allow_duplicates: bool = typer.Option(
True,
"--allow-duplicates",
"--allow-duplicates/--no-allow-duplicates",
envvar="ALLOW_DUPLICATES",
help="Allow submitting duplicate changes without error.",
),
Expand All @@ -582,7 +582,7 @@ def main(
),
close_merged_prs: bool = typer.Option(
True,
"--close-merged-prs",
"--close-merged-prs/--no-close-merged-prs",
envvar="CLOSE_MERGED_PRS",
help="Close GitHub PRs when corresponding Gerrit changes are merged.",
),
Expand Down Expand Up @@ -700,7 +700,7 @@ def main(
),
preserve_github_prs: bool = typer.Option(
True,
"--preserve-github-prs",
"--preserve-github-prs/--no-preserve-github-prs",
envvar="PRESERVE_GITHUB_PRS",
help="Do not close GitHub PRs after pushing to Gerrit.",
),
Expand Down Expand Up @@ -776,6 +776,16 @@ def main(
"--version",
help="Show version and exit.",
),
create_missing: bool = typer.Option(
False,
"--create-missing/--no-create-missing",
envvar="CREATE_MISSING",
help=(
"Create a Gerrit change when an UPDATE operation cannot find "
"an existing one. Also triggered by '@github2gerrit create "
"missing change' PR comment."
),
),
automation_only: bool = typer.Option(
True,
"--automation-only/--no-automation-only",
Expand Down Expand Up @@ -824,44 +834,59 @@ def main(
typer.echo("Version information not available")
sys.exit(int(ExitCode.SUCCESS))

# Override boolean parameters with properly parsed environment variables
# This ensures that string "false" from GitHub Actions is handled correctly
if os.getenv("SUBMIT_SINGLE_COMMITS"):
submit_single_commits = parse_bool_env(
os.getenv("SUBMIT_SINGLE_COMMITS")
)

if os.getenv("USE_PR_AS_COMMIT"):
use_pr_as_commit = parse_bool_env(os.getenv("USE_PR_AS_COMMIT"))

if os.getenv("PRESERVE_GITHUB_PRS"):
preserve_github_prs = parse_bool_env(os.getenv("PRESERVE_GITHUB_PRS"))

if os.getenv("DRY_RUN"):
dry_run = parse_bool_env(os.getenv("DRY_RUN"))

if os.getenv("ALLOW_DUPLICATES"):
allow_duplicates = parse_bool_env(os.getenv("ALLOW_DUPLICATES"))

if os.getenv("CI_TESTING"):
ci_testing = parse_bool_env(os.getenv("CI_TESTING"))

if os.getenv("SIMILARITY_FILES"):
similarity_files = parse_bool_env(os.getenv("SIMILARITY_FILES"))

if os.getenv("ALLOW_ORPHAN_CHANGES"):
allow_orphan_changes = parse_bool_env(os.getenv("ALLOW_ORPHAN_CHANGES"))

if os.getenv("PERSIST_SINGLE_MAPPING_COMMENT"):
persist_single_mapping_comment = parse_bool_env(
os.getenv("PERSIST_SINGLE_MAPPING_COMMENT")
)

if os.getenv("LOG_RECONCILE_JSON"):
log_reconcile_json = parse_bool_env(os.getenv("LOG_RECONCILE_JSON"))

if os.getenv("AUTOMATION_ONLY"):
automation_only = parse_bool_env(os.getenv("AUTOMATION_ONLY"))
# Override boolean parameters with properly parsed environment variables.
# This ensures that string "false" from GitHub Actions is handled
# correctly (Typer/Click treats any non-empty string as truthy).
#
# We only apply the env-var override when the parameter was NOT
# explicitly provided on the command line, so that CLI flags always
# take precedence over environment variables.
def _env_bool_override(
param_name: str, env_var: str, current: bool
) -> bool:
"""Return *current* if the CLI flag was explicit, else parse env."""
source = ctx.get_parameter_source(param_name)
if source == click.core.ParameterSource.COMMANDLINE:
return current
env_val = os.getenv(env_var)
if env_val is not None:
return parse_bool_env(env_val)
return current

submit_single_commits = _env_bool_override(
"submit_single_commits", "SUBMIT_SINGLE_COMMITS", submit_single_commits
)
use_pr_as_commit = _env_bool_override(
"use_pr_as_commit", "USE_PR_AS_COMMIT", use_pr_as_commit
)
preserve_github_prs = _env_bool_override(
"preserve_github_prs", "PRESERVE_GITHUB_PRS", preserve_github_prs
)
dry_run = _env_bool_override("dry_run", "DRY_RUN", dry_run)
allow_duplicates = _env_bool_override(
"allow_duplicates", "ALLOW_DUPLICATES", allow_duplicates
)
ci_testing = _env_bool_override("ci_testing", "CI_TESTING", ci_testing)
similarity_files = _env_bool_override(
"similarity_files", "SIMILARITY_FILES", similarity_files
)
allow_orphan_changes = _env_bool_override(
"allow_orphan_changes", "ALLOW_ORPHAN_CHANGES", allow_orphan_changes
)
persist_single_mapping_comment = _env_bool_override(
"persist_single_mapping_comment",
"PERSIST_SINGLE_MAPPING_COMMENT",
persist_single_mapping_comment,
)
log_reconcile_json = _env_bool_override(
"log_reconcile_json", "LOG_RECONCILE_JSON", log_reconcile_json
)
create_missing = _env_bool_override(
"create_missing", "CREATE_MISSING", create_missing
)
automation_only = _env_bool_override(
"automation_only", "AUTOMATION_ONLY", automation_only
)

# Store netrc options in environment for use by processing functions
os.environ["G2G_NO_NETRC"] = "true" if no_netrc else "false"
Expand Down Expand Up @@ -1002,6 +1027,7 @@ def main(
"true" if persist_single_mapping_comment else "false"
)
os.environ["LOG_RECONCILE_JSON"] = "true" if log_reconcile_json else "false"
os.environ["CREATE_MISSING"] = "true" if create_missing else "false"
os.environ["AUTOMATION_ONLY"] = "true" if automation_only else "false"
# URL mode handling
if target_url:
Expand Down Expand Up @@ -1161,6 +1187,7 @@ def _build_inputs_from_env() -> Inputs:
"PERSIST_SINGLE_MAPPING_COMMENT", True
),
log_reconcile_json=env_bool("LOG_RECONCILE_JSON", True),
create_missing=env_bool("CREATE_MISSING", False),
)


Expand Down Expand Up @@ -1441,7 +1468,7 @@ def process_single_pr(
if show_progress and RICH_AVAILABLE:
summary = progress_tracker.get_summary()
safe_console_print(
f"⏱️ Total time: {summary.get('elapsed_time', 'unknown')}"
f" Total time: {summary.get('elapsed_time', 'unknown')}"
)
safe_console_print(f"📊 PRs processed: {processed_count}")
safe_console_print(f"✅ Succeeded: {succeeded_count}")
Expand Down Expand Up @@ -1486,7 +1513,7 @@ def _process_single(

try:
if progress_tracker:
progress_tracker.update_operation("📋 Preparing local checkout")
progress_tracker.update_operation("📂 Preparing local checkout")
log.debug(
"Preparing workspace checkout in temporary directory: %s",
workspace,
Expand Down Expand Up @@ -1518,7 +1545,9 @@ def _process_single(
)

if progress_tracker:
progress_tracker.update_operation("⬆️ Extracting commit information")
progress_tracker.update_operation(
"🔍 Extracting commit information"
)

log.debug("Extracting commit information from PR")
log.debug("PR commits range: base_sha..head_sha (not available)")
Expand Down Expand Up @@ -1781,6 +1810,7 @@ def _load_effective_inputs() -> Inputs:
allow_orphan_changes=data.allow_orphan_changes,
persist_single_mapping_comment=data.persist_single_mapping_comment,
log_reconcile_json=data.log_reconcile_json,
create_missing=data.create_missing,
)
log.debug("Derived reviewers: %s", data.reviewers_email)
except Exception as exc:
Expand Down Expand Up @@ -1859,7 +1889,7 @@ def _process_close_gerrit_change(
pr_url = extract_pr_url_from_gerrit_change(gerrit_change_url)
if not pr_url:
no_action_msg = (
"☑️ No action required: Gerrit change did NOT originate in GitHub"
" No action required: Gerrit change did NOT originate in GitHub"
)
log.debug(no_action_msg)
safe_console_print(no_action_msg)
Expand Down Expand Up @@ -2572,7 +2602,7 @@ def _process() -> None:
style="green" if pipeline_success else "red",
)
safe_console_print(
f"⏱️ Total time: {summary.get('elapsed_time', 'unknown')}"
f" Total time: {summary.get('elapsed_time', 'unknown')}"
)
if summary.get("prs_processed", 0) > 0:
safe_console_print(f"📊 PRs processed: {summary['prs_processed']}")
Expand Down Expand Up @@ -2804,13 +2834,13 @@ def _get_ssh_agent_status() -> str:
elif has_private_key:
# SSH key explicitly provided - don't use agent
if agent_running:
return "☑️ Available, Unused"
return " Available, Unused"
else:
return "❎ Unavailable, Unused"
elif use_ssh_agent and agent_running:
return "✅ Available, Used"
elif agent_running:
return "☑️ Available, Unused"
return " Available, Unused"
else:
return "❎ Unavailable, Unused"

Expand Down Expand Up @@ -2947,11 +2977,11 @@ def _display_effective_config(data: Inputs, gh: GitHubContext) -> None:

# Show cleanup abandoned status if enabled
if cleanup_abandoned:
config_info["CLEANUP_ABANDONED"] = "☑️"
config_info["CLEANUP_ABANDONED"] = ""

# Show Gerrit cleanup status if enabled
if cleanup_gerrit:
config_info["CLEANUP_GERRIT"] = "☑️"
config_info["CLEANUP_GERRIT"] = ""

# Display the configuration table
display_pr_info(config_info, "GitHub2Gerrit Configuration")
Expand Down
Loading
Loading