mirror of
https://github.com/penpot/penpot.git
synced 2026-10-10 05:01:54 -04:00
Merge remote-tracking branch 'origin/main' into staging
This commit is contained in:
commit
84f068aca7
13 files changed
+1957
-846
No files matched your search
@@ -508,6 +508,7 @@ JSON, REPL access, and so on.
|
||||
| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. |
|
||||
| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
|
||||
| [`update-changelog`](skills/update-changelog/SKILL.md) | Regenerates `CHANGES.md` from a GitHub milestone. |
|
||||
| [`find-project-anomalies`](skills/find-project-anomalies/SKILL.md) | Checks a milestone against the Main project board and reports anomalies to `tmp/<MILESTONE>-ANOMALIES.md`. |
|
||||
|
||||
### A typical round
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: find-project-anomalies
|
||||
description: Check a GitHub milestone against the Main project board, report the five anomaly types to tmp/<MILESTONE>-ANOMALIES.md, and fix missing milestone assignments on request.
|
||||
---
|
||||
|
||||
# Skill: find-project-anomalies
|
||||
|
||||
Check every issue and PR in a given GitHub milestone against the Main
|
||||
project board. Report the five anomaly types below, then fix the ones a
|
||||
human confirms.
|
||||
|
||||
## Getting the version ($ARGUMENTS)
|
||||
|
||||
The milestone version comes from the skill arguments (`$ARGUMENTS`), the
|
||||
same way other skills receive theirs. Accept `2.17.0`, `v2.17.0`,
|
||||
`milestone 2.17.0`, or any text clearly containing one `X.Y.Z` version.
|
||||
|
||||
If no version is given and none can be deduced from the arguments or the
|
||||
conversation context, **stop and ask the user** which milestone to check.
|
||||
Do not guess, do not default to the latest release.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before a release, to catch board/milestone drift while there is still time
|
||||
to fix it
|
||||
- After triage sessions, to verify every milestone item is tracked, owned,
|
||||
and correctly assigned
|
||||
- Whenever someone asks "is milestone X.Y.Z clean on Main?"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Python 3.8+
|
||||
- `scripts/gh.py` for GitHub queries, `scripts/project-anomalies.py` for
|
||||
detection and reporting (`python3 scripts/project-anomalies.py check --help`)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Resolve the version (see above), then run the check
|
||||
|
||||
```bash
|
||||
python3 scripts/project-anomalies.py check "2.17.0"
|
||||
# explicit path:
|
||||
python3 scripts/project-anomalies.py check "2.17.0" --output tmp/2.17.0-ANOMALIES.md
|
||||
```
|
||||
|
||||
This fetches milestone issues and PRs in bulk (one GraphQL call per 50
|
||||
items), resolves outsiders the same way, and overwrites
|
||||
`tmp/<MILESTONE>-ANOMALIES.md`. Every number renders as a full
|
||||
`[#N](https://github.com/penpot/penpot/issues/N)` or
|
||||
`[#N](https://github.com/penpot/penpot/pull/N)` link.
|
||||
|
||||
### 2. Read the report and judge each anomaly
|
||||
|
||||
The five types, in report order:
|
||||
|
||||
1. **OPEN issue with a MERGED PR** — the fix landed but the issue never
|
||||
closed (or it reopened). Fix: close the issue, or move it out of the
|
||||
milestone if the fix did not actually land here.
|
||||
2. **Milestone issue, PR elsewhere or issue off Main** — a *merged* closing
|
||||
PR in another milestone (or none), or the issue itself not on the Main
|
||||
board. An unmerged PR's milestone means nothing (it landed nowhere), so
|
||||
it never triggers this type. Fix: align the milestones (see step 3), or
|
||||
add the issue to Main.
|
||||
3. **Milestone PR, issue elsewhere or off Main** — the mirror view over
|
||||
*merged* PRs only: a PR released here closes an issue tracked elsewhere
|
||||
(or nowhere on Main). Unmerged PRs never trigger this type — their
|
||||
milestone means nothing yet. Fix: align the milestones (see step 3), or
|
||||
add the issue to Main.
|
||||
4. **MERGED PR on a `needs triage` issue** — landed without triage. Only a
|
||||
human can triage; never auto-remove the label.
|
||||
5. **MERGED PR on an unassigned, non-community issue** — no owner and no
|
||||
`community contribution` label (checked on both issue and PR). Either
|
||||
assign an owner or confirm it is community work. Never invent an owner.
|
||||
|
||||
Scope notes (deliberate, not bugs):
|
||||
- Project membership is checked **on the issue side only**. PRs are not the
|
||||
unit tracked on Main, so an unprojected PR alone is never an anomaly.
|
||||
- An issue with *no* milestone closed by a milestone PR **is** reported
|
||||
here (type 3): unlike the changelog flow, this pairing always needs a
|
||||
human look.
|
||||
|
||||
### 3. Fix missing milestone assignments (only these, only on confirmation)
|
||||
|
||||
The only mechanical fix in this skill is assigning a missing milestone.
|
||||
The report already prints the exact command next to each such case:
|
||||
|
||||
```bash
|
||||
gh pr edit <PR> --milestone "<MILESTONE>"
|
||||
gh issue edit <ISSUE> --milestone "<MILESTONE>"
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Apply **only** when one side lacks a milestone. When both sides have
|
||||
*different* milestones, a human decides which one moves — never pick a
|
||||
side yourself.
|
||||
- Confirm **each fix (or each small batch)** with the user before running
|
||||
it. Read back what the command will change.
|
||||
- Everything else (closing issues, triaging, assigning owners, adding
|
||||
items to the Main board) is human work: point at it, do not do it.
|
||||
|
||||
### 4. Re-run until clean
|
||||
|
||||
After fixes land, re-run step 1 and confirm the report shows zero
|
||||
anomalies. The `tmp/` report is scratch output (gitignored): quote or
|
||||
paste entries into chat when reporting back, do not commit it.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **No version, no run.** Stop and ask when the milestone is unknown.
|
||||
- **Report first, touch nothing.** No board or milestone changes without
|
||||
explicit per-item confirmation.
|
||||
- **Milestone gaps auto-fix; judgment calls do not.** Only a missing
|
||||
milestone is mechanical. triage, ownership, and close/reopen decisions
|
||||
belong to humans.
|
||||
- **Every number clickable.** The report is useless without full GitHub links.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -110,6 +110,7 @@ opencode.json
|
||||
/.opencode/reports
|
||||
/.opencode/prompts
|
||||
/.ci-logs
|
||||
/tmp/
|
||||
/.codex/
|
||||
/tools/__pycache__
|
||||
/scripts/__pycache__
|
||||
@@ -55,6 +55,8 @@ The memory is structured in a way that you can get the critical information abou
|
||||
- `scripts/psql` — PostgreSQL client wrapper with devenv defaults. Companion: `scripts/db-schema` for DDL dumps. See `mem:scripts/psql`.
|
||||
- `scripts/taiga.py` — Fetch public issues, user stories, and tasks from the Penpot Taiga project without authentication. See `mem:scripts/taiga`.
|
||||
- `scripts/gh.py` — GitHub operations helper: list milestone issues, fetch PR details, compare against CHANGES.md. Requires `gh` CLI. See `mem:scripts/gh`.
|
||||
- `scripts/project-anomalies.py` — Main-board anomaly check for a milestone (`check` writes `tmp/<MILESTONE>-ANOMALIES.md`). Bulk resolution via `scripts/gh.py`. See `mem:scripts/project-anomalies`.
|
||||
- `scripts/changelog.py` — Changelog checks for the `update-changelog` skill: `check-merged` (PR merge status), `cross-ref` (milestone PRs vs changelog section), `report` (anomaly report to CHANGES-ISSUES.md). Calls `scripts/gh.py` via subprocess. See `mem:scripts/changelog`.
|
||||
- `scripts/error-reports.mjs` — Query error reports via RPC API with token authentication. Supports list/get operations with filtering and pagination. See `mem:scripts/error-reports`.
|
||||
- `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm workspaces (root, modules, member packages). Keeps the shared pnpm store at `<repo>/.pnpm-store` unless `--store`; ignores `external/` and `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`.
|
||||
- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module (`frontend backend common render-wasm exporter mcp plugins library`). Logs go to `.ci-logs/`; read the log file on failure. See `mem:scripts/ci`.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog helper
|
||||
|
||||
`scripts/changelog.py` holds the checks used by the `update-changelog` skill (extracted from the skill file so the skill stays workflow-only). Calls `scripts/gh.py` via subprocess; never call the GitHub API directly from this script.
|
||||
|
||||
## Subcommands
|
||||
|
||||
- `check-merged <PR...>` (`--file`, `--stdin` accepted) — warn on any non-merged PR, exit 1 when found; run before writing changelog entries.
|
||||
- `cross-ref <MILESTONE> [--changes CHANGES.md]` — fetch milestone PRs with `--state all` once, report merged PRs missing from the version section plus CLOSED (unmerged) warnings; GHSA entries never appear here by design.
|
||||
- `report <MILESTONE> [--changes CHANGES.md --output CHANGES-ISSUES.md]` — overwrite the anomaly report (milestone mismatches type A/B plus `:rocket:` gaps C/D); rule violations never belong in the report, they are fixed in CHANGES.md first.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Milestone resolution is batched, never per-item: unknown PRs go through `gh.py prs <numbers>` and unknown issues through `gh.py issue <numbers>` (one GraphQL call per 50 items); a lookup miss resolves to null, never to an extra call.
|
||||
|
||||
- Version-section lookup accepts an optional `(suffix)` after the version header (e.g. `(Unreleased)`); a missing section is an error for `cross-ref` and an empty section for `report`.
|
||||
- PR/issue reference parsing covers standard `[#N](.../pull/N)` / `[#N](.../issues/N)` links plus legacy `PR:[N]` / `[Github #N]` forms.
|
||||
- Shared parsing helpers (`extract_version_section`, `extract_subsection`, `collect_changelog_prs/issues`) live at module top so future subcommands reuse them instead of duplicating regexes.
|
||||
- Report anomaly types: A/B milestone mismatches, E `:boom:` entry whose issue lacks the `breaking change` label (counted, entries preserved — human labels the issue or moves the entry); C/D are `:rocket:` gaps (warnings, never counted).
|
||||
@@ -49,11 +49,13 @@ python3 scripts/gh.py issues "2.16.0" --compare CHANGES.md
|
||||
- Issues with type "Task" are excluded (`--include-tasks` to keep them).
|
||||
- Issues with "Rejected" project status are excluded (`--include-rejected` to keep them).
|
||||
|
||||
Every issue entry carries `assignees` (logins) and `projects` (board titles); every PR entry carries `assignees`.
|
||||
|
||||
**Output**: JSON array to stdout; progress to stderr.
|
||||
|
||||
### `prs`
|
||||
|
||||
Fetch PR details by number or by milestone.
|
||||
Fetch PR details by number or by milestone. Every entry includes a `milestone` field (title or null).
|
||||
|
||||
```bash
|
||||
# Fetch specific PRs
|
||||
@@ -74,6 +76,12 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
|
||||
**Output**: JSON array to stdout; progress to stderr.
|
||||
|
||||
### `issue`
|
||||
|
||||
Fetch issue details by number in batches of 50 (same input styles as `prs`: numbers, `--file`, `--stdin`). No filters. A dead number fails its whole batch (unlike PRs, issues error instead of resolving to null), so the batch falls back to one-by-one lookups and reports misses as `{"number", "error": "not_found"}`. Any other failure aborts.
|
||||
|
||||
**Output**: JSON array to stdout; progress to stderr.
|
||||
|
||||
### `link-issue`
|
||||
|
||||
Explicitly assign a GitHub issue to a pull request:
|
||||
@@ -111,7 +119,10 @@ python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
|
||||
|
||||
## Key principles
|
||||
|
||||
- Changelog-specific checks (merge-status verification, milestone-vs-changelog cross-reference, anomaly report) live in `scripts/changelog.py`, which calls this script via subprocess — see `mem:scripts/changelog`.
|
||||
|
||||
- All output is JSON — pipe into `jq` or other tools for further processing.
|
||||
- Transient HTTP 504 responses from `gh` are retried automatically (3 attempts, 5s/15s backoff); any other error fails fast with no retry.
|
||||
- Milestone lookup is by exact title match.
|
||||
- `issues` subcommand auto-paginates (100 items per page).
|
||||
- `prs` subcommand batches PR number lookups (50 per GraphQL query).
|
||||
@@ -0,0 +1,17 @@
|
||||
# Project anomalies helper
|
||||
|
||||
`scripts/project-anomalies.py` implements the `find-project-anomalies` skill: `check <MILESTONE>` fetches milestone issues/PRs with `--state all` via `scripts/gh.py`, resolves outsiders in bulk (one GraphQL call per 50 items), and writes `tmp/<MILESTONE>-ANOMALIES.md` (gitignored scratch output, every number a full GitHub link).
|
||||
|
||||
## Anomaly types (all scoped to the milestone)
|
||||
|
||||
- Open-with-merged-PR: OPEN issue with a MERGED closing PR (close it or move it out).
|
||||
- Issue-PR mismatch: milestone issue whose *merged* closing PR is elsewhere/nowhere (an unmerged PR's milestone is irrelevant), or issue off the Main board.
|
||||
- PR-issue mismatch: *merged* milestone PR closing an issue elsewhere/nowhere (milestone-less issues ARE reported here, unlike the changelog flow), or off the Main board. Unmerged PRs never count anywhere: their milestone is meaningless.
|
||||
- Needs-triage: MERGED PR on a `needs triage` issue (human triage only, never auto-unlabel).
|
||||
- Unassigned: MERGED PR on an issue with no assignees and no `community contribution` label on either side (valid states: assigned, or unassigned+community).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Project membership is checked on the issue side only (`projects` list from `gh.py`); PRs are not the Main-tracked unit, so an unprojected PR alone is never an anomaly.
|
||||
- Detection (`find_anomalies`) and rendering (`render_report`) are pure functions over pre-resolved dicts; outsiders must be merged into the lookup maps first (missing numbers are skipped, never flagged).
|
||||
- The only mechanical fix is assigning a missing milestone (`gh pr/issue edit --milestone`); everything else is human judgment with per-item confirmation.
|
||||
@@ -308,6 +308,7 @@
|
||||
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
|
||||
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
|
||||
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
|
||||
- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@@ -415,6 +416,14 @@
|
||||
- Fix text shape position-data to include required fills in WASM and DOM calculation paths [#10646](https://github.com/penpot/penpot/issues/10646) (PR: [#10650](https://github.com/penpot/penpot/pull/10650))
|
||||
- Log expired OIDC tokens as auth failures instead of server errors [#10635](https://github.com/penpot/penpot/issues/10635) (PR: [#10636](https://github.com/penpot/penpot/pull/10636))
|
||||
- Return 400 instead of 500 when ImageMagick rejects invalid uploaded images [#10642](https://github.com/penpot/penpot/issues/10642) (PR: [#10643](https://github.com/penpot/penpot/pull/10643))
|
||||
- Fix Plugin API board.guides setting and clearing throwing malli invalid-schema error (by @filipsajdak) [#9773](https://github.com/penpot/penpot/issues/9773) (PR: [#10503](https://github.com/penpot/penpot/pull/10503))
|
||||
- Fix Plugin API rejecting open-overlay and toggle-overlay interactions when position is not set (by @mvanhorn) [#10201](https://github.com/penpot/penpot/issues/10201) (PR: [#10503](https://github.com/penpot/penpot/pull/10503))
|
||||
- Fix Plugin API close-overlay interaction requiring animation field (by @mvanhorn) [#10202](https://github.com/penpot/penpot/issues/10202) (PR: [#10503](https://github.com/penpot/penpot/pull/10503))
|
||||
- Fix boards hidden from View Mode reappearing when adding prototype interactions (by @jeffrey701) [#10289](https://github.com/penpot/penpot/issues/10289) (PR: [#9695](https://github.com/penpot/penpot/pull/9695))
|
||||
- Fix Plugin API PenpotUtils.createVariantContainer assigning variant properties to wrong components [#10506](https://github.com/penpot/penpot/issues/10506) (PR: [#10562](https://github.com/penpot/penpot/pull/10562))
|
||||
- Fix mask inside flex layout shifting position or size when child image visibility changes [#10537](https://github.com/penpot/penpot/issues/10537) (PR: [#10697](https://github.com/penpot/penpot/pull/10697))
|
||||
- Fix path ends selector missing arrow icons and incorrect divider colors [#10593](https://github.com/penpot/penpot/issues/10593) (PR: [#10631](https://github.com/penpot/penpot/pull/10631))
|
||||
- Fix webhook form prefilling hardcoded metadata URI default [#10722](https://github.com/penpot/penpot/issues/10722) (PR: [#10723](https://github.com/penpot/penpot/pull/10723))
|
||||
|
||||
## 2.16.2
|
||||
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
changelog.py — Helper commands for the ``update-changelog`` skill.
|
||||
|
||||
Uses ``scripts/gh.py`` (via subprocess) and the local ``CHANGES.md`` so the
|
||||
skill file itself stays free of inline programs.
|
||||
|
||||
Subcommands:
|
||||
check-merged Verify that every given PR is merged (warns otherwise)
|
||||
cross-ref Compare merged milestone PRs against the changelog section
|
||||
report Generate the anomaly report (CHANGES-ISSUES.md)
|
||||
|
||||
Usage:
|
||||
python3 scripts/changelog.py check-merged 9179 9204 9311
|
||||
cat prs.txt | python3 scripts/changelog.py check-merged --stdin
|
||||
python3 scripts/changelog.py cross-ref "2.16.0"
|
||||
python3 scripts/changelog.py report "2.16.0"
|
||||
python3 scripts/changelog.py report "2.16.0" --changes CHANGES.md --output CHANGES-ISSUES.md
|
||||
|
||||
Prerequisites:
|
||||
- gh CLI authenticated (gh auth status)
|
||||
- Python 3.8+
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = "penpot/penpot"
|
||||
GH_PY = Path(__file__).resolve().parent / "gh.py"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Shared helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_gh_py(*args: str, input_text: str | None = None) -> str:
|
||||
"""Run ``scripts/gh.py`` with the given arguments, return stdout."""
|
||||
cmd = [sys.executable, str(GH_PY), *args]
|
||||
result = subprocess.run(cmd, input=input_text, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"gh.py error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def read_pr_numbers(args: argparse.Namespace) -> list[int]:
|
||||
"""Collect PR numbers from positional args, --file, and/or --stdin."""
|
||||
numbers: list[int] = []
|
||||
if args.numbers:
|
||||
numbers.extend(args.numbers)
|
||||
if args.file:
|
||||
with open(args.file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
numbers.append(int(line))
|
||||
if args.stdin:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if line:
|
||||
numbers.append(int(line))
|
||||
seen: set[int] = set()
|
||||
return [n for n in numbers if not (n in seen or seen.add(n))]
|
||||
|
||||
|
||||
def fetch_prs_by_numbers(pr_numbers: list[int]) -> list[dict]:
|
||||
"""Fetch PR details via ``gh.py prs`` for explicit PR numbers."""
|
||||
stdout = run_gh_py("prs", *[str(n) for n in pr_numbers])
|
||||
return json.loads(stdout)
|
||||
|
||||
|
||||
def fetch_milestone_prs(milestone: str, state: str) -> list[dict]:
|
||||
"""Fetch all PRs in a milestone via ``gh.py prs --milestone``."""
|
||||
stdout = run_gh_py("prs", "--milestone", milestone, "--state", state)
|
||||
return json.loads(stdout)
|
||||
|
||||
|
||||
def extract_version_section(content: str, milestone: str) -> str:
|
||||
"""Return the changelog body of a ``## <VERSION>`` section (or "")."""
|
||||
match = re.search(
|
||||
rf"^## {re.escape(milestone)}(?:\s*\([^)]*\))?\n(.*?)(?=^## |\Z)",
|
||||
content,
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def extract_subsection(section: str, heading_re: str) -> str:
|
||||
"""Return the body of a ``### <heading>`` subsection (or "")."""
|
||||
match = re.search(rf"^{heading_re}", section, re.MULTILINE)
|
||||
if not match:
|
||||
return ""
|
||||
body = section[match.end():]
|
||||
return re.split(r"(?m)^#{2,3}\s", body)[0]
|
||||
|
||||
|
||||
def collect_changelog_prs(section: str) -> set[int]:
|
||||
"""Collect all PR numbers referenced in a changelog section."""
|
||||
numbers = set()
|
||||
for num in re.findall(
|
||||
r"\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)", section
|
||||
):
|
||||
numbers.add(int(num))
|
||||
for num in re.findall(r"PR:\[(\d+)\]", section):
|
||||
numbers.add(int(num))
|
||||
return numbers
|
||||
|
||||
|
||||
def collect_changelog_issues(section: str) -> set[int]:
|
||||
"""Collect all issue numbers referenced in a changelog section."""
|
||||
numbers = set()
|
||||
for num in re.findall(
|
||||
r"\[#(\d+)\]\(https://github\.com/penpot/penpot/issues/\d+\)", section
|
||||
):
|
||||
numbers.add(int(num))
|
||||
for num in re.findall(r"\[Github #(\d+)\]", section):
|
||||
numbers.add(int(num))
|
||||
return numbers
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Subcommand: check-merged
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_check_merged(args: argparse.Namespace) -> None:
|
||||
"""Warn about any given PR that is not merged. Exits 1 when found."""
|
||||
pr_numbers = read_pr_numbers(args)
|
||||
if not pr_numbers:
|
||||
print(
|
||||
"ERROR: no PR numbers provided (pass numbers, --file, or --stdin)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
prs = fetch_prs_by_numbers(pr_numbers)
|
||||
bad = 0
|
||||
for pr in prs:
|
||||
number = pr.get("number")
|
||||
state = pr.get("state")
|
||||
if state != "MERGED":
|
||||
bad += 1
|
||||
print(f"WARNING: #{number} is {state} (not merged)")
|
||||
|
||||
if bad:
|
||||
print(f"{bad} of {len(prs)} PRs are NOT merged", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"OK: all {len(prs)} PRs are merged", file=sys.stderr)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Subcommand: cross-ref
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_cross_ref(args: argparse.Namespace) -> None:
|
||||
"""Compare merged milestone PRs against the changelog section."""
|
||||
with open(args.changes) as f:
|
||||
content = f.read()
|
||||
section = extract_version_section(content, args.milestone)
|
||||
if not section:
|
||||
print(
|
||||
f'ERROR: no "## {args.milestone}" section found in {args.changes}',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
changelog_refs = collect_changelog_prs(section)
|
||||
milestone_prs = fetch_milestone_prs(args.milestone, "all")
|
||||
|
||||
merged = {pr["number"] for pr in milestone_prs if pr.get("state") == "MERGED"}
|
||||
closed = [pr for pr in milestone_prs if pr.get("state") == "CLOSED"]
|
||||
|
||||
missing = sorted(merged - changelog_refs)
|
||||
print(f"Milestone merged PRs: {len(merged)}")
|
||||
print(f"Changelog referenced PRs: {len(changelog_refs)}")
|
||||
print(f"PRs in milestone but NOT in changelog: {len(missing)}")
|
||||
for num in missing:
|
||||
pr = next(p for p in milestone_prs if p["number"] == num)
|
||||
print(f" #{num} {pr['title'][:80]}")
|
||||
|
||||
if closed:
|
||||
print("WARNING: CLOSED (unmerged) PRs in milestone:")
|
||||
for pr in closed:
|
||||
print(f" #{pr['number']} {pr['title'][:80]}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Subcommand: report
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def issue_url(n: int) -> str:
|
||||
return f"https://github.com/{REPO}/issues/{n}"
|
||||
|
||||
|
||||
def pr_url(n: int) -> str:
|
||||
return f"https://github.com/{REPO}/pull/{n}"
|
||||
|
||||
|
||||
def issue_link(n: int) -> str:
|
||||
return f"[#{n}]({issue_url(n)})"
|
||||
|
||||
|
||||
def pr_link(n: int) -> str:
|
||||
return f"[#{n}]({pr_url(n)})"
|
||||
|
||||
|
||||
def issue_link_title(n: int, title: str) -> str:
|
||||
url = issue_url(n)
|
||||
if title:
|
||||
return f"[#{n}]({url}) — [{title}]({url})"
|
||||
return f"[#{n}]({url})"
|
||||
|
||||
|
||||
def pr_link_title(n: int, title: str) -> str:
|
||||
url = pr_url(n)
|
||||
if title:
|
||||
return f"[#{n}]({url}) — [{title}]({url})"
|
||||
return f"[#{n}]({url})"
|
||||
|
||||
|
||||
def cmd_report(args: argparse.Namespace) -> None:
|
||||
"""Generate the changelog anomaly report and write it to a file."""
|
||||
milestone = args.milestone
|
||||
|
||||
all_issues = json.loads(run_gh_py("issues", milestone, "--state", "all"))
|
||||
issue_by_num = {i["number"]: i for i in all_issues}
|
||||
|
||||
all_prs = fetch_milestone_prs(milestone, "all")
|
||||
pr_by_num = {p["number"]: p for p in all_prs}
|
||||
|
||||
with open(args.changes) as f:
|
||||
content = f.read()
|
||||
section = extract_version_section(content, milestone)
|
||||
changelog_issues = collect_changelog_issues(section)
|
||||
changelog_prs = collect_changelog_prs(section)
|
||||
|
||||
# PRs and issues returned by milestone queries are KNOWN to be in the
|
||||
# milestone. Everything else is resolved in bulk via batched gh.py
|
||||
# lookups (one GraphQL call per 50 items, never one call per item).
|
||||
pr_milestone_cache = {p["number"]: milestone for p in all_prs}
|
||||
issue_milestone_cache = {i["number"]: milestone for i in all_issues}
|
||||
|
||||
unknown_prs = set()
|
||||
for issue in issue_by_num.values():
|
||||
unknown_prs.update(issue.get("closing_prs", []))
|
||||
unknown_prs -= set(pr_milestone_cache)
|
||||
if unknown_prs:
|
||||
print(
|
||||
f"Resolving milestones for {len(unknown_prs)} PRs outside {milestone}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for pr in json.loads(run_gh_py("prs", *[str(n) for n in sorted(unknown_prs)])):
|
||||
pr_milestone_cache[pr["number"]] = pr.get("milestone")
|
||||
|
||||
unknown_issues = set()
|
||||
for pr in pr_by_num.values():
|
||||
unknown_issues.update(pr.get("closing_issues", []))
|
||||
unknown_issues |= collect_changelog_issues(extract_subsection(section, r"### :boom:"))
|
||||
unknown_issues -= set(issue_milestone_cache)
|
||||
external_issues: dict[int, dict] = {}
|
||||
if unknown_issues:
|
||||
print(
|
||||
f"Resolving milestones for {len(unknown_issues)} issues outside {milestone}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for issue in json.loads(
|
||||
run_gh_py("issue", *[str(n) for n in sorted(unknown_issues)])
|
||||
):
|
||||
issue_milestone_cache[issue["number"]] = issue.get("milestone")
|
||||
if "error" not in issue:
|
||||
external_issues[issue["number"]] = issue
|
||||
|
||||
def get_issue_labels(issue_num: int) -> list[str] | None:
|
||||
"""Return the labels of an issue, or None when they cannot be known."""
|
||||
issue = issue_by_num.get(issue_num)
|
||||
if issue is not None:
|
||||
return issue.get("labels", [])
|
||||
issue = external_issues.get(issue_num)
|
||||
if issue is not None:
|
||||
return issue.get("labels", [])
|
||||
return None
|
||||
|
||||
def get_pr_milestone(pr_num: int) -> str | None:
|
||||
"""Return the milestone title for a PR, or None if unassigned."""
|
||||
return pr_milestone_cache.get(pr_num)
|
||||
|
||||
def get_issue_milestone(issue_num: int) -> str | None:
|
||||
"""Return the milestone title for an issue, or None if unassigned."""
|
||||
return issue_milestone_cache.get(issue_num)
|
||||
|
||||
excluded_labels = {"release blocker", "no changelog"}
|
||||
|
||||
def issue_excluded(issue: dict | None) -> bool:
|
||||
if not issue:
|
||||
return True
|
||||
if issue.get("state") != "CLOSED":
|
||||
return True
|
||||
if issue.get("issue_type") in {"Task"}:
|
||||
return True
|
||||
if issue.get("project_status") in {"Rejected"}:
|
||||
return True
|
||||
if excluded_labels & set(issue.get("labels", [])):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Type A: issue in milestone, referenced PR in different milestone or none
|
||||
anomalies_a = []
|
||||
for issue_num in sorted(changelog_issues):
|
||||
issue = issue_by_num.get(issue_num)
|
||||
if not issue:
|
||||
continue
|
||||
if get_issue_milestone(issue_num) != milestone:
|
||||
continue
|
||||
for pr_num in issue.get("closing_prs", []):
|
||||
pr_ms = get_pr_milestone(pr_num)
|
||||
if pr_ms != milestone:
|
||||
anomalies_a.append(
|
||||
{
|
||||
"issue": issue_num,
|
||||
"issue_title": issue.get("title", ""),
|
||||
"pr": pr_num,
|
||||
"pr_milestone": pr_ms,
|
||||
}
|
||||
)
|
||||
|
||||
# Type B: PR in milestone, the issue it closes is in a different milestone.
|
||||
# An issue with NO milestone belongs to another (probably private) project
|
||||
# and is NOT an anomaly.
|
||||
anomalies_b = []
|
||||
for pr_num in sorted(changelog_prs):
|
||||
pr = pr_by_num.get(pr_num)
|
||||
if not pr:
|
||||
continue
|
||||
if get_pr_milestone(pr_num) != milestone:
|
||||
continue
|
||||
for issue_num in pr.get("closing_issues", []):
|
||||
issue_ms = get_issue_milestone(issue_num)
|
||||
if issue_ms is None:
|
||||
continue
|
||||
if issue_ms != milestone:
|
||||
anomalies_b.append(
|
||||
{
|
||||
"pr": pr_num,
|
||||
"pr_title": pr.get("title", ""),
|
||||
"issue": issue_num,
|
||||
"issue_milestone": issue_ms,
|
||||
}
|
||||
)
|
||||
|
||||
# Type E: :boom: entry whose issue lacks the `breaking change` label.
|
||||
# These stay in the changelog (they are preserved, not removed) — the
|
||||
# human decides whether to label the issue or move the entry elsewhere.
|
||||
anomalies_e = []
|
||||
boom_body = extract_subsection(section, r"### :boom:")
|
||||
if boom_body:
|
||||
for issue_num in sorted(collect_changelog_issues(boom_body)):
|
||||
labels = get_issue_labels(issue_num)
|
||||
if labels is None:
|
||||
continue
|
||||
if "breaking change" not in labels:
|
||||
issue = issue_by_num.get(issue_num) or external_issues.get(issue_num) or {}
|
||||
anomalies_e.append(
|
||||
{
|
||||
"issue": issue_num,
|
||||
"issue_title": issue.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Type C: released X.Y.0 sections without a :rocket: subsection.
|
||||
# Patches (X.Y.Z with Z != 0) never carry :rocket: — only minors/majors.
|
||||
anomalies_c = []
|
||||
rocket_heading_re = re.compile(r"^### :rocket:", re.MULTILINE)
|
||||
version_sections = re.split(
|
||||
r"(?=^## \d+\.\d+\.\d+)", content, flags=re.MULTILINE
|
||||
)
|
||||
for vs in version_sections:
|
||||
m = re.match(r"^## (\d+\.\d+\.\d+)(.*)", vs)
|
||||
if not m:
|
||||
continue
|
||||
ver, suffix = m.group(1), m.group(2)
|
||||
if "unreleased" in suffix.lower():
|
||||
continue
|
||||
if ver.split(".")[2] != "0":
|
||||
continue
|
||||
if not rocket_heading_re.search(vs):
|
||||
anomalies_c.append(ver)
|
||||
|
||||
# Type D: :rocket: entries without issue AND PR references.
|
||||
anomalies_d = []
|
||||
issue_ref_re = re.compile(
|
||||
r"\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)"
|
||||
)
|
||||
pr_ref_re = re.compile(
|
||||
r"\(PR:\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\)"
|
||||
r"(\s*,\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\))*\)"
|
||||
)
|
||||
for vs in version_sections:
|
||||
m = re.match(r"^## (\d+\.\d+\.\d+)(.*)", vs)
|
||||
if not m:
|
||||
continue
|
||||
ver = m.group(1)
|
||||
rocket_body = extract_subsection(vs, r"### :rocket:")
|
||||
if not rocket_body:
|
||||
continue
|
||||
for line in rocket_body.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("- ") and not (
|
||||
issue_ref_re.search(line) and pr_ref_re.search(line)
|
||||
):
|
||||
anomalies_d.append({"version": ver, "line": line[:100]})
|
||||
|
||||
def fmt_ms(ms: str | None) -> str:
|
||||
return ms if ms else "_none_"
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
f.write(f"# Changelog Anomaly Report — {milestone}\n\n")
|
||||
f.write(
|
||||
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}\n\n"
|
||||
)
|
||||
f.write("---\n\n")
|
||||
|
||||
n_a, n_b, n_c, n_d, n_e = (
|
||||
len(anomalies_a),
|
||||
len(anomalies_b),
|
||||
len(anomalies_c),
|
||||
len(anomalies_d),
|
||||
len(anomalies_e),
|
||||
)
|
||||
|
||||
f.write("## Summary\n\n")
|
||||
f.write(
|
||||
f"- **Issue in {milestone}, referenced PR in different milestone or no milestone:** {n_a}\n"
|
||||
)
|
||||
f.write(
|
||||
f"- **PR in {milestone}, closing issue in a different milestone:** {n_b}\n"
|
||||
)
|
||||
f.write(f"- **:boom: entry without breaking change label:** {n_e}\n")
|
||||
f.write(f"- **Total anomalies:** {n_a + n_b + n_e}\n")
|
||||
f.write(
|
||||
f"- **Released X.Y.0 version missing :rocket: section (gap):** {n_c}\n"
|
||||
)
|
||||
f.write(f"- **:rocket: entry without issue AND PR references (gap):** {n_d}\n\n")
|
||||
|
||||
if n_a or n_b or n_e:
|
||||
f.write("## Anomalies\n\n")
|
||||
f.write(
|
||||
"Types A and B are milestone mismatches between an issue in the changelog "
|
||||
"and its referenced PR (or vice-versa). The changelog claim "
|
||||
'"this issue is fixed by this PR, all in this milestone" is '
|
||||
"inconsistent with the actual milestone assignments. "
|
||||
"Resolve by either updating the milestone on the issue/PR or "
|
||||
"removing the misleading entry from the changelog.\n\n"
|
||||
"Type E entries stay in the changelog: either label the issue "
|
||||
"as `breaking change` or move the entry out of `:boom:`.\n\n"
|
||||
)
|
||||
|
||||
if n_a:
|
||||
f.write(
|
||||
f"### Issue in {milestone}, PR in different milestone or no milestone\n\n"
|
||||
)
|
||||
by_issue: dict[int, list[dict]] = {}
|
||||
for a in anomalies_a:
|
||||
by_issue.setdefault(a["issue"], []).append(a)
|
||||
for issue_num in sorted(by_issue):
|
||||
entries = by_issue[issue_num]
|
||||
title = entries[0]["issue_title"]
|
||||
f.write(f"- {issue_link_title(issue_num, title[:80])}\n")
|
||||
for e in entries:
|
||||
ms_label = fmt_ms(e["pr_milestone"])
|
||||
badge = "🔴" if e["pr_milestone"] is None else "⚠️"
|
||||
f.write(
|
||||
f" - {badge} Referenced {pr_link(e['pr'])} is in milestone **{ms_label}** (expected: {milestone})\n"
|
||||
)
|
||||
f.write("\n")
|
||||
|
||||
if n_b:
|
||||
f.write(
|
||||
f"\n### PR in {milestone}, closing issue in a different milestone\n\n"
|
||||
)
|
||||
by_pr: dict[int, list[dict]] = {}
|
||||
for b in anomalies_b:
|
||||
by_pr.setdefault(b["pr"], []).append(b)
|
||||
for pr_num in sorted(by_pr):
|
||||
entries = by_pr[pr_num]
|
||||
title = entries[0]["pr_title"]
|
||||
f.write(f"- {pr_link_title(pr_num, title[:80])}\n")
|
||||
for e in entries:
|
||||
ms_label = fmt_ms(e["issue_milestone"])
|
||||
badge = "🔴" if e["issue_milestone"] is None else "⚠️"
|
||||
f.write(
|
||||
f" - {badge} Closing {issue_link(e['issue'])} is in milestone **{ms_label}** (expected: {milestone})\n"
|
||||
)
|
||||
f.write("\n")
|
||||
|
||||
if n_e:
|
||||
f.write("\n### :boom: entry without breaking change label\n\n")
|
||||
f.write(
|
||||
"These entries sit under `### :boom: Breaking changes & Deprecations` "
|
||||
"but their issue has no `breaking change` label. They are kept "
|
||||
"in the changelog — add the label on the issue or move the "
|
||||
"entry to its regular section.\n\n"
|
||||
)
|
||||
for e in anomalies_e:
|
||||
f.write(f"- ⚠️ {issue_link_title(e['issue'], e['issue_title'][:80])}\n")
|
||||
f.write("\n")
|
||||
else:
|
||||
f.write(
|
||||
"✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n"
|
||||
)
|
||||
|
||||
if n_c or n_d:
|
||||
f.write("## Highlight gaps\n\n")
|
||||
f.write(
|
||||
"These are warnings, not anomalies: they do not affect the "
|
||||
"milestone-mismatch total above. They track `:rocket:` coverage "
|
||||
"across all released X.Y.0 versions. Historical entries (e.g. "
|
||||
"Taiga links) predate the current reference convention and are "
|
||||
"expected to appear here.\n\n"
|
||||
)
|
||||
|
||||
if n_c:
|
||||
f.write("### Released X.Y.0 version missing :rocket: section\n\n")
|
||||
f.write(
|
||||
"These released minors/majors have no `### :rocket: Epics and highlights` subsection. "
|
||||
"Add highlights to help self-hosted users understand what they are missing.\n\n"
|
||||
)
|
||||
for ver in anomalies_c:
|
||||
f.write(f"- Version **{ver}**\n")
|
||||
f.write("\n")
|
||||
|
||||
if n_d:
|
||||
f.write("### :rocket: entry without issue AND PR references\n\n")
|
||||
f.write(
|
||||
"These highlight entries lack the required issue AND PR references. "
|
||||
"Add `[#ISSUE](...)` and `(PR: [#PR](...))` links.\n\n"
|
||||
)
|
||||
for d in anomalies_d:
|
||||
f.write(f"- **{d['version']}**: `{d['line']}`\n")
|
||||
f.write("\n")
|
||||
elif not (n_a or n_b or n_e):
|
||||
f.write(
|
||||
"✅ No highlight gaps found. All released X.Y.0 versions have properly referenced :rocket: entries.\n\n"
|
||||
)
|
||||
|
||||
f.write("---\n\n")
|
||||
f.write("## Context\n\n")
|
||||
f.write(f"- Milestone: **{milestone}**\n")
|
||||
f.write(f"- Milestone total issues (all states): {len(all_issues)}\n")
|
||||
f.write(
|
||||
f"- Closed issues in milestone: {sum(1 for i in all_issues if i.get('state') == 'CLOSED')}\n"
|
||||
)
|
||||
f.write(
|
||||
f"- Valid issues after exclusions: {len([i for i in all_issues if not issue_excluded(i)])}\n"
|
||||
)
|
||||
f.write(f"- Issues referenced in changelog: {len(changelog_issues)}\n")
|
||||
f.write(f"- PRs referenced in changelog: {len(changelog_prs)}\n")
|
||||
|
||||
print(f"Anomaly report written to {args.output}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI entrypoint
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Helper commands for the update-changelog skill"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True, title="subcommands")
|
||||
|
||||
p_check = sub.add_parser(
|
||||
"check-merged", help="Verify that every given PR is merged"
|
||||
)
|
||||
p_check.add_argument("numbers", type=int, nargs="*",
|
||||
help="PR numbers to check (space-separated)")
|
||||
p_check.add_argument("--file", type=str,
|
||||
help="File with one PR number per line")
|
||||
p_check.add_argument("--stdin", action="store_true",
|
||||
help="Read PR numbers from stdin (one per line)")
|
||||
p_check.set_defaults(func=cmd_check_merged)
|
||||
|
||||
p_cross = sub.add_parser(
|
||||
"cross-ref",
|
||||
help="Compare merged milestone PRs against the changelog section",
|
||||
)
|
||||
p_cross.add_argument("milestone", help="Milestone title (e.g. '2.16.0')")
|
||||
p_cross.add_argument("--changes", default="CHANGES.md",
|
||||
help="Path to the changelog file (default: CHANGES.md)")
|
||||
p_cross.set_defaults(func=cmd_cross_ref)
|
||||
|
||||
p_report = sub.add_parser(
|
||||
"report", help="Generate the anomaly report (CHANGES-ISSUES.md)"
|
||||
)
|
||||
p_report.add_argument("milestone", help="Milestone title (e.g. '2.16.0')")
|
||||
p_report.add_argument("--changes", default="CHANGES.md",
|
||||
help="Path to the changelog file (default: CHANGES.md)")
|
||||
p_report.add_argument("--output", default="CHANGES-ISSUES.md",
|
||||
help="Report output path (default: CHANGES-ISSUES.md)")
|
||||
p_report.set_defaults(func=cmd_report)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+275
-55
@@ -25,6 +25,7 @@ Usage:
|
||||
cat prs.txt | python3 scripts/gh.py prs --stdin
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" (default: state=merged)
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
python3 scripts/gh.py issue 11235 11236
|
||||
python3 scripts/gh.py advisories (list all advisories)
|
||||
python3 scripts/gh.py advisories --severity critical (filter by severity)
|
||||
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail)
|
||||
@@ -40,6 +41,7 @@ import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -47,21 +49,70 @@ REPO = "penpot/penpot"
|
||||
OWNER = "penpot"
|
||||
REPO_NAME = "penpot"
|
||||
|
||||
# Transient gateway timeouts from the GitHub API are retried, nothing else.
|
||||
GH_RETRIES = 3
|
||||
GH_RETRY_DELAYS = (5, 15) # seconds waited before retry N (last delay repeats)
|
||||
|
||||
|
||||
def run_gh_command(cmd: list[str], input_text: str | None = None) -> str:
|
||||
"""Run a ``gh`` command, retrying transient HTTP 504s.
|
||||
|
||||
Raises `GhCommandFailed` on failure so callers can choose between
|
||||
aborting (normal commands) and degrading (batched lookups).
|
||||
"""
|
||||
last_stderr = ""
|
||||
for attempt in range(GH_RETRIES):
|
||||
if attempt:
|
||||
delay = GH_RETRY_DELAYS[min(attempt - 1, len(GH_RETRY_DELAYS) - 1)]
|
||||
print(
|
||||
f"gh: HTTP 504, retrying in {delay}s"
|
||||
f" (attempt {attempt + 1}/{GH_RETRIES})...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
time.sleep(delay)
|
||||
result = subprocess.run(
|
||||
cmd, input=input_text, capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
last_stderr = result.stderr
|
||||
if "504" not in result.stderr:
|
||||
break
|
||||
raise GhCommandFailed(last_stderr)
|
||||
|
||||
|
||||
class GhCommandFailed(Exception):
|
||||
"""A ``gh`` invocation failed (stderr kept for the caller to judge)."""
|
||||
|
||||
def __init__(self, stderr: str) -> None:
|
||||
super().__init__(stderr)
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def fail_gh(stderr: str) -> None:
|
||||
"""Report a ``gh`` failure and exit (standard behavior for CLI commands)."""
|
||||
print(f"gh error: {stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Shared helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def post_graphql(query: str, variables: dict) -> Any:
|
||||
"""POST a GraphQL query via ``gh`` and return the raw response body."""
|
||||
payload = json.dumps({"query": query, "variables": variables})
|
||||
stdout = run_gh_command(["gh", "api", "graphql", "--input", "-"], payload)
|
||||
return json.loads(stdout)
|
||||
|
||||
|
||||
def run_gh_graphql(query: str, variables: dict) -> Any:
|
||||
"""Run a GraphQL query via ``gh api graphql --input -``."""
|
||||
payload = json.dumps({"query": query, "variables": variables})
|
||||
cmd = ["gh", "api", "graphql", "--input", "-"]
|
||||
result = subprocess.run(cmd, input=payload, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"gh error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
body = json.loads(result.stdout)
|
||||
try:
|
||||
body = post_graphql(query, variables)
|
||||
except GhCommandFailed as err:
|
||||
fail_gh(err.stderr)
|
||||
if "errors" in body:
|
||||
for err in body["errors"]:
|
||||
print(f"GraphQL error: {err.get('message')}", file=sys.stderr)
|
||||
@@ -71,12 +122,10 @@ def run_gh_graphql(query: str, variables: dict) -> Any:
|
||||
|
||||
def run_gh_rest(path: str) -> Any:
|
||||
"""Run a REST API call via ``gh api``."""
|
||||
cmd = ["gh", "api", path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"gh error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return json.loads(result.stdout)
|
||||
try:
|
||||
return json.loads(run_gh_command(["gh", "api", path]))
|
||||
except GhCommandFailed as err:
|
||||
fail_gh(err.stderr)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -219,6 +268,7 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
|
||||
state
|
||||
issueType { name }
|
||||
labels(first: 20) { nodes { name } }
|
||||
assignees(first: 10) { nodes { login } }
|
||||
closedByPullRequestsReferences(first: 5) { nodes { number } }
|
||||
projectItems(first: 10) {
|
||||
nodes {
|
||||
@@ -257,6 +307,7 @@ query($query: String!, $cursor: String) {
|
||||
milestone { title }
|
||||
issueType { name }
|
||||
labels(first: 20) { nodes { name } }
|
||||
assignees(first: 10) { nodes { login } }
|
||||
closedByPullRequestsReferences(first: 5) { nodes { number } }
|
||||
projectItems(first: 10) {
|
||||
nodes {
|
||||
@@ -275,6 +326,32 @@ query($query: String!, $cursor: String) {
|
||||
"""
|
||||
|
||||
|
||||
def node_assignees(node: dict) -> list[str]:
|
||||
"""Extract assignee logins from a GraphQL issue/PR node."""
|
||||
return [
|
||||
a["login"] for a in (node.get("assignees") or {}).get("nodes") or []
|
||||
]
|
||||
|
||||
|
||||
def node_projects(node: dict) -> list[str]:
|
||||
"""Extract project board titles from a GraphQL issue node."""
|
||||
return [
|
||||
(pi.get("project") or {}).get("title")
|
||||
for pi in (node.get("projectItems") or {}).get("nodes") or []
|
||||
if (pi.get("project") or {}).get("title")
|
||||
]
|
||||
|
||||
|
||||
def node_main_status(node: dict) -> str | None:
|
||||
"""Extract the "Main" project board status from a GraphQL issue node."""
|
||||
for pi in (node.get("projectItems") or {}).get("nodes") or []:
|
||||
project = pi.get("project") or {}
|
||||
if project.get("title") == "Main":
|
||||
status_field = pi.get("fieldValueByName") or {}
|
||||
return status_field.get("name")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_no_milestone_issues(states: str, labels: str | None = None) -> list[dict]:
|
||||
"""
|
||||
Fetch all issues that belong to NO milestone via paginated GraphQL search.
|
||||
@@ -284,7 +361,7 @@ def fetch_no_milestone_issues(states: str, labels: str | None = None) -> list[di
|
||||
labels: optional comma-separated labels to include (built into the search query)
|
||||
|
||||
Returns:
|
||||
List of {number, title, state, milestone, issue_type, labels, closing_prs, project_status}
|
||||
List of {number, title, state, milestone, issue_type, labels, assignees, closing_prs, project_status, projects}
|
||||
"""
|
||||
all_nodes: list[dict] = []
|
||||
cursor: str | None = None
|
||||
@@ -315,13 +392,6 @@ def fetch_no_milestone_issues(states: str, labels: str | None = None) -> list[di
|
||||
continue
|
||||
issue_type = node.get("issueType")
|
||||
ms = node.get("milestone")
|
||||
project_status = None
|
||||
for pi in (node.get("projectItems") or {}).get("nodes") or []:
|
||||
project = pi.get("project") or {}
|
||||
if project.get("title") == "Main":
|
||||
status_field = pi.get("fieldValueByName") or {}
|
||||
project_status = status_field.get("name")
|
||||
break
|
||||
all_nodes.append({
|
||||
"number": node["number"],
|
||||
"title": node["title"],
|
||||
@@ -329,8 +399,10 @@ def fetch_no_milestone_issues(states: str, labels: str | None = None) -> list[di
|
||||
"milestone": ms["title"] if ms else None,
|
||||
"issue_type": issue_type["name"] if issue_type else None,
|
||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||
"assignees": node_assignees(node),
|
||||
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
||||
"project_status": project_status,
|
||||
"project_status": node_main_status(node),
|
||||
"projects": node_projects(node),
|
||||
})
|
||||
|
||||
total = len(all_nodes)
|
||||
@@ -352,7 +424,7 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
||||
states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"``
|
||||
|
||||
Returns:
|
||||
List of {number, title, state, issue_type: str|None, labels: [str], closing_prs: [int]}
|
||||
List of {number, title, state, issue_type: str|None, labels: [str], assignees: [str], closing_prs: [int], project_status, projects}
|
||||
"""
|
||||
query = GQL_ISSUES_QUERY.replace("__STATES__", states)
|
||||
all_nodes: list[dict] = []
|
||||
@@ -373,22 +445,16 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
||||
if node is None:
|
||||
continue
|
||||
issue_type = node.get("issueType")
|
||||
# Extract project status from the "Main" project board (if present)
|
||||
project_status = None
|
||||
for pi in (node.get("projectItems") or {}).get("nodes") or []:
|
||||
project = pi.get("project") or {}
|
||||
if project.get("title") == "Main":
|
||||
status_field = pi.get("fieldValueByName") or {}
|
||||
project_status = status_field.get("name")
|
||||
break
|
||||
all_nodes.append({
|
||||
"number": node["number"],
|
||||
"title": node["title"],
|
||||
"state": node["state"],
|
||||
"issue_type": issue_type["name"] if issue_type else None,
|
||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||
"assignees": node_assignees(node),
|
||||
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
||||
"project_status": project_status,
|
||||
"project_status": node_main_status(node),
|
||||
"projects": node_projects(node),
|
||||
})
|
||||
|
||||
total = len(all_nodes)
|
||||
@@ -490,7 +556,9 @@ GQL_PRS_QUERY_ITEM = """\
|
||||
state
|
||||
mergedAt
|
||||
createdAt
|
||||
milestone {{ title }}
|
||||
author {{ login }}
|
||||
assignees(first: 10) {{ nodes {{ login }} }}
|
||||
labels(first: 20) {{ nodes {{ name }} }}
|
||||
closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
|
||||
}}
|
||||
@@ -537,7 +605,9 @@ def fetch_prs_batch(pr_numbers: list[int]) -> list[dict]:
|
||||
"state": pr["state"],
|
||||
"merged_at": pr.get("mergedAt"),
|
||||
"created_at": pr.get("createdAt"),
|
||||
"milestone": (pr.get("milestone") or {}).get("title"),
|
||||
"author": pr["author"]["login"] if pr["author"] else None,
|
||||
"assignees": node_assignees(pr),
|
||||
"labels": [lbl["name"] for lbl in pr["labels"]["nodes"]],
|
||||
"closing_issues": [iss["number"] for iss in pr["closingIssuesReferences"]["nodes"]],
|
||||
})
|
||||
@@ -561,6 +631,7 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
|
||||
createdAt
|
||||
headRefName
|
||||
author { login }
|
||||
assignees(first: 10) { nodes { login } }
|
||||
labels(first: 20) { nodes { name } }
|
||||
files(first: 100) { nodes { path } }
|
||||
closingIssuesReferences(first: 5) { nodes { number } }
|
||||
@@ -583,7 +654,7 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]:
|
||||
|
||||
Returns:
|
||||
List of {number, title, body, state, merged_at, created_at,
|
||||
head_ref_name, author, labels: [str], files: [str],
|
||||
head_ref_name, author, assignees, labels: [str], files: [str],
|
||||
closing_issues: [int]}
|
||||
"""
|
||||
query = GQL_MILESTONE_PRS_QUERY.replace("__STATES__", states)
|
||||
@@ -613,6 +684,7 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]:
|
||||
"created_at": node.get("createdAt"),
|
||||
"head_ref_name": node.get("headRefName"),
|
||||
"author": node["author"]["login"] if node["author"] else None,
|
||||
"assignees": node_assignees(node),
|
||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||
"files": [file["path"] for file in node["files"]["nodes"]],
|
||||
"closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]],
|
||||
@@ -641,38 +713,19 @@ def cmd_prs(args: argparse.Namespace) -> None:
|
||||
|
||||
print(f"Fetching {args.state} PRs via GraphQL...", file=sys.stderr)
|
||||
prs = fetch_milestone_prs(ms["number"], gql_states)
|
||||
for pr in prs:
|
||||
pr["milestone"] = ms["title"]
|
||||
print(f"Fetched {len(prs)} PRs total", file=sys.stderr)
|
||||
print(json.dumps(prs, indent=2))
|
||||
return
|
||||
|
||||
# ── Number-based path ───────────────────────────────────────────
|
||||
pr_numbers: list[int] = []
|
||||
|
||||
if args.numbers:
|
||||
pr_numbers.extend(args.numbers)
|
||||
|
||||
if args.file:
|
||||
with open(args.file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
pr_numbers.append(int(line))
|
||||
|
||||
if args.stdin:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if line:
|
||||
pr_numbers.append(int(line))
|
||||
|
||||
pr_numbers = read_numbers_from_args(args)
|
||||
if not pr_numbers:
|
||||
print("ERROR: no PR numbers provided (pass numbers, --file, --stdin, or --milestone)",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen: set[int] = set()
|
||||
pr_numbers = [n for n in pr_numbers if not (n in seen or seen.add(n))]
|
||||
|
||||
print(f"Fetching {len(pr_numbers)} PRs in batches of {PRS_BATCH_SIZE}...",
|
||||
file=sys.stderr)
|
||||
|
||||
@@ -686,6 +739,155 @@ def cmd_prs(args: argparse.Namespace) -> None:
|
||||
print(json.dumps(all_results, indent=2))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Subcommand: issue (fetch issues by number, batched)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
GQL_ISSUE_BY_NUMBER_QUERY_ITEM = """\
|
||||
issue_{num}: issue(number: {num}) {{
|
||||
number
|
||||
title
|
||||
state
|
||||
milestone {{ title }}
|
||||
issueType {{ name }}
|
||||
labels(first: 20) {{ nodes {{ name }} }}
|
||||
assignees(first: 10) {{ nodes {{ login }} }}
|
||||
closedByPullRequestsReferences(first: 5) {{ nodes {{ number }} }}
|
||||
projectItems(first: 10) {{
|
||||
nodes {{
|
||||
project {{ title }}
|
||||
fieldValueByName(name: "Status") {{
|
||||
... on ProjectV2ItemFieldSingleSelectValue {{
|
||||
name
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def issue_node_to_dict(node: dict) -> dict:
|
||||
"""Convert a GraphQL issue node to the shared issue dict shape."""
|
||||
issue_type = node.get("issueType")
|
||||
ms = node.get("milestone")
|
||||
return {
|
||||
"number": node["number"],
|
||||
"title": node["title"],
|
||||
"state": node["state"],
|
||||
"milestone": ms["title"] if ms else None,
|
||||
"issue_type": issue_type["name"] if issue_type else None,
|
||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||
"assignees": node_assignees(node),
|
||||
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
||||
"project_status": node_main_status(node),
|
||||
"projects": node_projects(node),
|
||||
}
|
||||
|
||||
|
||||
class IssueBatchFailed(Exception):
|
||||
"""One aliased issue lookup failed; the batch cannot be trusted as a whole."""
|
||||
|
||||
|
||||
def _fetch_issues_batch(issue_numbers: list[int]) -> list[dict]:
|
||||
"""Single batched issue lookup; raises `IssueBatchFailed` on any failure."""
|
||||
items = "\n".join(
|
||||
GQL_ISSUE_BY_NUMBER_QUERY_ITEM.format(num=n) for n in issue_numbers
|
||||
)
|
||||
query = GQL_PRS_QUERY_WRAPPER.format(items=items)
|
||||
variables = {"owner": OWNER, "repo": REPO_NAME}
|
||||
|
||||
try:
|
||||
body = post_graphql(query, variables)
|
||||
except GhCommandFailed as err:
|
||||
raise IssueBatchFailed(err.stderr) from err
|
||||
if "errors" in body:
|
||||
raise IssueBatchFailed("; ".join(e.get("message", "") for e in body["errors"]))
|
||||
repo = body["data"]["repository"]
|
||||
|
||||
results: list[dict] = []
|
||||
for num in issue_numbers:
|
||||
node = repo.get(f"issue_{num}")
|
||||
if node is None:
|
||||
results.append({
|
||||
"number": num,
|
||||
"error": "not_found",
|
||||
})
|
||||
continue
|
||||
results.append(issue_node_to_dict(node))
|
||||
return results
|
||||
|
||||
|
||||
def fetch_issues_batch(issue_numbers: list[int]) -> list[dict]:
|
||||
"""
|
||||
Fetch details for a list of issue numbers in a single GraphQL query.
|
||||
|
||||
Uses numbered aliases (issue_1234, …) so each issue is looked up by
|
||||
number in one round-trip. Returns entries in the same order as the input.
|
||||
|
||||
Unlike PRs (which resolve to null), a dead issue number fails the whole
|
||||
batch, so when the failure names an unresolvable issue each number is
|
||||
retried on its own and reported as ``not_found`` instead of aborting.
|
||||
Any other failure (auth, outage, exhausted 504 retries) still aborts.
|
||||
"""
|
||||
try:
|
||||
return _fetch_issues_batch(issue_numbers)
|
||||
except IssueBatchFailed as err:
|
||||
if "Could not resolve" not in str(err):
|
||||
print(f"GraphQL error: {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(" batch lookup failed, retrying issues one by one...",
|
||||
file=sys.stderr)
|
||||
results: list[dict] = []
|
||||
for num in issue_numbers:
|
||||
try:
|
||||
results.extend(_fetch_issues_batch([num]))
|
||||
except IssueBatchFailed:
|
||||
results.append({"number": num, "error": "not_found"})
|
||||
return results
|
||||
|
||||
|
||||
def read_numbers_from_args(args: argparse.Namespace) -> list[int]:
|
||||
"""Collect numbers from positional args, --file, and/or --stdin (deduplicated)."""
|
||||
numbers: list[int] = []
|
||||
if args.numbers:
|
||||
numbers.extend(args.numbers)
|
||||
if args.file:
|
||||
with open(args.file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
numbers.append(int(line))
|
||||
if args.stdin:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if line:
|
||||
numbers.append(int(line))
|
||||
seen: set[int] = set()
|
||||
return [n for n in numbers if not (n in seen or seen.add(n))]
|
||||
|
||||
|
||||
def cmd_issue(args: argparse.Namespace) -> None:
|
||||
"""Handle the ``issue`` subcommand (batched by-number lookup, no filters)."""
|
||||
issue_numbers = read_numbers_from_args(args)
|
||||
if not issue_numbers:
|
||||
print("ERROR: no issue numbers provided (pass numbers, --file, or --stdin)",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Fetching {len(issue_numbers)} issues in batches of {PRS_BATCH_SIZE}...",
|
||||
file=sys.stderr)
|
||||
|
||||
all_results: list[dict] = []
|
||||
for i in range(0, len(issue_numbers), PRS_BATCH_SIZE):
|
||||
batch = issue_numbers[i : i + PRS_BATCH_SIZE]
|
||||
print(f" batch {i // PRS_BATCH_SIZE + 1}: issues {batch[0]}..{batch[-1]}",
|
||||
file=sys.stderr)
|
||||
all_results.extend(fetch_issues_batch(batch))
|
||||
|
||||
print(json.dumps(all_results, indent=2))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Subcommand: advisories
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -860,6 +1062,24 @@ def main() -> None:
|
||||
p_link.add_argument("pr_number", type=int, help="Pull request number")
|
||||
p_link.set_defaults(func=cmd_link_issue)
|
||||
|
||||
# --- issue (by-number lookup) ---
|
||||
p_issue = sub.add_parser(
|
||||
"issue", help="Fetch details for one or more issues by number (batched)"
|
||||
)
|
||||
p_issue.add_argument(
|
||||
"numbers", type=int, nargs="*",
|
||||
help="Issue numbers to fetch (space-separated)"
|
||||
)
|
||||
p_issue.add_argument(
|
||||
"--file", type=str,
|
||||
help="File with one issue number per line"
|
||||
)
|
||||
p_issue.add_argument(
|
||||
"--stdin", action="store_true",
|
||||
help="Read issue numbers from stdin (one per line)"
|
||||
)
|
||||
p_issue.set_defaults(func=cmd_issue)
|
||||
|
||||
# --- advisories ---
|
||||
p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories")
|
||||
p_adv.add_argument(
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
project-anomalies.py — Find anomalies on the Main project board for a milestone.
|
||||
|
||||
Used by the ``find-project-anomalies`` skill. Fetches milestone issues and
|
||||
PRs in bulk via ``scripts/gh.py`` (one GraphQL call per 50 items, never one
|
||||
call per item) and writes a clickable report to
|
||||
``tmp/<MILESTONE>-ANOMALIES.md``.
|
||||
|
||||
Anomaly types (all scoped to the given milestone):
|
||||
1. open-with-merged-pr — OPEN issue with a MERGED closing PR.
|
||||
2. issue-pr-mismatch — milestone issue whose closing PR is in a different
|
||||
milestone (or none), or issue not associated to the Main project.
|
||||
3. pr-issue-mismatch — milestone PR whose closed issue is in a different
|
||||
milestone (or none), or not associated to the Main project.
|
||||
4. needs-triage — MERGED PR whose closed issue has the `needs triage` label.
|
||||
5. unassigned — MERGED PR whose closed issue has no assignees and no
|
||||
`community contribution` label (on the issue or the PR).
|
||||
|
||||
Project membership is only checked on the issue side: PRs are not the unit
|
||||
tracked on the Main board, so an unprojected PR alone is never an anomaly.
|
||||
|
||||
Usage:
|
||||
python3 scripts/project-anomalies.py check "2.17.0"
|
||||
python3 scripts/project-anomalies.py check "2.17.0" --output tmp/2.17.0-ANOMALIES.md
|
||||
|
||||
Prerequisites:
|
||||
- gh CLI authenticated (gh auth status)
|
||||
- Python 3.8+
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = "penpot/penpot"
|
||||
GH_PY = Path(__file__).resolve().parent / "gh.py"
|
||||
MAIN_PROJECT = "Main"
|
||||
COMMUNITY_LABEL = "community contribution"
|
||||
TRIAGE_LABEL = "needs triage"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Shared helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_gh_py(*args: str) -> str:
|
||||
"""Run ``scripts/gh.py`` with the given arguments, return stdout."""
|
||||
cmd = [sys.executable, str(GH_PY), *args]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"gh.py error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def issue_url(n: int) -> str:
|
||||
return f"https://github.com/{REPO}/issues/{n}"
|
||||
|
||||
|
||||
def pr_url(n: int) -> str:
|
||||
return f"https://github.com/{REPO}/pull/{n}"
|
||||
|
||||
|
||||
def issue_link(n: int) -> str:
|
||||
return f"[#{n}]({issue_url(n)})"
|
||||
|
||||
|
||||
def pr_link(n: int) -> str:
|
||||
return f"[#{n}]({pr_url(n)})"
|
||||
|
||||
|
||||
def issue_link_title(n: int, title: str) -> str:
|
||||
url = issue_url(n)
|
||||
if title:
|
||||
return f"[#{n}]({url}) — [{title}]({url})"
|
||||
return f"[#{n}]({url})"
|
||||
|
||||
|
||||
def pr_link_title(n: int, title: str) -> str:
|
||||
url = pr_url(n)
|
||||
if title:
|
||||
return f"[#{n}]({url}) — [{title}]({url})"
|
||||
return f"[#{n}]({url})"
|
||||
|
||||
|
||||
def in_main_project(item: dict) -> bool:
|
||||
"""Whether an issue/PR dict is associated to the Main project board."""
|
||||
return MAIN_PROJECT in (item.get("projects") or [])
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Anomaly detection (pure: no network inside)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_anomalies(
|
||||
milestone: str,
|
||||
issues: list[dict],
|
||||
prs: list[dict],
|
||||
pr_by_num: dict[int, dict],
|
||||
issue_by_num: dict[int, dict],
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Detect the five anomaly types for a milestone.
|
||||
|
||||
Callers pre-resolve outsiders: any closing PR/issue not in the milestone
|
||||
must already sit in ``pr_by_num``/``issue_by_num`` (with at least
|
||||
``milestone``, ``state``, ``labels``, ``assignees`` and ``projects``).
|
||||
Unknown numbers are skipped, never flagged.
|
||||
"""
|
||||
t1_open_merged: list[dict] = []
|
||||
t2_issue_pr: list[dict] = []
|
||||
t3_pr_issue: list[dict] = []
|
||||
t4_needs_triage: list[dict] = []
|
||||
t5_unassigned: list[dict] = []
|
||||
|
||||
for issue in issues:
|
||||
if issue.get("state") != "OPEN":
|
||||
continue
|
||||
merged = [
|
||||
n for n in issue.get("closing_prs", [])
|
||||
if (pr_by_num.get(n) or {}).get("state") == "MERGED"
|
||||
]
|
||||
if merged:
|
||||
t1_open_merged.append({
|
||||
"issue": issue["number"],
|
||||
"issue_title": issue.get("title", ""),
|
||||
"prs": sorted(merged),
|
||||
})
|
||||
|
||||
for issue in issues:
|
||||
for pr_num in issue.get("closing_prs", []):
|
||||
pr = pr_by_num.get(pr_num)
|
||||
if pr is None:
|
||||
continue
|
||||
# An unmerged PR has landed nowhere: its milestone says nothing
|
||||
# about any release, so only merged PRs count here.
|
||||
if pr.get("state") != "MERGED":
|
||||
continue
|
||||
if pr.get("milestone") != milestone:
|
||||
t2_issue_pr.append({
|
||||
"issue": issue["number"],
|
||||
"issue_title": issue.get("title", ""),
|
||||
"pr": pr_num,
|
||||
"pr_milestone": pr.get("milestone"),
|
||||
})
|
||||
if not in_main_project(issue):
|
||||
t2_issue_pr.append({
|
||||
"issue": issue["number"],
|
||||
"issue_title": issue.get("title", ""),
|
||||
"pr": None,
|
||||
"pr_milestone": None,
|
||||
"projects": issue.get("projects") or [],
|
||||
})
|
||||
|
||||
for pr in prs:
|
||||
# Like type 2: only a merged PR has landed somewhere, so only
|
||||
# merged PRs take part in milestone pairing. (Whether the issue is
|
||||
# on the Main board is checked regardless.)
|
||||
if pr.get("state") != "MERGED":
|
||||
continue
|
||||
for issue_num in pr.get("closing_issues", []):
|
||||
issue = issue_by_num.get(issue_num)
|
||||
if issue is None:
|
||||
continue
|
||||
if issue.get("milestone", milestone) != milestone or not in_main_project(issue):
|
||||
t3_pr_issue.append({
|
||||
"pr": pr["number"],
|
||||
"pr_title": pr.get("title", ""),
|
||||
"issue": issue_num,
|
||||
"issue_title": issue.get("title", ""),
|
||||
"issue_milestone": issue.get("milestone", milestone),
|
||||
"projects": issue.get("projects") or [],
|
||||
})
|
||||
|
||||
for pr in prs:
|
||||
if pr.get("state") != "MERGED":
|
||||
continue
|
||||
for issue_num in pr.get("closing_issues", []):
|
||||
issue = issue_by_num.get(issue_num)
|
||||
if issue is None:
|
||||
continue
|
||||
if TRIAGE_LABEL in (issue.get("labels") or []):
|
||||
t4_needs_triage.append({
|
||||
"pr": pr["number"],
|
||||
"pr_title": pr.get("title", ""),
|
||||
"issue": issue_num,
|
||||
"issue_title": issue.get("title", ""),
|
||||
})
|
||||
if not issue.get("assignees") and COMMUNITY_LABEL not in (
|
||||
(issue.get("labels") or []) + (pr.get("labels") or [])
|
||||
):
|
||||
t5_unassigned.append({
|
||||
"pr": pr["number"],
|
||||
"pr_title": pr.get("title", ""),
|
||||
"issue": issue_num,
|
||||
"issue_title": issue.get("title", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"open_merged": t1_open_merged,
|
||||
"issue_pr": t2_issue_pr,
|
||||
"pr_issue": t3_pr_issue,
|
||||
"needs_triage": t4_needs_triage,
|
||||
"unassigned": t5_unassigned,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Report rendering (pure)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def milestone_fix(kind: str, number: int, milestone: str) -> str:
|
||||
"""Render the one-line `gh` command that assigns a missing milestone."""
|
||||
return f"Fix: `gh {kind} edit {number} --milestone \"{milestone}\"`"
|
||||
|
||||
|
||||
def render_report(
|
||||
milestone: str,
|
||||
anomalies: dict[str, list[dict]],
|
||||
total_issues: int,
|
||||
closed_issues: int,
|
||||
total_prs: int,
|
||||
merged_prs: int,
|
||||
) -> str:
|
||||
"""Render the full anomalies report as Markdown."""
|
||||
t1 = anomalies["open_merged"]
|
||||
t2 = anomalies["issue_pr"]
|
||||
t3 = anomalies["pr_issue"]
|
||||
t4 = anomalies["needs_triage"]
|
||||
t5 = anomalies["unassigned"]
|
||||
total = len(t1) + len(t2) + len(t3) + len(t4) + len(t5)
|
||||
|
||||
lines = [
|
||||
f"# Project Anomalies — {milestone}",
|
||||
"",
|
||||
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- **OPEN issue with a MERGED PR:** {len(t1)}",
|
||||
f"- **Milestone issue, PR elsewhere/unprojected:** {len(t2)}",
|
||||
f"- **Milestone PR, issue elsewhere/unprojected:** {len(t3)}",
|
||||
f"- **MERGED PR on a `needs triage` issue:** {len(t4)}",
|
||||
f"- **MERGED PR on an unassigned, non-community issue:** {len(t5)}",
|
||||
f"- **Total anomalies:** {total}",
|
||||
"",
|
||||
]
|
||||
|
||||
if total == 0:
|
||||
lines += [
|
||||
"✅ No anomalies found. The milestone and the Main project board agree.",
|
||||
"",
|
||||
]
|
||||
else:
|
||||
lines += ["## Anomalies", ""]
|
||||
if t1:
|
||||
lines += [
|
||||
"### OPEN issue with a MERGED PR",
|
||||
"",
|
||||
"The fix already landed but the issue never closed (or it was "
|
||||
"reopened). Close the issue or move it out of the milestone.",
|
||||
"",
|
||||
]
|
||||
for e in t1:
|
||||
lines.append(f"- {issue_link_title(e['issue'], e['issue_title'][:80])}")
|
||||
for n in e["prs"]:
|
||||
lines.append(f" - 🔴 Merged {pr_link(n)}")
|
||||
lines.append("")
|
||||
|
||||
if t2:
|
||||
lines += [
|
||||
"### Milestone issue, PR elsewhere or issue off the Main board",
|
||||
"",
|
||||
]
|
||||
by_issue: dict[int, list[dict]] = {}
|
||||
for e in t2:
|
||||
by_issue.setdefault(e["issue"], []).append(e)
|
||||
for issue_num in sorted(by_issue):
|
||||
entries = by_issue[issue_num]
|
||||
lines.append(
|
||||
f"- {issue_link_title(issue_num, entries[0]['issue_title'][:80])}"
|
||||
)
|
||||
for e in entries:
|
||||
if e["pr"] is None:
|
||||
projects = ", ".join(e["projects"]) or "_none_"
|
||||
lines.append(
|
||||
" - 🔴 Issue is not on the Main project board "
|
||||
f"(projects: {projects})"
|
||||
)
|
||||
else:
|
||||
ms = e["pr_milestone"] or "_none_"
|
||||
badge = "🔴" if e["pr_milestone"] is None else "⚠️"
|
||||
lines.append(
|
||||
f" - {badge} Closing {pr_link(e['pr'])} is in milestone "
|
||||
f"**{ms}** (expected: {milestone})"
|
||||
)
|
||||
if e["pr_milestone"] is None:
|
||||
lines.append(
|
||||
f" {milestone_fix('pr', e['pr'], milestone)}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if t3:
|
||||
lines += [
|
||||
"### Milestone PR, issue elsewhere or off the Main board",
|
||||
"",
|
||||
]
|
||||
by_pr: dict[int, list[dict]] = {}
|
||||
for e in t3:
|
||||
by_pr.setdefault(e["pr"], []).append(e)
|
||||
for pr_num in sorted(by_pr):
|
||||
entries = by_pr[pr_num]
|
||||
lines.append(
|
||||
f"- {pr_link_title(pr_num, entries[0]['pr_title'][:80])}"
|
||||
)
|
||||
for e in entries:
|
||||
ms = e["issue_milestone"] or "_none_"
|
||||
if e["issue_milestone"] != milestone:
|
||||
lines.append(
|
||||
f" - ⚠️ Closing {issue_link(e['issue'])} is in milestone "
|
||||
f"**{ms}** (expected: {milestone})"
|
||||
)
|
||||
if e["issue_milestone"] is None:
|
||||
lines.append(
|
||||
f" {milestone_fix('issue', e['issue'], milestone)}"
|
||||
)
|
||||
else:
|
||||
projects = ", ".join(e["projects"]) or "_none_"
|
||||
lines.append(
|
||||
f" - 🔴 Closing {issue_link(e['issue'])} is not on the "
|
||||
f"Main project board (projects: {projects})"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if t4:
|
||||
lines += [
|
||||
"### MERGED PR on a `needs triage` issue",
|
||||
"",
|
||||
"The fix landed but the issue was never triaged. Human triage needed.",
|
||||
"",
|
||||
]
|
||||
for e in t4:
|
||||
lines.append(
|
||||
f"- ⚠️ {pr_link_title(e['pr'], e['pr_title'][:80])}"
|
||||
f" closes {issue_link(e['issue'])}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if t5:
|
||||
lines += [
|
||||
"### MERGED PR on an unassigned, non-community issue",
|
||||
"",
|
||||
"No owner and no community label. Assign an owner or confirm it "
|
||||
"is a community contribution.",
|
||||
"",
|
||||
]
|
||||
for e in t5:
|
||||
lines.append(
|
||||
f"- ⚠️ {pr_link_title(e['pr'], e['pr_title'][:80])}"
|
||||
f" closes {issue_link(e['issue'])}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines += [
|
||||
"---",
|
||||
"",
|
||||
"## Context",
|
||||
"",
|
||||
f"- Milestone: **{milestone}**",
|
||||
f"- Milestone issues (all states): {total_issues}",
|
||||
f"- Open issues in milestone: {total_issues - closed_issues}",
|
||||
f"- Milestone PRs (all states): {total_prs}",
|
||||
f"- Merged PRs in milestone: {merged_prs}",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI entrypoint
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace) -> None:
|
||||
"""Fetch milestone data in bulk, detect anomalies, write the report."""
|
||||
milestone = args.milestone
|
||||
|
||||
all_issues = json.loads(run_gh_py("issues", milestone, "--state", "all"))
|
||||
issue_by_num = {i["number"]: i for i in all_issues}
|
||||
|
||||
all_prs = json.loads(run_gh_py("prs", "--milestone", milestone, "--state", "all"))
|
||||
pr_by_num = {p["number"]: p for p in all_prs}
|
||||
|
||||
# Resolve outsiders in bulk (one GraphQL call per 50 items).
|
||||
unknown_prs = set()
|
||||
for issue in all_issues:
|
||||
unknown_prs.update(issue.get("closing_prs", []))
|
||||
unknown_prs -= set(pr_by_num)
|
||||
if unknown_prs:
|
||||
print(
|
||||
f"Resolving {len(unknown_prs)} PRs outside {milestone}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for pr in json.loads(run_gh_py("prs", *[str(n) for n in sorted(unknown_prs)])):
|
||||
pr_by_num[pr["number"]] = pr
|
||||
|
||||
unknown_issues = set()
|
||||
for pr in all_prs:
|
||||
unknown_issues.update(pr.get("closing_issues", []))
|
||||
unknown_issues -= set(issue_by_num)
|
||||
if unknown_issues:
|
||||
print(
|
||||
f"Resolving {len(unknown_issues)} issues outside {milestone}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for issue in json.loads(
|
||||
run_gh_py("issue", *[str(n) for n in sorted(unknown_issues)])
|
||||
):
|
||||
if "error" not in issue:
|
||||
issue_by_num[issue["number"]] = issue
|
||||
|
||||
anomalies = find_anomalies(milestone, all_issues, all_prs, pr_by_num, issue_by_num)
|
||||
report = render_report(
|
||||
milestone,
|
||||
anomalies,
|
||||
total_issues=len(all_issues),
|
||||
closed_issues=sum(1 for i in all_issues if i.get("state") == "CLOSED"),
|
||||
total_prs=len(all_prs),
|
||||
merged_prs=sum(1 for p in all_prs if p.get("state") == "MERGED"),
|
||||
)
|
||||
|
||||
output = Path(args.output or f"tmp/{milestone}-ANOMALIES.md")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(report)
|
||||
total = sum(len(v) for v in anomalies.values())
|
||||
print(f"{total} anomalies written to {output}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find Main-project anomalies for a milestone"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True, title="subcommands")
|
||||
|
||||
p_check = sub.add_parser("check", help="Detect anomalies and write the report")
|
||||
p_check.add_argument("milestone", help="Milestone title (e.g. '2.17.0')")
|
||||
p_check.add_argument(
|
||||
"--output",
|
||||
default=None,
|
||||
help="Report path (default: tmp/<MILESTONE>-ANOMALIES.md)",
|
||||
)
|
||||
p_check.set_defaults(func=cmd_check)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for scripts/project-anomalies.py (pure logic, no network).
|
||||
|
||||
Run with:
|
||||
|
||||
python3 scripts/test_project_anomalies.py
|
||||
"""
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
# Loading scripts/project-anomalies.py should not emit scripts/__pycache__/.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
SCRIPT_PATH = pathlib.Path(__file__).resolve().parent / "project-anomalies.py"
|
||||
|
||||
|
||||
def load_pa():
|
||||
"""Load scripts/project-anomalies.py as a module without running its CLI."""
|
||||
loader = importlib.machinery.SourceFileLoader("project_anomalies", str(SCRIPT_PATH))
|
||||
spec = importlib.util.spec_from_loader("project_anomalies", loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
pa = load_pa()
|
||||
|
||||
M = "2.17.0"
|
||||
|
||||
|
||||
def issue(n, **kw):
|
||||
base = {
|
||||
"number": n,
|
||||
"title": f"issue {n}",
|
||||
"state": "CLOSED",
|
||||
"milestone": M,
|
||||
"labels": [],
|
||||
"assignees": ["someone"],
|
||||
"closing_prs": [],
|
||||
"project_status": "Done",
|
||||
"projects": ["Main"],
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def pr(n, **kw):
|
||||
base = {
|
||||
"number": n,
|
||||
"title": f"pr {n}",
|
||||
"state": "MERGED",
|
||||
"milestone": M,
|
||||
"labels": [],
|
||||
"assignees": ["someone"],
|
||||
"closing_issues": [],
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def run(issues, prs):
|
||||
return pa.find_anomalies(
|
||||
M, issues, prs,
|
||||
{p["number"]: p for p in prs},
|
||||
{i["number"]: i for i in issues},
|
||||
)
|
||||
|
||||
|
||||
class OpenMergedTests(unittest.TestCase):
|
||||
def test_open_issue_with_merged_pr_is_flagged(self):
|
||||
out = run(
|
||||
[issue(1, state="OPEN", closing_prs=[11])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(len(out["open_merged"]), 1)
|
||||
self.assertEqual(out["open_merged"][0]["prs"], [11])
|
||||
|
||||
def test_open_issue_with_unmerged_pr_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, state="OPEN", closing_prs=[11])],
|
||||
[pr(11, state="OPEN", closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(out["open_merged"], [])
|
||||
|
||||
def test_closed_issue_with_merged_pr_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, closing_prs=[11])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertTrue(all(v == [] for v in out.values()))
|
||||
|
||||
|
||||
class IssuePrMismatchTests(unittest.TestCase):
|
||||
def test_pr_in_other_milestone_is_flagged(self):
|
||||
out = run(
|
||||
[issue(1, closing_prs=[11])],
|
||||
[pr(11, milestone="2.18.0")],
|
||||
)
|
||||
self.assertEqual(len(out["issue_pr"]), 1)
|
||||
self.assertEqual(out["issue_pr"][0]["pr_milestone"], "2.18.0")
|
||||
|
||||
def test_pr_without_milestone_is_flagged(self):
|
||||
out = run(
|
||||
[issue(1, closing_prs=[11])],
|
||||
[pr(11, milestone=None)],
|
||||
)
|
||||
self.assertEqual(len(out["issue_pr"]), 1)
|
||||
|
||||
def test_unmerged_pr_milestone_is_ignored(self):
|
||||
# An OPEN (unmerged) PR has landed nowhere: its milestone is
|
||||
# irrelevant, even when it differs.
|
||||
out = run(
|
||||
[issue(1, closing_prs=[11])],
|
||||
[pr(11, state="OPEN", milestone="2.18.0")],
|
||||
)
|
||||
self.assertEqual(out["issue_pr"], [])
|
||||
|
||||
def test_off_board_issue_flagged_despite_unmerged_pr(self):
|
||||
# The project check does not depend on PR merge state.
|
||||
out = run(
|
||||
[issue(1, projects=["Other"], closing_prs=[11])],
|
||||
[pr(11, state="OPEN", milestone="2.18.0")],
|
||||
)
|
||||
self.assertEqual(len(out["issue_pr"]), 1)
|
||||
self.assertIsNone(out["issue_pr"][0]["pr"])
|
||||
|
||||
def test_issue_off_main_board_is_flagged_without_pr(self):
|
||||
out = run([issue(1, projects=["Other"], project_status=None)], [])
|
||||
self.assertEqual(len(out["issue_pr"]), 1)
|
||||
self.assertIsNone(out["issue_pr"][0]["pr"])
|
||||
|
||||
def test_aligned_pair_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, closing_prs=[11])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(out["issue_pr"], [])
|
||||
self.assertEqual(out["pr_issue"], [])
|
||||
|
||||
|
||||
class PrIssueMismatchTests(unittest.TestCase):
|
||||
def test_issue_in_other_milestone_is_flagged(self):
|
||||
other = issue(2, milestone="2.16.0")
|
||||
out = pa.find_anomalies(
|
||||
M, [issue(1)], [pr(11, closing_issues=[2])],
|
||||
{11: pr(11, closing_issues=[2])}, {1: issue(1), 2: other},
|
||||
)
|
||||
self.assertEqual(len(out["pr_issue"]), 1)
|
||||
|
||||
def test_issue_without_milestone_is_flagged(self):
|
||||
# Unlike the changelog flow, here a milestone-less issue closed by a
|
||||
# milestone PR is reported: the pairing needs human judgment.
|
||||
other = issue(2, milestone=None, projects=[], project_status=None)
|
||||
out = pa.find_anomalies(
|
||||
M, [issue(1)], [pr(11, closing_issues=[2])],
|
||||
{11: pr(11, closing_issues=[2])}, {1: issue(1), 2: other},
|
||||
)
|
||||
self.assertEqual(len(out["pr_issue"]), 1)
|
||||
|
||||
def test_issue_off_main_board_is_flagged(self):
|
||||
other = issue(2, projects=["Other"], project_status=None)
|
||||
out = pa.find_anomalies(
|
||||
M, [issue(1)], [pr(11, closing_issues=[2])],
|
||||
{11: pr(11, closing_issues=[2])}, {1: issue(1), 2: other},
|
||||
)
|
||||
self.assertEqual(len(out["pr_issue"]), 1)
|
||||
|
||||
def test_milestone_issue_keeps_milestone_in_record(self):
|
||||
# Milestone issues carry no "milestone" key (known by construction);
|
||||
# an off-board one must still render its own milestone, never _none_.
|
||||
mine = issue(1, projects=["Other"], project_status=None)
|
||||
del mine["milestone"]
|
||||
out = pa.find_anomalies(
|
||||
M, [mine], [pr(11, closing_issues=[1])],
|
||||
{11: pr(11, closing_issues=[1])}, {1: mine},
|
||||
)
|
||||
self.assertEqual(len(out["pr_issue"]), 1)
|
||||
self.assertEqual(out["pr_issue"][0]["issue_milestone"], M)
|
||||
report = pa.render_report(M, out, 1, 0, 1, 1)
|
||||
self.assertNotIn("gh issue edit 1", report)
|
||||
|
||||
def test_unmerged_pr_is_ignored(self):
|
||||
# Closed-unmerged (or open) milestone PRs take no part in milestone
|
||||
# pairing: only merged PRs landed somewhere.
|
||||
other = issue(2, milestone="2.16.0")
|
||||
for state in ("OPEN", "CLOSED"):
|
||||
out = pa.find_anomalies(
|
||||
M, [issue(1)], [pr(11, state=state, closing_issues=[2])],
|
||||
{11: pr(11, state=state, closing_issues=[2])},
|
||||
{1: issue(1), 2: other},
|
||||
)
|
||||
self.assertEqual(out["pr_issue"], [], f"state={state}")
|
||||
|
||||
|
||||
class NeedsTriageTests(unittest.TestCase):
|
||||
def test_merged_pr_on_triaged_issue_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, labels=["bug"])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(out["needs_triage"], [])
|
||||
|
||||
def test_merged_pr_on_needs_triage_issue_is_flagged(self):
|
||||
out = run(
|
||||
[issue(1, labels=["needs triage"])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(len(out["needs_triage"]), 1)
|
||||
|
||||
def test_unmerged_pr_on_needs_triage_issue_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, labels=["needs triage"])],
|
||||
[pr(11, state="OPEN", closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(out["needs_triage"], [])
|
||||
|
||||
|
||||
class UnassignedTests(unittest.TestCase):
|
||||
def test_assigned_issue_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, assignees=["owner"])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(out["unassigned"], [])
|
||||
|
||||
def test_unassigned_community_issue_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, assignees=[], labels=["community contribution"])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(out["unassigned"], [])
|
||||
|
||||
def test_unassigned_community_pr_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, assignees=[])],
|
||||
[pr(11, closing_issues=[1], labels=["community contribution"])],
|
||||
)
|
||||
self.assertEqual(out["unassigned"], [])
|
||||
|
||||
def test_unassigned_non_community_issue_is_flagged(self):
|
||||
out = run(
|
||||
[issue(1, assignees=[])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(len(out["unassigned"]), 1)
|
||||
|
||||
def test_unmerged_pr_is_clean(self):
|
||||
out = run(
|
||||
[issue(1, assignees=[])],
|
||||
[pr(11, state="CLOSED", closing_issues=[1])],
|
||||
)
|
||||
self.assertEqual(out["unassigned"], [])
|
||||
|
||||
|
||||
class RenderTests(unittest.TestCase):
|
||||
def test_clean_report_says_so(self):
|
||||
out = run([issue(1, closing_prs=[11])], [pr(11, closing_issues=[1])])
|
||||
report = pa.render_report(M, out, 1, 0, 1, 1)
|
||||
self.assertIn("Total anomalies:** 0", report)
|
||||
self.assertIn("✅ No anomalies", report)
|
||||
|
||||
def test_every_number_is_a_clickable_link(self):
|
||||
out = run(
|
||||
[issue(1, state="OPEN", closing_prs=[11])],
|
||||
[pr(11, closing_issues=[1])],
|
||||
)
|
||||
report = pa.render_report(M, out, 1, 0, 1, 1)
|
||||
self.assertIn("[#1](https://github.com/penpot/penpot/issues/1)", report)
|
||||
self.assertIn("[#11](https://github.com/penpot/penpot/pull/11)", report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in new issue
Block a user