poi-tracker 0.24.0

Package-of-interest tracker for Fedora, EPEL, and CentOS SIGs
poi-tracker-0.24.0 is not a library.

poi-tracker

Package-of-interest tracker for Fedora, EPEL, and CentOS SIGs.

Manages TOML-based inventories of packages that an organization tracks across distributions. Supports exporting to content-resolver YAML and hs-relmon manifest formats.

poi-tracker is the act side of the inventory tooling: it curates the inventory itself (sync, prune, add/remove) and takes credentialed actions on Bugzilla (triaging, closing, and claiming bugs). Its read-only counterpart is sandogasa-pkg-health, which observes the same inventories — periodic, credential-free health checks with persisted, incrementally-refreshed reports. Rule of thumb: anything that writes (to Bugzilla or the inventory) belongs here; anything that produces a report to watch over time belongs in pkg-health.

Installation

cargo install poi-tracker

Some subcommands shell out to external tools: prune-retired and sync-distgit --mark-unshipped need fedrq (sudo dnf install fedrq); sync-gitlab --mark-unshipped needs koji configured with the cbs profile (from the centos-packager package); semver-audit and triage-updates use koji (sudo dnf install koji) to verify that a version is actually tagged into a release before calling a bug stale — both degrade with a warning when it's missing (unverifiable bugs are left open / reported as up to date).

Usage

Naming inventories: -i, -I, or a workspace

Three ways, from most to least specific. -i FILE (repeatable) names exactly these inventories and always wins. -I DIR takes every *.toml in a directory as an inventory, roles unknown — right for the commands that treat inventories alike (show, find, validate, export), wrong for the ones where it matters which file is the keeps and which the packages you own. A workspace file says which is which, once, so those subcommands need no -i at all; anything typed on the command line still overrides it.

The workspace file

The maintenance subcommands — kondo, keep, unkeep, deps, derive, dependents, act, announce — each take a handful of paths that never change between runs: which inventory is the packages you own, which ones are the keeps, where the graph is, what branch the walk runs against. Write them once in a kondo.toml next to the inventories, and the subcommands read them from there (-w PATH names another file; ./kondo.toml is found on its own):

user = "salimma"                                  # routes kondo and act
owned = "inventory-salimma-direct.toml"           # sync-distgit --no-groups
cull = "cull.toml"                                # the standing verdicts
retired = ["inventory-salimma-essential-retired.toml"]

[[closure]]                                       # one dependency world
name = "fedora"
keeps = ["inventory-salimma-essential.toml", "inventory-salimma-essential-rust.toml"]
closure = "fedora-build-deps.toml"                # deps -o
derived = "inventory-salimma-essential-deps.toml" # derive -o
graph = "fedora-build-deps-graph.json"
branch = "rawhide"
from = ["rawhide"]

[[closure]]
name = "hyperscale-el9"
external = true                                   # not ours on dist-git
keeps = ["inventory-hyperscale.toml"]
closure = "inventory-hyperscale-el9-deps.toml"    # the walk output is the essential list
graph = "hyperscale-el9-graph.json"               # written by the first deps run
branch = "hs.el9"
repo = "stack"

A closure is one set of keeps walked against one repo configuration. external = true marks keeps that are not yours on dist-git — another SIG's inventory, another distribution's — whose dependencies you track all the same: the walk's output counts as essential, and nothing will offer to cull or demote the keeps themselves. The essential set kondo uses is every closure's keeps, walk outputs and derived inventories plus the retired ones (a walk output names packages that are not yours too, which is harmless: only owned packages can be candidates). Walk subcommands take the first closure unless --closure NAME says otherwise; a closure whose graph does not exist yet is one deps run away from having it. Paths are relative to the file.

With the file in place, poi-tracker kondo, poi-tracker keep ripgrep or poi-tracker --closure hyperscale-el9 dependents are complete commands, and -C DIR runs any of them as if started in the data directory, the way git -C does. The commands about the packages you maintain — semver-audit, triage-updates, triage-retired, prune-retired, show, validate — take the owned inventory as their -i from it too, so poi-tracker -C ../data semver-audit --non-breaking is the whole safe-updates question. A flag on the command line still wins, and --no-defaults ignores the file (and the config file's [defaults]) for one run.

Act on the cull verdicts

The workflow's last verb — walk the standing verdicts and give the packages away, one confirmed action at a time:

poi-tracker -C ../data act                     # with a workspace file
poi-tracker -i cull.toml act --user salimma \
    --personal personal.toml                   # without one

Each package is classified fresh (cache-first) and prompted by level: owner-level offers (y) orphan / (g <user>) give / (u)ncull / (s)kip / (q)uit, admin-level offers removing your own ACL, and ask-level entries prompt too (uncull is the one action always open). Enter skips — the safe default for server-side ownership changes — and there is deliberately no bulk mode. u lifts a verdict you no longer stand behind: the entry leaves the cull inventory and the package returns to candidacy on the next kondo run — unless you type u <inventory>, which also files it into that essential inventory so it never comes back. Filing this way adds a keep the graph has never walked, so the prompt says to keep it afterwards to record its dependencies.

Before any orphan prompt, the package's reverse dependencies are probed distro-wide through fedrq, across all subpackages, so feature capabilities (crate(x/feature)) are covered: orphaning starts the retirement clock, and retirement is what leaves dependents uninstallable. A package something still requires says so at the prompt, where skip is already the default — and a hard dependency makes the orphan reconfirm. Each dependent is resolved to its source and classified: one that reaches the package only through an optional foo+feature subpackage is shown as (optional feature) and, if every dependent is such, flagged likely severable rather than a block (python-dulwich needs merge3 only via its +merge feature, breezy outright). The probe aims itself: by default (--branch auto) each package is probed on the branches it actually carries per dist-git — rawhide plus its own EPEL branches — because which branches matter belongs to the package, not to a flag (python39-* lives on epel8, invisible from rawhide). An explicit --branch list overrides.

On each success the entry leaves the cull inventory and the package leaves the --personal inventory, so an interrupted walk loses nothing already enacted. Requires a dist-git API token (--api-token, PAGURE_API_TOKEN, or poi-tracker config) and fedrq.

Add a package

poi-tracker add systemd -i inventory.toml \
    --poc "Team <team@example.com>" \
    --rpm systemd-networkd \
    --workload hyperscale \
    --track upstream

Adopt orphaned packages

The action counterpart to sandogasa-pkg-health's orphaned flag: walk the inventory, find packages whose dist-git owner is the orphan sentinel user, show each one's orphaning reason, and take ownership via the same API as the web UI's "Take" button. An orphaned package is retired ~6 weeks after orphaning unless someone adopts it.

poi-tracker -i inventory.toml adopt --dry-run   # list, no token needed
poi-tracker -i inventory.toml adopt             # prompt per package
poi-tracker -i inventory.toml adopt -y          # adopt all matches

Adoption is a per-package commitment, so interactive runs confirm each package individually (default no) rather than batching one yes/no over the list; -y adopts every match. --pattern <glob> scopes the walk. Packages marked retired or unshipped in the inventory are skipped — dist-git refuses to hand out retired packages (those need a releng ticket).

Adopting needs a dist-git API token with the "Modify an existing project" ACL (you must also be in the packager group): generate one at https://src.fedoraproject.org/settings/token/new and store it with poi-tracker config (or pass --api-token / set PAGURE_API_TOKEN). --dry-run works without a token.

Announce the standing cull verdicts

Render the whole cull inventory as the grouped, announcement-ready report — the artifact the act phase posts to the mailing list before anything runs:

poi-tracker -C ../data announce                        # with a workspace file
poi-tracker -i cull.toml announce --user salimma       # without one

The text report is for humans — the mailing list gets package names grouped by level, never commands. Enacting the verdicts is act's job, one confirmed prompt at a time. Levels are re-classified per run (day-fresh cache first; --refresh-acls forces fresh lookups) rather than read from the cull file, whose reason text is free-form. kondo's own report covers only the packages culled in that run, and re-runs skip the already-decided — this is how the accumulated verdicts of a whole triage get rendered. --json prints the report machine-readably.

Configure (Bugzilla API key)

poi-tracker config

Prompts for a Bugzilla API key, validates it with a quick test search, and saves it to ~/.config/poi-tracker/config.toml. Lookup order at runtime: --api-key flag → BUGZILLA_API_KEY env var → config file.

Generate an API key at https://bugzilla.redhat.com/userprefs.cgi?tab=apikey.

Classify keeps by their dependents

The pruning report for shrinking a keep set over time — over the graph a deps --graph run saved, classify every package of the given inventories by who needs it:

poi-tracker -C ../data dependents                      # with a workspace file
poi-tracker -C ../data --closure hyperscale-el9 dependents
poi-tracker -i essential.toml -i essential-rust.toml \
    dependents --graph fedora-build-deps-graph.json    # without one

Reach for it when you want the whole picture — every keep classified — rather than the changes reconcile reports; the [devel-only] carried entries are the ones reconcile offers to demote.

Leaves have no dependent in the closure: one shipping real binaries is presumably kept for its own sake, while a [devel-only] leaf (every binary ends in -devel — the library shape) is kept for nobody. Carried packages are needed by other packages of the same inventories — if such an entry is only tracked as a dependency, stop curating it: track the dependent and let the dependency walk carry it in the derived inventory. The edge says whom the graph could carry a package for, never why the entry exists, so carried lines wear the same [devel-only] marker: a devel-only carried crate is almost always dependency-only and safe to bulk-prune, while one shipping real binaries (a tool whose satellite plugin is the dependent, say) is likely kept on purpose — decide those per package. Externally needed ones are required only by closure packages outside the inventories. Packages the graph never saw are reported as unknown rather than guessed at (a stale graph — or a stale keep: a package renamed upstream shows up as a leaf shipping nothing). --json prints the report machine-readably.

Inventory dependencies from other repos

Walk the transitive dependency graph of the inventory's packages against a fedrq branch and repo stack, and collect what they pull from repos of interest — e.g. which EPEL 9 packages a Hyperscale inventory depends on:

$ poi-tracker -C ../data --closure hyperscale-el9 deps   # with a workspace file
$ poi-tracker -i hyperscale-packages.toml deps -b hs.el9 -r stack \
    -o hyperscale-epel9-deps.toml                       # without one
1 runtime dependency from [epel] for 2 package(s) on hs.el9:
  python-zstandard (epel) — systemd-ukify requires python3dist(zstandard)
wrote hyperscale-epel9-deps.toml

A capability here is RPM's own term for the strings in Provides: and Requires: — a package name, a versioned expression like python3dist(zstandard), a soname like libhwloc.so.15()(64bit), or a file path (rpm -q --provides lists a package's). The walk repeatedly resolves the capabilities the current packages require into the packages that provide them, the way dnf would.

Providers whose repo id matches --base-repo (a prefix, default fedrq-centos-stream-) satisfy a dependency and end the walk there — the base distro is a given. Every other provider is walked further, and collected when its repo id is in --from (exact, default epel). Those defaults describe an EPEL-on-CentOS-Stream stack; for a Fedora walk there is no base distro beneath the branch, so the default prefix never matches and the only setting needed is the branch's own repo id, e.g. -b rawhide --from rawhide. The output inventory records why each package was pulled in, in its reason field. The walk also seeds the roots' own BuildRequires (attributed src:<name>), so a kept application justifies its crate graph — those edges exist only at build time. Build dependencies beyond the roots are not walked: the closure keeps the roots working and the roots rebuildable, not the world. --runtime-only skips the BuildRequires seeding when the question is the deployment surface rather than what must stay for the roots to survive.

--fixpoint <inventory> iterates to closure in one run: collected packages found in that inventory (yours) become roots of their own, their BuildRequires seeding further rounds until a round adds nothing — because keeping a package means keeping it rebuildable, and its test-only BuildRequires appear in no -devel's runtime Requires. Rounds reuse the walk's seen-sets, so each costs only the genuinely new capabilities; the manual three-invocation equivalent measured ~90 minutes against rawhide where the in-process fixpoint is the first walk plus small change. Conflicts with --runtime-only.

Whenever -o writes an inventory, the walk's full dependency graph lands beside it as <output>-graph.json (--graph moves it) — every requirer of every capability and every provider, where the report keeps only first attributions. That is the input unkeep and dependents answer from in milliseconds instead of a fresh walk, and the periodic full walk is its refresh; a closure is never written without it.

The shared walk filters apply: --pattern (glob, CSV or repeated), --start-from and --end-with restrict which inventory packages are used as roots — handy for a quick look at one package's pull before walking a whole inventory.

Requires fedrq, with the branch configured — hs.el9 and hs.el10 come from this repository's configs/fedrq/. Rich (boolean) dependencies made only of conjunctions (with, and — the shape of rust-packaging's crate version ranges) resolve by their leaves; conditional ones (or, if, unless) are skipped with a warning. File dependencies resolve and classify normally, but appear under unmatched in --json output, since tying a path to its provider would need file lists.

Recompute a derived inventory offline (derive)

The derived dependency inventory is a view the graph can rebuild: owned packages the keeps reach, minus the keeps themselves —

poi-tracker -C ../data derive                          # with a workspace file
poi-tracker -i essential.toml -i essential-rust.toml \
    derive --graph fedora-build-deps-graph.json \
    --owned personal.toml -o essential-deps.toml       # without one

reconcile runs this for every closure; on its own it is the quick "is the derived inventory current?" check, report-only until --apply.

The report is the diff against the file's current content (+/- lines); --apply replaces its packages wholesale, reason chains taken from witness edges in the graph. Because the recompute is idempotent, keep-set edits never need a fresh walk to update the derived inventory — but the view is only as true as the last full walk, which remains the periodic calibration against distro drift. --json prints the report machine-readably.

Export to content-resolver YAML

# Export all workloads (one YAML per workload)
poi-tracker export content-resolver -i inventory.toml

# Export a single workload
poi-tracker export content-resolver -i inventory.toml \
    --workload hyperscale

# Custom output path (single workload only)
poi-tracker export content-resolver -i inventory.toml \
    --workload hyperscale -o custom.yaml

Export to hs-relmon manifest

# Merge multiple inventories into one manifest
poi-tracker export hs-relmon \
    -i inv-cloud.toml -i inv-hw.toml -o manifest.toml

# Filter by workload
poi-tracker export hs-relmon -i inventory.toml \
    --workload hyperscale -o manifest.toml

Existing manifest entries and their per-package overrides are preserved across re-exports. An archived_builds package gets archived = true written so hs-relmon can prune its stale CBS builds; an unshipped package (gone, no builds) is dropped from the manifest entirely — hs-relmon has nothing to track for it (the inventory keeps the tombstone). Both are reconciled every export, so a revived package returns to normal tracking.

Find a package

poi-tracker find systemd -i inv1.toml -i inv2.toml

Import from legacy JSON

poi-tracker import old-inventory.json -o inventory.toml \
    --private-fields poc,reason,team,task \
    --workload hyperscale

Intersect inventories

Keep only the main inventory's packages that also appear in the --with inventories, optionally merging them into another file. The motivating case: a deps closure lists everything a keep-set needs — thousands of packages, most of them other people's — and the durable fact is its intersection with the packages you maintain. Entries come from the main (-i) side, so deps' reason chains survive into the merge target, and the essentials file explains itself:

poi-tracker -i fedora-build-deps.toml intersect --with personal.toml \
    -o my-essential-deps.toml

-o accumulates like kondo's: existing entries win, new ones are added sorted.

Start keeping packages (keep)

The add half of incremental maintenance — the mirror of unkeep. Walk the new keeps' dependencies against the saved graph, so fedrq is only consulted for capabilities the graph has never seen:

poi-tracker -C ../data keep ripgrep                    # with a workspace file
poi-tracker -C ../data keep python-lazr-config         # a keep need not be yours
poi-tracker -i essential.toml -i essential-rust.toml \
    keep ripgrep --into essential.toml \
    --graph fedora-build-deps-graph.json \
    --owned personal.toml --deps essential-deps.toml \
    -b rawhide --from rawhide                          # without one

This is the one manual step reconcile cannot replace: reconcile walks keeps that are new to the graph, so a keep the graph has already seen — rust-totp-rs before and after its rawhide build grew a feature — is re-walked with keep, which records the new edges without re-filing anything.

Known capabilities resolve offline from the graph (their providers' Requires come from the recorded edges, so the walk cascades through the known world for free); only the frontier goes to fedrq, and the summary says how many went each way. The new edges merge into the graph file, the names are filed into --into (default: the first -i; a name already kept in any -i inventory is walked but not re-filed, since the keep set is their union), --deps recomputes the derived inventory in the same run, and -o brings the closure inventory (the deps -o file) up to date with the graph the same way — one command instead of an hour-long walk plus three edits. The new roots themselves always expand live, every feature subpackage included: a package the graph met as a mere dependency carries only the features something requested. Takes the same -b/-r/--from/ --base-repo/--runtime-only as deps. --json prints the report machine-readably.

Triage cull candidates (kondo)

The set difference between the inventory ("packages I maintain") and the union of essential inventories — work inventories, and the dependency inventories deps writes — is the list of packages nothing justifies. kondo walks that list with the shared review prompt, worded for what happens here: the finding is "nothing essential needs this package", so cull (c, or Enter) confirms it as a cull candidate, essential (e path.toml) files the package into the inventory that makes it so (the file is created if needed), and skip (s) leaves it undecided for this run. Filing is immediate, so an interrupted triage loses nothing already decided.

poi-tracker -C ../data kondo                           # with a workspace file
poi-tracker -i personal.toml kondo --user salimma \
    --essential inventory-hyperscale.toml \
    --essential hs-el9-deps.toml --essential hs-el10-deps.toml \
    -o cull.toml                                       # without one

Confirmed candidates are then classified by your own (direct) dist-git access, because the level routes the action — owner: orphanable directly; admin: you can remove your own ACL; commit, collaborator or ticket: you have to ask. The report groups them accordingly and prints ready-to-run sandogasa-pkg-acl command lines for the first two groups, in a form fit for a mailing-list announcement — which comes first: kondo itself never touches dist-git ACLs. Group-granted access is deliberately ignored (pair with sync-distgit --no-groups); a group grant is not yours to walk away from.

Access levels are looked up before the prompt (answers are cached for a day under ~/.cache/poi-tracker/, since ownership changes on human timescales — --refresh-acls forces a fresh look), so each candidate line carries its context — old-toy (commit) — nothing essential needs it reads differently from (owner).

e path.toml files the package in one line; a bare e asks for the inventory next. --explain-into PATH sets a default: Enter at that prompt files the package there, so a pass that sorts many packages into one inventory is two keystrokes each (e, Enter); an explicit path still wins.

The cull file records why each package is condemned. The stock reason is kondo cull candidate (<level>); --reason TEXT replaces the wording for packages culled this run (the sitting usually has one shared story — "retiring my GNOME extensions"), and c <note> at the prompt records this one package's own words instead. The access level is always appended, entries already in the file keep their reason, and only names are ever read back — so hand-editing a reason later is safe. The natural multi-pass flow is one pass per destination, passing the same file as --essential too — filed packages then never reappear as candidates. Candidates are computed once, up front, so filing into an essential inventory mid-run is safe; and when the --explain-into file does not exist yet, it is tolerated as an empty essential input rather than failing the load. Any other missing essential path is still an error — treating a typo as an empty inventory would make every package it names look cullable.

The shared walk filters (--pattern, --start-from, --end-with) restrict the candidates, which is how a large triage gets eaten in sittings: one themed pass at a time (--pattern 'rust-*' --explain-into keep-rust.toml), with --start-from as the resume point — candidates are sorted, and access lookups run a few at a time so even hundreds classify in under a minute. -o merges rather than overwrites, so every pass adds its slice of the verdict to one cull inventory; packages already present are left untouched — and consulted: a candidate a previous pass already culled is skipped, not re-asked, so re-running with the same -o only ever prompts for what is genuinely undecided. That makes the file itself the undo mechanism: delete an entry (a mistaken keep, say) and the next run asks about that package again. The reverse correction is automatic: a culled package that has since become essential — after a deps run justified it, say — is rescued from the file and reported, so the verdict never contradicts the inputs. skip decisions are deliberately not persisted — a skipped candidate returns on the next run until whatever the analysis missed is fixed in the essential inputs. Sessions running at the same time should still write distinct files — the merge is load-modify-save, so simultaneous finishes can drop each other's additions — and let a later pass fold them together.

Without a terminal, with --json, or with -y, no prompt fires and every candidate stays a candidate — which acts on nothing.

Mark packages no longer shipped anywhere

prune-retired finds inventory packages that are no longer carried on any active branch — the dist-git project is gone (404), it has no branch on an active release, or it carries a dead.package marker on every active branch it has. The active branch set is queried from Bodhi's active releases (plus rawhide) or overridden with --branch:

poi-tracker -i inventory.toml prune-retired --dry-run
poi-tracker -i inventory.toml prune-retired

By default matches are marked with an unshipped reason in the inventory rather than deleted: retired packages keep their ACLs, so a deleted entry would come straight back on the next sync-distgit run, and the marker is what lets the rest of the tooling do the right thing. triage-updates and semver-audit skip unshipped packages; triage-retired still processes them so their remaining bugs get closed; the sync commands' --prune preserves them. Markers are refreshed in both directions — a revived package gets its marker cleared. Pass --remove to delete the entries outright instead. Packages are checked concurrently (-j/--jobs, default 8 in-flight dist-git requests).

An entry with no rpms/ dist-git project (404) is reported as invalid rather than marked: the entry itself is wrong — a non-RPM repo (module, container image, tests) imported under its bare name by an older group sync (e.g. modules/askalono-cli showing up as askalono-cli), a binary subpackage name recorded instead of the source package, or a typo. The fix is editing or removing the entry, which is a human call. A stale unshipped marker on such an entry is cleared by the next run.

sync-distgit --mark-unshipped runs the same check on the packages a sync adds, so a fresh inventory starts with its unshipped markers in place instead of needing a follow-up prune-retired run. This catches retired packages, which keep their ACLs and so still appear in sync listings. A package whose dist-git project was deleted outright never appears in a listing at all — harmless for a fresh inventory (it simply isn't added), but if an existing inventory recorded it before the project vanished, only prune-retired notices the 404.

Reconcile the workspace (reconcile)

The maintenance loop as one command. reconcile reads the workspace file and, for every closure in it, brings the inventories up to date with the keeps:

$ poi-tracker reconcile
reconciling workspace kondo.toml
closure fedora:
  new keeps walked (2): ripgrep, fd-find
    41 capabilities answered from the graph, 3 resolved live
  entered the derived inventory (3): rust-buf-min, rust-tree_magic_db, rust-ufmt-write
closure hyperscale-el9 (external):
  skipped — hyperscale-el9-deps-graph.json does not exist yet: run `poi-tracker --closure hyperscale-el9 deps` once

12 owned package(s) no essential inventory justifies — triaging with kondo

Per closure: keeps the graph has never walked — not a root, or a root with no recorded requirements, as a keep filed with u at a prompt is — are walked (graph first, fedrq only for what it lacks) and merged into the graph; the closure inventory and the derived inventory are both recomputed from the graph — the closure one as what a walk of the keeps collects (the closure's --from repos, ending at its base-distro prefixes), less the keeps and, where a derived inventory holds them, the owned packages — so a package the graph reaches enters whichever file it belongs in whether or not this run's walk touched it, and a keep or owned package that strayed into the closure file leaves it. An entry the graph does not reach at all is kept and listed as such: the graph is what is incomplete there (a walk records no edge for a provider it could not attribute), and the next full deps walk settles it. Then the decisions a program cannot make are asked, one line each, written as answered so an interrupted run resumes where it stopped:

  • a package that newly rides in the derived inventory: Enter lets it ride, e makes it essential in its own right (filed into the closure's first keeps inventory, or --into PATH) and it is walked as a keep in the next round;
  • a keep only devel-only edges carry — the library-only shape your first prune demoted by hand: Enter keeps it essential, d demotes it to the derived inventory.

a takes the default for the rest, q stops asking. External closures are walked and recomputed but never asked about: their keeps are not yours to promote or demote. Once every closure has had its say, the owned packages no essential inventory justifies are looked at once more before the triage: one that a closure package you do not own needs — python-zope-testrunner, the test runner of the python-lazr-config that mailman3 pulls in — can be kept by making that package essential instead: e files it as a keep in the closure's first keeps inventory (or --into), e <inventory> in that one — the same letter and word as at the triage, where e makes the candidate itself essential. It is walked with its build dependencies, and the candidate becomes a derived entry instead of a cull. When several packages need the candidate the first is offered; poi-tracker keep <other> takes a different one. What remains goes through kondo's cull/essential/skip triage into the cull file, exactly as poi-tracker kondo would.

--yes takes every default without asking (and leaves the triage candidates as cull candidates, as kondo's -y does); --dry-run computes and reports without writing, walking or asking; --json prints the per-closure report and the triage candidates instead of triaging. The manual subcommands remain for surgical use — keep for one package, unkeep to see what a removal frees before doing it, dependents for the full leaves/carried picture — and deps is the periodic full walk that refreshes a graph when Fedora has moved.

Remove a package

poi-tracker remove systemd -i inventory.toml
poi-tracker remove systemd -i inventory.toml --rpm systemd-networkd

Audit pending updates by semver impact

semver-audit looks at each maintained package's pending upstream release notification (the open upstream-release-monitoring@ "X is available" bug) and classifies the version bump against the version currently packaged in rawhide dist-git, so you can see which updates are safe to push and which need care:

# All pending updates, grouped by impact
poi-tracker -C ../data semver-audit           # with a workspace file
poi-tracker -i inventory.toml semver-audit      # without one

# Just the safe ones for your Rust packages
poi-tracker -i inventory.toml semver-audit --pattern 'rust-*' --non-breaking

# Machine-readable
poi-tracker -i inventory.toml semver-audit --json

Bumps are classified with Cargo's compatibility rule (the Rust convention): a change at or before the version's leftmost non-zero component is breaking. So 1.4 → 1.5 is non-breaking, but 0.4 → 0.5 is breaking (pre-1.0 minor bumps can break), and 0.0.3 → 0.0.4 is breaking too. Versions that aren't plain dotted integers — pre-releases, dates, git snapshots — are reported as needs review rather than guessed at. A package whose packaged version already equals the "available" version is reported as up to date (stale bug) — but only after verifying (via koji) that a build with that version is actually in rawhide's tag chain. A version merely committed to dist-git whose build sits in a side tag or is still gating is reported as committed, awaiting release instead: the bug isn't stale, the update just hasn't shipped. A package that's retired on rawhide (a dead.package marker — the same signal triage-retired uses) is reported as retired (update request invalid), since there's no live package to update; run triage-retired to close those bugs.

--pattern <glob> (comma-separated or repeated, e.g. rust-*) limits the audit to matching packages, and --non-breaking shows only the safe updates. The audit makes a Bugzilla search and a dist-git spec fetch per matching package, so scope it with --pattern for a large inventory — or use --batch [EMAIL], which replaces the per-package searches with one Bugzilla query for all open release-monitoring bugs assigned to or CC'ing EMAIL (default: the email set via poi-tracker config), matched against the inventory locally. Batch mode misses bugs where that email is neither assignee nor CC'd, so it fits inventories of packages you (co-)maintain or watch.

Show inventory

poi-tracker show -i inventory.toml
poi-tracker show -i inventory.toml --workload hyperscale
poi-tracker show -i inventory.toml --json

Sync from dist-git

Create or update an inventory from packages a user or group has access to on Fedora dist-git (Pagure). Re-running merges new packages without overwriting existing entries or annotations.

# All packages for a user
poi-tracker sync-distgit --user salimma -o my.toml

# All packages for a group
poi-tracker sync-distgit --group kde-sig -o kde.toml

# Exclude packages with only group-based access
poi-tracker sync-distgit --user salimma --no-groups

# Only packages from specific groups
poi-tracker sync-distgit --user salimma \
    --include-group rust-sig,python-packagers-sig

# Exclude specific groups
poi-tracker sync-distgit --user salimma \
    --exclude-group rust-sig

# Add workload tags to all imported packages
poi-tracker sync-distgit --group kde-sig \
    --workload kde -o kde.toml

# Remove packages no longer in dist-git results
poi-tracker sync-distgit --user salimma --prune -o my.toml

# Resume an interrupted sync from f*, stop before m*
poi-tracker sync-distgit --user salimma \
    --start-pattern f --end-pattern m

User syncs query Pagure one name prefix at a time (a*–z*, 0*–9*) by default: an unfiltered per-user project query is too expensive for Pagure to answer within its gateway timeout, so it returns HTTP 504. Splitting the query by name prefix keeps each request small enough to succeed. (See sandogasa-distgit's development notes for the details — including why group syncs need no such workaround.) --pattern switches to a single patterned query instead, and --no-auto-prefix forces a single unfiltered query. --start-pattern / --end-pattern bound the prefix scan (e.g. to resume an interrupted sync: start at this prefix / stop before this prefix) and imply prefix mode, as does --auto-prefix — which is how a group sync opts into scanning. If both --auto-prefix and --no-auto-prefix are given, the last one wins.

Packages where the user has both direct and group-based access are always included, regardless of group filters.

Without --prune, packages in the inventory that are no longer in the dist-git results are listed as a warning but kept.

Transient network failures are retried with backoff (both 5xx responses and connection errors). If a fetch still fails, the progress so far is saved to <output>.partial along with the failed pattern in <output>.partial.state — re-running the same command resumes from the failed pattern, and a completed run replaces <output> and removes both files. Delete the .partial to start over instead.

For user syncs, --fast replaces the whole prefix scan with one request against Pagure's owner-alias dump. The trade-off: the dump only records direct owner/admin/commit maintainers, so collaborator- and ticket-level grants won't appear (and --prune --fast would remove them from an inventory the full scan had populated). It implies --no-groups; --pattern and --exclude still apply, client-side.

A fast layout that covers nearly everything: keep one --fast inventory for your own packages plus one group inventory per SIG you're in (each a single cheap query) —

poi-tracker sync-distgit --user salimma --fast -o mine.toml
poi-tracker sync-distgit --group rust-sig -o rust-sig.toml
poi-tracker sync-distgit --group go-sig -o go-sig.toml

Together those cover everything except user-level collaborator/ticket grants, which only the full prefix scan can see — run one occasionally to true up. The full trade-off analysis lives in sandogasa-distgit's development notes.

Sync from GitLab

Create or update an inventory from a CentOS SIG's GitLab RPM group. Presets cover the common groups:

poi-tracker sync-gitlab --preset hyperscale -o hyperscale.toml
poi-tracker sync-gitlab --preset proposed-updates -o pu.toml
poi-tracker sync-gitlab --url https://gitlab.com/CentOS/Hyperscale/rpms

--mark-unshipped cross-checks each project against CBS (CentOS koji) and records archival state. A project's GitLab repo being archived means upstream maintenance stopped; what happens next depends on whether CBS still carries the package:

  • archived, no released CBS build → marked unshipped (a tombstone, skipped by triage/audit like a retired Fedora package).
  • archived, but release builds remain → marked archived_builds: it still ships, so it is not skipped, but its lingering builds are a cleanup candidate — the command prints a reminder to run hs-relmon to prune them.

"Released" follows each SIG's lifecycle: Hyperscale ships for both RHEL N and CentOS Stream Ns, so a release build in either hyperscaleN-*-release or hyperscaleNs-*-release counts; Proposed Updates is Stream-only. --centos-release sets which major releases count (default 9,10). Requires koji with the cbs profile. Markers are refreshed in both directions on each run.

Close retired packages' update bugs

When a package gets retired on a dist-git branch (a dead.package file is committed), any open release-monitoring bug for that branch is dead weight — there's no live spec to update. triage-retired walks the inventory, checks dist-git for retirement, and closes those bugs as CLOSED/CANTFIX:

poi-tracker -i inventory.toml triage-retired --dry-run
poi-tracker -i inventory.toml triage-retired

The --branch flag controls which dist-git branch(es) are checked (default rawhide); each branch scopes its own Bugzilla search, so an epel9 retirement closes the Fedora EPEL/epel9 bug. Pass it more than once (or as a comma-separated list) to check several branches in one run — a package retired on some branches but live on others only has its bugs closed for the branches where it's actually dead:

poi-tracker -i inventory.toml triage-retired --branch epel10
poi-tracker -i inventory.toml triage-retired --branch epel8,epel9

Note that retirement (a dead.package marker) is distinct from "never existed": a bug filed against a branch the package was never built for is not a retirement and is left untouched — triage-retired only closes bugs on branches where a dead.package is present.

By default only release-monitoring bugs (filed by the Anitya / the-new-hotness bot) are closed — those are mechanical and safe to bulk-close. Pass --all-reporters to instead close every open bug on the retired branch, including human-filed ones (CVEs, FTBFS, etc.). Use it deliberately: across a full inventory run it closes a lot, and a CVE filed only against the retired branch (with no live-branch counterpart) would be closed as CANTFIX too:

poi-tracker -i inventory.toml triage-retired \
    --branch epel8,epel9 --all-reporters

Bugs that are already CLOSED are skipped. Each closure adds a short comment naming the package and the retired branch.

Interactive runs offer to claim ownership of each closed bug (set assigned_to to your configured Bugzilla email). Pass --claim to claim without prompting — under -y this is the only way to opt in. The email is set via poi-tracker config.

Pass --mark (needs a single -i file; conflicts with --dry-run) to record the run's findings in each package's retired_on field — in both directions, so a branch found live again is removed. semver-audit and triage-updates skip packages marked retired on rawhide, saving their per-package queries; re-running triage-retired --mark is how the markers are refreshed.

Useful flags for big inventories (shared by all inventory-walking commands — semver-audit, triage-retired, and triage-updates — and freely combinable):

  • --pattern <glob> — only process matching packages (comma-separated or repeated; a bare name matches exactly, e.g. --pattern python-django3 to check a single package).
  • --start-from <name> — resume from this package onwards in the inventory's iteration order, e.g. to continue an interrupted run.
  • --end-with <name> — stop after this package (inclusive). Combine with --start-from to scope to a name-range, e.g. --start-from rust-nu-cli --end-with rust-nu-utils to test the change against every rust-nu-* package in one shot.
  • --batch [EMAIL] — one Bugzilla query for everything assigned to or CC'ing EMAIL (default: the configured email) instead of one query per retired package per branch; with --all-reporters the batch query drops the reporter filter too.

Network reads (dist-git probes, Bugzilla searches) retry up to 3 times with exponential backoff, so a transient connection hiccup against src.fedoraproject.org doesn't abort the whole inventory.

Triage update bugs

Some packages reliably need attention when a new upstream version appears — python-django* updates almost always fix CVEs, for instance. Mark them in the inventory with a priority field (or a workload-level default_priority), then have poi-tracker triage the auto-filed release-monitoring bugs by raising their Bugzilla priority:

poi-tracker -i inventory.toml triage-updates --dry-run
poi-tracker -i inventory.toml triage-updates

For each inventoried package with a resolved priority, this queries OPEN bugs reported by upstream-release-monitoring@ fedoraproject.org (against Fedora and Fedora EPEL) and raises any whose priority is unspecified. Bugs already triaged by a human are left alone.

Per-package priority wins over default_priority; if a package is in multiple workloads, the highest workload default applies. Set priority = "unspecified" on a package to explicitly opt out of a workload default.

Independently of priorities, every open release-monitoring bug is also checked against Bodhi for builds that already carry the advertised version (or newer). When found, the latest addressing build per release is recorded in the bug's Fixed In Version field, and:

  • stable in every active release the package has a branch for → the bug is closed as ERRATA, with a comment listing the Bodhi updates;
  • any addressing update still in testing → the bug is moved to MODIFIED (a later run closes it once everything is stable);
  • addressed only in some releases (commonly just rawhide, since stable branches often intentionally stay behind) → you are asked before closing. --close-stale closes these without asking; under -y they are skipped unless --close-stale is given.

Bodhi records updates per release, so a release can carry a build Bodhi has no update for in that release — content inherited at branching, or answers missing while Bodhi is degraded. For those, the release's Koji tag chain is consulted (via koji, checking fXX-updates + fXX and the EPEL equivalents through tag inheritance), so years-old stale bugs close too — while a version merely committed to dist-git and built into a side tag or -candidate/-testing tag correctly stays pending. Without the koji CLI this check is skipped (with a warning) and such bugs are simply left open. Genuinely pending bugs are cheap: rawhide is checked first, and since a stable release may never carry a newer version than rawhide, a version absent from rawhide skips the stable-release queries entirely (EPEL bugs, whose branches update independently, are always checked in full). Pass --skip-stale to disable the whole check (also restoring the cheaper priority-only scan), and --pattern <glob> (e.g. rust-*) to scope the run. --batch [EMAIL] works as in semver-audit: one Bugzilla query for everything assigned to or CC'ing EMAIL (default: the configured email) instead of one query per package.

As in triage-retired, interactive runs offer to claim ownership of the bugs being closed (set assigned_to to your configured Bugzilla email). Pass --claim to claim without prompting — under -y this is the only way to opt in. Bugs moved to MODIFIED keep their assignee: they stay open and belong to whoever owns the in-flight update. The email is set via poi-tracker config.

Stop keeping packages (unkeep)

Remove packages from the keep inventories and learn, without re-walking anything, what else stops being needed — over the dependency graph a deps --graph run saved:

poi-tracker -C ../data unkeep neovim GraphicsMagick    # with a workspace file
poi-tracker -i essential.toml -i essential-rust.toml \
    unkeep neovim GraphicsMagick \
    --graph fedora-build-deps-graph.json \
    --deps essential-deps.toml                         # without one

Use it before dropping a keep to see what the removal frees; reconcile only notices a keep that is already gone.

The report names each freed package with its former requirers; adding --apply removes the unkept packages from the -i keep inventories and the freed ones from the --deps derived inventories, so the next kondo run offers them all as candidates. An unkept keep the remaining closure still reaches is not freed — it is moved: provably still needed, it is filed into the first --deps inventory immediately, with a reason chain taken from the graph, so pruning redundant curated entries needs no follow-up walk and leaves no window where they are essential in no file. Unkeeping a package that was never a keep and is still reached only warns — culling it would be rescued right back.

Reachability follows every provider the walk recorded, so nothing is freed while any alternative chain still holds, and a derived package's src: build-dependency edges lapse with it, so test-only build dependencies cascade out the way the fixpoint pulled them in. The graph is a snapshot: the periodic full deps --graph walk is its refresh. --json prints the report machine-readably.

Validate

poi-tracker validate -i inventory.toml

Inventory format

[inventory]
name = "hyperscale-packages"
description = "CentOS Hyperscale SIG packages"
maintainer = "centos-hyperscale"
labels = ["eln-extras"]
private_fields = ["poc", "reason", "team", "task"]

[inventory.workloads.hyperscale]
name = "hs-packages"
description = "Hyperscale SIG workload"
labels = ["eln-extras"]

[inventory.workloads.epel]
name = "hs-epel-packages"
description = "Hyperscale EPEL workload"

[[package]]
name = "systemd"
poc = "Linux Userspace <team@example.com>"
reason = "Core init system"
rpms = ["systemd-networkd"]
workloads = ["hyperscale"]
track = "upstream"

[package.arch_rpms]
x86_64 = ["systemd-boot-unsigned"]
aarch64 = ["systemd-boot-unsigned"]

[[package]]
name = "fish"
rpms = ["fish"]
workloads = ["hyperscale", "epel"]
track = "upstream"

Fields

Field Level Description
name inventory/package Name (required)
description inventory Human-readable description
maintainer inventory Maintainer (person or team)
labels inventory Default labels for content-resolver
workloads inventory Workload definitions (map)
workloads package Workload membership (list)
private_fields inventory Fields stripped on export
poc package Point of contact
reason package Reason for tracking
team package Team responsible
task package Internal task/ticket
rpms package Binary RPMs to track
arch_rpms package Architecture-specific RPMs
track package hs-relmon tracking branch
repology_name package Repology name override
distros package hs-relmon distribution list
file_issue package File GitLab issues
priority package Bugzilla priority for triage-updates (unspecified/low/medium/high/urgent)
retired_on package Dist-git branches where the package is retired; written by triage-retired --mark
unshipped package Reason the package is no longer shipped on any active branch; written by prune-retired. Skipped by most operations, still processed by triage-retired, preserved by sync --prune
archived_builds package Reason an archived-upstream package still has CBS builds; written by sync-gitlab --mark-unshipped. Still ships (not skipped); a build-cleanup candidate for hs-relmon
default_priority workload Default Bugzilla priority for packages in this workload

Each [inventory.workloads.<key>] section can override name, description, maintainer, labels, and default_priority for content-resolver export and triage-updates. Omitted fields fall back to inventory-level values.

System-wide configuration

Settings are read from /etc/poi-tracker/config.toml first, then overridden per key by ~/.config/poi-tracker/config.toml, with command-line flags overriding both. A system file alone is enough — no per-user file is required — and either may also carry a [defaults] table pinning flag defaults (see the root DEVELOPMENT.md).

poi-tracker config writes the user file only, with 700 on the directory and 600 on the file. Nothing writes under /etc: a system file is admin-authored, holds shared non-secret settings, and is normally shipped root:root 0644.

Credentials belong in the per-user file, which is 600, or in an environment variable — a token under /etc is readable by every local user. For an unattended machine, give the job its own user and its own 600 config.

License

Licensed under either of

at your option.