You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Non-blocking findings deferred from the review of #895 (Jira deployment-preview feedback). The four review blockers were fixed in 1f2e37ab — bounded delivery with deadline cancellation, safe convergence outcomes, mutation-sensitive race coverage, and corrected routing docs — and the items below were deliberately left out of that round. All line numbers are as of 1f2e37ab; the symbol names are the anchors.
The first item is the one with a customer-visible wrong outcome; the rest are drift-prevention, observability, and type ergonomics.
1. The preview claim latch conflates definite failure with uncertain failure, and never releases.jira-deployment-preview.ts:124-160. jira_preview_claimed is a one-way latch — nothing in cdk/src removes it. On a Jira 429/503 the response was received, so nothing was created, yet the claim stays set and the preview is permanently lost for that task: every later delivery takes the ConditionalCheckFailedException branch, finds no comment id, logs one warn, and returns. writeComment already distinguishes "Jira answered non-2xx" (jira-feedback.ts:284-286) from its catch (genuinely uncertain), and JiraPostResult collapses both into { ok: false, retryable }. The documented trade — "Jira may have committed before a transport timeout" — is correct for the uncertain half only. Add a third outcome state and release the claim (REMOVE jira_preview_claimed, conditional on attribute_not_exists(jira_preview_comment_id)) on definite failure.
2. POST succeeds but the comment-id write fails → the issue shows a stale screenshot forever.jira-deployment-preview.ts:150-158. State becomes claimed ✓ / comment exists in Jira ✓ / id unrecorded ✗, so every subsequent deploy hits the !commentId branch at :141-146 and gives up. The Jira issue keeps the first commit's screenshot indefinitely — worse than showing nothing, because a stale screenshot is indistinguishable from a current one. Write the claim and the comment id in one conditional update where possible; failing that, this branch and item 1's should log at error with an error_id from constants/errorIds.ts rather than warn, since "a comment exists in Jira that we can never update again" is an operator-actionable inconsistency.
3. An empty-string comment id is persisted as if valid.jira-feedback.ts:718 returns { ok: true, commentId: '' } (pre-existing, deliberately, so a caller does not create a duplicate). jira-deployment-preview.ts:157 writes that verbatim, and the duplicate branch at :141 accepts it because typeof '' === 'string'. updateIssueCommentAdf then fails its /^\d+$/ guard forever, logging "Refusing to update Jira comment with an invalid id" — which misdescribes the cause: the id is not corrupt, it was never captured. Only persist a non-empty id, and route the empty case to item 2's "posted but unaddressable" path.
4. The heartbeat sweep drops the Jira result on the floor.iteration-heartbeat-sweep.ts:152-161 — if (result.ok) edited += 1; continue; with no else. A Jira tenant whose token has been revoked produces a sweep reporting edited: 0, indistinguishable from a sweep with nothing eligible, and with no per-task line correlating the failure (unlike the non-Jira path, whose failures land in the catch with task_id). Mirror that catch, and count attempts separately from successes.
5. jira_preview_claimed?: boolean contradicts its own DynamoDB predicate.types.ts:335. The condition is attribute_not_exists(jira_preview_claimed), so the invariant is presence, not truth — but the type admits false, which a future writer would read as "not claimed" while writing it permanently blocks delivery. One-character fix: readonly jira_preview_claimed?: true;. Better still, collapse it with jira_preview_comment_id into one field so {claimed: false, comment_id: 'x'} stops being representable.
6. The ADF document is the one structured outbound payload in the repo that is not typed. It is Record<string, unknown> | null at every boundary — buildAdfDocument, postIssueCommentAdf, updateIssueCommentAdf, TaskRecord.jira_iteration_status.body — while the repo already types inbound ADF (jira-adf.ts:41AdfNode) and the closest outbound analogue in full (slack-blocks.ts:27-88SlackBlock/SlackMessage). The cost is visible at jira-preview.ts:71, which needs two casts and a ?? [] to concatenate two documents; with an AdfDocument interface that line is cast-free, and a non-array content (from an older or partially-written jira_iteration_status) stops being representable — today it spreads characters into the posted body.
7. Positional string runs in the two new public functions.deliverJiraDeploymentPreview now takes 9 parameters and updateJiraIterationComment 8. (screenshotUrl, previewUrl) swapped compiles, passes the allowlist, and is caught by no test — the labels simply point at the wrong URLs. (tableName, registryTableName) swapped cross-wires the task table and the registry and fails silently (ValidationException, swallowed). updateJiraIterationComment gained six call sites in feat(jira): surface deployment preview feedback (#698) #895, so this gets more expensive with each one. An options object, or three one-line brands (TaskTableName, TaskId, JiraCommentId), makes every swap a compile error.
8. normalizeAmplifyPreviewCheck returns a bare null for ~19 distinct rejection reasons, with no log anywhere.github-deployment-status.ts:67-104; the receiver returns { skipped_check: true } at github-webhook.ts:106. Compare the neighbours: a dedup hit logs info with the key, an unrelated event type logs info with the type, a malformed deployment_status logs warn and returns 400. So a dropped Amplify preview is strictly less observable than a duplicate one. If AWS renames the check, changes the app-slug shape, or moves previews to a custom domain, the entire Amplify path dies permanently and silently behind a green webhook delivery page. Return a discriminated result carrying the reason, log it, and the nosemgrep: ts-silent-success-masking suppression at :88 should become removable.
9. Existing Amplify operators are silently broken by the environment filter. The normalizer hardcodes environment: 'Preview' (github-deployment-status.ts:101) while the receiver still applies SCREENSHOT_TARGET_ENVIRONMENT (github-webhook.ts:130). An operator following the previous guide has that set to a branch name; they subscribe to Check runs as the new guide instructs and every Amplify check drops with only skipped_environment in the HTTP response body — while setting it to Preview breaks their existing branch-deploy path, because the filter is a single fixed string, so the two Amplify modes are mutually exclusive. Either exempt the check_run path (check name + aws-amplify-console app owner + pr-N.<app-id>.amplifyapp.com hostname already prove it is a PR preview) or set the synthetic environment to targetEnv; either way add a migration note for operators currently on a branch-name value.
10. The validated PR number is discarded, then re-derived from the SHA.github-deployment-status.ts:90-103 proves pr-<n> matches a listed pull request whose head.sha equals check.head_sha, then drops preview[1]; github-webhook-processor.ts:195 asks GitHub again by SHA. When one SHA heads two open PRs (retargeted base, stacked series, duplicated PR), the screenshot can be posted onto a different PR — and persistScreenshotUrl then resolves the task from that PR's headRefName, so the whole Jira/Linear delivery chain follows the wrong task. Green, plausible, wrong. Carry the validated number on the normalized payload and prefer it; if the SHA lookup disagrees, log error and skip rather than guess.
11. Module naming.jira-preview.ts's dominant export is updateJiraIterationComment, imported by four handlers — it is the iteration-comment writer, not a preview module. Sitting beside jira-deployment-preview.ts (the delivery orchestrator) the two are near-indistinguishable at an import site; jira-iteration-comment.ts would read truer. Its module-private render() is also very generic for a shared module.
12. Two small consequences of the 1f2e37ab fix itself. (a) jira-deployment-preview.ts:48 computes document and throws at the top of the try, so a disallowed screenshot URL now aborts the iteration path too — where the document is never used, since updateJiraIterationComment re-renders from durable state. A misconfigured SCREENSHOT_PUBLIC_HOST would skip the entire status-comment update, including completion metrics and the PR link, rather than just omitting the preview block; moving the check into the standalone branch keeps it fail-closed without that side effect. (b) :104 tightened task.head_sha && task.head_sha !== sha to task.head_sha !== sha, which correctly closes a hole but means iteration records written before head_sha was persisted now receive no preview at all — worth confirming that is intended and, if so, noting it.
13. Residual test gaps. Jira 4xx vs 5xx classification is not pinned at the jira-preview layer (both collapse into one warn); screenshot.jira_missing_registry is unexercised; Number.isSafeInteger(check.id) has no non-safe-integer case; and the 400 Invalid webhook payload branch is unreachable in tests because the 'null'/'[]' bodies are sent as check_run and return skipped_check first — a deployment_status event with body null would exercise it.
Use case
Items 1-3 are the same failure class #698 set out to eliminate: a preview that cannot be delivered is accepted silently and resurfaces as either nothing at all or a confidently-wrong artifact on a customer's Jira issue. Items 8-10 are the Amplify receiver's observability and correctness edges — the path has no signal when it stops working. The rest keep the invariants #895 established enforceable rather than remembered.
Proposed solution
Items 4, 5, 11, 12a and 13 are small and can land as one cleanup PR. Items 1-3 belong together — they are one state machine (claim / posted / unaddressable) and are best fixed as a unit with the outcome type widened. Items 6 and 7 are mechanical but touch many call sites; worth doing before updateJiraIterationComment gains a ninth parameter. Items 8-10 concern the Amplify check_run receiver, which is tracked separately by #900 — they are recorded here to keep the review's findings in one place, and could equally be folded into that issue.
Already fixed in 1f2e37ab, do not redo: deadline cancellation and bounded lookups (JiraPreviewBudget, MAX_CANDIDATE_READS, POST_CAPTURE_RESERVE_MS 8s→30s, screenshot.jira_budget_exhausted / screenshot.jira_lookup_limited); convergence returning ok: true after successful PUTs plus jira.preview.task_missing for an absent record; LookupResult on persistScreenshotUrl so a lookup failure skips both channel deliveries; jiraPreviewDocument returning null and the standalone path rejecting it before claiming; the page-limit throw becoming a break; mutation-sensitive race coverage (resolution-order assertions, PUT call counts, terminal: true at the fan-out and reconciler call sites, a literal ConditionExpression assertion); the PutSecretValue justification comment and the enumerated AwsSolutions-IAM5 reason; the routing sentence in JIRA_SETUP_GUIDE.md plus the full emitted-event list; and the stale preservePreview, parseMarkdownRuns subset, TASK_TABLE-unset, and ALL_OLD comments.
Component
CDK Jira adapter and screenshot pipeline —
cdk/src/handlers/shared/jira-deployment-preview.ts,jira-preview.ts,jira-feedback.ts,github-deployment-status.ts,cdk/src/handlers/iteration-heartbeat-sweep.ts,cdk/src/handlers/shared/types.tsDescribe the feature
Non-blocking findings deferred from the review of #895 (Jira deployment-preview feedback). The four review blockers were fixed in
1f2e37ab— bounded delivery with deadline cancellation, safe convergence outcomes, mutation-sensitive race coverage, and corrected routing docs — and the items below were deliberately left out of that round. All line numbers are as of1f2e37ab; the symbol names are the anchors.The first item is the one with a customer-visible wrong outcome; the rest are drift-prevention, observability, and type ergonomics.
jira-deployment-preview.ts:124-160.jira_preview_claimedis a one-way latch — nothing incdk/srcremoves it. On a Jira 429/503 the response was received, so nothing was created, yet the claim stays set and the preview is permanently lost for that task: every later delivery takes theConditionalCheckFailedExceptionbranch, finds no comment id, logs onewarn, and returns.writeCommentalready distinguishes "Jira answered non-2xx" (jira-feedback.ts:284-286) from itscatch(genuinely uncertain), andJiraPostResultcollapses both into{ ok: false, retryable }. The documented trade — "Jira may have committed before a transport timeout" — is correct for the uncertain half only. Add a third outcome state and release the claim (REMOVE jira_preview_claimed, conditional onattribute_not_exists(jira_preview_comment_id)) on definite failure.jira-deployment-preview.ts:150-158. State becomes claimed ✓ / comment exists in Jira ✓ / id unrecorded ✗, so every subsequent deploy hits the!commentIdbranch at:141-146and gives up. The Jira issue keeps the first commit's screenshot indefinitely — worse than showing nothing, because a stale screenshot is indistinguishable from a current one. Write the claim and the comment id in one conditional update where possible; failing that, this branch and item 1's should log aterrorwith anerror_idfromconstants/errorIds.tsrather thanwarn, since "a comment exists in Jira that we can never update again" is an operator-actionable inconsistency.jira-feedback.ts:718returns{ ok: true, commentId: '' }(pre-existing, deliberately, so a caller does not create a duplicate).jira-deployment-preview.ts:157writes that verbatim, and the duplicate branch at:141accepts it becausetypeof '' === 'string'.updateIssueCommentAdfthen fails its/^\d+$/guard forever, logging "Refusing to update Jira comment with an invalid id" — which misdescribes the cause: the id is not corrupt, it was never captured. Only persist a non-empty id, and route the empty case to item 2's "posted but unaddressable" path.iteration-heartbeat-sweep.ts:152-161—if (result.ok) edited += 1; continue;with noelse. A Jira tenant whose token has been revoked produces a sweep reportingedited: 0, indistinguishable from a sweep with nothing eligible, and with no per-task line correlating the failure (unlike the non-Jira path, whose failures land in thecatchwithtask_id). Mirror thatcatch, and count attempts separately from successes.jira_preview_claimed?: booleancontradicts its own DynamoDB predicate.types.ts:335. The condition isattribute_not_exists(jira_preview_claimed), so the invariant is presence, not truth — but the type admitsfalse, which a future writer would read as "not claimed" while writing it permanently blocks delivery. One-character fix:readonly jira_preview_claimed?: true;. Better still, collapse it withjira_preview_comment_idinto one field so{claimed: false, comment_id: 'x'}stops being representable.Record<string, unknown> | nullat every boundary —buildAdfDocument,postIssueCommentAdf,updateIssueCommentAdf,TaskRecord.jira_iteration_status.body— while the repo already types inbound ADF (jira-adf.ts:41AdfNode) and the closest outbound analogue in full (slack-blocks.ts:27-88SlackBlock/SlackMessage). The cost is visible atjira-preview.ts:71, which needs two casts and a?? []to concatenate two documents; with anAdfDocumentinterface that line is cast-free, and a non-arraycontent(from an older or partially-writtenjira_iteration_status) stops being representable — today it spreads characters into the posted body.stringruns in the two new public functions.deliverJiraDeploymentPreviewnow takes 9 parameters andupdateJiraIterationComment8.(screenshotUrl, previewUrl)swapped compiles, passes the allowlist, and is caught by no test — the labels simply point at the wrong URLs.(tableName, registryTableName)swapped cross-wires the task table and the registry and fails silently (ValidationException, swallowed).updateJiraIterationCommentgained six call sites in feat(jira): surface deployment preview feedback (#698) #895, so this gets more expensive with each one. An options object, or three one-line brands (TaskTableName,TaskId,JiraCommentId), makes every swap a compile error.normalizeAmplifyPreviewCheckreturns a barenullfor ~19 distinct rejection reasons, with no log anywhere.github-deployment-status.ts:67-104; the receiver returns{ skipped_check: true }atgithub-webhook.ts:106. Compare the neighbours: a dedup hit logsinfowith the key, an unrelated event type logsinfowith the type, a malformeddeployment_statuslogswarnand returns 400. So a dropped Amplify preview is strictly less observable than a duplicate one. If AWS renames the check, changes the app-slug shape, or moves previews to a custom domain, the entire Amplify path dies permanently and silently behind a green webhook delivery page. Return a discriminated result carrying the reason, log it, and thenosemgrep: ts-silent-success-maskingsuppression at:88should become removable.environment: 'Preview'(github-deployment-status.ts:101) while the receiver still appliesSCREENSHOT_TARGET_ENVIRONMENT(github-webhook.ts:130). An operator following the previous guide has that set to a branch name; they subscribe to Check runs as the new guide instructs and every Amplify check drops with onlyskipped_environmentin the HTTP response body — while setting it toPreviewbreaks their existing branch-deploy path, because the filter is a single fixed string, so the two Amplify modes are mutually exclusive. Either exempt thecheck_runpath (check name +aws-amplify-consoleapp owner +pr-N.<app-id>.amplifyapp.comhostname already prove it is a PR preview) or set the synthetic environment totargetEnv; either way add a migration note for operators currently on a branch-name value.github-deployment-status.ts:90-103provespr-<n>matches a listed pull request whosehead.shaequalscheck.head_sha, then dropspreview[1];github-webhook-processor.ts:195asks GitHub again by SHA. When one SHA heads two open PRs (retargeted base, stacked series, duplicated PR), the screenshot can be posted onto a different PR — andpersistScreenshotUrlthen resolves the task from that PR'sheadRefName, so the whole Jira/Linear delivery chain follows the wrong task. Green, plausible, wrong. Carry the validated number on the normalized payload and prefer it; if the SHA lookup disagrees, logerrorand skip rather than guess.jira-preview.ts's dominant export isupdateJiraIterationComment, imported by four handlers — it is the iteration-comment writer, not a preview module. Sitting besidejira-deployment-preview.ts(the delivery orchestrator) the two are near-indistinguishable at an import site;jira-iteration-comment.tswould read truer. Its module-privaterender()is also very generic for a shared module.1f2e37abfix itself. (a)jira-deployment-preview.ts:48computesdocumentand throws at the top of thetry, so a disallowed screenshot URL now aborts the iteration path too — where the document is never used, sinceupdateJiraIterationCommentre-renders from durable state. A misconfiguredSCREENSHOT_PUBLIC_HOSTwould skip the entire status-comment update, including completion metrics and the PR link, rather than just omitting the preview block; moving the check into the standalone branch keeps it fail-closed without that side effect. (b):104tightenedtask.head_sha && task.head_sha !== shatotask.head_sha !== sha, which correctly closes a hole but means iteration records written beforehead_shawas persisted now receive no preview at all — worth confirming that is intended and, if so, noting it.jira-previewlayer (both collapse into onewarn);screenshot.jira_missing_registryis unexercised;Number.isSafeInteger(check.id)has no non-safe-integer case; and the400 Invalid webhook payloadbranch is unreachable in tests because the'null'/'[]'bodies are sent ascheck_runand returnskipped_checkfirst — adeployment_statusevent with bodynullwould exercise it.Use case
Items 1-3 are the same failure class #698 set out to eliminate: a preview that cannot be delivered is accepted silently and resurfaces as either nothing at all or a confidently-wrong artifact on a customer's Jira issue. Items 8-10 are the Amplify receiver's observability and correctness edges — the path has no signal when it stops working. The rest keep the invariants #895 established enforceable rather than remembered.
Proposed solution
Items 4, 5, 11, 12a and 13 are small and can land as one cleanup PR. Items 1-3 belong together — they are one state machine (claim / posted / unaddressable) and are best fixed as a unit with the outcome type widened. Items 6 and 7 are mechanical but touch many call sites; worth doing before
updateJiraIterationCommentgains a ninth parameter. Items 8-10 concern the Amplifycheck_runreceiver, which is tracked separately by #900 — they are recorded here to keep the review's findings in one place, and could equally be folded into that issue.Other information
1f2e37ab.1f2e37ab, do not redo: deadline cancellation and bounded lookups (JiraPreviewBudget,MAX_CANDIDATE_READS,POST_CAPTURE_RESERVE_MS8s→30s,screenshot.jira_budget_exhausted/screenshot.jira_lookup_limited); convergence returningok: trueafter successful PUTs plusjira.preview.task_missingfor an absent record;LookupResultonpersistScreenshotUrlso a lookup failure skips both channel deliveries;jiraPreviewDocumentreturningnulland the standalone path rejecting it before claiming; the page-limitthrowbecoming abreak; mutation-sensitive race coverage (resolution-order assertions, PUT call counts,terminal: trueat the fan-out and reconciler call sites, a literalConditionExpressionassertion); thePutSecretValuejustification comment and the enumeratedAwsSolutions-IAM5reason; the routing sentence inJIRA_SETUP_GUIDE.mdplus the full emitted-event list; and the stalepreservePreview,parseMarkdownRunssubset,TASK_TABLE-unset, andALL_OLDcomments.LookupResultconvention these paths should follow), feat(jira): mature comment-triggered iteration feedback in place #697 (maturing Jira iteration status comments).