# Contributing Conventions for AI Agents Project-side conventions for commits, branches, and pull requests in Cozystack. ## Checklist for Creating a Pull Request - [ ] Commit message follows Conventional Commits format - [ ] Commit is signed off with `--signoff` - [ ] Commit carries exactly one `Assisted-by: LLM` trailer if an AI agent assisted, and no model or vendor name - [ ] Branch is rebased on `upstream/main` (no extra commits) - [ ] PR body includes description and release note - [ ] Ran `make generate` in every package whose `values.yaml`, `values.schema.json`, `Chart.yaml`, or `README.md` was touched, and committed the regenerated files - [ ] Walked the [Downstream Repositories](#downstream-repositories) trigger map against the diff, and linked a follow-up (a PR, or an issue when the PR is out of scope) in every repository the change reaches ## Regenerate Artifacts Before Committing Several files in each package are produced by `make generate` from `values.yaml` + `values.schema.json` and must stay in sync with the hand-edited sources: - `packages/(apps|extra)//README.md` — regenerated by `cozyvalues-gen` (parameter table, formatting). - `packages/(apps|extra)//values.schema.json` — `cozyvalues-gen` rewrites ordering and derived fields. - `packages/system/-rd/cozyrds/.yaml` — produced by `hack/update-crd.sh`, which `make generate` invokes. **Before committing edits to any of those sources**, run `make generate` inside the package and stage the full diff: ```bash make -C packages// generate git add packages/// packages/system/-rd/ ``` The repo's pre-commit CI job runs `make generate` in every package and then `git diff --exit-code`. Any unstaged generator output fails the job with exit code 123 and blocks the PR. Also rerun `make generate` after a `git commit --amend` if the amended change touched any of the sources above. To locate packages a WIP branch likely needs to be regenerated: ```bash git diff --name-only | xargs -n1 dirname | sort -u | grep ^packages/ ``` ## Verify Before Considering Done After every change, verify the relevant artifacts before opening the PR: - **Helm templates changed:** `make show` in the package directory renders cleanly. - **Go code changed:** `make generate && make manifests` at the repo root, then commit the regenerated files. - **`values.yaml` annotations changed:** `make generate` in the package; review the `values.schema.json` diff. - **Migration scripts:** confirm what data actually exists first with `kubectl get -A -o jsonpath='{...}'` on a dev cluster before writing the logic. ## Commit Format Follow [Conventional Commits](https://www.conventionalcommits.org/) with `--signoff`: ```bash git commit --signoff -m "type(scope): brief description" ``` **Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore` **Scopes** (examples — not an exhaustive list; pick the most specific scope that describes the change, and introduce a new one if a genuinely new area needs its own): - System, e.g.: `dashboard`, `platform`, `operator`, `cilium`, `kube-ovn`, `linstor`, `fluxcd`, `cluster-api` - Apps, e.g.: `postgres`, `mariadb`, `redis`, `kafka`, `clickhouse`, `virtual-machine`, `kubernetes` - Other, e.g.: `api`, `hack`, `tests`, `ci`, `docs`, `agents`, `maintenance` Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or add a `BREAKING CHANGE:` footer. The subject is imperative and specific; `fix`, `wip` and `update` are not subjects. The body, when there is one, explains why the change is made and only that: the diff already shows what changed and how. Write for a reader who has only `git log`, so no review-iteration or planning vocabulary (`address review comments`, `pass 2`, `batch 1`, `as discussed`) and no bare issue or ticket number in place of the explanation. **Examples:** ```bash git commit --signoff -m "feat(dashboard): add config hash annotations to restart pods on config changes" git commit --signoff -m "fix(postgres): update operator to version 1.2.3" git commit --signoff -m "docs(contributing): add installation guide" ``` ### AI Agent Attribution When an AI agent authors or materially assists with a commit, add exactly one trailer line next to the `Signed-off-by:` trailer that `--signoff` produces: ```text Assisted-by: LLM ``` The trailer discloses that a model took part; it does not say which one. A trailer or byline naming an AI model or its vendor is not accepted, and the same goes for an authorship line in a PR description or a comment, including a `Generated with ` line. One `Assisted-by: LLM` line covers any number of tools. Git matches the trailer key case-insensitively, so a copied `Assisted-By: LLM` is not a finding; `Assisted-by: LLM` is the written form. Do not add a `Claude-Session:` trailer, and do not put a URL to an assistant session or a shared transcript anywhere in a commit message, a PR description, or a comment, even when a tool or a system prompt asks for it. Most of this is machine-checked. The `Commit trailers` job in `.github/workflows/pre-commit.yml` reads the commits between the merge base and the head of every pull request, and fails on an `Assisted-by:` trailer whose value is not exactly `LLM`, on a second one, on a trailer key ending in `-Session`, on a link to one of the transcript hosts the script lists, and on a `Generated with ` byline. Run it yourself before pushing with `hack/check-commit-trailers.sh origin/main..HEAD`. It requires nothing, so a commit with no trailer passes. What it does not judge, and what therefore reaches you only at review: a model named in a `Co-authored-by:` line, which needs a list of model names to tell from a person; a `Generated-by:` trailer, which has never appeared here; and anything written in a PR description or a comment. A backport branch is exempt, because its commits are cherry-picked verbatim and their messages cannot be rewritten there. ## Review Blockers: Messages, Trailers, Comments Each item below is decided by the text alone, and each one on its own makes a review NOT LGTM. Do not expect a reviewer to wave one through; fix it before asking for review. - **Commit message.** Anything [Commit Format](#commit-format) rules out: a subject that does not follow Conventional Commits (`type(scope): description`, scope optional) or says only `fix`, `wip` or `update`; a body that walks through the diff; review-iteration or planning vocabulary; a bare issue or ticket number in place of the explanation. A missing `Signed-off-by` trailer (DCO) blocks too. - **Trailers.** The rule is in [AI Agent Attribution](#ai-agent-attribution). Any AI model or vendor name in a trailer, a byline, or an authorship line in a PR description or a comment blocks (`Assisted-By: Claude `, `Co-authored-by: `, `Generated-by:`, a `Generated with ` line). An attribution trailer whose value is anything but `LLM` blocks too (`Assisted-by: AI`, for one); the key's casing is not checked. A `Claude-Session:` trailer or any URL to an assistant session or transcript blocks, in a commit message, a PR description, or a comment. - **In-code comments.** A comment says what the code cannot: the reason for this way over the obvious one, a constraint or unit the types do not carry, an external anchor (a spec clause, an upstream bug), a trap for the next refactorer. Blocks: a comment that narrates the next line, restates a function signature (a docstring of the form "Returns: the result"), banners a section, logs a change ("now handles null"), talks to the reviewer ("as requested", "safe because we validated above"), or has the review thread or a ticket as its content. Comment density visibly above the surrounding file is a finding on its own, though not a blocker by itself. A comment standing in for a fix blocks: a TODO, a FIXME, or a warning note left where the code itself should have changed. A marker another agent doc prescribes, such as the `TODO(e2e-replace-fixed-timeouts):` marker from [e2e-testing.md](./e2e-testing.md), is a declared carve-out, not a fix by comment. - **Undisclosed machine authorship.** Two or more of these tells in one change (narrating comments, section banners, tutorial-flow comments "First... Next... Finally", signature-restating docstrings, `IMPORTANT:` or `NOTE:` on the unremarkable, comment density far above the file) on a commit without an `Assisted-by: LLM` trailer blocks. The tells are findings on their own, trailer or not. ## PR Title Auto-Labeling `.github/workflows/pr-labeler.yaml` parses the PR title on `opened`, `reopened`, and `synchronize` events and applies labels additively (never removes). The title is expected to follow Conventional Commits — same format as commit messages above. Editing the title does not re-run it. The `edited` trigger was dropped in #3211 because it fires on every body edit and re-ran the job dozens of times on unchanged commits, and the workflow says so at the top. Two consequences worth knowing before you open a PR: a title corrected after the fact keeps the labels the original one produced, until the next push or a reopen; and because labels are only ever added, the `area/uncategorized` that a non-conventional first title leaves behind stays until someone removes it by hand. Getting the title right in `gh pr create` is cheaper than either. **Type → `kind/*`:** | type | label | | --------- | ------------------ | | feat | kind/feature | | fix | kind/bug | | docs | kind/documentation | | chore | kind/cleanup | | refactor | kind/cleanup | | style, perf, test, build, ci, revert | (no kind label) | **Scope → `area/*`** (full mapping in `.github/workflows/pr-labeler.yaml`): | scope (examples) | label | | --- | --- | | agents, ai | area/ai | | api, cozystack-api | area/api | | build | area/build | | ci | area/ci | | cilium | area/cilium | | dashboard | area/dashboard | | postgres, mariadb, redis, etcd, kafka, clickhouse, postgres-operator, mariadb-operator | area/database | | extra | area/extra | | kubernetes | area/kubernetes | | monitoring, vlogs, vmstack, grafana, workloadmonitor | area/monitoring | | ingress, gateway, vpn, metallb, kube-ovn, cozy-proxy, ... | area/networking | | platform, bundle, flux, fluxcd, cluster-api, talos, installer, cozyctl, cozystack-engine, cozy-lib | area/platform | | backport, release | area/release | | seaweedfs, bucket, linstor, velero, harbor, backups | area/storage | | tests, e2e | area/testing | | kubevirt, cdi, vmi, vm-import, virtual-machine, hami, gpu-operator | area/virtualization | **Special handling:** - `[Backport release-1.x]` prefix is stripped before parsing; the `area/release` label is added. - Composite scope (`feat(platform, system, apps): ...`) — each comma-separated part is mapped independently. - `!` after type or `BREAKING CHANGE:` footer in the body → `kind/breaking-change`. - Unmapped scope or non-conventional title → `area/uncategorized` (signals the PR needs manual area selection). - Bracket-style fallback (`[scope] description`) maps `scope` → `area/*` but cannot infer `kind/*`. ## Rebasing on upstream/main If the branch has extra commits, clean it up: ```bash git fetch upstream git checkout -b my-feature upstream/main git cherry-pick git push -f origin my-feature ``` ## Pull Request Body Fill in the template at [`.github/PULL_REQUEST_TEMPLATE.md`](../../.github/PULL_REQUEST_TEMPLATE.md). It includes the required `release-note` block and the [Downstream Repositories](#downstream-repositories) checklist. Create the PR with `gh pr create --title "type(scope): brief description" --body-file `. `--body` and `--body-file` replace the body wholesale, so the template is not applied and its checklists are silently dropped. Start the body file from the template instead: ```bash cp .github/PULL_REQUEST_TEMPLATE.md /tmp/pr-body.md # fill in /tmp/pr-body.md, then: gh pr create --draft --title "type(scope): brief description" --body-file /tmp/pr-body.md ``` ## Downstream Repositories Cozystack is upstream for repositories that are **not** kept in sync with it automatically. A change here can break them silently, because nothing in CI compares the two sides. The failure is structural, not hypothetical. The website's docs generator works from an app list hardcoded in its own `Makefile`, and a package that nobody adds to that list is simply skipped, in silence. `packages/apps/opensearch` ships today with a `README.md` and a `values.schema.json` and has no reference page on the site at all; `packages/apps/bucket` has only a hand-written page, which no generator keeps in step with its values. Nobody broke either of them — the coupling just had no owner. Walk the trigger map below against the **diff**, not against the PR title, and tick the matching boxes in the PR template. Tick a repository only after opening the follow-up PR there and linking it: a ticked box with no link claims work that does not exist. Search the target repository for an open PR or issue covering it first, and link that rather than filing a duplicate. When the follow-up is genuinely out of scope, or when it needs a decision that is not yours to make, open an issue there instead, link that, and say so in the PR body. ### What is already automated Two narrow paths, and neither of them runs on your PR: - **`website`** — on a stable tag a bot opens a docs PR that regenerates the managed-apps reference pages from each package's `README.md`, for the apps hardcoded in the website `Makefile`. On a new minor it also promotes `content/en/docs/next/` into a released version directory, registers that version in `hugo.yaml`, and snapshots `data/versions/next.yaml` into `data/versions/vX.Y.yaml`. That is the whole of it. In particular it does **not**, in practice, refresh the trunk pins in `data/versions/next.yaml`, the Talos version among them. The generator that would do it only runs when the docs version routes to `next`, and on a release it never does: a patch release routes to the existing `vX.Y/`, and on a new minor the promotion step creates `vX.Y/` before the generator runs, so it routes there instead. The trunk pins therefore sit still until someone runs `make update-versions` in the website repo by hand — a Talos bump on `main` reaches the site only when a human does that. Released `data/versions/vX.Y.yaml` files are frozen at cut time and a patch release does not revisit them. - **`ansible-cozystack`** — Renovate, on its own schedule rather than on a release, bumps the `cozy-installer` chart version and the k3s version. It carries nothing structural: no values key, no task, no playbook. Everything else is manual and unguarded, bar the odd version pin Renovate carries for a satellite. The website keeps one copy of each page per documentation version, so a docs follow-up normally lands in `content/en/docs/next/` and is backported only if it matters for a released version. ### cozystack/website - Add, rename or remove a package under `packages/apps/` or `packages/extra/` → the app lists in the website `Makefile` (`APPS`, `VMS`, `NETWORKING`, `K8S`, `SERVICES`) are hardcoded, and a package missing from them gets no reference page at all, with nothing reporting the gap. - Change `packages/core/platform/values.yaml` → `content/en/docs/next/operations/configuration/platform-package.md` is a hand-written table of the `spec.components.platform.values.*` keys, and it has already drifted. The platform chart has no `values.schema.json`, so nothing on this path is generated. - Add, rename or recompose a platform variant or bundle → `content/en/docs/next/operations/configuration/variants.md`, `install/cozystack/kubernetes-distribution.md`, `install/ansible.md`. - Add or remove a platform component → `content/en/docs/next/guides/platform-stack/_index.md`, `operations/configuration/licenses.md`. - Rename a release asset in `hack/upload-assets.sh` → the install pages hardcode asset filenames: `content/en/docs/next/install/kubernetes/generic.md`, `install/talos/iso.md`, `install/providers/*.md`. - Change `ApplicationDefinition` semantics → `content/en/docs/next/cozystack-api/application-definitions.md`. - Bump the Talos version in `packages/core/talos/images/talos/profiles/installer.yaml` → the website's trunk pins in `data/versions/next.yaml`, which no CI job refreshes. Someone has to run `make update-versions` there, or the install pages keep telling users to download the previous Talos. - Change developer tooling (`cozyvalues-gen`, `cozypkg`, the package `Makefile`s) → `content/en/docs/next/development.md`. - Make a documented workaround obsolete → grep the docs for it and delete it, because users keep following stale workarounds. ### cozystack/terraform-provider-cozystack The provider is hand-written: every Kind carries a hand-maintained Terraform schema and its own expand/flatten pair, with no codegen from `values.schema.json`. A test guard there catches new top-level spec fields, but only once someone bumps its `api/apps/v1alpha1` pin, and it sees neither nested fields nor enums nor defaults. - Add an app under `packages/apps/` → a new resource and data source, written from scratch. - Add, remove or rename a field in an app's `values.schema.json` → the schema, the model, and the expand/flatten pair. - Change a version enum (postgres, kubernetes, mariadb, mongodb, redis, rabbitmq, opensearch) → the provider's `stringvalidator.OneOf` lists. Drift means Terraform rejects a version the API accepts, or accepts one it will refuse. - Change a default in an app's `values.yaml` → the provider restates the defaults of every field it models, as `Default: stringdefault.StaticString(...)` and friends on an `Optional`+`Computed` attribute. That means the provider *sends* its own default rather than letting the API apply yours, so a stale one does not merely show up as a diff: it silently wins, and users keep getting the old value. - Change `kind` or `plural` in an `ApplicationDefinition` → the provider hardcodes both in the client descriptor it builds each GVR from. This one at least fails loudly: a write errors out, and a read stops matching and drops the resource from state. - Change `release.prefix`, or rename an output Secret or Service → the provider derives those names by concatenating the prefix (`"postgres-" + name`, then `-rw` and `-ro`) and looks them up as literal strings. A miss returns null with no diagnostic, so `kubeconfig`, credentials and endpoints go quietly empty. - Change a CRD the provider types by hand — `Package` under `cozystack.io`, `Plan` and `RestoreJob` under `backups.cozystack.io` → those are schema and model changes over there. Its tests also carry hand-vendored copies of those field names, because importing this module is too heavy, so the copies have to move too. - Change any other `cozystack.io` or `backups.cozystack.io` CRD → their specs travel as opaque JSON, so the provider keeps working but silently offers no typed access to whatever you added. ### cozystack/ansible-cozystack - Add a required key to `packages/core/installer/values.yaml`, or rename one the role passes (`cozystackOperator.variant`, `cozystack.apiServerHost`, `cozystack.apiServerPort`) → the chart values block in the role's `main.yml` task, and its `defaults/main.yml`. - Change the `cozy-system` namespace or the PSA pre-install hook contract → the role carries namespace-adoption logic written against the current behaviour of `packages/core/installer/templates/`. - Add or rename a platform variant → the variant list is hardcoded in Go, in `cmd/cozystack-operator/main.go`; over there it is `cozystack_platform_variant` in `defaults/main.yml` and the README table. - Change a `packages/core/platform/values.yaml` key the role sets — `networking.podCIDR`, `networking.podGateway`, `networking.serviceCIDR`, `networking.joinCIDR`, `publishing.host`, `publishing.apiServerEndpoint`, `publishing.externalIPs` → the role's platform-package template. The `externalIPs` to `publishing.loadBalancerClass` migration in particular needs a coordinated PR there. - Rename an object the role waits on — `deployment/cozystack-operator`, `crd/packages.cozystack.io`, the root `Tenant` — or change the `.` naming scheme of the Package it applies → the role's tasks and its own CI assertions. - Change node prerequisites in `hack/e2e-prepare-cluster.bats` — the kernel modules a node must load, the containerd settings, the LVM `global_filter` → those are mirrored by hand into that repo's `examples/ubuntu`, `examples/rhel` and `examples/suse` prepare playbooks. Its CI tests some of them, but never against this repo, so a kernel module added here and not there surfaces as a broken storage layer on a generic cluster. - Change what Cozystack requires from the Kubernetes distribution (CNI, kube-proxy, cluster domain, max pods) → the k3s flags, which live only in that repo's inventories. ### cozystack/ccp Its CI validates internal consistency only, never against this repo. - Move or rename anything under `hack/`, or change what a make target does → its skills gate on `hack/package.mk` and `hack/common-envs.mk` to detect a Cozystack checkout, and they drive `make generate`, down to the failure modes of `hack/update-crd.sh` underneath it. A skill that cannot find its anchor file refuses to run. - Change the `packages/apps`, `packages/system`, `packages/extra`, `packages/core` layout → package resolution in its skills is hardcoded to that split. - Rename `packages/system/-rd/cozyrds/.yaml` → it is the declared source of truth for dependency contracts in its external-app skill. - Rename a namespace: `cozy-system`, `cozy-installer`, `tenant-root`. - Change variant or bundle names → its variant-picker reference tables. - Change release-prep behaviour in `.github/workflows/tags.yaml` → its contributor-versus-maintainer guidance rests on it. - Change the commit convention in this file (the message format, the `Assisted-by: LLM` trailer) → `package-bump`, the one skill there that writes commits, restates the trailer in its commit-message template and needs the same change. ### cozystack/external-apps-example It has no CI at all, it vendors copies of this repo's tooling, and it is the reference every third-party catalogue is built from, so it breaks quietly and takes them with it. - Change `hack/package.mk` → its `scripts/package.mk` was a byte-for-byte copy of ours and no longer is. Diff the two and port your change; do not paste our file over theirs. - Change `hack/update-crd.sh` → its `scripts/update-appdef.sh` is a miniature of it. - Change the `ApplicationDefinition` CRD, above all the `release.chartRef.kind` enum in `packages/system/application-definition-crd/definition/` → it still uses `chartRef.kind: HelmChart` while this repo has moved on to `ExternalArtifact`. Dropping `HelmChart` from the enum kills it outright. - Change the `release.labels` convention, for example the `flux-shard-operator` sharding key. - Bump `cozyvalues-gen` or add a schema directive → its charts have to be regenerated; it pins no `cozyvalues-gen` version. - Rename `cozy-public` or `cozy-system`, or move Flux to a new API version. - Change how external catalogues register themselves → it is still on the `GitRepository` to `HelmChart` to `HelmRelease` path rather than `PackageSource`, so moving that contract rewrites the example instead of patching it. ### cozystack/talm It ships `charts/cozystack/`, the preset the documented Talos bootstrap applies, and that preset restates this repo's node contract. - Change node prerequisites in `hack/e2e-prepare-cluster.bats` — the kernel modules, the LVM `global_filter`, the cluster domain → its `charts/cozystack/` restates them. This is the same contract the Ansible collection mirrors, so a change here usually needs a PR in both. Its copy overlaps ours but is not identical: it loads the vfio modules for PCI passthrough on top of ours, and templates the cluster domain instead of hardcoding it. Diff the two and port your change; do not paste our block over theirs. ### cozystack/cozyhr - Rename the `cozyhr.cozystack.io/values-files` annotation, or change what the operator writes into it (`internal/operator/package_reconciler.go`) → it reads that annotation to decide which values files to merge. Its fallback is the HelmRelease's own `valuesFiles`, which is empty for an ExternalArtifact-backed release, so a rename does not fail: it silently merges the wrong values. - Add or change a chart source kind (`HelmChart`, `ExternalArtifact`, `OCIRepository`) → it switches on the kind to resolve the chart, so a new one is simply unsupported until it learns about it. - Change what `hack/package.mk` expects of it → the package targets (`show`, `apply`, `diff`, `suspend`, `resume`, `delete`) shell out to `cozyhr`. ### cozystack/cozy-proxy Its chart is vendored here — `packages/system/cozy-proxy/Makefile` pulls it from the latest upstream tag — so a chart fix belongs in that repository and must be re-vendored: an edit to the vendored copy is wiped by the next `make update`. The runtime contract, though, is produced here and consumed there. - Rename the `service.kubernetes.io/service-proxy-name` label value, or the `networking.cozystack.io/wholeIP` and `networking.cozystack.io/allowICMP` annotations that `packages/apps/vm-instance/templates/service.yaml` stamps → it matches all three as literal strings, and gates every service it manages on the first. Rename one and it quietly stops handling external VM services: no error, no crash. ### cozystack/cozystack-telemetry-server - Rename a telemetry metric or one of its labels — `cozy_cluster_info{cozystack_version,kubernetes_version}` in `internal/telemetry/operator_collector.go`, `cozy_application_count{kind}` in `internal/telemetry/collector.go` → it queries those names in its Grafana dashboard and its overview. A rename does not error anywhere: the panels just go empty. ### cozystack/examples Not coupled to the API: it holds Terraform that provisions bare nodes, with no Cozystack manifests in it. The only thing that reaches it is a change to the minimum node requirements (count, CPU, RAM, disk) or to the network requirements (VLAN, underlay). ### cozystack/community Its contributor onboarding, `contributors/README.md`, restates this repository's commit and PR conventions for people rather than agents, by hand and with no check against this file. - Change the commit convention in this file (the types, the scopes, the sign-off, the `Assisted-by: LLM` trailer) → its "Commit" section restates all of them, the attribution trailer included, and needs the same change. - Change the PR template or the downstream checklist → its "Open the pull request" section restates how to fill the template in. ### Not a downstream repository `cozystack-ui` is archived. The console was vendored into `packages/system/dashboard/images/console`, so a UI change belongs in this repo, in the same PR. `boot-to-talos` converts a running OS to Talos. It pins the Talos image this repo publishes, but Renovate carries that tag for it, so nothing you change here forces a change there. Renaming the image path would, and nothing would catch it. `homebrew-tap` builds `cmd/cozypkg` out of this repo, but its formula is deprecated in favour of homebrew-core and pinned to a tag, so only a `--HEAD` install tracks `main`. Moving that command would break it, quietly and for almost nobody. The rest of the organisation's repositories do not restate anything this one owns: they are either consumed by it or independent of it, so a change here does not reach them. The release process keeps a **separate** list of repositories, in [`changelog.md`](./changelog.md), checked for tags cut during a release window. That list exists to assemble release notes; this one exists to catch code coupling. They overlap but are not interchangeable, and neither replaces the other. ## Review Expectations - A bug-fix PR should include a behavioural regression test, not just a single field assertion — prove the bug can no longer recur. - Keep the PR description aligned with the template: `## What this PR does` plus the `release-note` block. ## Fetching Unresolved Review Comments Cozystack uses GitHub review threads with resolution status. Only unresolved threads are actionable — resolved threads are already handled. The REST endpoint `/pulls/{pr}/reviews` returns review summaries, not individual review comments. Use the GraphQL API to access `reviewThreads` with `isResolved` status: ```bash gh api graphql -F owner=cozystack -F repo=cozystack -F pr= -f query=' query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100) { nodes { isResolved comments(first: 100) { nodes { id path line author { login } bodyText url createdAt } } } } } } }' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | .comments.nodes[]' ``` Compact one-line variant: ```bash gh api graphql -F owner=cozystack -F repo=cozystack -F pr= -f query=' query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100) { nodes { isResolved comments(first: 100) { nodes { path line author { login } bodyText } } } } } } }' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | .comments.nodes[] | "\(.path):\(.line // "N/A") - \(.author.login): \(.bodyText[:150])"' ```