feat(ci): add Slack notifications for build success/failure#14
Conversation
Adds a notify job matching the standardized format used across qurl-service, nhp, website, and traefik-plugins repos. Header: "QURL Python SDK Build successful/failed" Fields: branch, trigger, test status, Python versions Footer: commit SHA link + workflow link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat(ci): add Slack notifications for build success/failureOverallStraightforward addition of a Slack notification job to the CI workflow. The approach is reasonable — single inline shell step, graceful skip when the secret is missing, Issues1. Script injection via expressions in run block (Security — Medium)
Recommendation: Move all expression interpolations into 2. Python version list is hardcoded (Maintainability — Low) The notification hardcodes 3. JSON construction via string concatenation (Robustness — Low) Building JSON by concatenating shell strings is fragile. The Looks Good
SummaryThe main item to address before merging is issue 1 (script injection via expression interpolation in |
Expression interpolation in run: blocks is vulnerable to injection via crafted branch names containing shell metacharacters. Move all github.* expressions to env: variables. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CR feedback addressedFixed
Deferred
|
|
PR Review - feat(ci): add Slack notifications. Overall well-structured. Security is good - github context in env vars prevents injection. Potential issues: (1) hardcoded Python versions will go stale if matrix changes, (2) JSON via bash string concat is fragile but acceptable with continue-on-error, (3) HEADER on line 92 missing Build. Looks good to merge once SLACK_WEBHOOK_URL secret is confirmed. |
|
Detailed Review Security: GitHub context in env vars prevents shell injection via crafted branch names or actor names. Webhook URL handling gracefully exits when secret is missing. Commit message sanitization (line 128) escapes backslashes and quotes, but newlines/control characters could break JSON - consider jq -Rs. Issue 1 - Hardcoded Python versions (line 121): The version string is hardcoded but the matrix is in the test job. If the matrix changes, notifications will be stale. Add a sync comment. Issue 2 - JSON string concatenation: Fragile but acceptable with continue-on-error. Consider jq if expanding. Issue 3 - Matrix result semantics: needs.test.result aggregates all matrix jobs correctly (success = all pass). Nit: HEADER on line 92 says QURL Python SDK but other branches say QURL Python SDK Build - missing word Build for incomplete case. Nit: curl write-out with http_code would help debug notification failures. Good: timeout-minutes + continue-on-error, skips PR builds, clean job separation. Verdict: Looks good to merge. Security is well-handled, continue-on-error prevents CI blocking. |
Brings the Python SDK into parity with every improvement made to
qurl-typescript and qurl-mcp during the recent review and seam-audit
rounds. Cross-references the qurl-service OpenAPI spec
(qurl/api/openapi.yaml) and the Go handler code.
### Critical — real bug
* parse_error detail fallback. RFC 7807 leaves `detail` optional and
the qurl Error schema only requires type/title/status/code.
Previously the parser used `err.get("detail", "")`, producing
"Forbidden (403): " when the API omitted detail. Now falls back
`detail -> message -> title -> HTTP {status}`. QURLError also
defaults detail to title in its constructor so Exception.args is
never empty-string padded.
### RFC 7807 structured fields
* QURLError now carries `type` and `instance` (the problem-type URI
and occurrence URI). Both are optional per the spec; the SDK was
silently dropping them before.
* parse_error extracts both from the envelope.
### Backward compatibility
* Legacy `{error: {code, message}}` envelope supported in the
fallback chain. If the API ever regresses to the pre-RFC-7807
shape, the SDK degrades gracefully instead of showing empty detail.
### Type narrowing
* QURLStatus clarified as resource-only ("active" | "revoked" | str).
* New TokenStatus for AccessToken ("active" | "consumed" | "expired"
| "revoked" | str) — per QurlSummary.status in the spec, tokens
have a wider enum than resources.
* AccessToken.status now uses TokenStatus.
* New QuotaPlan ("free" | "growth" | "enterprise" | str); Quota.plan
uses it. Uses the (Literal | str) pattern so the API can add new
plans without a breaking SDK change.
### Spec-derived input validation
New validate_create_input / validate_update_input / validate_mint_input
helpers in _utils.py enforcing the constraints documented on each
request schema in openapi.yaml:
- target_url: maxLength 2048
- label: maxLength 500 (on create + mint_link)
- description: maxLength 500 (on update)
- custom_domain: maxLength 253 (on create)
- max_sessions: 0-1000 integer (on create + mint_link)
- tags: max 10, each 1-50 chars, regex ^[a-zA-Z0-9][a-zA-Z0-9 _-]*$
batch_create runs validate_create_input on every item and attributes
errors by index (`items[N]: ...`) so bulk mistakes fail fast.
### Mutual-exclusion pre-flight checks
* update: rejects both extend_by + expires_at
* update: rejects empty input (at least one field required)
* mint_link: rejects both expires_in + expires_at
Extend() inherits the update() checks via delegation.
### delete() r_ prefix enforcement
Per the OpenAPI spec DELETE /v1/qurls/:id description: "Requires a
resource ID (r_ prefix). To revoke a single token, use DELETE
/v1/resources/:id/qurls/:qurl_id". New require_resource_id_prefix
helper raises ValueError client-side for q_ IDs with a clear message
pointing at the token-scoped endpoint.
### batch_create HTTP 400 passthrough
The API returns a populated BatchCreateOutput body on HTTP 400 (all
items rejected) — see qurl/internal/api/handlers/server.go:1126.
Added `allow_statuses` to _raw_request and _request, and batch_create
whitelists 400 so the per-item errors are surfaced instead of being
swallowed by the generic raise-on-error path. Non-400 errors (401,
403, 429, 5xx) still raise the appropriate QURLError subclass.
Matches the qurl-typescript and qurl-mcp implementations.
### create() parameter cleanup
Dropped the spurious `expires_at` kwarg from both sync and async
create(). CreateQurlRequest in openapi.yaml has only `expires_in` —
the previous signature let callers pass a field the API doesn't
accept.
### Dual-prefix documentation
get/update/extend/mint_link docstrings now document that both r_
(resource) and q_ (QURL display) IDs are accepted; the API resolves
q_ IDs to the parent resource automatically. delete() stays narrow
(r_ only) matching its client-side enforcement.
### parse_create_output: normalize empty qurl_id to None
Empty-string qurl_id from a response (mock or legacy shape) is now
normalized to None so callers can use `if result.qurl_id:` as a
presence check instead of having "" be silently truthy-false.
### _serialize_value: stop stripping None from nested dicts
Previously the dict branch filtered out None values, which would
silently drop explicit nulls callers send to clear nested fields
(e.g. `{"access_policy": {"ai_agent_policy": null}}`). Top-level
None-stripping still happens in build_body since that serves the
"drop unset kwargs" case. Nested None is now preserved; dataclass
fields still skip None (dataclasses distinguish unset vs explicit).
### Misc
* build_list_params type annotation tightened — the `int | None`
arm was misordered in the old union.
* test_update_with_tags corrected to use spec-compliant tags
(previous test used `team:engineering` with a colon that the
^[a-zA-Z0-9][a-zA-Z0-9 _-]*$ regex rejects).
* test_batch_create_empty_raises regex updated for the new error
message ("requires at least 1 item").
* test_create_sends_correct_body now covers one_time_use,
max_sessions, and session_duration alongside label (reviewer #9
gap note).
### Tests (74 -> 101)
Twenty-seven new tests covering:
- Create rejection: target_url > 2048, label > 500,
custom_domain > 253, max_sessions > 1000, max_sessions < 0
- Create boundaries: max_sessions 0 and 1000 both accepted
- Update rejection: description > 500, > 10 tags, tag > 50 chars,
tag regex pattern mismatch, empty input, mutual-exclusion
- Update success: empty tags array clears all tags
- mint_link rejection: label > 500, max_sessions > 1000,
mutual-exclusion
- delete q_ prefix rejection
- batch_create per-item validation with index attribution
- batch_create missing target_url surfaces index
- Async batch_create empty/>100 (reviewer #7 symmetry gap)
- batch_create HTTP 400 passthrough with per-item errors
- batch_create still raises on 401 (passthrough is surgical)
- Error type/instance surfacing
- Error detail fallback when RFC 7807 detail missing
- Legacy error.message fallback
- parse_create_output empty qurl_id normalization
BREAKING CHANGE: `active_qurls_percent` on `Quota.usage` is now
`float | None` instead of `float` with a `0.0` default; callers
doing arithmetic must None-check. Also `create()` no longer accepts
an `expires_at` kwarg — that field wasn't in `CreateQurlRequest`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 fix-now items (2 defensive bug fixes, 1 signature improvement,
3 test coverage gaps), 4 deferrals (2 filed as issues).
Fixed:
- _parse_access_policy: add isinstance(ap, dict) guard against non-dict
ai_agent_policy values. Without this, a bare string/bool from the API
would raise AttributeError on .get("block_all"). Consistent with the
defensive posture in _validate_batch_create_shape.
- QURLError.detail: change default from str="" to str|None=None with
`detail if detail is not None else title`. Distinguishes "not provided"
(falls back to title) from "explicitly empty" (stored as-is). The old
`detail or title` coercion silently converted `detail=""` to title,
which surprised callers who explicitly passed empty string.
- build_list_params: client-side limit validation (1-100) per OpenAPI
spec (GET /v1/qurls → limit: integer, minimum: 1, maximum: 100,
default: 20). Rejects 0, negative, floats, and bool (bool is subclass
of int — must be explicitly rejected). Python-parity follow-up from
qurl-typescript PR #14 round 11 (commit 3d471fa).
Tests: 144 → 157 passing (+13 new regression guards).
- batch_create 207 Multi-Status routes through success path (sync+async)
- create(expires_at=...) TypeError invariant guard (sync+async)
- _parse_access_policy null/missing ai_agent_policy yields None
- _parse_access_policy non-dict ai_agent_policy is silently ignored
- list() rejects limit: 0 / 101 / negative / float / bool
- list() accepts limit at boundaries (1 and 100)
- list() omitted limit produces no query param
Deferred:
- sync/async duplication refactor — filed as #20
- test_client.py split — filed as #21
- QURLError.type builtin shadow — docstring already acknowledges
- http:// URL scheme warning — caller's responsibility per reviewer
Drops the bespoke uv-based pyproject.toml age-check (introduced in #14) in favor of the shared ops-routines age-check-pip reusable workflow pinned at v0.1.4. The shared script now parses PEP 621 list form (dependencies = ["pkg==ver"]) directly — no uv resolver invocation needed. This finishes the migration started in the prior commit: - dependency-age-check-actions.yml -> shared actions check - dependency-age-check-pip.yml -> shared pip check (NEW) - dependency-age-check.yml (legacy) -> deleted Centralizes the age-gate so all LayerV repos run the same hardened implementation; this repo inherits future fixes for free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
qurl-python had no Slack notifications. Adds a notify job matching the standardized format across all LayerV repos.
Notification format
Checklist
SLACK_WEBHOOK_URLsecret exists in qurl-python repo settings🤖 Generated with Claude Code