Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-09-23 19:19:18 +02:00
commit b3c1aab720
27 changed files with 733 additions and 161 deletions

View File

@ -73,7 +73,8 @@ one. Title or description fixes belong to Update mode.
Write the title and body following `mem:workflow/creating-prs` (title format,
description structure, writing principles) and `mem:workflow/creating-commits`
(commit type emojis). Derive the title and body from the commits and, when
there is one, from the issue body. Reference the issue with `Closes #NNNN`.
there is one, from the issue body. Include `Closes #NNNN` for readable context,
but do not treat it as the link; use `python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>` after creating the PR.
Repeat the `AI-assisted-by:` trailer in the body, once per model that worked
on the branch, so the PR states the assistance where a reviewer reads it. The
@ -92,6 +93,13 @@ gh pr create --repo penpot/penpot --base "<BASE>" --title "<TITLE>" \
repository default, which is wrong for a branch cut from `staging`. `--project
"Main"` is required by `mem:workflow/creating-prs`.
If an issue is present, run the explicit assignment and verification command
from `mem:workflow/creating-prs` before reporting success:
```bash
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
```
### 5. Report
Report the PR URL and stop.
@ -108,6 +116,14 @@ gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-b
gh pr view <NUMBER> --repo penpot/penpot --json title,body
```
If the updated body contains `Closes #NNNN`, run the explicit assignment
command from `mem:workflow/creating-prs` and require its verification to
succeed:
```bash
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
```
4. Report and stop.
## User context

View File

@ -1,22 +1,9 @@
// Penpot opencode plugin: custom tools for Penpot development.
// Penpot OpenCode V2 plugin: custom tools for Penpot development.
//
// Dual V1 + V2 implementation from a single file:
// - OpenCode V1 (>= 1.18.29) calls the default export's `server()` and uses
// the returned `tool` map (built with the `tool()` helper from
// `@opencode-ai/plugin`).
// - OpenCode V2 reads the default export's `id` and `setup()` and ignores
// `server()`. Tools are registered via `ctx.tool.transform()` with JSON
// Schema inputs, and `execute` returns `{ content }`.
// See https://opencode.ai/v2/docs/build/plugins/migrate-v1
//
// NOTE: the V2 side intentionally does NOT
// `import { Plugin } from "@opencode/plugin"`. At runtime `Plugin.define` is
// the identity function, so a plain `{ id, setup }` object is equivalent, and
// skipping the import keeps this plugin dependency-free
// (`.opencode/package.json` is gitignored, so a new dependency declared there
// would not travel with this file).
// Tools are registered with `ctx.tool.transform()` and JSON Schema inputs.
// Keep this plugin dependency-free so the auto-discovered local plugin loads
// without project npm dependencies.
import { tool } from "@opencode-ai/plugin"
import path from "path"
import { spawn } from "child_process"
@ -106,64 +93,6 @@ function executeParenRepair({ files, code }, directory) {
})
}
// --- V1 tool definitions (OpenCode V1 calls `server()` below) ---
const penpotPsqlTool = tool({
description:
"Execute a SQL command against the Penpot database. Uses the defaults from scripts/psql.",
args: {
sql: tool.schema
.string()
.describe("SQL command to execute"),
test: tool.schema
.boolean()
.describe("Use the penpot_test database")
.optional(),
},
async execute(args, context) {
return executePsql(args.sql, args.test === true, context.worktree)
},
})
const parenRepairTool = tool({
description:
"Fix mismatched parentheses/braces in Clojure files (.clj, .cljs, .cljc) then reformat with cljfmt.",
args: {
// A string is used instead of an array so OpenCode displays it
// in the generic tool invocation.
files: tool.schema
.string()
.describe(
"Comma-separated file paths to fix, for example: frontend/src/app/config.cljs, backend/src/core.clj",
)
.optional(),
code: tool.schema
.string()
.describe("Code string to fix via stdin")
.optional(),
},
async execute(args, context) {
return executeParenRepair(args, context.worktree)
},
})
async function server() {
return {
tool: {
"paren-repair": parenRepairTool,
"penpot-psql": penpotPsqlTool,
},
}
}
// --- V2 setup (OpenCode V2 calls `setup()` and ignores `server()`) ---
const penpotPsqlInputSchema = {
type: "object",
properties: {
@ -199,10 +128,7 @@ const parenRepairInputSchema = {
}
async function setup(ctx) {
// Plugin instance location. This is not the location of every session the
// tools may run for, but it is the closest V2 equivalent of the V1
// per-execution `context.worktree` (the repo checkout the plugin loaded
// from), which is what both tools need as cwd / script base.
// Use the plugin instance location as the working directory for both tools.
const directory =
ctx.location.directory ?? ctx.location.project?.canonical
@ -240,5 +166,4 @@ async function setup(ctx) {
export default {
id: "penpot",
setup,
server,
}

View File

@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import test from "node:test"
import plugin from "../plugins/penpot.js"
test("exports only the OpenCode V2 plugin contract", () => {
assert.equal(plugin.id, "penpot")
assert.equal("server" in plugin, false)
})
test("registers the Penpot tools during setup", async () => {
const tools = []
const context = {
location: { directory: "/tmp/opencode/penpot-plugin-test" },
tool: {
async transform(apply) {
apply({
add(tool) {
tools.push(tool)
},
})
},
},
}
await plugin.setup(context)
assert.deepEqual(
tools.map((tool) => tool.name).sort(),
["paren-repair", "penpot-psql"],
)
})

View File

@ -0,0 +1,51 @@
# Backend Audit Log
Penpot records what users do as events in the Postgres `audit_log` table. There are two producers (the backend RPC layer and the frontend app) and four consumers (webhooks, error reporters, telemetry shipping, and the Nexus archive). Everything below follows that flow: purpose, storage, producers, consumers, archival.
## Purpose
- The audit log answers "who did what, when, from where": every RPC mutation and selected frontend actions become a row with `name`, `type`, `profile-id`, `ip-addr`, `props` and `context`. Product analytics, abuse investigation and compliance exports all read from here, so keep events truthful and never put secrets in `props`.
- It is also the trigger bus for side effects: the same event object fans out to webhooks, error reporting and telemetry without the RPC handler knowing. New features should reuse this bus instead of building parallel notification paths.
## Storage
- Live `audit_log` columns: `id` uuid PK default `gen_random_uuid()`; `name`/`type` text NOT NULL; `created_at` timestamptz NOT NULL default `now()` (server time, the source of truth); `tracked_at` timestamptz default `now()` (client-claimed time, corrected on ingest); `profile_id` uuid NOT NULL; `source` text telling full rows (`backend`/`frontend`) apart from anonymized copies (`telemetry:backend`/`telemetry:frontend`); `ip_addr` inet; `props`/`context` jsonb holding transit-encoded maps; `archived_at` timestamptz set once Nexus acknowledges the row.
- Indexes: PK on `(id)`; partial `created_at WHERE archived_at IS NULL` serving the archive scan; partial `archived_at WHERE archived_at IS NOT NULL` serving the GC; `(source, created_at)` serving the telemetry scan. Each consumer has its own index, so a slow consumer never blocks the others.
## Backend producers (`app.loggers.audit`)
- Most backend events need no manual code: `wrap-audit` in `app.rpc` runs after every RPC handler when `:webhooks`, `:audit-log` or `:telemetry` is on (unless the command sets `::audit/skip`) and builds the event via `prepare-rpc-event`. The event name defaults to the command name (prefixed with `<module>-` outside `main`), props default to the request params, and timestamps come from the server request time.
- Commands customize through result metadata (`rph/with-meta`): `::audit/replace-props` swaps the props wholesale (auth commands use `profile->props` so a register event carries the profile, not the password), `::audit/props` merges extras, `::audit/context`/`profile-id`/`name`/`type` override the defaults. `clean-props` always strips nils, qualified keys and `:session-id/:password/:old-password/:token/:client-secret` as a last line of defense.
- `submit` is the normal entry point (fills defaults, validates `schema:event`, runs inside `tx-run!`, logs failures without failing the RPC). `insert` is the low-level one for CLI/helpers and the webhook subsystem: direct write, no webhook/telemetry fan-out, silent unless `:audit-log` is on. Boot emits `trigger/instance-start` from `setup/props` so every restart is visible in the log.
## Consumers I: webhooks (`app.loggers.webhooks`)
- Webhooks are the first dependent: when an event carries `::webhooks/event?`, `process-event` (worker task `:process-webhook-event`) finds the team's active webhooks from the event props (`team-id`, else `project-id`, else `file-id`), records a `trigger webhook` row, and enqueues one `:run-webhook` delivery per match. Batching and dedupe come from the audit event itself (`batch-key` + `batch-timeout`), not from webhook config.
- `:run-webhook` POSTs the event in the webhook's `mtype` (JSON camelCase, transit, or form-encoded), logs each attempt in `webhook-delivery`, and disables the webhook after 3 consecutive errors. Delivery problems never touch the audit row itself.
## Consumers II: error reporters (`app.loggers.database`, `app.loggers.mattermost`)
- Both reporters listen for backend `:error` log records and for frontend crash events (recognized by the `::audit/event` marker), through a sliding-buffer channel so a flood of errors cannot stall the app. The database reporter persists them into `server-error-report` (source 4 = audit-log origin); the Mattermost reporter forwards a short notification to `:error-report-webhook` when configured.
- Consequence for producers: crash reports only exist if the frontend collector is running and `push-audit-events` accepts `unhandled-exception`/`exception-page` events. Disabling the whole pipeline also blinds error reporting from the frontend.
## Consumers III: telemetry (`app.loggers.audit` + `app.tasks.telemetry`)
- Telemetry reuses the same table with anonymized shadow rows (`source LIKE 'telemetry:%'`): day-truncated timestamps, `0.0.0.0` IPs, props reduced to uuid/boolean/number values plus a few allowlisted fields (`lang`, `auth-backend`, derived `email-domain`, never raw emails), and a minimal context allowlist. Both full and shadow rows can coexist per event; that duplication is intentional.
- The telemetry cron ships shadow rows to `:telemetry-uri` as JSON in 10k batches, deletes them on success, and purges leftovers older than 7d. Nothing is collected or sent on official hosts (`telemetry-excluded?` covers `penpot.app`/`penpot.dev`).
## Frontend ingestion (`app.rpc.commands.audit`, `app.main.data.event`)
- The browser cannot write to the table directly; it POSTs transit batches to `push-audit-events`, which stamps server `id`, session `profile-id`, request ip and server `created-at`, and distrusts the client clock (future or >1h-lagging `tracked-at` is reset, original preserved in context). The endpoint is a no-op without `:audit-log`/`:telemetry` or on a read-only pool.
- The in-browser collector (`app.main.data.event`) only starts after `get-enabled-flags` confirms the backend wants events. It turns Potok events and explicit `ev/event` calls (nitrate membership changes, workspace file stats, crash reports) into a capped buffer (1024, chunks of 100, 2s debounce, current profile only) and sends fire-and-forget. `skip-audit?` exists for resumed dashboard actions so one user gesture is not counted twice.
- Because collection is best-effort and includes `PerformanceObserver` noise (`performance-*` triggers), backend tests must never assert exact frontend event counts.
## Archival to Nexus and retention
- Long-term storage lives outside Penpot in Nexus. Every 5m the `:audit-log-archive` cron takes chunks of 128 unarchived rows (`FOR UPDATE SKIP LOCKED`), POSTs them as transit `{:events [...]}` authenticated with `x-shared-key: "nexus <key>"` (`:nexus-shared-key`, else derived from the instance secret), and marks `archived_at=now()` only on HTTP 204, in the same transaction. Anything else is retried on the next run; a missing URI with the flag on raises `:task-not-configured`.
- Every 5m the `:audit-log-gc` cron deletes all archived rows (no age filter), so archive must run before GC or data ships never. Cron dedup is best-effort (`mem:prod-infra/core`): two backends can fire the archiver twice, which is why the Nexus endpoint must be idempotent and the DB only marks acknowledged rows.
- Flags live in `common/flags.cljc` varia and are enabled as `PENPOT_FLAGS=enable-<name>`: `:audit-log`, `:audit-log-archive`, `:audit-log-gc`, `:audit-log-logger` (structured `app.audit` log). `:telemetry-enabled` config auto-adds `:enable-telemetry`.
## Tests
- `backend_tests/rpc-audit-test.clj` exercises the whole backend path (full-row insert, telemetry-only and dual-row modes, `submit*`, no-op without flags, `insert` gating, `prepare-rpc-event` resolution) with `with-redefs [cf/flags #{...}]` against real `audit_log` rows.
- Other RPC suites mock `app.loggers.audit/submit` (nil return; `helpers.clj` stubs it globally) and assert on `:call-args-list`; any new command that must (or must not) emit an event needs the same treatment.

View File

@ -8,6 +8,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
- Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment`
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
- Audit-log event collection (RPC wrapper, frontend ingestion), telemetry duality, webhook fan-out, error reporters, Nexus archival and retention: `mem:backend/audit-log`
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
## Stable namespace map

View File

@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
- Explicitly linking a GitHub issue to a pull request and verifying both sides.
- Listing or inspecting GitHub Security Advisories (GHSA).
## Prerequisites
@ -73,6 +74,17 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
**Output**: JSON array to stdout; progress to stderr.
### `link-issue`
Explicitly assign a GitHub issue to a pull request and verify the relationship from both sides:
```bash
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
# Short alias: python3 scripts/gh.py link <ISSUE_NUMBER> <PR_NUMBER>
```
The command resolves both node IDs, calls `addCloseIssueReferences`, and checks the issue's manually linked PRs and the PR's closing issue references. It is safe to rerun, works for merged PRs, and does not close an issue retroactively. JSON goes to stdout; progress and errors go to stderr; a missing link exits non-zero.
### `advisories`
List or inspect GitHub Security Advisories for the repository.

View File

@ -27,11 +27,11 @@ bb scripts/paren-repair --help
```
`bb` must be invoked from the repo root so the path `scripts/paren-repair` resolves.
## Native Tool Available (opencode)
## Native Tool Available (OpenCode V2)
A native opencode tool `paren-repair` is available (defined in
`.opencode/plugins/penpot.js`, which registers it for both opencode V1 via
`server()` and opencode V2 via `setup()`). The LLM can call it directly with:
A native OpenCode V2 tool `paren-repair` is available. It is defined in
`.opencode/plugins/penpot.js` and registered through `setup()`. The LLM can
call it directly with:
- `files`: comma-separated file paths to fix (a string, not an array)
- `code`: Code string to fix via stdin

View File

@ -25,12 +25,11 @@ scripts/psql --host myhost --user myuser --db mydb
`scripts/psql` must be invoked from the repo root so the path resolves.
## Native Tool Available (opencode)
## Native Tool Available (OpenCode V2)
A native opencode tool `penpot-psql` is available (defined in
`.opencode/plugins/penpot.js`, which registers it for both opencode V1 via
`server()` and opencode V2 via `setup()`). The LLM can call it directly
with:
A native OpenCode V2 tool `penpot-psql` is available. It is defined in
`.opencode/plugins/penpot.js` and registered through `setup()`. The LLM can
call it directly with:
- `sql`: SQL command string to execute
- `test`: Boolean flag to use the `penpot_test` database

View File

@ -50,4 +50,4 @@ that breaks them is wrong, even if the code is right.
## Referencing Issues
Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
Use `Closes #NNNN` (not `Fixes #NNNN`) in commit and PR body text for readable context. The explicit issue-to-PR link is assigned with `python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>` as described in `mem:workflow/creating-prs`; do not rely on the keyword to create that link.

View File

@ -271,21 +271,13 @@ the issue was sourced.
### Link the PR to the issue
Append `Closes #<ISSUE_NUMBER>` to the PR body:
Add `Closes #<ISSUE_NUMBER>` to the PR body for readable context, then run the explicit assignment command from `mem:workflow/creating-prs`:
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
# Verify
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
--jq '.body | test("Closes #<ISSUE_NUMBER>")'
python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>
```
**Note:** If the PR is already merged, `Closes` won't auto-close the issue —
it only creates the "Development" sidebar link. This is the desired
behavior since the issue is a tracking artifact.
The command creates the GitHub Development link and verifies it from both the issue and PR. It is safe to rerun and does not close an issue retroactively when the PR is already merged. Do not rely on the body keyword as the assignment operation.
### Clean up

View File

@ -36,7 +36,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering:
- what changed and why;
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- related GitHub issues or Taiga stories (`Closes #NNNN` for issues resolved by the PR, `Relates to #NNNN` for context, `Taiga #NNNN`); use the explicit issue assignment process below for each `Closes` issue;
- screenshots or recordings for UI-visible changes;
- testing performed and residual risk;
- breaking changes or migration notes, if any.
@ -77,6 +77,12 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- ❌ Migration notes unless breaking changes
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
## Explicit Issue Assignment
- For each GitHub issue that a PR resolves, run `python3 scripts/gh.py link-issue <ISSUE_NUMBER> <PR_NUMBER>` after creating or editing the PR. Do not rely on `Closes #NNNN` in the body; it is only human-readable context.
- The command calls `addCloseIssueReferences`, verifies the relationship from both the issue and PR, and exits non-zero if either side is missing. It is safe to rerun and also works for an already merged PR; it does not close an issue retroactively.
- Skip this process for `Relates to #NNNN` and Taiga references, which do not represent a closing relationship.
## Before Opening
- Follow `mem:workflow/creating-commits` for commits

View File

@ -47,7 +47,7 @@
- Export multiple fills to SVG [#11466](https://github.com/penpot/penpot/issues/11466) (PR: [#11467](https://github.com/penpot/penpot/pull/11467))
- Add Penpot-specific board size presets (file thumbnail, template cover, plugin icon/cover) [#11561](https://github.com/penpot/penpot/issues/11561) (PR: [#11565](https://github.com/penpot/penpot/pull/11565))
## 2.18.0 (Unreleased)
## 2.18.0
### :rocket: Epics and highlights
@ -177,9 +177,11 @@
- Fix workspace crash with index out of bounds when removing multiple interactions rapidly [#11546](https://github.com/penpot/penpot/issues/11546) (PR: [#11621](https://github.com/penpot/penpot/pull/11621))
- Fix workspace crash with 'too much recursion' error when editing grid layout [#11562](https://github.com/penpot/penpot/issues/11562) (PR: [#11563](https://github.com/penpot/penpot/pull/11563))
- Fix plugin API error when removing interactions [#11618](https://github.com/penpot/penpot/issues/11618) (PR: [#11621](https://github.com/penpot/penpot/pull/11621))
- Fix MCP REPL server starting in multi-user mode and inheriting the main server bind address [#11631](https://github.com/penpot/penpot/issues/11631) (PR: [#11632](https://github.com/penpot/penpot/pull/11632))
- Fix chunked upload storing duplicate objects when the same chunk index is sent twice [#11634](https://github.com/penpot/penpot/issues/11634) (PR: [#11635](https://github.com/penpot/penpot/pull/11635))
- Fix access token expiration select always showing Never after picking another option (by @chulgil) [#11663](https://github.com/penpot/penpot/issues/11663) (PR: [#11664](https://github.com/penpot/penpot/pull/11664))
- Fix workspace dropping unsaved changes when read-only blocks persistence [#11672](https://github.com/penpot/penpot/issues/11672) (PR: [#11699](https://github.com/penpot/penpot/pull/11699))
- Fix Plugin API failing silently when writing to an interaction the shape no longer has [#11780](https://github.com/penpot/penpot/issues/11780) (PR: [#11781](https://github.com/penpot/penpot/pull/11781))
### :sparkles: New features & Enhancements
@ -203,6 +205,8 @@
- Adopt React Aria [#10802](https://github.com/penpot/penpot/issues/10802) (PR: [#10675](https://github.com/penpot/penpot/pull/10675))
- Add plugin API function for awaiting component updates beyond waitForLayoutUpdate [#10927](https://github.com/penpot/penpot/issues/10927) (PR: [#10964](https://github.com/penpot/penpot/pull/10964))
- Emit open-workspace-file audit event with file statistics on workspace load [#11106](https://github.com/penpot/penpot/issues/11106) (PR: [#11138](https://github.com/penpot/penpot/pull/11138))
- Restrict optional ids to v4, v7 and v8 UUIDs in creation RPC endpoints [#11778](https://github.com/penpot/penpot/issues/11778) (PR: [#11777](https://github.com/penpot/penpot/pull/11777))
## 2.17.2
### :bug: Bugs fixed

View File

@ -11,12 +11,13 @@ Center](https://help.penpot.app/).
## Table of Contents
- [Prerequisites](#prerequisites)
- [AI-Assisted Contributions](#ai-assisted-contributions)
- [Reporting Bugs](#reporting-bugs)
- [Pull Requests](#pull-requests)
- [Workflow](#workflow)
- [Branch naming](#branch-naming)
- [Format](#format)
- [Title format](#title-format)
- [Title format](#title)
- [Description](#description)
- [Review process](#review-process)
- [What we won't accept](#what-we-wont-accept)
@ -55,6 +56,12 @@ Center](https://help.penpot.app/).
without switching the fallback off. Every one of these paths is
gitignored.
## AI-Assisted Contributions
We support the responsible use of AI tools in the development process. However, all contributions to Penpot - including issues, pull requests, and any other submissions - must meet a reasonable standard of quality, accuracy, and human oversight.
If AI-assisted content is used, it must be carefully reviewed and verified by a human before submission. Contributions that don't meet these standards may be rejected or closed without detailed review or a reply.
## Reporting Bugs
Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues).
@ -100,7 +107,7 @@ Branch names are not enforced, but we recommend the following:
and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
Since PRs are squash-merged, the branch name does not survive into the
commit history — what matters is the [PR title](#title-format).
commit history — what matters is the [PR title](#title).
### Format
@ -145,7 +152,7 @@ for more concrete information.
force-push during review, as it breaks comment threads.
- PRs require at least **one approval** before merge.
- We use **squash-merge** by default. The PR title becomes the final
commit message, so follow the [title format](#title-format) above.
commit message, so follow the [title format](#title) above.
### What we won't accept

View File

@ -204,7 +204,7 @@
token-id (::actoken/id request)
token-type (::actoken/type request)]
{:external-session-id session-id
:initiator (or key-id "app")
:initiator (or (d/name key-id) "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin

View File

@ -35,6 +35,21 @@
"x-forwarded-for" "127.0.0.44"
"x-real-ip" "127.0.0.43"))))
(t/deftest prepare-context-initiator-is-plain-string
;; The initiator must always be a plain string, never a keyword: shared-key
;; authenticated callers (exporter, admin-console) arrive as keywords on
;; :app.http/auth-key-id and transit would persist them as "~:exporter".
(let [base {:headers {"x-forwarded-for" "127.0.0.44"}}]
(t/is (= "app" (:initiator (audit/prepare-context-from-request base))))
(t/is (= "exporter"
(:initiator (audit/prepare-context-from-request
(assoc base :app.http/auth-key-id :exporter)))))
(t/is (= "admin-console"
(:initiator (audit/prepare-context-from-request
(assoc base :app.http/auth-key-id :admin-console)))))
(t/is (string? (:initiator (audit/prepare-context-from-request
(assoc base :app.http/auth-key-id :nexus)))))))
(t/deftest push-events-1
(with-redefs [app.config/flags #{:audit-log}]
(let [prof (th/create-profile* 1 {:is-active true})

View File

@ -1943,3 +1943,26 @@
(t/is (= "bar" (get-in event [:context :foo])))
(t/is (= (:full cf/version) (get-in event [:context :version])))
(t/is (= "app" (get-in event [:context :initiator]))))))))
(t/deftest push-audit-events-initiator-is-plain-string
;; Shared-key callers (e.g. admin-console) carry :app.http/auth-key-id as a
;; keyword; the stored initiator must be a plain string, and a
;; caller-supplied initiator must never survive (server context wins).
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
(binding [cf/flags #{:audit-log}]
(let [prof (th/create-profile* 1 {:is-active true})
params {::th/type :push-audit-events
:events [{:name "context-test"
:profile-id (:id prof)
:type "action"
:context {:custom-key "custom-val"
:initiator "spoofed"}}]}
params (with-meta params
{::http/request (assoc http-request
::http/auth-key-id :admin-console)})
out (th/management-command! params)]
(t/is (nil? (:error out)))
(let [[_ event] (:call-args @audit-mock)]
(t/is (= "custom-val" (get-in event [:context :custom-key])))
(t/is (= "admin-console" (get-in event [:context :initiator])))
(t/is (string? (get-in event [:context :initiator]))))))))

View File

@ -2783,7 +2783,7 @@
(let [shape-inst (ctn/get-shape container id)
objects (:objects container)
parent (get objects (:parent-id shape-inst))
head (ctn/get-component-shape container parent)
head (ctn/get-component-shape objects parent)
changes
(-> changes
(pcb/with-container container)
@ -2802,6 +2802,10 @@
(pcb/with-objects objects)
(generate-sync-shape-direct file-full libraries container id true))]
;; If the shape has been swapped, the reset also have to undo the swap. As the
;; shape has been reset and it's not touched now, by the sync above, if we
;; do another reset of the parent, the shape will be reset as it is in the
;; near main component.
(cond-> changes
(some? swap-slot)
(generate-sync-head file-full libraries container id true))))

View File

@ -82,3 +82,63 @@
(validate #(t/is (= (copy-type %) :rect)))
(validate #(t/is (= (nested-component-id %) (rectangle-component-id %))))
(validate #(t/is (nil? (nested-swap-slot %)))))))
;; The nested component sits inside a group, not directly under the instance root.
;; Resetting overrides after a swap must undo the swap without error.
(t/deftest test-swap-and-reset-override-inside-group
(letfn [(nested-in-copy [file]
(->> (ths/get-shape file :copy-group)
:shapes
first
(ths/get-shape-by-id file)))]
(let [;; ==== Setup
file
(-> (thf/sample-file :file1)
(tho/add-frame :frame-rectangle)
(ths/add-sample-shape :rectangle-shape :parent-label :frame-rectangle :type :rect)
(thc/make-component :rectangle :frame-rectangle)
(tho/add-frame :frame-circle)
(ths/add-sample-shape :circle :parent-label :frame-circle :type :circle)
(thc/make-component :circle :frame-circle)
(tho/add-frame :frame-main)
(tho/add-group :group-main :parent-label :frame-main)
(thc/instantiate-component :rectangle :nested-rectangle
:parent-label :group-main
:children-labels [:nested-rectangle-shape])
(thc/make-component :main :frame-main)
(thc/instantiate-component :main :copy
:children-labels [:copy-group
:copy-nested-rectangle
:copy-nested-rectangle-shape]))
rectangle-id (:id (thc/get-component file :rectangle))
circle-id (:id (thc/get-component file :circle))
main-nested-id (:id (ths/get-shape file :nested-rectangle))
;; ==== Action swap nested copy inside the group, then reset overrides
file-swapped
(tho/swap-component-in-shape file :copy-nested-rectangle :circle
:new-shape-label :copy-nested-swapped)
swapped (ths/get-shape file-swapped :copy-nested-swapped)
file'
(tho/reset-overrides file-swapped swapped)
restored (nested-in-copy file')]
;; ==== Check after swap
(t/is (= :circle (:type (tho/bottom-shape file-swapped :copy-nested-swapped))))
(t/is (= circle-id (:component-id swapped)))
(t/is (= main-nested-id (ctk/get-swap-slot swapped)))
;; ==== Check after reset: back to rectangle, no swap slot, file still valid
(t/is (some? restored))
(t/is (= :rect (:type (tho/bottom-shape-by-id file' (:id restored)))))
(t/is (= rectangle-id (:component-id restored)))
(t/is (nil? (ctk/get-swap-slot restored))))))

View File

@ -24,7 +24,7 @@
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
# 'disable-secure-session-cookies' and 'disable-email-verification'
x-flags: &penpot-flags
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: http://localhost:9001
@ -45,6 +45,13 @@ x-body-size: &penpot-http-body-size
x-secret-key: &penpot-secret-key
PENPOT_SECRET_KEY: change-this-insecure-key
## Single database configuration block, shared by backend and admin-console.
## Don't touch them unless you are using custom postgresql connection parameters.
x-database: &penpot-database
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
networks:
penpot:
@ -79,7 +86,7 @@ services:
# - "443:443"
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
image: "penpotapp/frontend:${PENPOT_VERSION:-2.18}"
restart: always
ports:
- 9001:8080
@ -89,8 +96,9 @@ services:
depends_on:
- penpot-backend
- penpot-exporter
- penpot-admin-console
- penpot-mcp
- penpot-exporter
networks:
- penpot
@ -108,11 +116,16 @@ services:
environment:
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
# Don't touch it; this uses an internal docker network to
# communicate with the admin-console.
PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
# Set to "true" on hosts where IPv6 is disabled at kernel boot level.
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
image: "penpotapp/backend:${PENPOT_VERSION:-2.18}"
restart: always
volumes:
@ -129,29 +142,24 @@ services:
## Configuration environment variables for the backend container.
environment:
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key]
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key, *penpot-database]
## Database connection parameters. Don't touch them unless you are using custom
## postgresql connection parameters.
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
# Don't touch it; this uses an internal docker network to
# communicate with the admin-console.
PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
## Valkey (or previously redis) is used for the websockets notifications. Don't touch
## unless the valkey container has different parameters or different name.
PENPOT_REDIS_URI: redis://penpot-valkey/0
## Default configuration for assets storage: using filesystem based with all files
## stored in a docker volume.
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage.
# AWS_ACCESS_KEY_ID: <KEY_ID>
# AWS_SECRET_ACCESS_KEY: <ACCESS_KEY>
# PENPOT_OBJECTS_STORAGE_BACKEND: s3
@ -162,7 +170,6 @@ services:
## instance. Telemetry data will enable us to learn how the application is used,
## based on real scenarios. If you want to help us, please leave it enabled. You can
## audit what data we send with the code available on github.
PENPOT_TELEMETRY_ENABLED: "true"
PENPOT_TELEMETRY_REFERER: compose
@ -170,7 +177,6 @@ services:
## service, but for production usage it is recommended to setup a real SMTP
## provider. Emails are used to confirm user registrations & invitations. Look below
## how the Mailpit service is configured.
PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com
PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com
PENPOT_SMTP_HOST: penpot-mailpit
@ -180,14 +186,32 @@ services:
PENPOT_SMTP_TLS: "false"
PENPOT_SMTP_SSL: "false"
penpot-admin-console:
image: "penpotapp/admin-console:${PENPOT_VERSION:-2.18}"
restart: always
depends_on:
penpot-postgres:
condition: service_healthy
networks:
- penpot
environment:
<< : [*penpot-public-uri, *penpot-secret-key, *penpot-database]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
image: "penpotapp/mcp:${PENPOT_VERSION:-2.18}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
image: "penpotapp/exporter:${PENPOT_VERSION:-2.18}"
restart: always
depends_on:
@ -199,6 +223,7 @@ services:
environment:
<< : [*penpot-flags, *penpot-secret-key, *penpot-public-uri]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_INTERNAL_URI: http://penpot-frontend:8080

View File

@ -1,5 +1,5 @@
---
title: 1.3 Install with Docker
title: 1.2 Install with Docker
desc: This Penpot technical guide covers self-hosting, Docker installation, configuration, updates, backups, and proxy setup with NGINX and Caddy. Try Penpot!
---
@ -115,7 +115,90 @@ This will fetch the latest images. When you do <code class="language-bash">docke
It is strongly recommended to update the Penpot version in small increments, rather than updating between two distant versions.
</p>
**Important: Upgrade from version 1.x to 2.0**
#### Upgrade to 2.18
This version deploys a new service, **`penpot-admin-console`**, in the official `docker-compose.yaml`
example. If you maintain your own compose file, you need to replicate the following changes manually:
**1. Add the `enable-admin-console` flag**
**NOTE:** Enabling the Admin Console is not required in this version, but it will be in a future release.
We recommend setting it up now to avoid a more complex upgrade later.
This step (the `enable-admin-console` flag) only applies to this version 2.18. In a future release, the
Admin Console will be enabled by default and this flag will be removed. If you're upgrading directly to a
version where the flag has already been removed, skip step 1 and go straight to steps 24, which remain
required.
Wherever you set `PENPOT_FLAGS` (frontend and backend):
```diff
- PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp
+ PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
```
**2. Add the new `penpot-admin-console` service**
```yaml
penpot-admin-console:
image: "penpotapp/admin-console:2.18"
restart: always
depends_on:
penpot-postgres:
condition: service_healthy
networks:
- penpot
environment:
PENPOT_PUBLIC_URI: http://localhost:9001
PENPOT_SECRET_KEY: change-this-insecure-key
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
```
> Use the same `PENPOT_PUBLIC_URI`, `PENPOT_SECRET_KEY`, and database credentials you already have configured for `penpot-backend`.
**3. Update `penpot-frontend`**
- Add `penpot-admin-console` to `depends_on`.
- Add the following environment variable:
```diff
environment:
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
PENPOT_PUBLIC_URI: http://localhost:9001
+
+ # Don't touch it; this uses an internal docker network to
+ # communicate with the admin-console.
+ PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
```
**4. Update `penpot-backend`**
Add the same variable:
```diff
environment:
PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
PENPOT_PUBLIC_URI: http://localhost:9001
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
PENPOT_SECRET_KEY: change-this-insecure-key
+
+ # Don't touch it; this uses an internal docker network to
+ # communicate with the admin-console.
+ PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
```
#### Upgrade from version 1.x to 2.0
The migration to version 2.0, due to the incorporation of the new v2 components, includes
an additional process that runs automatically as soon as the application starts. If your

View File

@ -1,5 +1,5 @@
---
title: 1.2 Install with Elestio
title: 1.4 Install with Elestio
desc: "Step-by-step guide to deploy a self-hosted Penpot on Elestio: 3-minute setup, managed DNS/SMTP/SSL/backups, Docker Compose config, updates & support."
---

View File

@ -1,5 +1,5 @@
---
title: 1.4 Install with Kubernetes
title: 1.3 Install with Kubernetes
desc: Learn how to install and configure Penpot on your Kubernetes cluster using Helm. Our technical guide provides step-by-step instructions for setup.
---

View File

@ -21,7 +21,6 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.storage :as storage]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
;; --- PAGE: Register
@ -82,46 +81,43 @@
(mf/deps form)
(fn [cause]
(reset! submitted? false)
(let [{:keys [type code] :as edata} (ex-data cause)]
(let [{:keys [type code] :as edata} (ex-data cause)
set-field-error!
(fn [field message]
(swap! form assoc-in [:extra-errors field] message)
(swap! form assoc-in [:touched field] true))]
(condp = [type code]
[:restriction :email-does-not-match-invitation]
(st/emit! (ntf/error (tr "errors.email-does-not-match-invitation")))
(set-field-error! :email {:message (tr "errors.email-does-not-match-invitation")})
[:restriction :registration-disabled]
(st/emit! (ntf/error (tr "errors.registration-disabled")))
(set-field-error! :email {:message (tr "errors.registration-disabled")})
[:restriction :email-domain-is-not-allowed]
(st/emit! (ntf/error (tr "errors.email-domain-not-allowed")))
(set-field-error! :email {:message (tr "errors.email-domain-not-allowed")})
[:restriction :email-has-permanent-bounces]
(st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata))))
(set-field-error! :email {:message (tr "errors.email-has-permanent-bounces" (:email edata))})
[:restriction :email-has-complaints]
(st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata))))
(set-field-error! :email {:message (tr "errors.email-has-permanent-bounces" (:email edata))})
;; Reported on the email input itself, the way the recovery and
;; password forms report server side errors, so the field that
;; needs fixing is the one marked as invalid
[:validation :email-already-exists]
(swap! form assoc-in [:extra-errors :email]
{:message (tr "errors.email-already-exists")})
(set-field-error! :email {:message (tr "errors.email-already-exists")})
[:validation :email-as-password]
(st/emit! (ntf/error (tr "errors.email-as-password")))
(set-field-error! :password {:message (tr "errors.email-as-password")})
[:validation :weak-password]
(let [details (:details edata)
items (when (seq details)
(->> details
(map #(str "<li>" (tr %) "</li>"))
(str/join "")))
detail (when items
(str "<ul>" items "</ul>"))]
(st/emit! (ntf/show {:content (tr "errors.weak-password")
:detail detail
:is-html true
:type :toast
:level :error})))
(let [options (when (seq (:details edata))
(mapv tr (:details edata)))]
(set-field-error! :password {:message (tr "errors.weak-password")
:options options}))
(do
(when-let [explain (get edata :explain)]
@ -133,7 +129,7 @@
(mf/deps on-success-callback)
(fn [params]
(if (fn? on-success-callback)
(on-success-callback (:email params))
(on-success-callback params)
(cond
(some? (:invitation-token params))
(let [token (:invitation-token params)]

View File

@ -125,13 +125,22 @@
success-register
(mf/use-fn
(fn [data]
(reset! register-token (:token data))
(reset! current-section :register-validate)))
(cond
(:invitation-token data)
(st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)}))
(:is-active data)
(st/emit! (rt/reload true))
:else
(do
(reset! user-email (:email data))
(reset! current-section :register-email-sent)))))
register-email-sent
(mf/use-fn
(fn [email]
(reset! user-email email)
(fn [data]
(reset! user-email (if (string? data) data (:email data)))
(reset! current-section :register-email-sent)))
recovery-email-sent
@ -355,7 +364,7 @@
[:span
(tr "labels.nitrate-not-configured.learn-more")
" "
[:a {:href "https://help.penpot.app/technical-guide/getting-started/"
[:a {:href "https://help.penpot.app/technical-guide/getting-started/docker/#update-penpot"
:target "_blank"
:rel "noopener noreferrer"}
(tr "labels.nitrate-not-configured.technical-guide")]]]]

View File

@ -10,6 +10,7 @@
[app.common.logging :as log]
[app.config :as cf]
[app.main.data.modal :as modal]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.auth.login :refer [login-dialog*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page*]]
@ -69,8 +70,17 @@
success-register
(fn [data]
(reset! register-token (:token data))
(set-current-section :register-validate))]
(cond
(:invitation-token data)
(st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)}))
(:is-active data)
(st/emit! (modal/hide))
:else
(do
(reset! user-email (:email data))
(set-current-section :email-sent))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}

View File

@ -8,6 +8,7 @@ Subcommands:
issues List issues in a milestone (or unassigned with milestone=none)
prs Fetch details for one or more PRs (by number or milestone)
advisories List or inspect GitHub security advisories
link-issue Explicitly link a GitHub issue to a pull request
Usage:
python3 scripts/gh.py issues <milestone-title> (default: state=closed)
@ -27,6 +28,7 @@ Usage:
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)
python3 scripts/gh.py link-issue 11235 11243
Prerequisites:
- gh CLI authenticated (gh auth status)
@ -77,6 +79,141 @@ def run_gh_rest(path: str) -> Any:
return json.loads(result.stdout)
# ─────────────────────────────────────────────
# Subcommand: link-issue
# ─────────────────────────────────────────────
GQL_LINK_TARGETS_QUERY = """\
query($owner: String!, $repo: String!, $issueNumber: Int!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) { id number }
pullRequest(number: $prNumber) { id number }
}
}
"""
GQL_ADD_CLOSE_ISSUE_REFERENCES = """\
mutation($issueId: ID!, $pullRequestIds: [ID!]!) {
addCloseIssueReferences(input: {issueId: $issueId, pullRequestIds: $pullRequestIds}) {
issue { id number }
}
}
"""
GQL_VERIFY_ISSUE_LINK_QUERY = """\
query($owner: String!, $repo: String!, $issueNumber: Int!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
number
state
closedByPullRequestsReferences(
includeClosedPrs: true
userLinkedOnly: true
first: 100
) {
nodes { number state url }
}
}
pullRequest(number: $prNumber) {
number
state
closingIssuesReferences(first: 100) {
nodes { number state url }
}
}
}
}
"""
def link_issue_to_pr(issue_number: int, pr_number: int) -> dict:
"""Add and verify an explicit GitHub issue-to-PR link."""
if issue_number <= 0 or pr_number <= 0:
raise ValueError("issue and pull request numbers must be positive")
variables = {
"owner": OWNER,
"repo": REPO_NAME,
"issueNumber": issue_number,
"prNumber": pr_number,
}
target_data = run_gh_graphql(GQL_LINK_TARGETS_QUERY, variables)
repository = target_data.get("repository") or {}
issue = repository.get("issue") or {}
pull_request = repository.get("pullRequest") or {}
if not issue.get("id"):
raise RuntimeError(f"issue #{issue_number} was not found in {REPO}")
if not pull_request.get("id"):
raise RuntimeError(f"pull request #{pr_number} was not found in {REPO}")
mutation_data = run_gh_graphql(
GQL_ADD_CLOSE_ISSUE_REFERENCES,
{
"issueId": issue["id"],
"pullRequestIds": [pull_request["id"]],
},
)
mutation_result = mutation_data.get("addCloseIssueReferences") or {}
linked_issue = mutation_result.get("issue") or {}
if linked_issue.get("number") != issue_number:
raise RuntimeError(f"GitHub did not link issue #{issue_number}")
verification_data = run_gh_graphql(GQL_VERIFY_ISSUE_LINK_QUERY, variables)
repository = verification_data.get("repository") or {}
issue = repository.get("issue") or {}
pull_request = repository.get("pullRequest") or {}
if not issue or not pull_request:
raise RuntimeError("GitHub did not return both link targets during verification")
issue_links = [
node
for node in issue["closedByPullRequestsReferences"]["nodes"]
if node.get("number") == pr_number
]
pr_links = [
node
for node in pull_request["closingIssuesReferences"]["nodes"]
if node.get("number") == issue_number
]
if not issue_links or not pr_links:
raise RuntimeError(
f"issue #{issue_number} and pull request #{pr_number} are not linked"
)
return {
"linked": True,
"issue": {
"number": issue["number"],
"state": issue["state"],
"linked_pull_requests": issue_links,
},
"pull_request": {
"number": pull_request["number"],
"state": pull_request["state"],
"linked_issues": pr_links,
},
}
def cmd_link_issue(args: argparse.Namespace) -> None:
"""Handle the ``link-issue`` subcommand."""
print(
f"Linking issue #{args.issue_number} to pull request #{args.pr_number}...",
file=sys.stderr,
)
try:
result = link_issue_to_pr(args.issue_number, args.pr_number)
except (ValueError, RuntimeError) as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
print(
f"Verified issue #{args.issue_number} -> pull request #{args.pr_number}",
file=sys.stderr,
)
print(json.dumps(result, indent=2))
# ─────────────────────────────────────────────
# Shared: milestone lookup
# ─────────────────────────────────────────────
@ -764,6 +901,16 @@ def main() -> None:
)
p_prs.set_defaults(func=cmd_prs)
# --- link-issue ---
p_link = sub.add_parser(
"link-issue",
aliases=["link"],
help="Explicitly link an issue to a pull request and verify both sides",
)
p_link.add_argument("issue_number", type=int, help="Issue number")
p_link.add_argument("pr_number", type=int, help="Pull request number")
p_link.set_defaults(func=cmd_link_issue)
# --- advisories ---
p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories")
p_adv.add_argument(

155
scripts/test_gh.py Normal file
View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Tests for scripts/gh.py.
Run with:
python3 scripts/test_gh.py
"""
import contextlib
import importlib.machinery
import importlib.util
import io
import json
import pathlib
import sys
import types
import unittest
from unittest.mock import patch
# Loading scripts/gh.py should not emit scripts/__pycache__/.
sys.dont_write_bytecode = True
SCRIPT_PATH = pathlib.Path(__file__).resolve().parent / "gh.py"
def load_gh():
"""Load scripts/gh.py as a module without running its CLI."""
loader = importlib.machinery.SourceFileLoader("gh_helper", str(SCRIPT_PATH))
spec = importlib.util.spec_from_loader("gh_helper", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
gh = load_gh()
class LinkIssueTests(unittest.TestCase):
def setUp(self):
self.target_response = {
"repository": {
"issue": {"id": "issue-id", "number": 11235},
"pullRequest": {"id": "pr-id", "number": 11243},
}
}
self.mutation_response = {
"addCloseIssueReferences": {
"issue": {"id": "issue-id", "number": 11235}
}
}
self.verification_response = {
"repository": {
"issue": {
"number": 11235,
"state": "OPEN",
"closedByPullRequestsReferences": {
"nodes": [
{
"number": 11243,
"state": "MERGED",
"url": "https://github.com/penpot/penpot/pull/11243",
}
]
},
},
"pullRequest": {
"number": 11243,
"state": "MERGED",
"closingIssuesReferences": {
"nodes": [
{
"number": 11235,
"state": "OPEN",
"url": "https://github.com/penpot/penpot/issues/11235",
}
]
},
},
}
}
@patch.object(gh, "run_gh_graphql")
def test_link_issue_to_pr_adds_and_verifies_reference(self, run_graphql):
run_graphql.side_effect = [
self.target_response,
self.mutation_response,
self.verification_response,
]
result = gh.link_issue_to_pr(11235, 11243)
self.assertTrue(result["linked"])
self.assertEqual(
result["issue"]["linked_pull_requests"][0]["number"],
11243,
)
self.assertEqual(
result["pull_request"]["linked_issues"][0]["number"],
11235,
)
self.assertEqual(run_graphql.call_count, 3)
self.assertEqual(
run_graphql.call_args_list[1].args[1],
{"issueId": "issue-id", "pullRequestIds": ["pr-id"]},
)
@patch.object(gh, "run_gh_graphql")
def test_link_issue_to_pr_fails_when_verification_is_missing(self, run_graphql):
self.verification_response["repository"]["issue"][
"closedByPullRequestsReferences"
]["nodes"] = []
run_graphql.side_effect = [
self.target_response,
self.mutation_response,
self.verification_response,
]
with self.assertRaisesRegex(RuntimeError, "are not linked"):
gh.link_issue_to_pr(11235, 11243)
@patch.object(gh, "link_issue_to_pr")
def test_cmd_link_issue_outputs_verified_result(self, link_issue):
expected = {
"linked": True,
"issue": {"number": 11235, "state": "OPEN"},
"pull_request": {"number": 11243, "state": "MERGED"},
}
link_issue.return_value = expected
args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
gh.cmd_link_issue(args)
self.assertEqual(json.loads(stdout.getvalue()), expected)
self.assertIn("Verified issue #11235", stderr.getvalue())
link_issue.assert_called_once_with(11235, 11243)
@patch.object(gh, "link_issue_to_pr", side_effect=RuntimeError("link missing"))
def test_cmd_link_issue_fails_when_verification_is_missing(self, _link_issue):
args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
with self.assertRaises(SystemExit) as error:
gh.cmd_link_issue(args)
self.assertEqual(error.exception.code, 1)
self.assertIn("link missing", stderr.getvalue())
if __name__ == "__main__":
unittest.main(verbosity=2)