Metadata-Version: 2.4
Name: gerrit-vote
Version: 0.0.6
Summary: Cast label votes (Code-Review by default) on Gerrit changes via REST — voting only, never submits.
Project-URL: Homepage, https://github.com/Junbo-Zheng/gerrit-vote
Project-URL: Repository, https://github.com/Junbo-Zheng/gerrit-vote
Project-URL: Issues, https://github.com/Junbo-Zheng/gerrit-vote/issues
Author: Junbo Zheng
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: cli,code-review,gerrit,vote
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Version Control
Requires-Python: >=3.10
Requires-Dist: questionary>=2.0
Requires-Dist: tomli>=2.0; python_version < '3.11'
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

<!-- SPDX-License-Identifier: Apache-2.0 -->
# gerrit-vote

[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](pyproject.toml)
[![CI](https://github.com/Junbo-Zheng/gerrit-vote/actions/workflows/ci.yml/badge.svg)](https://github.com/Junbo-Zheng/gerrit-vote/actions/workflows/ci.yml)

Cast **label votes** on Gerrit changes from the command line, via the Gerrit
REST API — `Code-Review` by default, or any label your server exposes (`Verified`,
etc.). Copy a pile of change links from anywhere, run `gerrit-vote`, confirm, done.
Beyond voting it can also **abandon**, **restore**, and **delete** changes, and
**list** the changes you own or need to review.

> [!IMPORTANT]
> `gerrit-vote` **never submits or merges** a change. Voting is the default and
> only touches the `labels` field. The lifecycle actions (`--abandon`,
> `--restore`, `--delete`) change a change's state and are opt-in behind their
> own flags; `--delete` is irreversible and owner-only, and always asks first.

## Features

- **Clipboard-first.** A bare `gerrit-vote` reads the system clipboard, so you can
  copy a batch of links and just run the command. Pass explicit URLs to bypass the
  clipboard, or `--from-stdin` to read piped text.
- **Confirm before voting.** The changes are listed, then an arrow-key menu lets
  you pick the value. The value rows come from the server's permitted range for
  the change (so a `0`/`+1` label shows only `0`/`+1`, while `Code-Review` shows
  `+2`/`+1`/`0`/`-1`/`-2`), plus quit. `-y` skips it; `--dry-run` previews
  without voting.
- **Owner-only combo actions.** When the first change is yours (its owner email
  matches your `~/.gitconfig` `user.email`), the menu adds three one-shot combos
  above the plain rows — `Code-Review +1` together with `Presubmit-Ready +1`
  and/or `Auto-Submit +1`, cast in a single atomic review. They stay hidden on
  changes you don't own, and are all-or-nothing: if any label in the combo isn't
  permitted on a change, that change is skipped rather than partially voted.
- **Extracts links from messy text.** Finds Gerrit change links in pasted chat
  logs — de-duplicated, ignoring non-Gerrit noise, tolerating trailing CJK
  punctuation.
- **Topic batches.** Pass a `/q/topic:NAME` URL or `--topic NAME` to vote on every
  open change in a Gerrit topic; the topic is expanded to its changes before the
  confirmation prompt. Add `--related` to also pull in each change's dependency
  chain, and `--related-topics` to also pull in every open change sharing a topic
  with any change in the set.
- **Clipboard auto-expands the related group.** A bare `gerrit-vote` (clipboard
  source) behaves as if both `--related` and `--related-topics` were given — it
  votes on the pasted patch plus its dependency chain and topic siblings, so you
  can copy one link and vote the whole related group. Explicit URL arguments are
  the precise path (no auto-expand); `--extract` and destructive actions
  (`--abandon` / `--restore` / `--delete`) never auto-expand — `--extract` is a
  pure "parse my clipboard" preview, and `--dry-run` already shows the expanded
  set with full detail.
- **Multi-host batches.** Each change resolves its own host's credentials, so a
  mix of Gerrit instances votes each on the right one.
- **Lifecycle actions.** Beyond voting, `--abandon` / `--restore` / `--delete`
  act on the same change sources (URLs, clipboard, `--all`). All list the target
  changes and confirm via an arrow-key menu first. `--abandon` / `--restore` are
  reversible and default the menu to *Yes*; `--delete` is irreversible,
  **owner-only** (changes you don't own are skipped), and defaults to *No*.
- **List your changes.** `--list` prints a read-only table of your non-merged
  changes across every configured host — both those you own (open + abandoned)
  and those assigned to you for review, with review rows first.
- **Bulk cleanup.** `--all` sweeps every one of your changes across all hosts
  (`owner:self`) for a one-shot `--abandon` / `--restore` / `--delete`; it must
  be given explicitly and still runs the usual confirmation.
- **Skips closed changes.** Merged or abandoned changes are detected and skipped,
  never re-voted (voting only — `--delete` can still delete abandoned changes).
- **Permission fallback.** If `+2` is not in your permitted range but `+1` is, it
  downgrades automatically (disable with `--no-fallback`).
- **Transient-failure retry.** Server `5xx` and connection-level errors are
  retried a couple of times with a short backoff; deterministic `4xx` (bad auth,
  not permitted, no such change) fail fast without retry.
- **Readable output.** Colored results on a TTY (honoring `NO_COLOR`) plus a
  summary line every run.

## Installation

```bash
pip install gerrit-vote
```

Requires Python 3.10 or newer.

> [!TIP]
> Installing as a standalone CLI? `pipx install gerrit-vote` keeps it isolated
> from your other Python environments.

## Configuration

`gerrit-vote` needs a Gerrit HTTP password (not your login password) — generate
one at **Gerrit → Settings → HTTP Credentials → Generate Password**.

Credentials resolve in this order:

0. **Command-line overrides** `-u/--user` and `-p/--password` — win over env and
   config for that field, and apply to every host in the batch. Handy for CI or a
   throwaway identity. The URL still comes from the change URL / `GERRIT_URL` /
   `default_host`. Beware: a password on the command line lands in shell history.

1. **Environment variables** (single host):

   ```bash
   export GERRIT_URL=https://gerrit.example.com
   export GERRIT_USER=your-username
   export GERRIT_HTTP_PASSWORD=your-http-password
   ```

2. **Config file** `~/.config/gerrit-vote/config.toml` — required for multiple
   hosts. Copy [`config.toml.example`](config.toml.example) and fill it in:

   ```toml
   default_host = "gerrit.example.com"

   [hosts."gerrit.example.com"]
   url = "https://gerrit.example.com"
   user = "your-username"
   password = "your-http-password"
   ```

   ```bash
   mkdir -p ~/.config/gerrit-vote
   cp config.toml.example ~/.config/gerrit-vote/config.toml
   chmod 600 ~/.config/gerrit-vote/config.toml
   ```

> [!NOTE]
> If credentials are missing or a host is not configured, `gerrit-vote` prints the
> exact environment-variable and `config.toml` snippet you need — including the
> correct hostname — so you are never left guessing.

## Usage

The easiest flow — copy the links, then:

```bash
gerrit-vote          # read clipboard, list changes, pick +2/+1/0/-1/-2/quit
gerrit-vote 1        # same, but the menu defaults to +1
gerrit-vote -y       # skip the menu, vote +2 immediately
```

Vote on explicit changes:

```bash
# One change (clipboard ignored when URLs are given)
gerrit-vote https://gerrit.example.com/c/proj/+/12345

# Several on ONE line (space-separated), all +1
gerrit-vote <url1> <url2> <url3> 1

# With a review message
gerrit-vote https://gerrit.example.com/c/proj/+/12345 1 --message "LGTM"
```

Vote on every open change in a topic:

```bash
# Paste a topic query URL straight from the browser
gerrit-vote https://gerrit.example.com/q/topic:my-feature

# Or name the topic explicitly (uses the default/single configured host)
gerrit-vote --topic my-feature

# Also include each change's dependency chain
gerrit-vote --topic my-feature --related

# Also include every open change sharing a topic with any change above
gerrit-vote --topic my-feature --related --related-topics
```

A bare `gerrit-vote` reads the clipboard and auto-expands both the dependency
chain and topic siblings — copy one patch link and vote the whole related group:

```bash
gerrit-vote              # clipboard: pasted patch + its chain + its topic siblings
gerrit-vote <url>        # explicit URL: votes on that one only (no auto-expand)
```

Abandon, restore, or delete changes (same sources as voting — URLs, clipboard,
`--from-stdin`):

```bash
# Abandon (reversible); lists the changes, then an arrow-key menu (defaults to Yes)
gerrit-vote --abandon https://gerrit.example.com/c/proj/+/12345

# Restore an abandoned change (the inverse of --abandon)
gerrit-vote --restore https://gerrit.example.com/c/proj/+/12345

# Delete (IRREVERSIBLE, owner-only): lists, then an arrow-key menu defaulting to No
gerrit-vote --delete https://gerrit.example.com/c/proj/+/12345
gerrit-vote --delete --force-delete <url>   # skip the prompt (scripts/CI)
```

List your own and to-review changes, or act on all of them at once:

```bash
# Read-only table across every configured host (owned + assigned-for-review)
gerrit-vote --list

# Sweep ALL your open changes on every host and abandon them (one confirmation)
gerrit-vote --all --abandon

# Restore every one of your abandoned changes; delete every open one
gerrit-vote --all --restore
gerrit-vote --all --delete            # menu: open / abandoned / both; still confirms
```

> [!CAUTION]
> `--all` targets **every** matching change of yours across **all** hosts in one
> go. Preview first with `--all --abandon --dry-run` (or `--delete --dry-run`)
> before committing to a bulk action.

Override credentials on the command line (handy for CI or a throwaway identity;
the URL still comes from the change link / `GERRIT_URL` / `default_host`):

```bash
gerrit-vote https://gerrit.example.com/c/proj/+/12345 -u ci-bot -p "$GERRIT_PW"
```

Preview, or pipe text in:

```bash
gerrit-vote --dry-run            # list what would be voted, cast nothing
gerrit-vote --extract            # just print the change links found
pbpaste | gerrit-vote --from-stdin -y
```

> [!WARNING]
> Pasting multiple lines directly at the shell prompt does **not** work — the
> shell runs each line as its own command. Put several URLs on one line
> (space-separated), or use the clipboard / `--from-stdin`.

Closed changes are skipped automatically, results are colored on a TTY, and a
summary line is printed every run. Exit status is non-zero if any change failed.

### Options

| Option | Description |
| --- | --- |
| `[value]` | Vote value `2 1 0 -1 -2`; defaults to `+2` |
| `-c`, `--clipboard` | Read change links from the system clipboard |
| `--from-stdin` | Read change links from piped stdin |
| `--topic TOPIC` | Vote on every open change under a Gerrit topic (host = default/single configured host) |
| `--related` | Also vote on each change's dependency (relation) chain, de-duplicated |
| `--related-topics` | Also vote on every open change sharing a topic with any change in the set (auto-enabled on clipboard, except with `--extract`) |
| `--extract` | Only print the change links found; do not vote |
| `--dry-run` | Resolve credentials and read changes, but change nothing (also previews `--abandon`/`--restore`/`--delete`/`--all`) |
| `-y`, `--yes` | Skip the confirmation menu (votes immediately; also skips the abandon/restore/delete prompt) |
| `--abandon` | Abandon the change(s) instead of voting (reversible; MERGED skipped) |
| `--restore` | Restore abandoned change(s) — the inverse of `--abandon` (only ABANDONED changes) |
| `--delete` | Delete the change(s) — **irreversible**, owner-only; lists + confirms first. With `--all`, a menu first picks open / abandoned / both |
| `--force-delete` | Skip the interactive confirm for `--delete` (for scripts; `-y` does the same) |
| `--all` | Target ALL your changes across every configured host (`owner:self`); requires `--abandon`/`--restore`/`--delete`. With `--delete`, a menu picks which statuses to sweep |
| `--list` | List your non-merged changes (owned + to-review) across all hosts, then exit; changes nothing |
| `--label LABEL` | Label to vote on (default `Code-Review`; common: `Code-Review`, `Verified`) |
| `--list-labels CHANGE` | Print the labels you may vote on `CHANGE` (name + allowed values), then exit; casts nothing |
| `-m`, `--message MSG` | Review message posted alongside the vote |
| `-u`, `--user USER` | Gerrit username override (else env/config); applies to every host |
| `-p`, `--password PW` | Gerrit HTTP password override (else env/config); visible in shell history |
| `--no-fallback` | Disable the `+2 → +1` downgrade when `+2` is not permitted |
| `-V`, `--version` | Print the version and exit |

### Labels

`Code-Review` is the default and, on most servers, the only label a human votes
by hand. But the available labels are **defined per Gerrit server** (a server may
also expose `Verified`, `Owners-Override`, and any number of CI/bot labels), and
**which ones you may vote on a given change** is decided per-change by your
project/branch permissions. Rather than hardcode a list that would be wrong on
another server, query it live:

```console
$ gerrit-vote --list-labels https://gerrit.example.com/c/12345
Labels you may vote on this change:
  Code-Review: -2, -1, 0, +1, +2
  Verified: -1, 0, +1
```

Then pass one to `--label`:

```console
$ gerrit-vote --label Verified https://gerrit.example.com/c/12345 1
```

### Combo actions on your own changes

When a change is yours, the interactive menu offers three one-shot combos above
the usual value rows, each casting several labels in a single review:

```text
❯ 1. Code-Review +1, Presubmit-Ready +1, Auto-Submit +1
  2. Code-Review +1, Presubmit-Ready +1
  3. Code-Review +1, Auto-Submit +1
  4. +2  approve
  5. +1  looks good
  ...
```

Ownership is decided by comparing the change's owner email with your global git
identity (`git config --global user.email`, i.e. `~/.gitconfig`), so a repo-local
override does not shadow it. Combos are hidden on changes you don't own. They are
also all-or-nothing per change: if the server doesn't permit every label in the
chosen combo on a given change (e.g. that project has no `Auto-Submit`), the
change is skipped rather than partially voted.

## Library use

The voting and extraction logic is importable:

```python
from gerrit_vote import extract_changes, vote

for url in extract_changes(pasted_text):
    result = vote(url)  # defaults to +2, never submits
    if result.skipped:
        print(result.change_id, "skipped:", result.skip_reason)
    else:
        print(result.change_id, "->", result.cast)  # may be 1 after fallback
```

Expand a topic into its open changes, then vote on each:

```python
from gerrit_vote import change_topic, changes_in_topic, resolve, vote

creds = resolve("gerrit.example.com")  # or None for the default host
for url in changes_in_topic(creds, "my-feature"):
    vote(url, creds=creds)

# change_topic(creds, url) returns a change's topic (or None), the primitive
# behind --related-topics.
```

Lifecycle actions and listing are importable too. `abandon` / `restore` return a
`VoteResult` (with `.action` set); `delete_change` takes an owner gate; and
`list_my_changes` returns `ChangeListItem`s tagged `is_owner`:

```python
from gerrit_vote import abandon, restore, delete_change, list_my_changes, resolve

abandon("https://gerrit.example.com/c/proj/+/12345")   # reversible
restore("https://gerrit.example.com/c/proj/+/12345")   # inverse of abandon

# Delete only your own change (require_owner_email gates it; None disables the gate)
delete_change("https://gerrit.example.com/c/proj/+/12345",
              require_owner_email="me@example.com")

creds = resolve("gerrit.example.com")
for item in list_my_changes(creds):
    tag = "mine" if item.is_owner else "REVIEW"
    print(item.number, item.status, tag, item.subject)
```

## Development

Run straight from the source tree — no build, no install:

```bash
./main.py --dry-run <url>   # the CLI, imported from src/ on the fly
pytest                      # tests import from src/ via pythonpath, no install needed
```

`pyproject.toml` sets `pythonpath = ["src"]`, so `pytest` runs against the working
tree directly and always shadows any installed copy. `./main.py` is a thin shim
that does the same for the CLI. Install the dev tools once with:

```bash
pip install ".[dev]" --no-deps   # pytest + ruff; the package itself need not be installed
```

CI runs on every push and pull request: a fast job lints and formats with `ruff`
and runs the suite against `src/`, plus a `package` job that builds the wheel, installs it
clean, and re-runs the suite with `pythonpath` cleared — so the *installed* package
is exercised and packaging bugs fail the build.

## Releasing

Publishing is automated: cut a **GitHub Release** and `publish.yml` builds the
distribution and uploads it to PyPI via Trusted Publishing (OIDC — no API token).
The repo must be registered as a trusted publisher on the PyPI project once. Bump
`__version__` in `src/gerrit_vote/__init__.py` before tagging — PyPI is
append-only.
