Skip to main content

Script-driven code changes with automated merge requests

Project description

repoactive - Script-driven code changes with automated merge requests

Warning: This project is in an early stage of development. Use at your own risk.

repoactive runs your scripts against a git repository and optionally keeps the corresponding merge requests up to date. You write the scripts that produce the code changes; repoactive handles the rest - branches, commits, and (with --mode publish) the full MR lifecycle.

Contents

Use cases

  • Keeping generated files (API clients, protobuf bindings, lock files) in sync with their sources
  • Applying organization-wide refactors or policy changes across many repositories
  • Automating any periodic code transformation that should go through a review process

Installation

repoactive is published on PyPI. Install it as a standalone command-line tool with uv or pipx:

uv tool install repoactive
# or
pipx install repoactive

repoactive drives the jj (Jujutsu) CLI, so a jj binary must be on your PATH. Publishing merge requests additionally needs a GitHub or GitLab API token in the environment. See Requirements for the complete list.

Quick start

  1. Point repoactive at a git repository. Any local git checkout works; if it is not already a colocated jj repository, repoactive runs jj git init --colocate for you on the first run (and prints how to undo that). If you have not used jj before, set the identity it records on commits once: jj config set --user user.name "My Name" and jj config set --user user.email "me@example.com".

  2. Add a minimal .repoactive.toml in the repository root with a single job. This one keeps uv's lock file current:

    [job.uv-lock-upgrade]
    command = "uv lock --upgrade"
    title = "build: upgrade dependencies"
    
  3. Check the config without running anything:

    repoactive validate-config
    
  4. Run the job locally. In the default local mode nothing is pushed and no MR is created - repoactive just records the diff your script produced on the branch repoactive/uv-lock-upgrade and prints a jj op restore command to undo the run:

    repoactive run
    
  5. Publish it. Put the API token your platform uses in the environment (GITHUB_TOKEN for GitHub.com, GITLAB_TOKEN for GitLab.com by default), fetch the latest base branch, then let repoactive push the branch and open - or update - the merge request:

    jj git fetch          # repoactive never fetches on its own
    repoactive run --mode publish
    

    From here, re-running repoactive run --mode publish on a schedule keeps that MR in sync with both your script's output and the base branch. The rest of this document covers the full configuration and command surface.

How it works

You configure one or more jobs, each with a script (any shell command or executable) that modifies the repository's working tree. repoactive runs each script, captures the resulting diff, and records the change locally. With --mode publish it also:

  • opens a new merge request if one does not already exist for that job, or
  • updates the existing merge request branch if the diff has changed.

Branches and MR descriptions are managed automatically - the only code you need to write is the script that produces the change.

[your script] → diff → repoactive → branch
                                       ↓ (with --mode push or --mode publish)
                                    git push → merge request
                                                    ↑ (with --mode publish)
                                            (create or update)
  1. repoactive creates a new commit on top of the base branch or on top of other repoactive managed branches.
  2. It runs the job's script against the working tree.
  3. If the script produced a diff, it records the change. With --mode push or --mode publish, it pushes the branch; with --mode publish, it also creates or updates the merge request. On a re-run, if the diff matches what is already on the branch, the commit and branch are left untouched - avoiding an unnecessary push that would re-trigger CI pipelines. The commit is updated (and the branch pushed) only when the diff itself changes or when title/commit_title_prefix changed. Command output alone changing does not update the commit. The MR title and description are always brought up to date, including any changed command output.
  4. If the script produced no diff, the empty commit is discarded and no MR is opened. A branch left over from an earlier run that did produce a diff is now stale, so it is deleted; with --mode push or --mode publish, the deletion is pushed to the remote.

jj commits the whole working tree. Because repoactive uses jj, every new file your script creates inside the working directory is added to the commit unless it is git-ignored. There is no way to select which working-tree changes become part of the commit - they all will. Keep .gitignore up to date so build artifacts, caches, and other stray files your script produces do not end up in the diff.

Keeping the local clone current

repoactive works entirely from the local repository view and never fetches from the remote on its own. Rebasing onto trunk(), cooldown throttling, and the unmerged-branch refresh all read the local trunk() / base branches, so a merge that happened on the remote is invisible until the local clone advances past it.

Fetch before each run. Run jj git fetch (or git fetch --prune) in the same cron job or CI pipeline that invokes repoactive, before it. If you do not, jobs rebase onto a stale base and - most importantly - cooldown_period never engages, because the commit that would trigger it has not reached the local base branch. See ADR 0005.

Usage

# Print the installed version and exit
repoactive --version
repoactive run [OPTIONS] [JOBS]...

Run all configured jobs (or a named subset - dependencies are auto-included):

# Apply all jobs locally (no push, no MR creation)
repoactive run

# Apply specific jobs locally
repoactive run regenerate-api-client sync-license-headers

# Run every job carrying a given tag (see "Selecting jobs with tags")
repoactive run --tag weekly

# Push branches to the remote without creating MRs
repoactive run --mode push

# Push branches and create or update merge requests
repoactive run --mode publish

# Enable debug logging
repoactive run --debug
Option Short Description
--config PATH -c Config file or directory of *.toml files; repeat to merge. Default: .repoactive.d/ and .repoactive.toml under --repo
--set NAME=VALUE -s Override a config value (repeatable); NAME is a dotted TOML key, VALUE a TOML expression. Wins over --config
--repo PATH -r jj repository path (default: .)
--mode [local|push|publish] -m How far to publish: local (default) applies only locally, push also pushes branches, publish also creates/updates MRs
--tag TAG -t Run jobs carrying any of these tags (repeatable). With no tags/jobs the default run targets the enabled tag
--debug -d Enable debug logging

Environment variables

Besides the command-line options, repoactive reads a few REPOACTIVE_* environment variables that tune how it presents itself in the current environment (as opposed to the configuration, which describes the jobs to run). They are handled uniformly: all of them go through a single settings model and are validated together at startup, so a misconfigured variable fails immediately with a one-line error naming it instead of surfacing mid-run or being silently ignored. Values are case-insensitive.

REPOACTIVE_UI

interactive (default) or noninteractive.

Every run captures the jj operation id beforehand and prints a jj op restore <id> command at the end of the run (last, since a run can produce a lot of output). Run it to roll the local repository - commits, bookmarks and colocated git refs - back to the state it was in before the run. It only undoes local changes: a branch already pushed or an MR already created by a --mode push/--mode publish run is not affected, as the hint panel itself points out.

noninteractive suppresses these "how to undo" hint panels. Set it where nobody is at the keyboard, say an unattended CI job. This is an explicit switch rather than CI auto-detection, because a CI container someone has logged in to is interactive.

REPOACTIVE_LOG_LEVEL

debug, info, warning, error, or critical; unset by default (logging off).

Enables logging at that level without passing --debug on every invocation; an explicit --debug takes precedence.

REPOACTIVE_LOG_HANDLER

rich or plain; defaults to rich, or plain when REPOACTIVE_UI is noninteractive.

How log records are rendered: a colorized column layout via rich, or the stdlib's plain stream handler (e.g. when the output is collected by a log aggregator).

REPOACTIVE_PROGRESS_LINES

An integer (default: 8).

While a job's command runs in an interactive terminal, repoactive shows a live, scrolling block of its most recent output lines. The block stays on screen once the command finishes, with the job's status line printed below it. REPOACTIVE_PROGRESS_LINES sets how many lines that live tail shows; 0 or less disables the live block entirely. When output is not a terminal (piped or in CI) the block is disabled and the command's output is left untouched.

Inspecting repoactive commits

repoactive recent-commits [OPTIONS] [JOBS]...

List commits produced by repoactive, filtered by a time window and optionally by job name or merge status:

# Show all repoactive commits from the last 2 weeks (default window)
repoactive recent-commits --repo /path/to/repo

# Narrow to a specific window
repoactive recent-commits --within 30d --repo /path/to/repo

# Filter by one or more job names
repoactive recent-commits --within 7d uv-lock-upgrade prek-autoupdate

# Only commits that have landed in trunk
repoactive recent-commits --status merged

# Only commits still on open branches
repoactive recent-commits --status unmerged
Option Short Description
--within How far back to look (default: 2w; e.g. 7d, 24h)
--repo PATH -r jj repository path (default: .)
--status [all|merged|unmerged] -s Filter by merge status into trunk (default: all)
--debug -d Enable debug logging

Validating configuration

repoactive validate-config [OPTIONS]

Check that a config file is syntactically and semantically valid without running any jobs:

# Validate the discovered defaults (.repoactive.d/ and .repoactive.toml)
repoactive validate-config

# Validate a specific config file or directory
repoactive validate-config --config myconfig.toml

# Validate a merged config (same merging rules as `run`)
repoactive validate-config --config base.toml --config override.toml

On success the command prints Config OK: N job(s) defined. and exits with code 0. On failure it prints the validation error to stderr and exits with code 1.

Validation checks include unknown keys, missing required fields, invalid depends_on references, and circular job dependencies.

The command accepts the same --config, --set, --repo, and --debug options as repoactive run.

Listing jobs

repoactive info jobs [OPTIONS]

Show all configured jobs (including disabled ones) as a dependency tree, in topological order: each job is nested under its depends_on targets (once per parent, so a job with several dependencies appears several times), and jobs without dependencies are roots. Each line also shows the job's title and effective tags in aligned columns:

repoactive info jobs
build           Build the project   enabled
├── test        Run the test suite  nightly
│   └── deploy  Deploy to staging   nightly, risky
└── docs        Build the docs      enabled
    └── deploy  Deploy to staging   nightly, risky

The command accepts the same --config, --set, --repo, and --debug options as repoactive run.

Listing tags

repoactive info tags [OPTIONS]

Group the configured jobs by tag and print each tag with the jobs carrying it. Jobs are grouped by their effective tags - the tags driving job selection: a job's explicit tags, or the implicit enabled/disabled tag when it has none. Within each tag, jobs are shown as a dependency tree in topological order: a job is nested under its depends_on targets carrying the same tag (once per parent, so a job with several dependencies appears several times), while a job whose dependencies all carry other tags stays at the root. Each line also shows the job's title and effective tags in aligned columns:

# List tags from the discovered defaults (.repoactive.d/ and .repoactive.toml)
repoactive info tags

# List tags from a specific config file or directory
repoactive info tags --config myconfig.toml
enabled:
  uv-lock-upgrade      Upgrade uv.lock         enabled
  └── prek-autoupdate  Autoupdate prek hooks   enabled
nightly:
  benchmark            Run nightly benchmarks  nightly

The command accepts the same --config, --set, --repo, and --debug options as repoactive run.

Configuration

repoactive is configured via .repoactive.toml in the repository root (or passed via --config). See Config file locations for how the defaults are discovered and how to split the config across several files.

A minimal config with a single job:

[job.uv-lock-upgrade]
command = "uv lock --upgrade"
title = "build: upgrade dependencies"

Every key in [job-defaults] supplies the default for the matching per-job key; any job may override it by setting the same key in its [job.<name>] block.

[job-defaults]

MR/PR options:

  • labels (default: []) - Labels applied to every MR/PR. Per-job labels are merged with, not replaced by, this list.
  • auto_merge (default: false) - When true, enable auto-merge on every MR/PR so it merges automatically once its pipeline passes. On GitHub, the repository must have "Allow auto-merge" enabled in its settings.

Branch and commit options:

  • branch_prefix (default: "repoactive/") - Prefix prepended to the job name to form the branch name.
  • mr_title_prefix (default: "[repoactive] ") - Prefix prepended to every MR/PR title. Set to "" to disable.
  • commit_title_prefix (default: "[repoactive] ") - Prefix prepended to every commit title. Set to "" to disable.
  • base_branch (default: repo default) - Target branch for all jobs that do not set their own.

Run control:

  • cooldown_period (default: none) - Minimum time between a landed change and the next run for any job. Format: <number><unit> where unit is s, m, h, d, or w (e.g. "7d"). See Throttling jobs with cooldown_period.
  • timeout (default: "2m") - Maximum runtime for a job's command. Same format as cooldown_period. See Limiting job runtime with timeout.

[job.<name>]

The table key is the job's unique name; the branch is always branch_prefix + name. Job names may contain letters, digits, hyphens, and underscores.

Required:

  • command - Shell command (or executable path) run in the repository working directory. A non-zero exit is a failure.
  • title - MR/PR title (also the commit subject, after commit_title_prefix).

MR/PR options:

  • description - Body text of the MR/PR.
  • labels (default: []) - Extra labels for this job's MR/PR, merged with job-defaults.labels.
  • draft (default: false) - Open the MR/PR as a draft. On GitHub, draft state cannot be changed after creation.
  • create_mr (default: true) - Whether to create an MR/PR: true (always), false (push the branch but skip the MR), or "unless-superseded" (skip when a dependent's MR from the same run already contains this job's changes - see Stacking MRs).
  • auto_merge (default: inherited from job-defaults) - When true, enable auto-merge on this job's MR/PR.

Branch and commit options:

  • base_branch (default: inherited) - Target branch for this job's MR/PR.
  • branch_prefix (default: inherited) - Override the branch-name prefix for this job only.
  • mr_title_prefix (default: inherited) - Override the MR/PR title prefix for this job only.
  • commit_title_prefix (default: inherited) - Override the commit title prefix for this job only.
  • output_in_commit (default: true) - Append the job's command and its captured output to the commit message. Set to false to keep the commit message clean.

Run control:

  • disabled (default: false) - Exclude this job from the bare repoactive run. Sugar for tags = ["disabled"]; mutually exclusive with tags. See Disabling jobs.
  • tags (default: none) - Tags for job selection. A job with no tags carries the implicit enabled tag and runs in the bare repoactive run; setting any explicit tag removes enabled. See Selecting jobs with tags.
  • depends_on (default: []) - Jobs whose output this job builds on. See Stacking MRs.
  • run_only_if_changed (default: []) - Only run this job if at least one listed job produced a diff in the current run. See Gating jobs with run_only_if_changed.
  • cooldown_period (default: inherited) - Minimum time between a landed change and the next run. See Throttling jobs with cooldown_period.
  • cooldown_on (default: []) - Broader jobs that subsume this one; a recent landing of any of them also throttles this job. Requires a cooldown_period. See Throttling a job when a superset lands with cooldown_on.
  • timeout (default: inherited) - Maximum runtime for this job's command. Set to "0s" to disable the timeout entirely. See Limiting job runtime with timeout.
  • emits_jobs (default: false) - Generator job: the command writes *.toml job fragments into $REPOACTIVE_JOBS_DIR instead of producing a diff. See Generating jobs dynamically.

Stacking MRs

When depends_on is set, repoactive starts the job's script from a working tree that already contains all listed dependencies' diffs, rather than from the plain base branch. The resulting MR branch includes both the dependency changes and the new job's changes on top, and links to the parent MRs are automatically added to the MR description.

Because a dependent's MR already contains its dependencies' changes, a dependency chain normally opens one MR per job that produced a diff. Setting create_mr = "unless-superseded" on the earlier jobs collapses that: such a job skips its MR when a dependent job produced an MR in the same run, so the chain yields a single MR on the topmost job that actually changed something

  • falling back to the job below it when the jobs above came up empty. The branch is still pushed either way. Only the current run counts: a dependent that is empty, failed, on cooldown, or not selected does not suppress anything. See ADR 0009 for details and limitations.

Example

[job-defaults]
base_branch = "main"
labels = ["repoactive"]
cooldown_period = "7d"
auto_merge = true
branch_prefix = "bot/"
commit_title_prefix = "[bot] "
timeout = "10m"

[job.regenerate-api-client]
command = "python scripts/regen_api.py"
title = "api: regenerate API client"
description = "Automated regeneration of the API client from the OpenAPI spec."
labels = ["api"]
mr_title_prefix = "[api] "
create_mr = "unless-superseded"

[job.sync-license-headers]
command = "./scripts/add_license_headers.sh"
title = "sync license headers"
draft = true

[job.integration-tests-update]
command = "./scripts/update_integration_tests.py"
title = "tests: update integration tests"
depends_on = ["regenerate-api-client", "sync-license-headers"]
timeout = "30m"

[platform.<name>]

For public GitHub.com or GitLab.com repositories no platform declaration is needed - repoactive detects the remote URL automatically. To use a self-hosted instance, add a [platform.<name>] section (the name is a label of your choosing; platforms are matched to a repository by their url):

  • url - Base URL of the platform instance (e.g. "https://gitlab.example.com").
  • type - "github" or "gitlab".
  • token_env - Name of the environment variable holding the API token.
[platform.company-gitlab]
url = "https://gitlab.example.com"
token_env = "GITLAB_TOKEN"
type = "gitlab"

Secrets are kept out of the config file by referencing environment variable names rather than inline values. The token named by token_env is stripped from the environment job commands run in, so a script cannot read the credential repoactive uses to push and create MRs. A job that needs its own credential must be given a separate one. repoactive otherwise trusts job commands - they run arbitrary code against the working tree - so the trust boundary is the config that defines them; see ADR 0006.

Config file locations

When no --config/-c option is given, repoactive looks for configuration inside the --repo directory (the current directory by default):

  • the .repoactive.d/ directory, if present, contributes every *.toml file it contains, merged in sorted filename order;
  • the .repoactive.toml file, if present, is merged last so it overrides the directory.

If neither exists, repoactive exits with an error. Splitting configuration across .repoactive.d/*.toml is handy for dropping in per-job files without touching a single large config.

--config/-c overrides this discovery. It may point at a file or at a directory of *.toml files, and may be repeated to merge several sources; later sources win. Explicit paths are resolved relative to the current directory, not --repo.

Overriding values on the command line

--set NAME=VALUE/-s tweaks individual config values without editing a file. It is merged as the last, highest-priority source, so it wins over everything discovered or passed with --config. NAME is a TOML key - dotted keys reach into tables - and VALUE is a TOML expression, so strings need quoting:

# override a job-defaults scalar and disable one job for this run
repoactive run \
  --set 'job-defaults.cooldown_period = "24h"' \
  --set 'job.lint.disabled = true'

Platforms are name-keyed tables ([platform.<name>]) merged by name, so a single field is reachable with a dotted key. The built-in defaults define github and gitlab, so pointing GitHub.com at a different token environment variable is just:

repoactive run --set 'platform.github.token_env = "MY_TOKEN"'

A platform name that does not already exist is created as a new platform, which then needs the full set of fields (url, type, token_env).

--set is available on run, validate-config, info jobs, and info tags. Each --set is validated as its own source (like a separate config file), so it can amend existing jobs but cannot introduce a brand-new job across several flags - put a new job's required fields in one expression (e.g. --set 'job.x = {command = "…", title = "…"}') or a config file. An unknown key or malformed value is reported with the offending --set argument named.

Selecting jobs with tags

Which jobs a repoactive run touches is decided by tags. Every job has a set of tags, with a smart default:

  • a plain job (no tags, not disabled) carries the implicit enabled tag;
  • disabled = true is sugar for tags = ["disabled"];
  • setting tags = [...] uses exactly those tags - and, importantly, drops the implicit enabled tag unless you list it yourself.

repoactive run with no arguments is shorthand for repoactive run --tag enabled: it runs every job carrying enabled. Pass --tag/-t (repeatable) to select a different set; a job runs if it carries any of the requested tags. Naming jobs and passing tags can be combined - the selection is the union of the two.

# Run all jobs tagged "weekly" (regardless of whether they also have "enabled")
repoactive run --tag weekly

# Union: the "nightly" jobs plus one named job
repoactive run --tag nightly regenerate-api-client

Requesting a tag that no job carries is an error, exactly like naming an unknown job - a typo in a crontab's --tag fails loudly instead of silently running zero jobs. This also means a scheduled entry whose tag has lost its last member fails until you remove the entry (or re-tag a job); a tag only exists as a value on jobs, so an empty tag is indistinguishable from a mistyped one.

Because assigning a tag removes the implicit enabled tag, tags are load-bearing, not free-form labels: tagging a job takes it out of the bare repoactive run. If you want a job to stay in the default run and belong to a group, list both: tags = ["enabled", "weekly"]. (For MR/PR labels, use labels - a separate concept.)

Tag selection is explicit selection, so - like naming a job - it ignores the enabled/disabled defaults and force-includes dependencies. The bare repoactive run is implicit selection: a job whose dependency is not itself selected is dropped (==> [name] skipped (dependency not in default run)).

Keeping unmerged branches current

The bare repoactive run additionally refreshes any job that currently has an unmerged branch, regardless of its tags. A branch is "unmerged" when the job's last commit has not yet landed in trunk(); repoactive finds these via the Repoactive-Job trailer on unmerged commits and pulls those jobs (and their dependencies) into the run, so each branch is rebased on the latest trunk() and the command is re-run against it. (With --mode publish such a branch has an open MR; with a plain run or --mode push it is just a branch.)

This means a job's schedule tag governs when a new branch is created, while the default run keeps an existing branch rebased and current - you don't have to wait for the next weekly run to resolve a conflict with trunk(). Once the branch lands, its commit becomes an ancestor of trunk(), so the job drops back to its normal tag-driven cadence. (A disabled job's unmerged branch is refreshed too: it was most likely created by an explicit run, and letting it drift out of date helps no one.)

Disabling jobs

Set disabled = true on a [job.<name>] to keep it in the config but leave it out of normal runs; it is exactly sugar for tags = ["disabled"] (so the two are mutually exclusive). The flag only affects the bare repoactive run:

[job.experimental-refactor]
command = "./scripts/refactor.sh"
title = "chore: apply experimental refactor"
disabled = true
  • On repoactive run, disabled jobs are skipped, along with any job that depends_on one (its dependency would not be produced).
  • Naming a job explicitly overrides it: repoactive run my-job runs my-job even when it is disabled. So does repoactive run --tag disabled, which runs everything currently turned off.

Running a job on a schedule

repoactive is not a daemon and has no built-in scheduler - the cadence of a job is whatever cadence you invoke it with. To run a job on a fixed schedule, tag it and have an OS cron job select that tag. The tag keeps the job out of the bare repoactive run, and the crontab decides the membership in one place - add or remove weekly jobs by editing the config, not the crontab:

[job.uv-lock-upgrade]
command = "uv lock --upgrade"
title = "build: upgrade all dependencies"
# Not in the bare `repoactive run`; runs only via `--tag weekly`.
tags = ["weekly"]
# Run every job tagged "weekly" each Sunday at 03:00
0 3 * * 0  repoactive run --tag weekly --mode publish

Because the cron is the sole trigger, the command runs exactly when cron fires - once, whether or not it produces a diff. This is more reliable than inferring a schedule from repoactive's own history: real cron is stateful and excludes the other days, whereas repoactive only ever sees what has landed (see cooldown_period below).

One run at a time per repository

A repoactive run takes an exclusive per-repository lock for its duration, so two runs against the same repository never interleave (and corrupt each other's branches and temporary workspaces). If a run is started while another is still in progress - a slow run overrunning the next cron tick, say - the second one exits immediately with status code 2 instead of waiting or racing. That code is distinct from the generic failure code (1), so a wrapper can treat "already running" as benign:

0 3 * * 0  repoactive run --tag weekly --mode publish || test $? -eq 2

The lock is an advisory flock on .jj/repoactive.lock; the OS releases it automatically if a run is killed, so a crashed run never leaves the repository locked.

Gating jobs with run_only_if_changed

run_only_if_changed lets a job declare that it should only run when specific upstream jobs actually produced a diff. If none of the named jobs changed anything in the current run, the job is skipped.

[job.prek-autoupdate]
command = "./scripts/prek.sh autoupdate"
title = "ci: update pre-commit hooks"
tags = ["weekly"]

[job.prek-run-all]
command = "sh -c './scripts/prek.sh run -a; true'"
title = "ci: apply pre-commit fixes"
depends_on = ["prek-autoupdate"]
run_only_if_changed = ["prek-autoupdate"]
tags = ["weekly"]

Here prek-run-all is only useful when prek-autoupdate changed something: if prek-autoupdate found no hooks to update, running prek-run-all would produce an empty diff anyway. With run_only_if_changed, the skip is explicit and immediate - prek-run-all never even starts.

Key behaviour:

  • At least one must have changed. The job runs if any named job produced a diff; it is skipped only when all of them were empty.
  • Missing results count as no diff. If a named job failed or was itself skipped, it is treated as having produced no diff.
  • Dependents are unaffected. A skipped job records a no-op result (like a job on cooldown), so any job that depends_on it still runs on the base branch - the skip does not cascade.
  • No ordering constraint. Names in run_only_if_changed do not have to appear in depends_on. Any job that runs before this one in topological order can be listed; in practice most usages name a direct dependency, as in the example above.

Throttling jobs with cooldown_period

Every commit repoactive creates carries a Repoactive-Job: <name> trailer identifying the job that produced it. When a job sets cooldown_period, repoactive looks at the base branch for a commit with that job's trailer and a committer date inside the window before running. If one is found the job is on cooldown and is skipped for this run (dependents proceed as if it produced no changes); otherwise the job runs normally. This keeps recurring jobs - for example a dependency upgrade - from landing more often than the configured interval.

The signal is the trailer on the base branch, so the cooldown only starts once a change has landed. An open, unmerged MR does not trigger it (the existing MR keeps being updated as usual). Because the check relies on the commit trailer reaching the base branch, MRs for throttled jobs must be merged with a merge commit or rebase - a squash merge discards the commit message and with it the trailer, so the cooldown would never trigger.

The trailer must also be present in the local base branch when the job runs: repoactive does not fetch, so a clone that has not pulled the merge will not see the cooldown and will re-run the job. See Keeping the local clone current.

Throttling a job when a superset lands with cooldown_on

Some jobs are strict supersets of others. uv lock --upgrade refreshes every dependency group, so its diff already contains everything uv lock --upgrade-group=dev would produce. Re-running the narrower job right after the broad one landed just opens a redundant MR.

Listing the broader jobs in cooldown_on widens the narrow job's cooldown check: it is throttled when its own trailer or any listed job's trailer last landed within its cooldown_period. So once the full upgrade lands, the group-scoped job stays quiet for the cooldown window instead of re-running.

[job.full-lock]
command = "uv lock --upgrade"
title = "chore: update all dependencies"
cooldown_period = "7d"

[job.dev-lock]
command = "uv lock --upgrade-group=dev"
title = "chore: update dev dependencies"
cooldown_period = "7d"
cooldown_on = ["full-lock"]

cooldown_on requires a cooldown_period on the same job (its own or one inherited from [job-defaults]); without a window there is nothing to throttle against, so a config that sets one without the other is rejected. The relationship is one-directional and needs no change to the broad job - it still writes only its own trailer.

This only suppresses starting a redundant run. A narrow job that already has an open, unmerged branch is always refreshed regardless of cooldown (see Keeping unmerged branches current), so its branch is rebased and, if the change is now redundant, self-closes on the next run.

Limiting job runtime with timeout

A job's command can hang or run away. Setting timeout caps how long the command may run; when the limit is reached repoactive kills the command's whole process group - the shell and any child processes it spawned - and the job fails (its workspace is abandoned, no branch or MR is created). The command runs in its own session/process group to make this possible. The value uses the same <number><unit> format as cooldown_period (e.g. "30m", "2h"). timeout may be set per job or in job-defaults; a per-job value overrides the default. The built-in default is "2m"; set timeout to a larger value in job-defaults for longer-running commands. A zero duration (timeout = "0s") disables the timeout entirely; since TOML has no null value, this is how a job opts out of a timeout set in job-defaults.

[job-defaults]
timeout = "10m"       # raise the built-in 2m default for all jobs

[job.slow-codegen]
command = "./scripts/slow_codegen.sh"
title = "chore: regenerate bindings"
timeout = "30m"       # this job needs extra time

[job.quick-lint]
command = "ruff check ."
title = "chore: fix lint"
timeout = "0s"        # opt out of the timeout entirely

Generating jobs dynamically

Sometimes the useful set of jobs depends on the repository's contents - one job per package in a monorepo, one per entry in a manifest - and you don't want to hand-maintain them. A generator is an ordinary [job.<name>] with emits_jobs = true. Instead of producing a diff, its command writes one or more *.toml job fragments into the directory named by the REPOACTIVE_JOBS_DIR environment variable, and the jobs it emits run in the same invocation.

[job.per-package]
command = "./scripts/emit_upgrade_jobs.sh"
title = "discover package upgrade jobs"
emits_jobs = true
# Inherited by every emitted job unless the job overrides it (see below).
tags = ["weekly"]
cooldown_period = "7d"

The command writes fragments using the normal [job.<name>] syntax, e.g.

# $REPOACTIVE_JOBS_DIR/pkg-a.toml
[job.upgrade-pkg-a]
command = "uv lock --upgrade --package pkg-a"
title = "build: upgrade pkg-a"

Key points:

  • Selection is unchanged. A generator is selected like any job (by the bare run, by name, or by --tag); selecting it runs it and everything it emits.
  • Inheritance with override. Each emitted job inherits the generator's tags, cooldown_period, base_branch, timeout, labels, branch_prefix/title prefixes, draft and create_mr unless its own fragment sets them. It also defaults to depends_on = ["<generator>"] (i.e. built flat on trunk()); override depends_on to a sibling emitted job to stack them into an MR chain.
  • Stable names are your responsibility. Cooldown, branches and the Repoactive-Job trailer all key on a job's name, so derive emitted names deterministically from repository state (upgrade-pkg-a, not a random id).
  • The generator never commits. Any change its command leaves in the working copy is discarded; its only output is the job list.
  • Batch cooldown. Each emitted commit carries a second Repoactive-Job trailer with the generator's name, so a cooldown_period on the generator throttles the whole fan-out: it is skipped until enough time has passed since the most recent emitted job landed.

The generator's cooldown is a floor for its jobs. An emitted job is only re-run when the generator re-emits it, and the generator is gated by the same landed commits (via the shared trailer). So overriding an emitted job's cooldown_period only matters when you make it longer than the generator's - then the job stays throttled even after the generator has run again. Making it shorter has no effect: while the generator is on its own cooldown the job is never re-emitted, and by the time the generator runs again the job's shorter window has long since elapsed (nothing landed for that job during the generator's cooldown). To upgrade an individual dependency more often than the batch, lower the generator's cooldown_period, not the emitted job's.

Emitted jobs may not themselves be generators (no recursion), may not reuse an existing job's name, and may only depends_on jobs that are part of the same run. See ADR 0004 for the full design.

Requirements

  • Python 3.11 or later

  • jj (Jujutsu) - repoactive uses jj to manage branches and commits in the target repository

  • A configured jj user name and email - jj records them as the commit author. Set them once with:

    jj config set --user user.name "My Name"
    jj config set --user user.email "me@example.com"
    
  • A GitLab or GitHub API token exposed via the environment variable named in platform.token_env (default: GITHUB_TOKEN for GitHub.com, GITLAB_TOKEN for GitLab.com)

Appendix

jj revset aliases

To query repoactive commits directly in jj, add these aliases to your repository config (jj config set --repo) or your global config (jj config set --user):

[revset-aliases]
'repoactive()' = 'description(regex:"(?m)^Repoactive-Job: ")'
'repoactive_merged()' = 'repoactive() & ::trunk()'
'repoactive_unmerged()' = 'repoactive() & ~(::trunk())'

Then use them directly in jj:

jj log -r 'repoactive()'
jj log -r 'repoactive_unmerged()'
jj log -r 'repoactive() & committer_date(after:"2025-01-01")'
jj log -r 'repoactive() & description(regex:"(?m)^Repoactive-Job: uv-lock-upgrade$")'

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

repoactive-0.2.6.tar.gz (86.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

repoactive-0.2.6-py3-none-any.whl (83.0 kB view details)

Uploaded Python 3

File details

Details for the file repoactive-0.2.6.tar.gz.

File metadata

  • Download URL: repoactive-0.2.6.tar.gz
  • Upload date:
  • Size: 86.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for repoactive-0.2.6.tar.gz
Algorithm Hash digest
SHA256 9517dcfbbc51165258a630d401b18784eaacafebe78047f5a768cb67384c9f39
MD5 2947fa394e394da7f546072d988b0ac0
BLAKE2b-256 3fbe62b424a54e2cfe2cfc5b11523a3b7022ec304e5ede95b05d02dab8bd334f

See more details on using hashes here.

Provenance

The following attestation bundles were made for repoactive-0.2.6.tar.gz:

Publisher: publish.yml on schmir/repoactive

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file repoactive-0.2.6-py3-none-any.whl.

File metadata

  • Download URL: repoactive-0.2.6-py3-none-any.whl
  • Upload date:
  • Size: 83.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for repoactive-0.2.6-py3-none-any.whl
Algorithm Hash digest
SHA256 18352642940a7755a81b1856718c9510b191b83b3d43b363c812dc77fdea5588
MD5 7babb0eee1f184b60110f8801a24147c
BLAKE2b-256 99481a50301f3a8d6c352b51d7610e55512ee3be3e71ea6659df09a4f1df7135

See more details on using hashes here.

Provenance

The following attestation bundles were made for repoactive-0.2.6-py3-none-any.whl:

Publisher: publish.yml on schmir/repoactive

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page