diff --git a/AGENTS.md b/AGENTS.md index 9e70c3c..f186288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,9 @@ issue title. ## Architecture `_use_case.py` drives one run; `_gitlab.py` and `_jira.py` are the only modules that speak HTTP; `_rows.py` is pure and -turns the range plus merge requests into rows; `_render.py` is pure and turns a report into the Markdown page. Tests +turns the range plus merge requests into rows; `_candidates.py` is pure and turns a service's rows into the tags a +release could ship. `_static/` is the site `collect` copies next to `report.json`: `index.html` with vendored Alpine.js, +which has no tests, so check it in a browser against a report from the CLI tests. Tests mock GitLab and Jira only with respx routes (pytest-httpx2): the `gitlab` and `jira` fixtures in `tests/conftest.py` declare one named static route per call of the scenario in `tests/payloads.py`. A test changes a response by re-mocking a named route; add no fakes, callbacks, or stubs. diff --git a/CONTEXT.md b/CONTEXT.md index 2c35160..4571eab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,11 +39,17 @@ the row belongs to. It need not be in the report. The Jira issues a `--jira` run was given. Its services are the projects those issues link to. **Target**: -In an issue scope, the newest row on the default branch whose merge request or commit an issue links to. Rows run -from the production baseline to the target. +In an issue scope, the newest row on the default branch whose merge request or commit an issue links to. Rows from +the production baseline to the target are in scope; rows above it are kept, out of scope, so their tags can still be +picked. **Release tag**: The nearest tag at or above the target: the earliest tag that ships it. Absent when a new tag is needed. +**Candidate**: +A tag in the range that a release could ship. It carries what shipping it means: the rows from its own down to the +production baseline, the Jira keys of those rows that are in scope, its tag pipeline, and the compare link from +production. + **Settled fact**: Data GitLab will not change for the same key, and so the only data the cache may hold. diff --git a/README.md b/README.md index db9ca16..b815478 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Jira issues, failed jobs. For every service it reads the latest successful production deployment, walks the default branch down to that -commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that +commit, and writes a static site with one JSON report: a row per merge request or direct commit, newest first, with the tags that point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search, and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the @@ -32,13 +32,14 @@ issue's Web links whenever a commit or MR mentions it; a link counts only if it ```sh export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com -export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope; api for publish +export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]' export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod -uvx release-scope collect --group team/backend --output report.json --cache cache.json +uvx release-scope collect --group team/backend --output public --cache cache.json ``` +`--output` is a directory: `collect` writes `report.json` there, next to the page that shows it (see Site). `--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to collect; the report is still written and names the error on that service. A service GitLab denies access to fails alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments @@ -51,11 +52,13 @@ and also exits `1`; the GitLab part is still written. `--jira` scopes the report to Jira issues instead of groups or projects, and needs the Jira settings: ```sh -uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output report.json --cache cache.json +uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output public --cache cache.json ``` -It reads the issues and their GitLab links, then collects every project they link to. In each project the rows run -from the production baseline up to the latest linked change, so they show everything that ships with the issues. The service +It reads the issues and their GitLab links, then collects every project they link to. In each project the rows from +the production baseline up to the latest linked change are in scope: they show everything that ships with the issues. +Rows above that change are kept with `in_scope: false`, so their tags can still be picked, but their Jira keys are +neither looked up nor counted as tasks of a release. The service records the release state: `pending` with the nearest tag at or above that change (or none, when a new tag is needed), `in_production` when every linked merge request is already deployed, `not_merged` when only open merge requests link to it, or `not_found`. Open merge requests and merges into other branches are listed either way. @@ -81,9 +84,10 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc The report is versioned by `schema_version`; the models live in [`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py). -Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status -category, issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira -request failed. One row, trimmed: +Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status category, +issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira request failed. +Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of +rows it ships, the in-scope Jira keys of those rows, and the compare link from production. One row, trimmed: ```json { @@ -97,60 +101,55 @@ request failed. One row, trimmed: } ``` -## Page +## Site -`render` turns a report into a Markdown page for a GitLab wiki, without calling GitLab: +Besides `report.json`, `collect` writes `index.html` and its script into the output directory. They come from the +installed package and change only with it, so the page always matches the report schema. The page loads +`report.json` from next to itself; it needs a web server, not a `file://` URL. -```sh -uvx release-scope collect --group team/backend --output report.json --cache cache.json; \ - uvx release-scope render report.json --output report.md -``` - -The page opens with a table of the services that have pending changes or problems, with the ref each environment runs -and a GitLab compare link from production to the newest pending tag (to the release tag in a `--jira` report); -services already up to date collapse into one expandable table. Each service with changes then has a collapsible table -of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status, -the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch -and tag pipelines. With Jira issues, the summary table also counts the issues per service whose status is not done. -A `--jira` report names its issues at the top, shows the tag to release per service, and marks the rows linked to -the issues. - -Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page -should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot -read the report or write the page. +**Services** lists every service with a production deployment, and every service that failed to collect, as one +line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs, +and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`. Opening a line shows the service's environments, +warnings, merge requests that are not merged yet, and its rows with tags and their pipelines, merge requests or +commits, Jira keys, environments, and failed jobs; rows out of scope are dimmed. Each tag has a **pick** button: +picking it highlights the rows it ships and closes the line again. A `--jira` report starts with each service's +release tag picked. Services without a production deployment are left out of the page. -## Wiki +**Release** at the bottom turns the picked tags into three lists, each with a copy button and a text box to copy from +by hand, since browsers allow the copy button only over HTTPS: -`publish` replaces the content of an existing page in a project wiki with a rendered page: +- **Jira tasks**: the keys of every in-scope row from each picked tag down to production, without duplicates, with + summary and status, flagging issues that are not done. Copy them one per line or as a JQL `key in (...)` clause, + each in its own box. +- **Tag pipelines**: the pipeline of each picked tag, as a Markdown list. +- **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list. -```sh -uvx release-scope publish report.md --project team/docs --page releases/backend -``` +## GitLab Pages -`--page` is the page slug, as in its URL after `/-/wikis/`. The page must already exist; `publish` never creates -one, so create it once in GitLab. It keeps the page title and format, and skips the write when the content is -unchanged, so a scheduled run does not add a page version every time. Publishing needs a token with the `api` scope -whose user has at least the Developer role in the wiki's project; a denied token exits `3`, a missing page or any -other failed request exits `4`. GitLab rejects pages larger than its wiki page size limit, 5 MB by default; the error -then names the size of the page. - -A scheduled GitLab CI job keeps the page current. It publishes even when a service failed, then fails the job: +A scheduled pipeline publishes the site with [GitLab Pages](https://docs.gitlab.com/user/project/pages/). Keep it in +a project of its own, such as `team/release-report`: Pages serves only the site of the project that runs the job, +and its members are who can view it. `collect` reads the services through the API, so they need no change. ```yaml -release-page: - image: python:3.13-slim +release-report: + image: ghcr.io/astral-sh/uv:python3.13-trixie-slim rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - - pip install 'release-scope>=0.4,<0.5' - - release-scope collect --group team/backend --output report.json || status=$? - - release-scope render report.json --output report.md - - release-scope publish report.md --project team/docs --page releases/backend - - exit "${status:-0}" + - uvx --from 'release-scope>=0.5,<0.6' release-scope collect --group team/backend --output public || [ $? -eq 1 ] + pages: true ``` -Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project -that runs the job, along with the other settings. +`|| [ $? -eq 1 ]` keeps the job green when only some services failed: the page shows their errors, and GitLab deploys +Pages only from a successful job. A configuration error, a rejected token, or an unreachable group still fails the +job and keeps the previous site. `pages: true` needs GitLab 17.6 and publishes `public` as the job artifact from 17.10; +on older versions name the job `pages` and add `artifacts: {paths: [public]}`. + +Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project, +along with the other settings, then add a pipeline schedule. Without [Pages access +control](https://docs.gitlab.com/administration/pages/#access-control), which an administrator of a self-managed +instance turns on, a Pages site is public to anyone who can reach it, even for a private project. With it, set +**Settings > General > Visibility > Pages** to *Only project members*. ## Cache @@ -167,8 +166,7 @@ requests and never changes the report. skill that runs `release-scope` through `uvx` and answers release questions from the report. Ask your coding agent what in the current repository has not reached production, what a group will ship with the next tag, or whether a Jira issue is released and which services it touches. For the current repository the skill takes `--project` from -the git remote. It keeps the report and cache outside the repository, and publishes to a wiki page only when you ask -for it and name the page. +the git remote. It keeps the report and cache outside the repository. Install it with [skills](https://github.com/vercel-labs/skills): @@ -177,4 +175,4 @@ npx skills add modern-python/release-scope ``` The agent reads the same environment variables as the CLI, so set them first as described under Configuration. -The skill runs `release-scope>=0.4,<0.5`, the range whose flags and report schema it describes. +The skill runs `release-scope>=0.5,<0.6`, the range whose flags and report schema it describes. diff --git a/docs/research/gitlab-pages-report.md b/docs/research/gitlab-pages-report.md new file mode 100644 index 0000000..9d378ac --- /dev/null +++ b/docs/research/gitlab-pages-report.md @@ -0,0 +1,213 @@ +# GitLab Pages report with interactive version picking + +Outcome: release 0.5.0 took the vendored Alpine.js page, with `report.json` next to it instead of inlined, and a +pick button per tag inside each service's line instead of radio groups. Jira release creation was left out of scope. + +Research date: 2026-10-06. GitLab docs were read from `gitlab-org/gitlab` master (`VERSION` = `19.5.0-pre`). +Source code links point to the same branch. Release dates come from the GitHub, npm and PyPI APIs on the research date. + +Legend: **[src]** is GitLab source code, **[doc]** is official docs. "Unverified" means no primary source was found. + +## 1. GitLab Pages mechanics + +### Which job publishes + +| Mechanism | Status | Version | Source | +|---|---|---|---| +| A job **named** `pages` | Deprecated but still works (legacy branch in code). No removal milestone is listed on the deprecations page. | Long-standing | [doc](https://docs.gitlab.com/ci/yaml/deprecated_keywords/#publish-keyword-and-pages-job-name-for-gitlab-pages), [src `Ci::Build#pages_generator?`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/build.rb) | +| `pages: true` or `pages: {...}` on **any** job name | Current way | 17.5 behind flag `customizable_pages_job_name`, **GA in 17.6** | [doc](https://docs.gitlab.com/user/project/pages/#user-defined-job-names) | +| A job named `pages` with `pages: false` | Does not deploy | 17.6+ | same | +| Job-level `publish: dir` | Deprecated in **17.9** | Introduced 16.1 (flag), on by default for self-managed in **16.2** | [doc](https://docs.gitlab.com/user/project/pages/introduction/#customize-the-default-folder) | +| `pages.publish: dir` | Current. Variables allowed. | **17.9** | [doc](https://docs.gitlab.com/ci/yaml/#pagespublish) | +| `publish` and `pages.publish` together | Deployment fails validation | n/a | [src `DeploymentValidations`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pages/deployment_validations.rb) | + +The code decides it like this ([src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/build.rb)). Pages must be enabled on the instance first: + +```ruby +return false unless Gitlab.config.pages.enabled +return true if options[:pages].is_a?(Hash) || options[:pages] == true +options[:pages] != false && name == 'pages' # Legacy behaviour +``` + +On an instance where Pages is disabled, the job runs as an ordinary job and nothing is deployed. + +### The `public/` artifact + +- The default content directory is `public`, and it needs a non-empty `index.html` at its root ([doc](https://docs.gitlab.com/ci/yaml/#pages)). +- From **17.10**, `public` (or `pages.publish`) is appended to `artifacts:paths` automatically ([doc](https://docs.gitlab.com/ci/yaml/#artifactspaths)). Before 17.10 you must list it yourself. +- The deploy runs only after the job reaches `success` (`after_transition any => [:success]` enqueues `PagesWorker`, see [src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/build.rb)). A failed job, including one with `allow_failure`, does **not** deploy. A job allowed to fail still shows as failed with a warning ([doc](https://docs.gitlab.com/ci/yaml/#allow_failure)). This matters because `collect` exits `1` when a service fails. +- The deploy appears in the pipeline as an extra `pages:deploy` status in the `deploy` stage ([src `UpdatePagesService`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/projects/update_pages_service.rb)). + +### Scheduled pipelines + +- No Pages-specific restriction on pipeline source was found in the docs or in `UpdatePagesService` / `DeploymentValidations`. A schedule deploys like any other pipeline. Gate the job with `rules: - if: $CI_PIPELINE_SOURCE == "schedule"` ([doc](https://docs.gitlab.com/ci/jobs/job_rules/#run-jobs-for-scheduled-pipelines)). +- Caveat ([src `validate_outdated_sha`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pages/deployment_validations.rb)): a deploy is rejected with "build SHA is outdated for this ref" in one case. This happens when the job's SHA is no longer the ref's HEAD **and** a newer pipeline already deployed to the same `path_prefix`. If only the schedule deploys, this can only hit two overlapping scheduled runs. +- If several Pages jobs share a `path_prefix`, the last one to finish wins ([doc](https://docs.gitlab.com/user/project/pages/#user-defined-job-names)). +- A schedule runs with its owner's permissions, and a manual "Run" uses the clicker's ([doc](https://docs.gitlab.com/ci/pipelines/schedules/#run-manually)). Unverified: whether a manual run of a schedule has `CI_PIPELINE_SOURCE == "schedule"`. The doc only says it triggers the schedule. + +### Access control (self-managed) + +- **Admin switch:** `gitlab_pages['access_control'] = true` in `gitlab.rb`, which is off by default ([doc](https://docs.gitlab.com/administration/pages/#access-control)). +- **Without that switch, every Pages site is public, even for a private project.** In `ProjectFeature#public_pages?`, the first line is `return true unless Gitlab.config.pages.access_control` ([src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/project_feature.rb)). "Public" here means anyone who can reach the Pages host. The site can still be firewalled: Pages behind a private network is reachable only from inside it ([doc](https://docs.gitlab.com/administration/pages/#prerequisites)). +- **With the switch on:** set per project in **Settings > General > Visibility > Pages** ([doc](https://docs.gitlab.com/user/project/pages/pages_access_control/)): + - Private project: "Only project members" or "Everyone". + - Internal project: "Only project members", "Everyone with access" (any logged-in non-external user), or "Everyone". + - Public project: "Only project members" or "Everyone with access". + - A member needs at least the Guest role. +- **Reuses GitLab auth:** yes. The Pages daemon is registered as an OAuth application. Unauthenticated users are redirected to GitLab to sign in, and the token is kept in a signed cookie. Each request is checked against the GitLab API ([doc](https://docs.gitlab.com/administration/pages/#access-control)). Since 17.10, scripts can also send `Authorization: Bearer ` with `read_api` scope ([doc](https://docs.gitlab.com/user/project/pages/pages_access_control/#authenticate-with-an-access-token)). +- Admins can force non-public sites instance-wide: "Disable public access to Pages sites" ([doc](https://docs.gitlab.com/administration/pages/#disable-public-access-to-all-pages-sites)). A group Owner can do the same for a group, from **17.9** ([doc](https://docs.gitlab.com/user/project/pages/pages_access_control/#remove-public-access-for-group-pages)). +- All of the above is **Free** tier. + +### Limits + +| Limit | Default | Tier | Source | +|---|---|---|---| +| Max site size (instance) | 100 MB, admin-configurable | Free | [doc](https://docs.gitlab.com/administration/pages/#set-global-maximum-size-of-each-gitlab-pages-site) | +| Per-group / per-project size override | n/a | Premium | same page | +| Files per site | 200,000 | Free | [doc](https://docs.gitlab.com/administration/instance_limits/#number-of-files-per-gitlab-pages-website) | +| Parallel deployments per top-level namespace | 1000 | Premium | [doc](https://docs.gitlab.com/administration/instance_limits/#number-of-parallel-pages-deployments) | + +The size is measured on the extracted publish directory ([src `total_size`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pages/deployment_validations.rb)). One HTML file with inline JSON is far below the limit unless the report is huge. + +### Parallel deployments and `path_prefix` + +- **Tier: Premium/Ultimate.** Experiment in 16.7 behind flag `pages_multiple_versions_setting`. Enabled by default in 17.4, project setting removed in 17.7, periods allowed in 17.8, **GA in 17.9** ([doc](https://docs.gitlab.com/user/project/pages/parallel_deployments/), [doc](https://docs.gitlab.com/ci/yaml/#pagespath_prefix)). +- `path_prefix` is lowercased, cut to 63 bytes, and characters other than `[a-z0-9.]` become `-`. The site is then served at `//`. +- **Parallel deployments expire after 24 h by default.** For long-lived per-group reports, set `pages.expire_in: never` (Premium, 17.4+) ([doc](https://docs.gitlab.com/ci/yaml/#pagesexpire_in)). An admin can change the default ([doc](https://docs.gitlab.com/administration/pages/#configure-the-default-expiry-for-parallel-deployments)). +- A prefix that matches a folder of the main deployment shadows it ([doc](https://docs.gitlab.com/user/project/pages/parallel_deployments/#path-clash)). +- **Free-tier alternatives for one report per group:** + - Render all groups into subfolders (`public/backend/`, `public/frontend/`) in **one** job, because each deploy replaces the whole site. + - Or use one Pages project per group. + +### Single `index.html` with inline JS and JSON + +Yes. Pages serves static files, including "plain HTML, CSS, JavaScript, and Wasm"; server-side processing is not supported ([doc](https://docs.gitlab.com/user/project/pages/)). Notes: + +- Project sites are served under `//`, and URLs ending in `/` break relative links ([doc](https://docs.gitlab.com/user/project/pages/introduction/#broken-relative-links)). A single file with everything inline avoids this. +- Admins can add response headers such as CSP via `gitlab_pages['headers']` ([doc](https://docs.gitlab.com/administration/pages/#global-settings)). No default CSP header was found in the docs (unverified that none is set). A strict CSP without `unsafe-eval` breaks standard Alpine.js, which then needs its CSP build ([doc](https://github.com/alpinejs/alpine/blob/main/packages/docs/src/en/advanced/csp.md)). + +### Hosting in a different project + +- A Pages site belongs to the project whose pipeline ran the Pages job. The deployment is created on `build.project` ([src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/projects/update_pages_service.rb)). A job **cannot** publish into another project's Pages. +- So the design is: a dedicated "docs" or "release-report" project holds `.gitlab-ci.yml`, the schedule, the CI variables and the Pages site. `collect` reads the service projects through the REST API with `RELEASE_SCOPE_GITLAB__TOKEN` (`read_api`), as the current README already describes. The service projects need no changes. +- Viewer access is governed by membership of the **docs project** when access control is "Only project members". Readers must be added there, or the docs project should be internal with "Everyone with access". + +## 2. Building an interactive page from Python-produced JSON + +Requirement recap: per-service radio groups with a "none" option, three derived lists (union of Jira keys, pipelines, compare links) that update live, copy-friendly output, and a static overview. + +### Options + +| Option | CI build needs | Runtime weight | Derived state | License | Latest release (verified) | Fit for `release-scope render --output public/` | +|---|---|---|---|---|---|---| +| **Python template + vanilla JS** (stdlib `string.Template`/`str.replace`, `json.dumps`) | Python only | ~0 KB library; your own JS | Hand-written: a `change` handler recomputes 3 lists | n/a (your code) | n/a | Best: no new runtime dependency | +| **Python template + Alpine.js, vendored** | Python only | `cdn.min.js` 3.17.4: 55.9 KB raw / 19.9 KB gzip (measured) | `x-data` getters act as computed properties, uncached ([doc](https://github.com/alpinejs/alpine/blob/main/packages/docs/src/en/directives/data.md)) | MIT | 3.17.4, 2026-09-21 (npm, GitHub) | Very good: ship one `.js` file as package data | +| Python template + Preact + htm standalone (ESM) | Python only | `htm/preact/standalone.mjs`: 13.2 KB raw / 5.3 KB gzip | Hooks/`useMemo` | MIT / Apache-2.0 | preact 11.0.0, 2026-09-30; **htm 3.1.1, 2022-04-26** | Good, but the htm bundle is stale and the code is more verbose than Alpine | +| Python template + petite-vue | Python only | 16.9 KB raw / 7.1 KB gzip | Vue-like reactivity | MIT | **0.4.1, 2022-01-18**; last push 2024-07-13 | Not recommended: effectively unmaintained | +| Jinja2 instead of stdlib templating | Adds `jinja2` (BSD-3-Clause) dependency | none | n/a (server side only) | BSD-3-Clause | 3.1.6, 2025-03-05 (PyPI) | Useful only if the overview is rendered server-side as HTML. Autoescaping helps; otherwise unnecessary | +| Observable Framework | **Node >= 18** ([doc](https://github.com/observablehq/framework/blob/main/docs/getting-started.md)); `npm:` imports downloaded from **jsDelivr at build time** ([doc](https://github.com/observablehq/framework/blob/main/docs/imports.md)) | Multi-file site, Observable runtime + Inputs | Excellent: reactive cells, `Inputs.radio([... , null])` supports "none" ([doc](https://github.com/observablehq/framework/blob/main/docs/inputs/radio.md)) | ISC | 1.13.4, 2026-03-02; last commit 2026-05-15 (slowing) | Poor: the CLI would shell out to `npx`; a Node image is needed in CI; build-time CDN access breaks behind a firewall | +| Evidence | Node toolchain; the repo now distributes a new CLI via `curl ... install.sh` and Evidence Studio ([README](https://github.com/evidence-dev/evidence/blob/main/README.md)) | SvelteKit site + DuckDB-wasm (unverified size) | Input components exist (e.g. `ButtonGroup`), SQL-driven | MIT | npm `@evidence-dev/evidence` 40.1.8, 2026-02-06; repo active (commits 2026-10-02), product in transition | Poor: SQL/BI-oriented, heavy, distribution in flux | +| Quarto + OJS | `quarto` binary. The PyPI `quarto-cli` is an sdist that **downloads the binary from GitHub at install time** (read in `setup.py`) | OJS runtime; `embed-resources: true` gives one file ([doc](https://github.com/quarto-dev/quarto-web/blob/main/docs/output-formats/html-basics.qmd)) | Excellent: `viewof` + Inputs, reactive ([doc](https://github.com/quarto-dev/quarto-web/blob/main/docs/interactive/ojs/index.qmd)) | MIT | 1.10.19, 2026-10-06 | Poor: a large external binary, GitHub download behind firewall; whether OJS pulls libraries from a CDN at runtime is unverified | +| Streamlit via stlite | None at build time (HTML embeds Python source) | Loads Pyodide + Streamlit wheels in the browser. `@stlite/browser` is 110 MB unpacked on npm; Pyodide comes **from a CDN by default**, and self-hosting needs the full Pyodide distribution ([README](https://github.com/whitphx/stlite/blob/main/README.md)) | Yes (Streamlit reruns) | Apache-2.0 | `@stlite/browser` 1.9.2, 2026-09-23 | Poor: seconds-to-load page, CDN dependency, heavy self-hosting | +| Datasette-lite | None (hosted app at lite.datasette.io) | Pyodide + Datasette | No picker UI; it is a SQL explorer. Data must be on a CORS-enabled URL ([README](https://github.com/simonw/datasette-lite/blob/main/README.md)) | Apache-2.0 | No releases (deployed app); last push 2026-08-09 | Not a fit | +| Panel (`panel convert` to Pyodide) | Python | Pyodide-based (unverified size) | Yes | BSD-3-Clause | 1.9.4, 2026-08-17 (PyPI) | Same Pyodide weight and CDN issue as stlite; listed for completeness, not deeply evaluated | + +### Vendor or CDN + +**Vendor the library into the wheel.** + +- `uv_build` packages everything under the module root, so `release_scope/_static/alpine.min.js` ships in the wheel with no config. Only `__pycache__`, `*.pyc` and `*.pyo` are excluded by default ([doc](https://github.com/astral-sh/uv/blob/main/docs/concepts/build-backend.md#file-inclusion-and-exclusion)). Read it with `importlib.resources.files("release_scope")` (stdlib). +- **Inline** it into `index.html` rather than copying it next to it. One file works opened from `file://`, as a CI artifact download, or on Pages. It is immune to the trailing-slash relative-link issue and has no runtime CDN dependency behind a firewall. +- A CDN `` cannot end the element ([spec](https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements)). Parse it with `JSON.parse`. +- **Keep the logic in Python:** precompute a view model in Python. For each service with a production deploy, list the candidate tags. For each tag, store the **cumulative** Jira keys from that tag's row down to the oldest row, the tag pipeline (`TagRef.pipeline`), and the compare URL. The current `_compare` in `_render.py` builds that link. + - JS then only takes a union in pick order and renders. The derivation stays under pytest and 100% coverage, and the JS stays at a few dozen lines regardless of framework. +- **Copy buttons:** `navigator.clipboard` is `[SecureContext]`, which means HTTPS or localhost only ([spec](https://w3c.github.io/clipboard-apis/)). If the instance serves Pages over plain HTTP, the API is missing. Always render each list in a ` + + +
+
+

Tag pipelines

+ +
+
    + +
+ +
+
+
+

Compare

+ +
+
    + +
+ +
+ + +

Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ no pipeline

+ + + + + + + diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py index cd55436..ff53597 100644 --- a/release_scope/_use_case.py +++ b/release_scope/_use_case.py @@ -6,6 +6,7 @@ from urllib.parse import quote from release_scope._cache import Cache, CachedPipeline +from release_scope._candidates import build_candidates from release_scope._errors import AuthError, ConfigError, GitLabError, JiraError from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest, Pipeline, Project from release_scope._jira import JiraApi @@ -78,7 +79,7 @@ def _commit_ref(commit: Commit) -> CommitRef: def _row_keys(services: list[Service]) -> list[str]: - return sorted({key.key for service in services for row in service.rows for key in row.jira_keys}) + return sorted({key.key for service in services for row in service.rows if row.in_scope for key in row.jira_keys}) @dataclasses.dataclass(slots=True, kw_only=True) @@ -254,9 +255,9 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked return service walk: typing.Final = self._walk(project, project.default_branch, production.sha, service, cache) - drafts, linked = walk.drafts, [False] * len(walk.drafts) + drafts, linked, in_scope = walk.drafts, [False] * len(walk.drafts), [True] * len(walk.drafts) if links is not None: - drafts, linked = self._scope_rows(project, project.default_branch, service, walk, links, cache) + drafts, linked, in_scope = self._scope_rows(project, project.default_branch, service, walk, links, cache) service.rows.extend( self._build_row( project=project, @@ -265,9 +266,10 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked main_pipelines=walk.main_pipelines, environments=service.environments, cache=cache, - ).model_copy(update={"linked": is_linked}) - for draft, is_linked in zip(drafts, linked, strict=True) + ).model_copy(update={"linked": is_linked, "in_scope": is_in_scope}) + for draft, is_linked, is_in_scope in zip(drafts, linked, in_scope, strict=True) ) + service.candidates.extend(build_candidates(service, production)) return service def _walk(self, project: Project, default_branch: str, baseline: str, service: Service, cache: Cache) -> _Walk: @@ -275,6 +277,7 @@ def _walk(self, project: Project, default_branch: str, baseline: str, service: S project.id, f"{baseline}..{default_branch}", max_items=self.settings.max_commits ) walk: typing.Final = _Walk(truncated=truncated) + service.truncated = truncated if truncated: service.warnings.append(f"Stopped after {self.settings.max_commits} commits; older changes are omitted.") if not commits: @@ -299,15 +302,20 @@ def _scope_rows( # noqa: PLR0913, PLR0917 walk: _Walk, links: list[LinkedChange], cache: Cache, - ) -> tuple[list[RowDraft], list[bool]]: + ) -> tuple[list[RowDraft], list[bool], list[bool]]: target: typing.Final = self._link_target(project, default_branch, links, cache) service.warnings.extend(target.warnings) index: typing.Final = next( (position for position, draft in enumerate(walk.drafts) if target.matches(draft)), None ) service.release = self._release(project, target, walk, index, cache) - drafts: typing.Final = walk.drafts[index:] if index is not None else [] - return drafts, [target.matches(draft) for draft in drafts] + if index is None: + return [], [], [] + return ( + walk.drafts, + [target.matches(draft) for draft in walk.drafts], + [position >= index for position in range(len(walk.drafts))], + ) def _link_target( self, project: Project, default_branch: str, links: list[LinkedChange], cache: Cache diff --git a/release_scope/ioc.py b/release_scope/ioc.py index bf2dbba..d1d8d03 100644 --- a/release_scope/ioc.py +++ b/release_scope/ioc.py @@ -6,7 +6,6 @@ from release_scope._gitlab import GitLabApi from release_scope._jira import JiraApi -from release_scope._publish import PublishUseCase from release_scope._settings import Settings, load_settings from release_scope._use_case import CollectUseCase @@ -74,7 +73,6 @@ class UseCasesGroup(modern_di.Group): collect_use_case = providers.Factory( scope=Scope.APP, creator=CollectUseCase, kwargs={"jira": ClientsGroup.jira_api} ) - publish_use_case = providers.Factory(scope=Scope.APP, creator=PublishUseCase) ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [SettingsGroup, ClientsGroup, UseCasesGroup] diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md index f7222c2..677d997 100644 --- a/skills/release-scope/SKILL.md +++ b/skills/release-scope/SKILL.md @@ -4,18 +4,17 @@ description: > Run the release-scope CLI through uvx to find what sits between production and the default branch of GitLab services: pending merge requests and commits, tags, environments, failed jobs, and Jira issues with their status. Use when the user asks what is not in production yet, what will ship with the next release or tag, whether a Jira - issue is released or which services it touches, or wants a release page for a GitLab wiki, for the current + issue is released or which services it touches, or which Jira issues a set of tags would release, for the current repository, a GitLab group, or a list of projects, even if they do not name release-scope. --- # release-scope -`release-scope` is a CLI on PyPI. `collect` and `render` only read GitLab and Jira, so they need no approval to run. -`publish` overwrites a GitLab wiki page; see Publish. Always run it through uvx with this version range; it matches -the flags and report schema described here: +`release-scope` is a CLI on PyPI. `collect` only reads GitLab and Jira, so it needs no approval to run. Always run it +through uvx with this version range; it matches the flags and report schema described here: ```bash -uvx --from 'release-scope>=0.4,<0.5' release-scope --help +uvx --from 'release-scope>=0.5,<0.6' release-scope --help ``` ## Check the settings @@ -29,7 +28,7 @@ env | cut -d= -f1 | grep -E '^(RELEASE_SCOPE_|GITLAB_TOKEN$|JIRA_TOKEN$)' | sort | Variable | Needed for | |---|---| | `RELEASE_SCOPE_GITLAB__ENDPOINT` | Any run; defaults to `https://gitlab.com` | -| `RELEASE_SCOPE_GITLAB__TOKEN` or `GITLAB_TOKEN` | Any run; `read_api` scope, `api` for `publish` | +| `RELEASE_SCOPE_GITLAB__TOKEN` or `GITLAB_TOKEN` | Any run; `read_api` scope | | `RELEASE_SCOPE_ENVIRONMENTS` | Environments to show, a JSON list such as `'["prod", "preview"]'` | | `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | The environment whose deployment starts the range; defaults to `production` | | `RELEASE_SCOPE_JIRA_ENDPOINT` and `RELEASE_SCOPE_JIRA_TOKEN` (or `JIRA_TOKEN`) | Jira summaries and statuses; required for `--jira` | @@ -48,21 +47,19 @@ token into the chat. Never echo a token. - Jira issue keys: `--jira KEY`, repeatable. It collects only the projects the issues link to, from production up to the latest linked change. It cannot be combined with `--group` or `--project`. -## Collect and render +## Collect -Keep the files outside the repository so they never land in its git tree. One cache file serves every run and only -saves requests: +Keep the files outside the repository so they never land in its git tree. `--output` is a directory; `collect` +writes `report.json` there, next to a static page for GitLab Pages that you do not need. One cache file serves every +run and only saves requests: ```bash out="${XDG_CACHE_HOME:-$HOME/.cache}/release-scope" mkdir -p "$out" -uvx --from 'release-scope>=0.4,<0.5' release-scope collect --project team/backend/shop \ - --output "$out/report.json" --cache "$out/cache.json"; \ - uvx --from 'release-scope>=0.4,<0.5' release-scope render "$out/report.json" --output "$out/report.md" +uvx --from 'release-scope>=0.5,<0.6' release-scope collect --project team/backend/shop \ + --output "$out/site" --cache "$out/cache.json" ``` -Chain with `;`, not `&&`: `render` should still run when `collect` exits `1`. - Exit codes of `collect`: - `0`: every service collected. @@ -76,8 +73,7 @@ For `2` to `4` nothing was written; show the message and stop. ## Summarize the report -Read `report.json` and answer the user's question from it, briefly. Point to `report.md` for the full page; it is -Markdown for a GitLab wiki, and publishing it is the user's call. +Read `$out/site/report.json` and answer the user's question from it, briefly. - `services[]`: `project`, `environments` (what each environment runs), `rows` (newest first, from the default branch head down to production), `warnings`, `error`. @@ -85,11 +81,17 @@ Markdown for a GitLab wiki, and publishing it is the user's call. `environments` already running it, `jira_keys`, and `main_pipeline` with `failed_jobs`. Tag pipelines carry their own `failed_jobs`. - A service with no rows is up to date with production. +- `truncated: true`: the walk stopped at `RELEASE_SCOPE_MAX_COMMITS`, so the oldest candidates miss rows and keys. +- `candidates` (newest first): the tags a release could ship, each with `tag` and its `pipeline`, `rows` (how many + rows run from that tag down to production), `jira_keys` (the in-scope keys of those rows, without duplicates), and + `compare_url`. + To answer which issues a set of tags releases, merge the `jira_keys` of the picked candidates. - `jira.issues` by key: `summary`, `status`, `status_category` (anything but `done` is not done), `links` to GitLab merge requests and commits. `jira.missing` lists keys Jira did not return; `jira.error` a failed request. `jira` is `null` without a Jira token. -- In a `--jira` report, `jira_scope` lists the issues, rows with `linked: true` are the ones the issues point to, and - each service has a `release`: +- In a `--jira` report, `jira_scope` lists the issues, rows with `linked: true` are the ones the issues point to, + rows with `in_scope: false` sit above the latest linked change and do not ship with the issues, and each service + has a `release`: - `pending` with `tag`: that tag ships the issue; without `tag`, a new tag is needed. - `in_production`: every linked merge request is already deployed. - `not_merged`: only open merge requests link to it. @@ -99,17 +101,3 @@ Markdown for a GitLab wiki, and publishing it is the user's call. Lead with what needs attention: failed services, failed jobs without `allow_failure`, Jira issues that are not done, and releases that need a new tag or are not merged. Deployment data comes from GitLab environments; it shows what was deployed, not proof of what serves traffic. - -## Publish - -Run `publish` only when the user asks to publish the page and names the wiki's project and page; it overwrites that -page for everyone. Do not infer either from the repository. Confirm both before running: - -```bash -uvx --from 'release-scope>=0.4,<0.5' release-scope publish "$out/report.md" \ - --project team/docs --page releases/backend -``` - -The page must exist; `publish` never creates one. It prints `Updated` or `Unchanged` with the page URL. Exit `3` -means the token lacks the `api` scope or the Developer role in that project; exit `4` means the page does not exist -or the request failed. Show the message and stop. diff --git a/tests/test_cli.py b/tests/test_cli.py index c238f19..4ad43a4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,14 +36,20 @@ def _invoke(*args: str) -> typing.Any: # noqa: ANN401 @pytest.mark.usefixtures("cli_env") def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib.Path) -> None: - output: typing.Final = tmp_path / "out" / "report.json" + output: typing.Final = tmp_path / "public" cache: typing.Final = tmp_path / "cache.json" first: typing.Final = _invoke("collect", "--group", "team", "--output", str(output), "--cache", str(cache)) assert first.exit_code == 0, first.output - report: typing.Final = json.loads(output.read_text()) - assert report["schema_version"] == 2 + assert sorted(item.name for item in output.iterdir()) == [ + "alpine.LICENSE.txt", + "alpine.min.js", + "index.html", + "report.json", + ] + report: typing.Final = json.loads((output / "report.json").read_text()) + assert report["schema_version"] == 3 assert report["jira"] is None assert report["production_environment"] == "production" assert [len(item["rows"]) for item in report["services"]] == [5] @@ -62,7 +68,7 @@ def test_unreadable_cache_is_ignored_with_a_warning(tmp_path: pathlib.Path) -> N cache: typing.Final = tmp_path / "cache.json" cache.write_text('{"schema_version": 99}') - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "r.json"), "--cache", str(cache)) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public"), "--cache", str(cache)) assert result.exit_code == 0, result.output assert f"Warning: Ignoring unreadable cache {cache}: ValidationError." in result.output @@ -73,13 +79,16 @@ def test_unreadable_cache_is_ignored_with_a_warning(tmp_path: pathlib.Path) -> N def test_failed_service_is_reported_and_exits_non_zero(gitlab: respx.Router, tmp_path: pathlib.Path) -> None: gitlab["group"].respond(json=[SERVICE, project(2, "team/broken")]) gitlab.get(f"{API}/projects/2/deployments").respond(400) - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) assert result.exit_code == 1 assert "Error: team/broken: GitLab returned 400 for deployments." in result.output - assert [item["error"] is None for item in json.loads(output.read_text())["services"]] == [False, True] + assert [item["error"] is None for item in json.loads((output / "report.json").read_text())["services"]] == [ + False, + True, + ] @pytest.mark.usefixtures("cli_env") @@ -88,12 +97,12 @@ def test_collect_reads_jira_issues_with_a_bearer_token( ) -> None: monkeypatch.setenv("RELEASE_SCOPE_JIRA_ENDPOINT", JIRA_ENDPOINT) monkeypatch.setenv("JIRA_TOKEN", "jira-pat") - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) assert result.exit_code == 0, result.output - assert sorted(json.loads(output.read_text())["jira"]["issues"]) == ["SHOP-12", "SHOP-9"] + assert sorted(json.loads((output / "report.json").read_text())["jira"]["issues"]) == ["SHOP-12", "SHOP-9"] assert jira["jira_search"].calls.last.request.headers["Authorization"] == "Bearer jira-pat" @@ -105,20 +114,20 @@ def test_jira_failure_is_reported_and_exits_non_zero( monkeypatch.setenv("RELEASE_SCOPE_JIRA_ENDPOINT", JIRA_ENDPOINT) monkeypatch.setenv("RELEASE_SCOPE_JIRA_TOKEN", "expired") jira["jira_search"].respond(401) - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) assert result.exit_code == 1 assert "Error: Jira rejected the token (401)." in result.output - assert json.loads(output.read_text())["services"][0]["rows"] + assert json.loads((output / "report.json").read_text())["services"][0]["rows"] @pytest.mark.usefixtures("cli_env") def test_jira_token_needs_a_jira_endpoint(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: monkeypatch.setenv("JIRA_TOKEN", "jira-pat") - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "Jira token is set but RELEASE_SCOPE_JIRA_ENDPOINT is not." in result.output @@ -134,13 +143,13 @@ def jira_env(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.usefixtures("cli_env", "jira_env", "scoped") @pytest.mark.httpx2(assert_all_called=False) def test_collect_for_jira_issues_writes_a_scoped_report(tmp_path: pathlib.Path) -> None: - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" cache: typing.Final = tmp_path / "cache.json" result: typing.Final = _invoke("collect", "--jira", "SHOP-12", "-o", str(output), "--cache", str(cache)) assert result.exit_code == 1 - report: typing.Final = json.loads(output.read_text()) + report: typing.Final = json.loads((output / "report.json").read_text()) assert report["jira_scope"] == ["SHOP-12"] assert [item["project"] for item in report["services"]] == ["team/svc", "team/web", "team/worker"] assert json.loads(cache.read_text())["merge_requests"]["1"]["12"]["iid"] == 12 @@ -150,7 +159,7 @@ def test_collect_for_jira_issues_writes_a_scoped_report(tmp_path: pathlib.Path) @pytest.mark.usefixtures("cli_env", "jira_env", "scoped") @pytest.mark.httpx2(assert_all_called=False) def test_jira_issue_missing_from_jira_exits_non_zero(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "--jira", "SHOP-404", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "--jira", "SHOP-404", "-o", str(tmp_path / "public")) assert result.exit_code == 1 assert "Error: Jira has no issue SHOP-404." in result.output @@ -165,7 +174,7 @@ def test_jira_issue_missing_from_jira_exits_non_zero(tmp_path: pathlib.Path) -> ], ) def test_jira_option_is_validated(tmp_path: pathlib.Path, args: tuple[str, ...], message: str) -> None: - result: typing.Final = _invoke("collect", *args, "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", *args, "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert message in result.output @@ -173,7 +182,7 @@ def test_jira_option_is_validated(tmp_path: pathlib.Path, args: tuple[str, ...], @pytest.mark.usefixtures("cli_env") def test_jira_option_needs_jira_settings(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "--jira", "SHOP-1", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "--jira", "SHOP-1", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "--jira needs RELEASE_SCOPE_JIRA_ENDPOINT and RELEASE_SCOPE_JIRA_TOKEN." in result.output @@ -184,7 +193,7 @@ def test_forbidden_service_fails_alone_and_exits_non_zero(gitlab: respx.Router, gitlab["group"].respond(json=[SERVICE, project(2, "team/nodeploy")]) gitlab.get(f"{API}/projects/2/deployments").respond(403) - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "report.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 1 assert "Error: team/nodeploy: GitLab denied access to deployments (403). Check that:" in result.output @@ -195,7 +204,7 @@ def test_forbidden_service_fails_alone_and_exits_non_zero(gitlab: respx.Router, def test_rejected_token_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: httpx2_mock.get(f"{API}/groups/team/projects").respond(401) - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "report.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 3 assert "Error: GitLab rejected the token (401)." in result.output @@ -204,7 +213,7 @@ def test_rejected_token_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path @pytest.mark.usefixtures("cli_env") def test_authentication_failure_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: httpx2_mock.get(f"{API}/groups/team/projects").respond(403) - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) @@ -215,14 +224,14 @@ def test_authentication_failure_exits_with_auth_code(httpx2_mock: respx.Router, @pytest.mark.usefixtures("cli_env") def test_collect_needs_a_group_or_project(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "Pass --jira, or at least one --group or --project." in result.output def test_collect_needs_a_token(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "GitLab token is missing" in result.output @@ -243,159 +252,3 @@ def test_module_entry_point_runs_the_app(monkeypatch: pytest.MonkeyPatch) -> Non runpy.run_module("release_scope", run_name="__main__") assert exc_info.value.code == 0 - - -def test_render_writes_markdown_from_a_report(tmp_path: pathlib.Path) -> None: - report: typing.Final = tmp_path / "report.json" - report.write_text( - '{"schema_version": 2, "collected_at": "2026-09-29T10:15:00Z", ' - '"production_environment": "prod", "services": []}' - ) - page: typing.Final = tmp_path / "out" / "report.md" - - result: typing.Final = _invoke("render", str(report), "--output", str(page)) - - assert result.exit_code == 0, result.output - assert page.read_text().startswith("# Release scope\n") - assert f"-> {page}" in result.output - - -@pytest.mark.parametrize( - ("content", "reason"), - [ - (None, "FileNotFoundError."), - ('{"schema_version": 2}', "ValidationError."), - ("not json", "JSONDecodeError."), - ("[]", "ValidationError."), - ('{"schema_version": 1}', "schema_version 1 is not supported; run collect again."), - ], -) -def test_render_rejects_an_unreadable_report(tmp_path: pathlib.Path, content: str | None, reason: str) -> None: - report: typing.Final = tmp_path / "report.json" - if content is not None: - report.write_text(content) - - result: typing.Final = _invoke("render", str(report), "-o", str(tmp_path / "report.md")) - - assert result.exit_code == 2 - assert f"Error: Cannot read report {report}: {reason}" in result.output - assert not (tmp_path / "report.md").exists() - - -def test_render_reports_a_page_it_cannot_write(tmp_path: pathlib.Path) -> None: - report: typing.Final = tmp_path / "report.json" - report.write_text( - '{"schema_version": 2, "collected_at": "2026-09-29T10:15:00Z", ' - '"production_environment": "prod", "services": []}' - ) - blocked: typing.Final = tmp_path / "report.md" - blocked.mkdir() - - result: typing.Final = _invoke("render", str(report), "-o", str(blocked)) - - assert result.exit_code == 1 - assert f"Error: Cannot write page {blocked}: IsADirectoryError." in result.output - - -_WIKI_PAGE: typing.Final = f"{API}/projects/team%2Fdocs/wikis/releases%2Fbackend" - - -def _wiki_page(content: str) -> dict[str, str]: - return {"slug": "releases/backend", "title": "backend", "format": "markdown", "content": content} - - -def _publish(tmp_path: pathlib.Path, content: str = "# Release scope\n") -> typing.Any: # noqa: ANN401 - page: typing.Final = tmp_path / "report.md" - page.write_text(content) - return _invoke("publish", str(page), "--project", "team/docs", "--page", "releases/backend") - - -@pytest.mark.usefixtures("cli_env") -def test_publish_updates_the_wiki_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Old\n")) - update: typing.Final = httpx2_mock.put(_WIKI_PAGE).respond(json=_wiki_page("# Release scope\n")) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 0, result.output - assert json.loads(update.calls.last.request.content) == {"content": "# Release scope\n"} - assert update.calls.last.request.headers["PRIVATE-TOKEN"] == "glpat-test" - assert f"Updated {ENDPOINT}/team/docs/-/wikis/releases/backend" in result.output - - -@pytest.mark.usefixtures("cli_env") -def test_publish_skips_an_unchanged_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Release scope\n")) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 0, result.output - assert [call.request.method for call in httpx2_mock.calls] == ["GET"] - assert f"Unchanged {ENDPOINT}/team/docs/-/wikis/releases/backend" in result.output - - -@pytest.mark.usefixtures("cli_env") -def test_publish_does_not_create_a_missing_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - httpx2_mock.get(_WIKI_PAGE).respond(404) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 4 - assert ( - "Error: Wiki page 'releases/backend' does not exist in project 'team/docs', or the token cannot see it. " - "Create the page in GitLab first." in result.output - ) - - -@pytest.mark.parametrize(("method", "status", "code"), [("get", 401, 3), ("get", 403, 3), ("put", 403, 3)]) -@pytest.mark.usefixtures("cli_env") -def test_publish_explains_a_denied_token( - httpx2_mock: respx.Router, tmp_path: pathlib.Path, method: str, status: int, code: int -) -> None: - if method == "put": - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Old\n")) - httpx2_mock.route(method=method.upper(), url=_WIKI_PAGE).respond(status) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == code - if status == 401: - assert "Error: GitLab rejected the token (401)." in result.output - else: - assert ( - "Error: GitLab denied access to the wiki of project 'team/docs' (403). Check that the token has " - "the 'api' scope and that its user has at least the Developer role there." in result.output - ) - - -@pytest.mark.parametrize(("method", "size"), [("get", ""), ("put", " The page is 20 bytes.")]) -@pytest.mark.usefixtures("cli_env") -def test_publish_reports_a_failed_request( - httpx2_mock: respx.Router, tmp_path: pathlib.Path, method: str, size: str -) -> None: - if method == "put": - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Old\n")) - httpx2_mock.route(method=method.upper(), url=_WIKI_PAGE).respond(400) - - result: typing.Final = _publish(tmp_path, "é" * 10) - - assert result.exit_code == 4 - assert f"Error: GitLab returned 400 for /api/v4/projects/team/docs/wikis/releases/backend.{size}\n" in result.output - - -@pytest.mark.usefixtures("cli_env") -def test_publish_rejects_an_unreadable_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - page: typing.Final = tmp_path / "report.md" - - result: typing.Final = _invoke("publish", str(page), "-p", "team/docs", "--page", "releases/backend") - - assert result.exit_code == 2 - assert f"Error: Cannot read page {page}: FileNotFoundError." in result.output - assert not httpx2_mock.calls - - -def test_publish_needs_a_token(tmp_path: pathlib.Path) -> None: - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 2 - assert "GitLab token is missing" in result.output diff --git a/tests/test_render.py b/tests/test_render.py deleted file mode 100644 index 4d66b41..0000000 --- a/tests/test_render.py +++ /dev/null @@ -1,456 +0,0 @@ -import datetime -import typing - -from release_scope._render import render_markdown -from release_scope._report import ( - CommitRef, - EnvironmentState, - FailedJob, - JiraIssue, - JiraKeyRef, - JiraState, - LinkedChange, - MergeRequestRef, - PipelineState, - Release, - Report, - Row, - Service, - TagRef, -) - - -_COLLECTED_AT: typing.Final = datetime.datetime(2026, 9, 29, 10, 15, tzinfo=datetime.UTC) -_WARNING: typing.Final = ( - "Environments are disabled, so it has no deployments. Enable them at " - "https://g.test/acme/utils/edit#js-shared-permissions → Visibility, project features, permissions → Environments." -) - - -def _environment(name: str, ref: str, url: str | None = None, *, tag: bool = False) -> EnvironmentState: - return EnvironmentState( - name=name, ref=ref, sha=f"sha-{ref}", deployed_at="2026-09-20T00:00:00Z", deployment_url=url, tag=tag - ) - - -def _job(name: str, *, allow_failure: bool = False, downstream: str | None = None) -> FailedJob: - return FailedJob( - kind="bridge" if downstream else "job", - name=name, - stage="test", - status="failed", - allow_failure=allow_failure, - url=f"https://g.test/j/{name}", - failure_reason="script_failure", - downstream_pipeline_url=downstream, - ) - - -def _pipeline(pipeline_id: int, status: str, *jobs: FailedJob) -> PipelineState: - return PipelineState(id=pipeline_id, status=status, url=f"https://g.test/p/{pipeline_id}", failed_jobs=list(jobs)) - - -def _merge_request(iid: int, title: str, author: str | None) -> MergeRequestRef: - return MergeRequestRef( - iid=iid, title=title, url=f"https://g.test/mr/{iid}", author=author, merged_at="2026-09-26T00:00:00Z" - ) - - -def _commit(sha: str, title: str) -> CommitRef: - return CommitRef( - sha=sha, - short_sha=sha[:7], - title=title, - url=f"https://g.test/c/{sha}", - author="J. Doe", - committed_at=_COLLECTED_AT, - ) - - -def _report(*services: Service, jira: JiraState | None = None) -> Report: - return Report(collected_at=_COLLECTED_AT, production_environment="prod", services=list(services), jira=jira) - - -def _issue(key: str, summary: str, status: str, category: str | None, *links: LinkedChange) -> JiraIssue: - return JiraIssue( - key=key, summary=summary, status=status, status_category=category, issue_type="Task", links=list(links) - ) - - -def _linked(project: str, iid: int) -> LinkedChange: - return LinkedChange( - kind="merge_request", - project=project, - project_url=f"https://g.test/{project}", - url=f"https://g.test/{project}/-/merge_requests/{iid}", - iid=iid, - ) - - -_JIRA: typing.Final = JiraState( - issues={ - "SHOP-140": _issue( - "SHOP-140", - "Refund | endpoint", - "In Progress", - "indeterminate", - _linked("acme/web", 7), - _linked("acme/api", 311), - _linked("acme/web", 8), - LinkedChange( - kind="commit", - project="acme/worker", - project_url="https://g.test/acme/worker", - url="https://g.test/acme/worker/-/commit/abc", - sha="abc", - ), - ), - "SHOP-9": _issue("SHOP-9", "Typo", "Done", "done"), - }, - missing=["OPS-1"], -) - - -_API: typing.Final = Service( - project="acme/api", - project_url="https://g.test/acme/api", - default_branch="main", - environments=[_environment("prod", "2.3.0", "https://g.test/d1", tag=True), _environment("preview", "2.4.0")], - rows=[ - Row( - kind="commit", - tags=[], - merge_requests=[], - commits=[_commit("9ac01f2aaaa", "fix | typo")], - jira_keys=[JiraKeyRef(key="SHOP-9", url="https://j.test/browse/SHOP-9")], - environments=[], - main_pipeline=_pipeline(5130, "running"), - ), - Row( - kind="merge_request", - tags=[ - TagRef( - name="2.4.0", - url="https://g.test/t/2.4.0", - pipeline=_pipeline(5120, "failed", _job("smoke")), - ) - ], - merge_requests=[ - _merge_request(311, "SHOP-140 [refund] endpoint", "jdoe"), - _merge_request(312, "Second\nline", None), - ], - commits=[_commit("b72e41daaaa", "Merge branch 'feature/SHOP-140'")], - jira_keys=[ - JiraKeyRef(key="SHOP-140", url="https://j.test/browse/SHOP-140"), - JiraKeyRef(key="OPS-1", url=None), - ], - environments=["preview"], - main_pipeline=_pipeline( - 5118, - "failed", - _job("lint", allow_failure=True), - _job("appsec", downstream="https://g.test/p/9"), - ), - ), - Row( - kind="merge_request", - tags=[TagRef(name="2.3.2", url="https://g.test/t/2.3.2", pipeline=None)], - merge_requests=[_merge_request(305, "Old change", "asmith")], - commits=[_commit("c0ffee0aaaa", "Old change")], - jira_keys=[], - environments=[], - main_pipeline=None, - ), - ], -) -_BILLING: typing.Final = Service( - project="acme/billing", - project_url="https://g.test/acme/billing", - default_branch="main", - environments=[_environment("prod", "1.8.1", "https://g.test/d2")], -) -_BROKEN: typing.Final = Service( - project="acme/broken", - project_url="https://g.test/acme/broken", - error=( - "acme/broken: GitLab denied access to deployments (403). Check that:\n" - "- Environments are enabled: https://g.test/acme/broken/edit\n" - "- the token's user has a role that can read them: https://g.test/acme/broken/-/project_members" - ), -) -_UTILS: typing.Final = Service(project="acme/utils", project_url="https://g.test/acme/utils", warnings=[_WARNING]) - - -def test_page_lists_attention_first_and_collapses_up_to_date_services() -> None: - page: typing.Final = render_markdown(_report(_API, _BILLING, _BROKEN, _UTILS, jira=_JIRA)) - - assert page.splitlines() == [ - "# Release scope", - "", - "Collected 2026-09-29 10:15 UTC. Changes run from the commit on `prod` to the head of the default branch.", - "", - "Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ warning or allowed failure", - "", - "| Service | prod | preview | Pending | Compare | Jira | Failed jobs |", - "|---|---|---|---|---|---|---|", - "| [acme/broken](https://g.test/acme/broken) | — | — | ❌ failed to collect | | | |", - "| [acme/utils](https://g.test/acme/utils) | — | — | ⚠️ see below | | | |", - ( - "| [acme/api](https://g.test/acme/api) | [2.3.0](https://g.test/d1) | 2.4.0 " - "| 3 changes · untagged head | [2.3.0...2.4.0](https://g.test/acme/api/-/compare/2.3.0...2.4.0) " - "| 1 not done | ❌ 2 · ⚠️ 1 allowed |" - ), - "", - "
", - "1 service up to date", - "", - "| Service | prod |", - "|---|---|", - "| [acme/billing](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) |", - "", - "
", - "", - "## acme/broken", - "", - "❌ acme/broken: GitLab denied access to deployments (403). Check that:", - "- Environments are enabled: https://g.test/acme/broken/edit", - "- the token's user has a role that can read them: https://g.test/acme/broken/-/project_members", - "", - "## acme/utils", - "", - f"⚠️ {_WARNING}", - "", - "## acme/api", - "", - "prod [2.3.0](https://g.test/d1) · preview 2.4.0 · 3 merge requests, 1 direct commit", - "", - "
", - "3 changes since 2.3.0", - "", - "| Tag | Change | Jira | Related services | Deployed to | Failed jobs |", - "|---|---|---|---|---|---|", - ( - "| | [`9ac01f2`](https://g.test/c/9ac01f2aaaa) fix \\| <b>typo</b> · J. Doe " - "| [SHOP-9](https://j.test/browse/SHOP-9) Typo · Done | | | main 🔄 [5130](https://g.test/p/5130) |" - ), - ( - "| [2.4.0](https://g.test/p/5120) ❌ " - "| [!311](https://g.test/mr/311) SHOP-140 \\[refund\\] endpoint · @jdoe" - "
[!312](https://g.test/mr/312) Second
line " - "| [SHOP-140](https://j.test/browse/SHOP-140) Refund \\| endpoint · In Progress
OPS-1 " - "| [acme/web](https://g.test/acme/web), [acme/worker](https://g.test/acme/worker) " - "| preview " - "| main ❌ [5118](https://g.test/p/5118): [lint](https://g.test/j/lint) (allowed), " - "[appsec](https://g.test/j/appsec) → [child](https://g.test/p/9)" - "
tag 2.4.0: [smoke](https://g.test/j/smoke) |" - ), - ( - "| [2.3.2](https://g.test/t/2.3.2) ⚠️ no pipeline " - "| [!305](https://g.test/mr/305) Old change · @asmith | | | | |" - ), - "", - "
", - ] - - -def test_page_without_changes_or_problems_says_so() -> None: - page: typing.Final = render_markdown(_report(_BILLING)) - - assert "All services are up to date." in page.splitlines() - assert "| Service | prod | Pending | Compare | Failed jobs |" not in page - assert "1 service up to date" in page - - -def test_empty_report_says_nothing_was_collected() -> None: - assert render_markdown(_report()).splitlines()[-1] == "No services were collected." - - -def test_services_up_to_date_are_counted_in_plural() -> None: - other: typing.Final = _BILLING.model_copy(update={"project": "acme/zeta"}) - - assert "2 services up to date" in render_markdown(_report(_BILLING, other)) - - -def test_service_with_changes_keeps_its_warnings_above_the_table() -> None: - warned: typing.Final = _API.model_copy(update={"warnings": ["Stopped after 3 commits; older changes are omitted."]}) - - lines: typing.Final = render_markdown(_report(warned)).splitlines() - - assert lines.index("⚠️ Stopped after 3 commits; older changes are omitted.") < lines.index("
") - - -def test_summary_counts_allowed_failures_apart_from_blocking_ones() -> None: - allowed_only: typing.Final = _API.model_copy( - update={ - "rows": [ - _API.rows[0].model_copy( - update={"main_pipeline": _pipeline(5130, "success", _job("lint", allow_failure=True))} - ) - ] - } - ) - - assert "| 1 change · untagged head | | ⚠️ 1 allowed |" in render_markdown(_report(allowed_only)) - - -def test_link_targets_cannot_break_out_of_markdown() -> None: - odd: typing.Final = _BILLING.model_copy( - update={"project": "acme/odd", "project_url": "https://g.test/a b)c", "rows": _API.rows[:1]} - ) - - assert "[acme/odd](https://g.test/a%20b%29c)" in render_markdown(_report(odd)) - - -def test_single_change_and_single_merge_request_are_singular() -> None: - single: typing.Final = _API.model_copy(update={"rows": _API.rows[2:]}) - - page: typing.Final = render_markdown(_report(single)) - - assert "1 change since 2.3.0" in page - assert "prod [2.3.0](https://g.test/d1) · preview 2.4.0 · 1 merge request" in page - assert "| 1 change |" in page - - -def test_service_with_rows_but_no_production_environment_still_renders() -> None: - orphan: typing.Final = _API.model_copy(update={"environments": [_environment("preview", "2.4.0")]}) - - page: typing.Final = render_markdown(_report(orphan)) - - assert "3 changes" in page - assert "preview 2.4.0 · 3 merge requests, 1 direct commit" in page - assert "| 3 changes · untagged head | | ❌ 2 · ⚠️ 1 allowed |" in page - - -def test_compare_starts_from_the_production_commit_when_it_was_not_deployed_from_a_tag() -> None: - production: typing.Final = EnvironmentState( - name="prod", ref="main", sha="0a1b2c3d4e5f", deployed_at="2026-09-20T00:00:00Z", deployment_url=None - ) - from_branch: typing.Final = _API.model_copy(update={"environments": [production]}) - - assert "| [`0a1b2c3d`...2.4.0](https://g.test/acme/api/-/compare/0a1b2c3d4e5f...2.4.0) |" in render_markdown( - _report(from_branch) - ) - - -def test_compare_quotes_the_tag_but_keeps_its_slashes() -> None: - odd_tag: typing.Final = TagRef(name="release/2.4#1", url="https://g.test/t/x", pipeline=None) - tagged: typing.Final = _API.model_copy( - update={"rows": [_API.rows[0].model_copy(update={"tags": [odd_tag]}), *_API.rows[1:]]} - ) - - assert "| [2.3.0...release/2.4#1](https://g.test/acme/api/-/compare/2.3.0...release/2.4%231) |" in render_markdown( - _report(tagged) - ) - - -def test_pipe_in_a_link_target_does_not_split_the_cell() -> None: - piped: typing.Final = _BILLING.model_copy( - update={"project": "acme/piped", "project_url": "https://g.test/a|b", "rows": _API.rows[:1]} - ) - - assert "[acme/piped](https://g.test/a%7Cb)" in render_markdown(_report(piped)) - - -def test_section_lists_production_first() -> None: - reordered: typing.Final = _API.model_copy(update={"environments": list(reversed(_API.environments))}) - - assert "prod [2.3.0](https://g.test/d1) · preview 2.4.0 · " in render_markdown(_report(reordered)) - - -def test_page_without_jira_lists_bare_keys_and_no_jira_column() -> None: - page: typing.Final = render_markdown(_report(_API)) - - assert "| Service | prod | preview | Pending | Compare | Failed jobs |" in page - assert "| Tag | Change | Jira | Deployed to | Failed jobs |" in page - assert "| [SHOP-140](https://j.test/browse/SHOP-140)
OPS-1 |" in page - - -def test_jira_failure_is_shown_under_the_legend() -> None: - lines: typing.Final = render_markdown( - _report(_API, jira=JiraState(error="Jira rejected the token (401).")), - ).splitlines() - - assert lines[4].startswith("Legend:") - assert lines[6] == "❌ Jira rejected the token (401)." - - -def _scoped(*services: Service) -> Report: - jira: typing.Final = JiraState( - issues={ - "SHOP-140": _JIRA.issues["SHOP-140"].model_copy(update={"url": "https://j.test/browse/SHOP-140"}), - "SHOP-9": _JIRA.issues["SHOP-9"], - }, - missing=["SHOP-404"], - ) - return _report(*services, jira=jira).model_copy(update={"jira_scope": ["SHOP-140", "SHOP-404"]}) - - -_TAG: typing.Final = TagRef(name="2.4.0", url="https://g.test/t/2.4.0", pipeline=_pipeline(5120, "success")) -_SCOPED_API: typing.Final = _API.model_copy( - update={ - "rows": [_API.rows[1].model_copy(update={"linked": True}), _API.rows[2]], - "release": Release(state="pending", tag=_TAG), - } -) - - -def test_scoped_page_names_the_issues_and_the_release_tag() -> None: - lines: typing.Final = render_markdown(_scoped(_SCOPED_API)).splitlines() - - assert lines[0] == "# Release scope: SHOP-140, SHOP-404" - assert lines[2].endswith("Changes run from the commit on `prod` to the latest change linked to the issues.") - assert lines[4:6] == [ - "- [SHOP-140](https://j.test/browse/SHOP-140) Refund \\| endpoint · In Progress", - "- SHOP-404 · not found in Jira", - ] - assert lines[7].endswith(" · 🎯 linked to the issues") - assert ( - "| 2 changes · release [2.4.0](https://g.test/p/5120) ✅ " - "| [2.3.0...2.4.0](https://g.test/acme/api/-/compare/2.3.0...2.4.0) | 1 not done | ❌ 2 · ⚠️ 1 allowed |" - ) in lines[11] - assert any(line.startswith("| [2.4.0](https://g.test/p/5120) ❌ | 🎯 [!311]") for line in lines) - - -def test_scoped_service_without_a_tag_needs_one() -> None: - untagged: typing.Final = _SCOPED_API.model_copy(update={"release": Release(state="pending")}) - - assert "| 2 changes · needs a new tag | |" in render_markdown(_scoped(untagged)) - - -def test_scoped_compare_goes_to_the_release_tag() -> None: - later: typing.Final = _SCOPED_API.model_copy( - update={"release": Release(state="pending", tag=_TAG.model_copy(update={"name": "2.4.1"}))} - ) - - assert "(https://g.test/acme/api/-/compare/2.3.0...2.4.1) |" in render_markdown(_scoped(later)) - - -def test_scoped_service_with_unmerged_work_asks_for_attention() -> None: - waiting: typing.Final = _BILLING.model_copy( - update={ - "release": Release( - state="not_merged", - pending_merge_requests=[ - MergeRequestRef(iid=7, title="WIP", url="https://g.test/mr/7", author="jdoe", merged_at=None) - ], - ) - } - ) - lost: typing.Final = _BILLING.model_copy(update={"project": "acme/lost", "release": Release(state="not_found")}) - - page: typing.Final = render_markdown(_scoped(waiting, lost)) - - assert ( - "| [acme/billing](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) | ⏳ not merged | | | |" in page - ) - assert ( - "| [acme/lost](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) | ⚠️ linked change not found | | | |" - in page - ) - assert "⏳ Not merged: [!7](https://g.test/mr/7) WIP · @jdoe" in page.splitlines() - - -def test_scoped_service_already_in_production_is_up_to_date() -> None: - shipped: typing.Final = _BILLING.model_copy(update={"release": Release(state="in_production")}) - - assert "1 service up to date" in render_markdown(_scoped(shipped)) diff --git a/tests/test_scope.py b/tests/test_scope.py index bbe9c32..51dde78 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -16,6 +16,7 @@ ENDPOINT, JIRA_ENDPOINT, SERVICE_API, + TAGS, commit, merge_request, remote_link, @@ -58,17 +59,32 @@ def test_scope_collects_every_service_the_issues_link_to() -> None: @pytest.mark.httpx2(assert_all_called=False) @pytest.mark.usefixtures("scoped") -def test_rows_run_from_production_to_the_latest_linked_change() -> None: +def test_rows_above_the_latest_linked_change_are_kept_out_of_scope() -> None: service: typing.Final = _service(_scope()) - assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[12], [11], [10], [9]] - assert [row.linked for row in service.rows] == [True, False, False, False] + assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[], [12], [11], [10], [9]] + assert [row.linked for row in service.rows] == [False, True, False, False, False] + assert [row.in_scope for row in service.rows] == [False, True, True, True, True] assert service.release is not None assert service.release.state == "pending" assert service.release.tag is not None assert service.release.tag.name == "1.2.0" +@pytest.mark.httpx2(assert_all_called=False) +def test_tag_above_the_target_is_a_candidate_without_out_of_scope_issues(scoped: respx.Router) -> None: + scoped["tags"].respond(json=[{"name": "1.3.0", "commit": {"id": "head"}}, *TAGS]) + scoped.get(f"{SERVICE_API}/pipelines", params={"ref": "1.3.0"}, name="pipeline:1.3.0").respond(json=[]) + + candidates: typing.Final = _service(_scope()).candidates + + assert [(item.tag.name, item.rows, [key.key for key in item.jira_keys]) for item in candidates] == [ + ("1.3.0", 5, ["SHOP-12", "SHOP-13"]), + ("1.2.0", 4, ["SHOP-12", "SHOP-13"]), + ("1.1.0", 2, []), + ] + + @pytest.mark.httpx2(assert_all_called=False) def test_release_tag_is_the_nearest_tag_above_an_untagged_target(scoped: respx.Router) -> None: scoped["remote_links:SHOP-12"].respond(json=_links(f"{ENDPOINT}/team/svc/-/merge_requests/11")) @@ -78,7 +94,8 @@ def test_release_tag_is_the_nearest_tag_above_an_untagged_target(scoped: respx.R service: typing.Final = _service(_scope()) - assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[11], [10], [9]] + assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[], [12], [11], [10], [9]] + assert [row.in_scope for row in service.rows] == [False, False, True, True, True] assert service.release is not None assert service.release.tag is not None assert service.release.tag.name == "1.2.0" diff --git a/tests/test_use_case.py b/tests/test_use_case.py index f944f16..f9d90b5 100644 --- a/tests/test_use_case.py +++ b/tests/test_use_case.py @@ -22,8 +22,11 @@ JIRA_ENDPOINT, JIRA_ISSUE_API, JIRA_ISSUES, + PRODUCTION_DEPLOYMENT, PUSH_PIPELINES, SERVICE, + SERVICE_API, + TAGS, commit, jira_issue, jira_page, @@ -103,6 +106,42 @@ def test_tags_carry_their_latest_pipeline() -> None: assert untagged_pipeline.pipeline is None +@pytest.mark.usefixtures("gitlab") +def test_each_tag_in_the_range_is_a_candidate_carrying_everything_down_to_production() -> None: + candidates: typing.Final = _only_service(_collect()).candidates + + assert [(item.tag.name, item.rows, [key.key for key in item.jira_keys]) for item in candidates] == [ + ("1.2.0", 4, ["SHOP-12", "SHOP-13"]), + ("1.1.0", 2, []), + ] + assert [item.compare_url for item in candidates] == [ + f"{ENDPOINT}/team/svc/-/compare/1.0.0...1.2.0", + f"{ENDPOINT}/team/svc/-/compare/1.0.0...1.1.0", + ] + assert candidates[0].tag.pipeline is not None + assert candidates[0].tag.pipeline.id == 201 + + +def test_each_tag_of_a_row_is_a_candidate_shipping_the_same_rows(gitlab: respx.Router) -> None: + gitlab["tags"].respond(json=[{"name": "1.2.0-rc", "commit": {"id": "c3"}}, *TAGS]) + gitlab.get(f"{SERVICE_API}/pipelines", params={"ref": "1.2.0-rc"}, name="pipeline:1.2.0-rc").respond(json=[]) + + candidates: typing.Final = _only_service(_collect()).candidates + + assert [(item.tag.name, item.rows, [key.key for key in item.jira_keys]) for item in candidates[:2]] == [ + ("1.2.0-rc", 4, ["SHOP-12", "SHOP-13"]), + ("1.2.0", 4, ["SHOP-12", "SHOP-13"]), + ] + + +def test_candidate_compares_from_the_production_commit_when_production_runs_a_branch(gitlab: respx.Router) -> None: + gitlab["deploy:production"].respond(json=[{**PRODUCTION_DEPLOYMENT, "ref": "main", "deployable": None}]) + + candidates: typing.Final = _only_service(_collect()).candidates + + assert candidates[0].compare_url == f"{ENDPOINT}/team/svc/-/compare/prod...1.2.0" + + @pytest.mark.usefixtures("gitlab") def test_main_pipeline_is_the_latest_push_pipeline_with_its_failed_jobs() -> None: rows: typing.Final = _only_service(_collect()).rows @@ -476,6 +515,7 @@ def test_range_spanning_pages_is_read_to_the_end(gitlab: respx.Router) -> None: service: typing.Final = _only_service(_collect()) assert len(service.rows) == 5 + assert not service.truncated assert [call.request.url.params["page"] for call in gitlab["commits"].calls] == ["1", "2"] @@ -487,6 +527,7 @@ def test_long_range_is_truncated_with_a_warning(gitlab: respx.Router) -> None: assert [row.commits[0].sha for row in service.rows] == ["head", "c3"] assert service.warnings == ["Stopped after 2 commits; older changes are omitted."] + assert service.truncated assert gitlab["commits"].call_count == 1