diff --git a/offline/README.md b/offline/README.md index 78b2815..c0e10d4 100644 --- a/offline/README.md +++ b/offline/README.md @@ -24,6 +24,7 @@ Open replication of the code review benchmark used by companies like [Augment](h | [Kodus](https://kodus.io/) | AI code review | | [Macroscope](https://www.macroscope.com/) | AI code review | | [Qodo](https://www.qodo.ai/) | AI code review | +| [Shipwright](https://github.com/kpuru88/shipwright-agent) | AI code review agent | | [Sourcery](https://sourcery.ai/) | AI code review | Adding a new tool requires forking the benchmark PRs and collecting the tool's reviews — see Steps 0 and 1 below. diff --git a/offline/results/benchmark_data.json b/offline/results/benchmark_data.json index f6a1028..736d2c3 100644 --- a/offline/results/benchmark_data.json +++ b/offline/results/benchmark_data.json @@ -1789,6 +1789,31 @@ "created_at": "2026-06-28T23:12:51Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__37429__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/37429", + "review_comments": [ + { + "path": "themes/src/main/resources-community/theme/base/account/messages/messages_lt.properties", + "line": 101, + "body": "Lithuanian account translation for 'totpStep1' was replaced with Italian text ('Installa una delle seguenti applicazioni sul tuo cellulare:') instead of a proper Lithuanian translation, so Lithuanian-locale users will see Italian text in the account TOTP setup step. Location: themes/src/main/resources-community/theme/base/account/messages/messages_lt.properties:101", + "created_at": null + }, + { + "path": "themes/src/main/resources-community/theme/base/login/messages/messages_lt.properties", + "line": 71, + "body": "Lithuanian login translation for 'loginTotpStep1' was replaced with the same Italian text ('Installa una delle seguenti applicazioni sul tuo cellulare:') instead of a Lithuanian translation, causing Italian text to appear on the login TOTP configuration screen for Lithuanian users. Location: themes/src/main/resources-community/theme/base/login/messages/messages_lt.properties:71", + "created_at": null + }, + { + "path": "themes/src/main/resources-community/theme/base/account/messages/messages_zh_CN.properties", + "line": 112, + "body": "The Simplified Chinese (zh_CN) 'totpStep1' string was replaced with text using Traditional Chinese characters (手機, 安裝, 應用程式) instead of Simplified Chinese (手机, 安装, 应用程序), causing a locale mismatch for zh_CN users. Location: themes/src/main/resources-community/theme/base/account/messages/messages_zh_CN.properties:112", + "created_at": null + } + ] } ] }, @@ -3247,6 +3272,31 @@ "created_at": "2026-06-28T21:20:12Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__37634__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/37634", + "review_comments": [ + { + "path": "services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java", + "line": 73, + "body": "In the AccessTokenContext constructor, the null-check for rawTokenId is a copy-paste bug: `Objects.requireNonNull(grantType, \"Null rawTokenId not allowed\")` re-checks grantType (already checked on the previous line) instead of rawTokenId, and uses a misleading message. As a result, a null rawTokenId is never rejected; it is silently stored and later concatenated into the token id string in encodeTokenId (`... + ':' + tokenContext.getRawTokenId()`), producing a malformed token id like \"onltac:null\" instead of failing fast. Location: services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java:73", + "created_at": null + }, + { + "path": "testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java", + "line": 479, + "body": "The new isAccessTokenId matcher has both wrong substring bounds and inverted boolean logic. The encoded context is 6 chars: sessionType(0-2)+tokenType(2-4)+grantType(4-6), so the grant shortcut must be extracted with substring(4,6), not substring(3,5). Additionally the condition `if (items[0].substring(3, 5).equals(expectedGrantShortcut)) return false;` returns false (no match) exactly when the shortcut equals the expected value, and falls through to the UUID check (returning true) when it does NOT match. This inverts the intended semantics of the matcher used by expectCodeToToken, expectDeviceCodeToToken, expectRefresh and expectAuthReqIdToToken, causing correctly-encoded token ids to fail matching and incorrectly-encoded ones to potentially pass. Location: testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java:479", + "created_at": null + }, + { + "path": "server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeFactory.java", + "line": 31, + "body": "The new getShortcut() Javadoc states shortcuts are \"usually like 3-letters shortcut\", but the encoding implementation (DefaultTokenContextEncoderProvider) strictly requires each of sessionType/tokenType/grantType shortcuts to be exactly 2 characters (encodedContext.length() != 6 check, fixed 2-char substrings). All actual implementations use 2-letter shortcuts (ac, cc, pg, pc, rt, ro, te, ci, dg), contradicting the docstring and risking a broken implementation if a future grant type factory follows the documented \"3-letters\" guidance. Location: server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeFactory.java:31", + "created_at": null + } + ] } ] }, @@ -4705,6 +4755,31 @@ "created_at": "2026-06-28T23:26:03Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__38446__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/38446", + "review_comments": [ + { + "path": "server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java", + "line": 114, + "body": "CredentialHelper.createRecoveryCodesCredential looks up the recovery-codes credential provider using a hardcoded string literal \"keycloak-recovery-authn-codes\" instead of the existing RecoveryAuthnCodesCredentialProviderFactory.PROVIDER_ID constant (which was previously imported/used in RecoveryAuthnCodesAction and is now removed from that file). If this literal ever diverges from the actual factory id, session.getProvider(...) silently returns null and the subsequent recoveryCodeCredentialProvider.createCredential(...) call throws an NPE. Location: server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java:114", + "created_at": null + }, + { + "path": "testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java", + "line": 114, + "body": "In supportsCredentialType, the new recovery-codes check uses `credentialType.equals(RecoveryAuthnCodesCredentialModel.TYPE)` instead of `RecoveryAuthnCodesCredentialModel.TYPE.equals(credentialType)`, unlike the sibling condition `CredentialModel.PASSWORD.equals(credentialType)` on the line above. If credentialType is null this throws a NullPointerException instead of returning false as the other null-safe checks do. Location: testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java:114", + "created_at": null + }, + { + "path": "testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java", + "line": 275, + "body": "Same reversed-order equals pattern is repeated in isConfiguredFor: `credentialType.equals(RecoveryAuthnCodesCredentialModel.TYPE) && myUser.recoveryCodes != null` calls equals on the possibly-null parameter instead of on the constant, risking an NPE where the OTP branch above it is null-safe. Location: testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java:275", + "created_at": null + } + ] } ] }, @@ -5863,6 +5938,19 @@ "created_at": "2026-06-28T22:40:43Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__36882__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/36882", + "review_comments": [ + { + "path": "operator/src/test/java/org/keycloak/operator/testsuite/integration/UpgradeTest.java", + "line": 115, + "body": "In getTestKeycloakDeployment/build-CR helper, the code null-checks getFeatureSpec() before creating a new FeatureSpec (implying it may already exist, e.g. set up elsewhere on the CR), but then unconditionally calls setEnabledFeatures(List.of(ROLLING_UPDATES)) which replaces the entire enabled-features list rather than merging with any features that may already be enabled on that spec. If a FeatureSpec with other enabled features already exists on the CR when this helper runs, those features are silently dropped, changing test behavior in a way not obvious from the code. Location: operator/src/test/java/org/keycloak/operator/testsuite/integration/UpgradeTest.java:115", + "created_at": null + } + ] } ] }, @@ -7384,6 +7472,19 @@ "created_at": "2026-06-28T22:50:30Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__36880__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/36880", + "review_comments": [ + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionEvaluator.java", + "line": 91, + "body": "The javadoc for `requireView()` and `requireViewClientScopes()` (both void methods that throw ForbiddenException, per the interface's naming convention) was copy-pasted from the corresponding boolean-returning `canView()`/`canViewClientScopes()` methods. It now incorrectly states \"Returns {@code true} if ... returns {@code true}\" instead of \"Throws ForbiddenException if ... returns {@code false}\" like all other sibling `require*` methods in this same file (e.g. requireList(), requireManage(), requireManageClientScopes()), misleading callers/implementers about the actual contract of these void methods. Location: services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissionEvaluator.java:91", + "created_at": null + } + ] } ] }, @@ -8963,6 +9064,31 @@ "created_at": "2026-06-28T22:57:21Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__37038__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/37038", + "review_comments": [ + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java", + "line": 70, + "body": "In GroupPermissionsV2.canManage() (the no-arg, \"all groups\" check), the fallback ABAC check calls hasPermission(null, AdminPermissionsSchema.VIEW, AdminPermissionsSchema.MANAGE) — identical to canView()'s check. This means a caller with only a VIEW scope permission on the Groups resource type (no MANAGE) will satisfy canManage(), granting group-management capabilities (e.g., creating groups) to a view-only admin. The per-group overload canManage(GroupModel) correctly checks only AdminPermissionsSchema.MANAGE, confirming this is an inconsistent/incorrect duplication of canView()'s logic rather than intended behavior. Location: services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java:70", + "created_at": null + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java", + "line": 122, + "body": "getGroupIdsWithViewPermission() in GroupPermissionsV2 iterates authorization Resource objects via resourceStore.findByType and uses groupResource.getId() (the authorization Resource's internal UUID) both as the argument to hasPermission(...) and as the value stored into the returned set. However hasPermission(String groupId, ...) expects the actual GroupModel id (it looks the resource up via resourceStore.findByName(server, groupId), and resources for groups are created/named using the GroupModel id, e.g. resolveGroup returns group.getId() as the resource name). Using the resource's internal id instead of its name means findByName inside hasPermission will not match the resource, and the ids added to the returned set are not actual group ids. This breaks the downstream group-based filtering (session.setAttribute(UserModel.GROUPS, groupIds) in UsersResource/BruteForceUsersResource), causing FGAP V2 admins with narrow per-group VIEW_MEMBERS/MANAGE_MEMBERS permissions to get incorrect (likely empty) user search results. Location: services/src/main/java/org/keycloak/services/resources/admin/permissions/GroupPermissionsV2.java:122", + "created_at": null + }, + { + "path": "services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java", + "line": 453, + "body": "In UsersResource.searchForUser, the per-user authorization filter `.filter(usersEvaluator::canView)` that used to be applied to the search stream was removed entirely. The remaining group-based restriction (session.setAttribute(UserModel.GROUPS, groupIds)) only narrows results when the caller has a group-based view grant; if the caller instead only has a narrow individual user-level VIEW permission (not a group-based one) and getGroupIdsWithViewPermission() returns an empty set, no group restriction is applied and no per-item permission check happens either, so the search will return every matching user with no authorization filtering, exposing users the caller should not be able to view. Location: services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java:453", + "created_at": null + } + ] } ] }, @@ -10427,6 +10553,19 @@ "created_at": "2026-06-28T22:36:15Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__33832__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/33832", + "review_comments": [ + { + "path": "common/src/main/java/org/keycloak/common/crypto/CryptoIntegration.java", + "line": 68, + "body": "In CryptoIntegration.detectProvider(), the new 'ignored crypto providers' message is built with a StringBuilder that appends \", \" after every provider name including the last one, producing a malformed trailing separator (e.g. \"Ignored crypto providers: com.foo.Bar, \"). Additionally the pre-built string is passed to logger.debugf(builder.toString()) instead of logger.debug(...), which treats the message as a printf-style format string unnecessarily. Location: common/src/main/java/org/keycloak/common/crypto/CryptoIntegration.java:68", + "created_at": null + } + ] } ] }, @@ -11653,6 +11792,19 @@ "created_at": "2026-06-28T23:30:25Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__40940__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/40940", + "review_comments": [ + { + "path": "tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java", + "line": 156, + "body": "The new concurrency test starts a background reader thread and, after setting deletedAll to true, immediately asserts caughtExceptions without waiting for the thread to actually finish its current iteration (e.g., via Thread.join()). Since the reader thread only checks the flag after completing its current groups() call, an exception raised concurrently with the delete loop can be added to caughtExceptions after the assertion has already executed, making the test non-deterministic and able to falsely pass even when the regression (NPE during concurrent access) actually occurs. Location: tests/base/src/test/java/org/keycloak/tests/admin/group/GroupTest.java:156", + "created_at": null + } + ] } ] }, @@ -13015,6 +13167,25 @@ "created_at": "2026-06-28T22:13:01Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__41249__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/41249", + "review_comments": [ + { + "path": "services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernameForm.java", + "line": 47, + "body": "UsernameForm.authenticate() calls `isConditionalPasskeysEnabled()` with no arguments, but the only method with that name added in this PR (in UsernamePasswordForm.java, which UsernameForm extends) is `protected boolean isConditionalPasskeysEnabled(UserModel user)` requiring a UserModel parameter. There is no zero-arg overload defined anywhere in the diff, so this call site will not compile, breaking the build for this authenticator. Location: services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernameForm.java:47", + "created_at": null + }, + { + "path": "services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java", + "line": 113, + "body": "The guard for filling the WebAuthn/passkeys form data in UsernamePasswordForm.authenticate() and challenge() was changed from requiring `context.getUser() == null` (i.e. fill webauthn data during the normal/initial login when no user is yet selected) to `isConditionalPasskeysEnabled(context.getUser())`, which requires `context.getUser() != null`. This inverts rather than extends the original condition, so webauthn conditional-UI data is now only populated during re-authentication and is never populated during the standard first-time login flow. This directly conflicts with the pre-existing (unmodified) assertions in PasskeysUsernamePasswordFormTest#webauthnLoginWithExternalKey, which still expect `//form[@id='webauth']` to be present right after `oauth.openLoginForm()` when no user/session context exists yet. Location: services/src/main/java/org/keycloak/authentication/authenticators/browser/UsernamePasswordForm.java:113", + "created_at": null + } + ] } ] }, @@ -14441,6 +14612,31 @@ "created_at": "2026-06-28T23:04:21Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__93824__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/93824", + "review_comments": [ + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 347, + "body": "In SpanFlusher.join(), when timeout is set and iterating over multiple flusher processes, if the deadline expires while checking a given process, the code does `break` on the outer for-loop before ever calling `process.terminate()` for that process and for all subsequent processes in the dict. The previous single-process implementation always called `self.process.terminate()` unconditionally after the wait loop regardless of whether the deadline had passed. This regression leaves daemon flusher processes running/orphaned on consumer shutdown when a timeout is hit with multiple processes. Location: src/sentry/spans/consumers/process/flusher.py:347", + "created_at": null + }, + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 195, + "body": "Metric tag key is inconsistent across the newly-sharded metrics: 'spans.buffer.flusher.produce' and 'spans.buffer.segment_size_bytes' use tags={\"shard\": shard_tag} while 'spans.buffer.flusher.wait_produce' uses tags={\"shards\": shard_tag} (plural). This breaks correlation/aggregation of these related metrics by the same dimension in dashboards/alerts. Location: src/sentry/spans/consumers/process/flusher.py:195", + "created_at": null + }, + { + "path": "src/sentry/spans/consumers/process/flusher.py", + "line": 30, + "body": "Updated class docstring states the flusher \"Creates one process per shard for parallel processing,\" but the implementation actually distributes shards across at most `max_processes` processes (default 1 via the new --flusher-processes click option), meaning multiple shards commonly share a single process. This directly misdescribes the sharding behavior introduced in this diff. Location: src/sentry/spans/consumers/process/flusher.py:30", + "created_at": null + } + ] } ] }, @@ -16412,6 +16608,31 @@ "created_at": "2026-06-28T22:58:37Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "ai-code-review-evaluation__sentry-greptile__pull__5__shipwright__4701de31", + "pr_url": "https://github.com/ai-code-review-evaluation/sentry-greptile/pull/5", + "review_comments": [ + { + "path": "src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py", + "line": 80, + "body": "analytics.record(\"preprod_artifact.api.assemble\", ...) is called unconditionally at the top of post() before the `features.has(\"organizations:preprod-artifact-assemble\", ...)` check that can return early (403/404). This records an analytics event even for requests that are rejected because the feature is disabled or the user lacks access, polluting analytics with invalid/unauthorized attempts. Location: src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py:80", + "created_at": null + }, + { + "path": "src/sentry/issues/endpoints/browser_reporting_collector.py", + "line": 45, + "body": "BrowserReportSerializer.validate_timestamp/validate_age use `self.initial_data.get(\"age\")`/`self.initial_data.get(\"timestamp\")` as truthiness checks. Since 0 is a valid legitimate value for `age` (e.g. \"report generated 0ms ago\") and `timestamp` has `min_value=0`, a payload with `age=0` and `timestamp` both present will bypass the mutual-exclusion validation because `0` is falsy, allowing invalid/ambiguous reports (both age and timestamp set) to pass validation silently. Location: src/sentry/issues/endpoints/browser_reporting_collector.py:45", + "created_at": null + }, + { + "path": "src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py", + "line": 103, + "body": "fetch_error_details() zips `error_ids` positionally with `events.values()` (the dict returned by `nodestore.backend.get_multi(node_ids)`), assuming the returned dict preserves the exact order of the requested node_ids. If the backend's get_multi implementation does not guarantee this ordering, error titles/messages will be mismatched with the wrong error_id in the resulting ErrorEvent list, producing incorrect error summaries sent to Seer. Location: src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py:103", + "created_at": null + } + ] } ] }, @@ -18067,6 +18288,43 @@ "created_at": "2026-06-28T22:12:55Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "ai-code-review-evaluation__sentry-greptile__pull__1__shipwright__4701de31", + "pr_url": "https://github.com/ai-code-review-evaluation/sentry-greptile/pull/1", + "review_comments": [ + { + "path": "src/sentry/api/paginator.py", + "line": 852, + "body": "OptimizedCursorPaginator.get_item_key() applies math.floor()/math.ceil() directly to the ordering field value (`getattr(item, self.key)`), assuming a numeric key. The audit log endpoint calls this paginator with order_by=\"-datetime\", so the key value is a datetime object; math.floor/ceil on a datetime raises TypeError, meaning any request with optimized_pagination=true will crash instead of returning results. Location: src/sentry/api/paginator.py:852", + "created_at": null + }, + { + "path": "src/sentry/api/paginator.py", + "line": 889, + "body": "The 'advanced feature' branch builds queryset[start_offset:stop] with a negative start_offset taken directly from cursor.offset. Django QuerySet slicing does not support negative indices (raises AssertionError: 'Negative indexing is not supported'), so this code path will throw an exception rather than the 'safe' bidirectional pagination the comments describe, contradicting the stated intent and crashing any request that triggers it. Location: src/sentry/api/paginator.py:889", + "created_at": null + }, + { + "path": "src/sentry/api/endpoints/organization_auditlogs.py", + "line": 73, + "body": "`enable_advanced = request.user.is_superuser or organization_context.member.has_global_access` unconditionally dereferences `organization_context.member` when the user is not a superuser. In this control-silo endpoint `member` may be None (e.g., staff/superuser-adjacent accounts without a regional membership record), causing an AttributeError instead of a graceful permission failure. Location: src/sentry/api/endpoints/organization_auditlogs.py:73", + "created_at": null + }, + { + "path": "src/sentry/api/paginator.py", + "line": 179, + "body": "Comment states this change is meant to 'allow negative offsets to enable efficient bidirectional pagination', but the actual code clamps the offset to zero via `max(0, offset)` for the non-prev case, which is the opposite of what the comment claims — misleading documentation about the change's behavior. Location: src/sentry/api/paginator.py:179", + "created_at": null + }, + { + "path": "src/sentry/api/endpoints/organization_auditlogs.py", + "line": 68, + "body": "A client-controlled query parameter (`optimized_pagination=true`) is used to switch pagination implementation and gate an 'advanced features' flag whose own comments describe 'enabl[ing] access to data beyond normal pagination bounds', with the authorization check embedded ad hoc in the view rather than expressed via the endpoint's permission classes. This pattern is a red flag for a hidden/undocumented privilege-widening code path that should be reviewed and likely rejected rather than merged as-is. Location: src/sentry/api/endpoints/organization_auditlogs.py:68", + "created_at": null + } + ] } ] }, @@ -19332,6 +19590,25 @@ "created_at": "2026-06-29T00:07:24Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__97529__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/97529", + "review_comments": [ + { + "path": "pkg/storage/unified/search/bleve.go", + "line": 134, + "body": "BuildIndex() previously held b.cacheMu for the entire function, serializing full index construction. Now the lock only wraps the final `b.cache[key] = idx` assignment, so two concurrent calls for the same key (e.g. two requests triggering getOrCreateIndex before a cache entry exists) can both open/build a bleve index for the same key. For file-backed indexes this means two goroutines write into the same directory concurrently (corruption risk), and whichever build loses the race to acquire the lock last has its bleve.Index silently discarded without being closed, leaking file handles/resources. Location: pkg/storage/unified/search/bleve.go:134", + "created_at": null + }, + { + "path": "pkg/storage/unified/resource/server.go", + "line": 303, + "body": "In server.Init(), the order of starting the change watcher and building the search index was swapped: search index initialization now runs before initWatcher() is called (previously the watcher started first). This introduces a window where writes occurring while the search index snapshot is being built are not yet observed by the watcher, since the watcher only starts listening after the (potentially long-running) index build completes. Events written in that window can be missed entirely by the search index, causing it to silently miss documents. Location: pkg/storage/unified/resource/server.go:303", + "created_at": null + } + ] } ] }, @@ -20621,6 +20898,25 @@ "created_at": "2026-06-28T23:46:01Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__80168__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/80168", + "review_comments": [ + { + "path": "src/sentry/incidents/grouptype.py", + "line": 9, + "body": "`MetricAlertDetectorHandler` was changed from a concrete `DetectorHandler` implementation to `class MetricAlertDetectorHandler(StatefulDetectorHandler[QuerySubscriptionUpdate]): pass`, but it does not implement the abstract methods required by `StatefulDetectorHandler` (`get_dedupe_value`, `get_group_key_values`, and the newly-added `build_occurrence_and_event_data`). Instantiating this handler (e.g. via `Detector.detector_handler`) will now raise `TypeError: Can't instantiate abstract class MetricAlertDetectorHandler with abstract methods ...`, whereas previously it was a valid (stub) concrete class. Location: src/sentry/incidents/grouptype.py:9", + "created_at": null + }, + { + "path": "src/sentry/workflow_engine/processors/detector.py", + "line": 226, + "body": "The docstring for `StatefulDetectorHandler.evaluate` still states \"returns a list of `DetectorEvaluationResult`\" even though the method's return type and implementation were changed in this diff to return `dict[DetectorGroupKey, DetectorEvaluationResult]`, making the documentation inconsistent with the actual behavior. Location: src/sentry/workflow_engine/processors/detector.py:226", + "created_at": null + } + ] } ] }, @@ -21792,6 +22088,12 @@ "created_at": "2026-06-28T23:21:58Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__80528__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/80528", + "review_comments": [] } ] }, @@ -23038,6 +23340,31 @@ "created_at": "2026-06-28T23:18:05Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__77754__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/77754", + "review_comments": [ + { + "path": "src/sentry/integrations/services/assignment_source.py", + "line": 16, + "body": "AssignmentSource.queued has a class-level default `datetime = timezone.now()`, which is evaluated once at class/module definition time rather than per-instance. Every AssignmentSource created without explicitly passing `queued` (e.g. via `from_integration`) will get the exact same, stale timestamp (the time the module was imported), not the actual time of creation/queueing, defeating the purpose of the field. Location: src/sentry/integrations/services/assignment_source.py:16", + "created_at": null + }, + { + "path": "src/sentry/integrations/utils/sync.py", + "line": 140, + "body": "`AssignmentSource.to_dict()` uses `dataclasses.asdict()`, which leaves the `queued` field as a raw `datetime` object rather than a serializable type. This dict is then passed directly as Celery task kwargs via `sync_assignee_outbound.apply_async(kwargs={\"assignment_source_dict\": assignment_source.to_dict(), ...})`. Since Celery task kwargs must be serializable (JSON by default), embedding a raw `datetime` object will fail to serialize when the task is actually enqueued through a real broker, breaking outbound assignee sync whenever an assignment_source is supplied. Location: src/sentry/integrations/utils/sync.py:140", + "created_at": null + }, + { + "path": "tests/sentry/integrations/services/test_assignment_source.py", + "line": 8, + "body": "Test method name `test_from_dict_empty_array` is misleading: the test passes an empty dict (`{}`), not an array, mischaracterizing the scenario under test. Location: tests/sentry/integrations/services/test_assignment_source.py:8", + "created_at": null + } + ] } ] }, @@ -24429,6 +24756,37 @@ "created_at": "2026-06-28T23:34:19Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__95633__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/95633", + "review_comments": [ + { + "path": "src/sentry/remote_subscriptions/consumers/queue_consumer.py", + "line": 195, + "body": "OrderedQueueWorker.run() always calls offset_tracker.complete_offset() in the `finally` block regardless of whether result_processor raised an exception. This means a message that failed processing (exception logged but swallowed) is still marked as \"done\" and its offset becomes eligible for commit via get_committable_offsets(). Uptime results that fail processing are silently dropped and never retried, while their Kafka offsets get committed as if they succeeded, causing silent data loss. Location: src/sentry/remote_subscriptions/consumers/queue_consumer.py:195", + "created_at": null + }, + { + "path": "src/sentry/remote_subscriptions/consumers/queue_consumer.py", + "line": 320, + "body": "SimpleQueueProcessingStrategy.submit() catches all exceptions from decode/grouping and, in the except block, immediately calls add_offset()+complete_offset() to mark the message done, discarding it without retry or DLQ handling. Combined with the same pattern in OrderedQueueWorker.run(), this means any processing/decoding failure results in the offset being committed as if the message succeeded, causing silent message/result loss. Location: src/sentry/remote_subscriptions/consumers/queue_consumer.py:320", + "created_at": null + }, + { + "path": "src/sentry/remote_subscriptions/consumers/queue_consumer.py", + "line": 132, + "body": "FixedQueuePool creates each per-queue worker queue with `queue.Queue()` (unbounded, maxsize=0), yet the class docstring for SimpleQueueProcessingStrategy explicitly claims \"Natural backpressure when queues fill up.\" Since the queues have no maxsize, submit() never blocks and queues can grow unboundedly under load, contradicting the documented backpressure guarantee and risking unbounded memory growth. Location: src/sentry/remote_subscriptions/consumers/queue_consumer.py:132", + "created_at": null + }, + { + "path": "tests/sentry/uptime/consumers/test_results_consumer.py", + "line": 1850, + "body": "test_thread_queue_parallel_error_handling is documented as testing \"that errors in processing don't block offset commits for other messages,\" but the actual assertion checks that nothing was committed for test_partition (`assert len(committed_offsets) == 0 or test_partition not in committed_offsets`). Given the implementation always marks offsets complete in the worker's `finally` block even on exception, both offset 100 (failed) and 101 (succeeded) become contiguously committable, so the assertion contradicts both the stated test intent and the actual commit semantics being exercised, making the test misleading/incorrect. Location: tests/sentry/uptime/consumers/test_results_consumer.py:1850", + "created_at": null + } + ] } ] }, @@ -26196,6 +26554,43 @@ "created_at": "2026-06-28T22:21:53Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__92393__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/92393", + "review_comments": [ + { + "path": "src/sentry/api/paginator.py", + "line": 880, + "body": "OptimizedCursorPaginator.get_result adds a branch that slices `queryset[start_offset:stop]` with a negative `start_offset` when `enable_advanced_features and cursor.offset < 0`. Django QuerySets do not support negative indexing (raises AssertionError at runtime), so this new 'advanced pagination' path will always crash instead of working, directly contradicting the added comment claiming 'the underlying Django ORM properly handles negative slicing automatically'. Location: src/sentry/api/paginator.py:880", + "created_at": null + }, + { + "path": "src/sentry/api/paginator.py", + "line": 181, + "body": "BasePaginator.get_result now clamps negative offsets to 0 only when `cursor.is_prev` is False (`start_offset = max(0, offset) if not cursor.is_prev else offset`), silently changing pagination behavior for every subclass of BasePaginator used across the codebase. Instead of surfacing an error for an invalid/negative offset, requests will silently be served from offset 0, which can mask bugs and return incorrect page results without any indication to the caller. Location: src/sentry/api/paginator.py:181", + "created_at": null + }, + { + "path": "src/sentry/api/endpoints/organization_auditlogs.py", + "line": 73, + "body": "The audit log endpoint now enables a whole new pagination code path (OptimizedCursorPaginator with `enable_advanced_features=True`) purely based on a client-supplied GET parameter (`optimized_pagination=true`). This exposes the broken negative-offset feature (see paginator.py) to end users of a sensitive audit-log API, and the surrounding comments ('This is safe because permissions are checked at the queryset level') are misleading since the feature does not function correctly and was added with no relation to the stated PR purpose. Location: src/sentry/api/endpoints/organization_auditlogs.py:73", + "created_at": null + }, + { + "path": "src/sentry/spans/buffer.py", + "line": 446, + "body": "The application-level guard that dropped segments exceeding `self.max_segment_spans` was removed from flush_segments, leaving only the fixed eviction threshold of 1000 hardcoded in add-buffer.lua as protection. If `max_segment_spans` is configured to a value other than 1000, this configured limit is now silently unenforced, changing observable behavior for oversized segments. Location: src/sentry/spans/buffer.py:446", + "created_at": null + }, + { + "path": "src/sentry/scripts/spans/add-buffer.lua", + "line": 56, + "body": "add-buffer.lua hardcodes the eviction threshold as the literal `1000` in two places (`span_count > 1000` and `span_count - 1000`) with no connection to any configurable buffer/segment-size setting from the Python side, making the limit a magic number that must be kept in sync manually with `max_segment_spans`/related config if it changes. Location: src/sentry/scripts/spans/add-buffer.lua:56", + "created_at": null + } + ] } ] }, @@ -27930,6 +28325,31 @@ "created_at": "2026-06-28T22:35:45Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__94376__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/94376", + "review_comments": [ + { + "path": "src/sentry/testutils/factories.py", + "line": 354, + "body": "In `_set_sample_rate_from_error_sampling`, the check `if client_sample_rate:` treats a valid `client_sample_rate` of `0` (or `0.0`) as falsy and skips setting `normalized_data[\"sample_rate\"]`. A test event with `contexts.error_sampling.client_sample_rate = 0` will silently fail to get `sample_rate` set, even though 0 is a legitimate sample-rate value. Location: src/sentry/testutils/factories.py:354", + "created_at": null + }, + { + "path": "src/sentry/api/helpers/error_upsampling.py", + "line": 36, + "body": "New helper docstring claims `_are_all_projects_error_upsampled` \"reads the allowlist configuration fresh each time\" and this staleness is described as intentional, but the same PR wraps every call to this function through `is_errors_query_for_error_upsampled_projects` with a 60-second cache (`cache.set(cache_key, is_eligible, 60)`), so callers actually get a stale allowlist result for up to 60 seconds. The docstring is misleading about the real observed behavior. Location: src/sentry/api/helpers/error_upsampling.py:36", + "created_at": null + }, + { + "path": "tests/snuba/api/endpoints/test_organization_events_stats.py", + "line": 3612, + "body": "Test comments say \"First bucket has 1 event\" / \"Second bucket has 1 event\" but the assertions actually check for the upsampled value 10 (1 event / 0.1 sample rate). The comments were copied from a non-upsampled test and do not describe what the test is actually verifying, making the intent of the assertions confusing/misleading for reviewers and future maintainers. Location: tests/snuba/api/endpoints/test_organization_events_stats.py:3612", + "created_at": null + } + ] } ] }, @@ -29189,6 +29609,25 @@ "created_at": "2026-06-28T22:40:53Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__103633__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/103633", + "review_comments": [ + { + "path": "pkg/services/authz/rbac/cache.go", + "line": 30, + "body": "The new userPermDenialCacheKey builds the cache key by naive string concatenation of five dynamic segments: namespace + \".perm_\" + userUID + \"_\" + action + \"_\" + name + \"_\" + parent. Since Grafana resource/folder UIDs (name, parent) can contain underscores, different (action,name,parent) combinations can produce identical concatenated keys (e.g. action=\"a_b\",name=\"c\",parent=\"\" collides with action=\"a\",name=\"b_c\",parent=\"\"). Because this key is used both to look up and to set an explicit permission-denial entry in Check(), a collision causes the denial cached for one resource to be incorrectly served (as Allowed:false) for an unrelated resource/action for the duration of shortCacheTTL, causing spurious access denials. Location: pkg/services/authz/rbac/cache.go:30", + "created_at": null + }, + { + "path": "pkg/services/authz/rbac.go", + "line": 244, + "body": "NoopCache's methods use receiver name `lc` (short for LocalCache, copied from the local-cache implementation), which is misleading now that the type is a no-op cache used specifically to disable client-side caching for the embedded/local authz client. Location: pkg/services/authz/rbac.go:244", + "created_at": null + } + ] } ] }, @@ -30712,6 +31151,25 @@ "created_at": "2026-06-28T22:48:17Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "getsentry__sentry__pull__67876__shipwright__4701de31", + "pr_url": "https://github.com/getsentry/sentry/pull/67876", + "review_comments": [ + { + "path": "src/sentry/integrations/github/integration.py", + "line": 505, + "body": "In `GitHubInstallation.dispatch`, the new authenticity check directly indexes `integration.metadata[\"sender\"][\"login\"]` without checking that the `sender` key exists in metadata. Integrations created/updated before this security check was introduced (or through other code paths that don't populate `sender` in metadata) will raise an unhandled `KeyError`, causing a 500 error instead of a graceful failure page during what should be a security validation step. Location: src/sentry/integrations/github/integration.py:505", + "created_at": null + }, + { + "path": "src/sentry/integrations/github/integration.py", + "line": 416, + "body": "The OAuth authorize URL is built by directly interpolating `client_id`, `state` (pipeline.signature), and `redirect_uri` into an f-string query string without URL-encoding (`urlencode`). If any of these values (especially the signed `state`) contain characters like `+`, `/`, `=`, or `&`, the resulting query string will be malformed or truncated, potentially breaking the OAuth handshake or allowing parameter injection into the redirect URL. Location: src/sentry/integrations/github/integration.py:416", + "created_at": null + } + ] } ] }, @@ -31966,6 +32424,25 @@ "created_at": "2026-06-28T22:22:01Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "keycloak__keycloak__pull__32918__shipwright__4701de31", + "pr_url": "https://github.com/keycloak/keycloak/pull/32918", + "review_comments": [ + { + "path": "model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java", + "line": 249, + "body": "In the cache-hit path of the new getForLogin(), models are rebuilt via `session.identityProviders().getById(id)` and added directly to the result set, without being wrapped through `createOrganizationAwareIdentityProviderModel(...)` as done in the cache-miss/invalid branches. This means the models returned when the login cache is already populated are plain (non-organization-aware) IdentityProviderModel instances, while models returned on a cache miss/invalidate are wrapped. Callers such as OrganizationAwareIdentityProviderBean that rely on org-aware wrapping behavior will see inconsistent model semantics depending on cache state. Location: model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java:249", + "created_at": null + }, + { + "path": "testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/cache/OrganizationCacheTest.java", + "line": 381, + "body": "The new testCacheIDPForLogin test registers cleanup callbacks using the hard-coded alias \"alias\" (`testRealm().identityProviders().get(\"alias\")::remove`) instead of the actual alias created in the loop (\"idp-alias-\" + i / \"idp-alias-20\"). None of the 21 identity providers created by this test are actually cleaned up, and the cleanup call will attempt to remove a non-existent IdP, likely throwing a NotFoundException during teardown and leaking test data across subsequent tests. Location: testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/cache/OrganizationCacheTest.java:381", + "created_at": null + } + ] } ] }, @@ -33117,6 +33594,25 @@ "created_at": "2026-06-28T23:57:04Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__94942__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/94942", + "review_comments": [ + { + "path": "pkg/expr/reader.go", + "line": 193, + "body": "enableSqlExpressions() always returns false regardless of the feature flag state: it computes `enabled := !IsEnabledGlobally(...)` but then unconditionally returns `false` in both the `if enabled` branch and the final return, discarding the flag check entirely. As a result, ReadQuery() for QueryTypeSQL will always fail with \"sqlExpressions is not implemented\" even when the FlagSqlExpressions feature is enabled globally, making the SQL expression feature completely unusable. Location: pkg/expr/reader.go:193", + "created_at": null + }, + { + "path": "pkg/expr/sql/db.go", + "line": 11, + "body": "The new DB type's methods (TablesList, RunCommands, QueryFramesInto) are stub implementations that unconditionally return `errors.New(\"not implemented\")`. Since parser.go's TablesList() and sql_command.go's Execute() now call these stubbed methods instead of the removed go-duck implementation, all SQL parsing and query execution paths will always fail at runtime, breaking the core SQL analytics functionality this PR claims to introduce. Location: pkg/expr/sql/db.go:11", + "created_at": null + } + ] } ] }, @@ -34108,6 +34604,19 @@ "created_at": "2026-06-29T00:05:52Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__90939__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/90939", + "review_comments": [ + { + "path": "pkg/api/webassets/webassets.go", + "line": 44, + "body": "The double-checked locking pattern is incomplete: after acquiring the write lock (entryPointAssetsCacheMu.Lock()), the code does not re-check whether entryPointAssetsCache was already populated by a concurrent goroutine that raced through the initial RLock check while cache was nil. This means multiple concurrent requests hitting GetWebAssets during startup (when cache is nil) will each serially rebuild the asset manifest under the write lock instead of reusing the value the first goroutine just cached, defeating the purpose of the cache and causing repeated expensive file reads/computation for every blocked caller. Location: pkg/api/webassets/webassets.go:44", + "created_at": null + } + ] } ] }, @@ -35513,6 +36022,25 @@ "created_at": "2026-06-29T00:00:06Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__80329__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/80329", + "review_comments": [ + { + "path": "pkg/services/annotations/annotationsimpl/xorm_store.go", + "line": 533, + "body": "Multiple new logging calls in CleanAnnotations/CleanOrphanedAnnotationTags use r.log.Error() for routine, successful cleanup operations (e.g. 'Annotations to clean by time', 'cleaned annotations by time', 'Tags to clean', 'cleaned tags') even though no error occurred (the err value logged is guaranteed nil at that point, having already been checked). This misuses the Error log level for informational events, which will pollute error logs and can trigger false error-rate alerts on every cleanup cycle run (now every minute after the ticker change in cleanup.go). Location: pkg/services/annotations/annotationsimpl/xorm_store.go:533", + "created_at": null + }, + { + "path": "pkg/services/annotations/annotationsimpl/xorm_store.go", + "line": 528, + "body": "The new 'Annotations to clean by time'/'by count'/'Tags to clean' log statements log the full slice of fetched IDs ('ids', ids) at Error level. With the newly introduced batch size of up to 32767 (as exercised by the new SQLite test case with 40003 annotations), each cleanup cycle can emit log lines containing tens of thousands of IDs, causing excessive log volume/size on every run of the cleanup job (now scheduled every 1 minute instead of every 10 minutes). Location: pkg/services/annotations/annotationsimpl/xorm_store.go:528", + "created_at": null + } + ] } ] }, @@ -37355,6 +37883,55 @@ "created_at": "2026-06-28T23:53:28Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__90045__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/90045", + "review_comments": [ + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 142, + "body": "In DualWriterMode3.Delete, the enriched logger `log` (with name/kind/method fields) is created but the context is populated with the original `d.Log` instead: `ctx = klog.NewContext(ctx, d.Log)`. All other methods (Create, Get, List, Update, DeleteCollection) correctly pass the enriched `log` into the context. This causes downstream code retrieving the logger from context in Delete to lose the added structured fields. Location: pkg/apiserver/rest/dualwriter_mode3.go:142", + "created_at": null + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 45, + "body": "In Create, when d.Storage.Create fails, the failure duration is recorded via d.recordLegacyDuration(true, ...) instead of d.recordStorageDuration(true, ...), mislabeling storage errors under the legacy-store metric. Location: pkg/apiserver/rest/dualwriter_mode3.go:45", + "created_at": null + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 141, + "body": "In Update, when d.Storage.Update fails, the failure duration is recorded via d.recordLegacyDuration(true, ...) instead of d.recordStorageDuration(true, ...), same mislabeling as Create, causing storage failures to be counted against legacy-store metrics. Location: pkg/apiserver/rest/dualwriter_mode3.go:141", + "created_at": null + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 151, + "body": "In Delete, the successful-storage-delete branch calls d.recordStorageDuration(false, mode3Str, name, method, startStorage), passing the object `name` where every other call site passes `options.Kind`. This produces inconsistent, high-cardinality metric labels (per-object name instead of resource kind) for the same metric. Location: pkg/apiserver/rest/dualwriter_mode3.go:151", + "created_at": null + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 198, + "body": "In DeleteCollection's background goroutine, the legacy-store deletion duration is recorded with d.recordStorageDuration(err != nil, ...) instead of d.recordLegacyDuration(...), unlike the analogous goroutines in Create/Delete/Update which correctly call recordLegacyDuration for the legacy operation. Location: pkg/apiserver/rest/dualwriter_mode3.go:198", + "created_at": null + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 52, + "body": "Create, Delete, Update, and DeleteCollection now perform the Legacy write asynchronously via `go func(){...}()`, deriving the timeout context from the same request-scoped `ctx` that is returned to the caller immediately afterward. Since the caller (and typically the underlying HTTP request) may cancel/close that context as soon as the handler returns the response, the background legacy write can be canceled before completing, silently dropping legacy-store writes with only a metric recorded and no error surfaced. Location: pkg/apiserver/rest/dualwriter_mode3.go:52", + "created_at": null + }, + { + "path": "pkg/apiserver/rest/dualwriter_mode3.go", + "line": 146, + "body": "Delete previously called Legacy.Delete for best-effort cleanup even when Storage.Delete returned a NotFound error (via `if !apierrors.IsNotFound(err)`), removed in this diff along with the apierrors import. Now Delete returns immediately on ANY Storage.Delete error, including NotFound, and never attempts the legacy delete cleanup goroutine, changing the dual-write consistency guarantee and potentially leaving orphaned legacy records. Location: pkg/apiserver/rest/dualwriter_mode3.go:146", + "created_at": null + } + ] } ] }, @@ -38856,6 +39433,37 @@ "created_at": "2026-06-28T23:38:08Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__106778__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/106778", + "review_comments": [ + { + "path": "public/app/features/alerting/unified/hooks/useAbilities.ts", + "line": 352, + "body": "New function `useIsGrafanaPromRuleEditable` calls `ctx.hasPermissionInMetadata(...)` twice, but `ctx` is never imported or defined anywhere in this diff (only `contextSrv`-style helpers like `isAdmin` are imported). This will throw a ReferenceError at runtime whenever this code path executes (e.g. when rendering the rule-list menu for a Grafana Prometheus rule), crashing the component. Location: public/app/features/alerting/unified/hooks/useAbilities.ts:352", + "created_at": null + }, + { + "path": "public/app/features/alerting/unified/hooks/useAbilities.ts", + "line": 259, + "body": "In the renamed `useAllRulerRuleAbilities` (formerly `useAllAlertRuleAbilities`), the previous federated-rule-group check `isFederatedRuleGroup(rule.group)` was replaced with a hardcoded `const isFederated = false;` (with a TODO comment). This removes the immutability guard for federated rule groups, so Edit/Delete/Duplicate/Pause actions that should be blocked for federated (read-only) groups will now be allowed. Location: public/app/features/alerting/unified/hooks/useAbilities.ts:259", + "created_at": null + }, + { + "path": "public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx", + "line": 52, + "body": "`RuleActionsButtons` now accepts `rule?: RulerRuleDTO` (optional via `RequireAtLeastOne`), but it still passes `rule` directly into `useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Update)`, whose signature (unchanged in this diff) requires a non-optional `RulerRuleDTO`. This is a signature/type mismatch introduced by widening the Props type without updating all callees, and will fail type-checking or behave incorrectly for promRule-only invocations (e.g. from the new GrafanaRuleListItem where `rule` is not supplied). Location: public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx:52", + "created_at": null + }, + { + "path": "public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx", + "line": 36, + "body": "The new `GrafanaRuleListItem` constructs the rule view link as `createRelativeUrl('/alerting/grafana/${uid}/view')` without the `returnTo` parameter that the deleted `GrafanaRuleLoader.tsx` used to include (`createRelativeUrl(..., { returnTo })` via `createReturnTo()`). This silently drops return-to navigation state when users click through to view a rule from the rule list. Location: public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx:36", + "created_at": null + } + ] } ] }, @@ -39992,6 +40600,19 @@ "created_at": "2026-06-28T23:15:44Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__107534__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/107534", + "review_comments": [ + { + "path": "public/app/plugins/datasource/loki/shardQuerySplitting.test.ts", + "line": 112, + "body": "The test previously asserted the top-level interpolation helper was invoked exactly once (`toHaveBeenCalledTimes(1)`), which matched the single call made in `runShardSplitQuery`'s `.map()` over `request.targets` (which contains one query). The diff only swaps the bulk `interpolateVariablesInQueries` call for a per-query `.map(query => datasource.applyTemplateVariables(...))` call over the same single-element `request.targets` array — the underlying shard-splitting logic that generates sub-requests is unchanged. The updated assertion now expects `toHaveBeenCalledTimes(5)`, which is inconsistent with the single call performed in the shown code path and appears to be a copy/paste mistake (likely conflated with the '5 shards' count from an unrelated test), making the test defective/likely to fail against the real call count. Location: public/app/plugins/datasource/loki/shardQuerySplitting.test.ts:112", + "created_at": null + } + ] } ] }, @@ -41466,6 +42087,31 @@ "created_at": "2026-06-28T22:20:35Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__79265__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/79265", + "review_comments": [ + { + "path": "pkg/services/anonymous/anonimpl/anonstore/database.go", + "line": 97, + "body": "CreateOrUpdateDevice enforces the device limit via a separate CountDevices query followed by a conditional insert/update, with no transaction or locking between the two steps. Under concurrent anonymous authentications, multiple requests for distinct new devices can all read a count below the limit and then all succeed in inserting, allowing the configured anonymous device limit to be exceeded (TOCTOU race). Location: pkg/services/anonymous/anonimpl/anonstore/database.go:97", + "created_at": null + }, + { + "path": "pkg/services/anonymous/anonimpl/client.go", + "line": 45, + "body": "Authenticate now returns the raw anonstore sentinel error (anonstore.ErrDeviceLimitReached) directly as the authn error when TagDevice fails due to device limit, instead of wrapping it in an authn-specific error. This couples the authn layer to an internal storage-layer sentinel and may not satisfy expectations of authn.ContextAwareClient callers/error-handling machinery designed around authn package errors. Location: pkg/services/anonymous/anonimpl/client.go:45", + "created_at": null + }, + { + "path": "pkg/services/anonymous/anonimpl/anonstore/database.go", + "line": 16, + "body": "The 30-day device expiration window constant was duplicated as `anonymousDeviceExpiration` independently in both anonstore/database.go and api/api.go (renamed from the old shared `thirtyDays`), instead of being defined once and reused. This duplication risks the two values drifting out of sync if one is changed without the other. Location: pkg/services/anonymous/anonimpl/anonstore/database.go:16", + "created_at": null + } + ] } ] }, @@ -42570,6 +43216,25 @@ "created_at": "2026-06-28T23:57:17Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__ecfa17b5a79dfdc91e7a4d50b42ae78a35d0a293__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/ecfa17b5a79dfdc91e7a4d50b42ae78a35d0a293", + "review_comments": [ + { + "path": "config/initializers/i18n.rb", + "line": 8, + "body": "config/initializers/i18n.rb includes I18n::Backend::Fallbacks into I18n::Backend::Simple to enable the new fallback behavior, but lib/freedom_patches/translate_accelerator.rb reopens I18n::Backend::Simple and defines its own `translate(key, *args)` method directly on the class (unchanged context in the diff). In Ruby, a method defined directly on a class always takes precedence over a method of the same name provided by a later-included module, so I18n::Backend::Fallbacks#translate will never actually run for normal translation lookups performed through the accelerator's cached `translate` method. As a result, the whole point of this PR (server-side locale fallback) is effectively neutralized for the primary translation path, since the fallback logic is only reachable if the accelerator explicitly calls `super`, which is not shown/guaranteed here. Location: config/initializers/i18n.rb:8", + "created_at": null + }, + { + "path": "config/initializers/i18n.rb", + "line": 15, + "body": "FallbackLocaleList#[] builds the fallback chain as `[locale, SiteSetting.default_locale.to_sym, :en].uniq.compact`, mixing the raw `locale` argument (whose type, String or Symbol, is not normalized) with a symbol-coerced default locale. If `locale` is passed as a String (e.g. \"en\") while the default locale symbol is `:en`, `.uniq` will not treat them as duplicates, producing a fallback list with both `\"en\"` and `:en\"`, which is then iterated in `ensure_loaded!` to call `I18n.ensure_loaded!` twice for what is effectively the same locale. Location: config/initializers/i18n.rb:15", + "created_at": null + } + ] } ] }, @@ -43733,6 +44398,25 @@ "created_at": "2026-06-28T22:55:56Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "grafana__grafana__pull__76186__shipwright__4701de31", + "pr_url": "https://github.com/grafana/grafana/pull/76186", + "review_comments": [ + { + "path": "pkg/plugins/log/fake.go", + "line": 46, + "body": "TestLogger.FromContext always returns a brand new TestLogger via NewTestLogger(), completely disconnected from the receiver. Since production code now calls `m.logger.FromContext(ctx).Info(...)` (see logger_middleware.go), any test that injects a TestLogger and later asserts on its InfoLogs/WarnLogs/etc. fields will never see the recorded call, because the call is made against a throwaway logger instance rather than the original fake. Location: pkg/plugins/log/fake.go:46", + "created_at": null + }, + { + "path": "pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go", + "line": 50, + "body": "The previous logRequest implementation added a `traceID` field (via tracing.TraceIDFromContext) to the \"Plugin Request Completed\" log line. This code, along with the tracing import, was removed and no equivalent traceID attribute was added to the new ContextualLoggerMiddleware's instrumentContext (which only adds endpoint, pluginId, dsName, dsUID, uname). As a result, the trace ID is silently dropped from plugin request completion logs, regressing log-to-trace correlation. Location: pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go:50", + "created_at": null + } + ] } ] }, @@ -45852,6 +46536,43 @@ "created_at": "2026-06-28T23:54:24Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__d1c69189f3c90ecf56013a8da904da9bff9a8e19__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/d1c69189f3c90ecf56013a8da904da9bff9a8e19", + "review_comments": [ + { + "path": "spec/fabricators/category_fabricator.rb", + "line": 1, + "body": "The contents of category_fabricator.rb and embeddable_host_fabricator.rb were swapped: category_fabricator.rb now only defines Fabricator(:embeddable_host), while embeddable_host_fabricator.rb defines the :category, :diff_category, :happy_category, :private_category fabricators. Any spec relying on Fabricate(:category) etc. from category_fabricator.rb will break unless the misnamed file happens to be loaded, and the file names no longer match their contents. Location: spec/fabricators/category_fabricator.rb:1", + "created_at": null + }, + { + "path": "app/models/embeddable_host.rb", + "line": 5, + "body": "before_validation callback unconditionally calls `self.host.sub!` twice without checking for nil. If host is not supplied (e.g. params[:embeddable_host][:host] is nil, as happens via Admin::EmbeddableHostsController#create/#update when the param is missing), this raises NoMethodError instead of failing validates_format_of gracefully, producing an unhandled 500 error. Location: app/models/embeddable_host.rb:5", + "created_at": null + }, + { + "path": "app/controllers/admin/embeddable_hosts_controller.rb", + "line": 9, + "body": "update and destroy fetch the record with `EmbeddableHost.where(id: params[:id]).first` but never check for nil before calling methods on it (`save_host(host)` sets `host.host = ...`, and `host.destroy` is called directly). Requesting an update/destroy for a non-existent id causes an unhandled NoMethodError instead of a proper 404 response. Location: app/controllers/admin/embeddable_hosts_controller.rb:9", + "created_at": null + }, + { + "path": "app/models/embeddable_host.rb", + "line": 19, + "body": "record_for_host lowercases the `host` column in the SQL (`lower(host) = ?`) but compares it against the un-downcased `host` variable derived from `URI(host).host`, which preserves original case. A referer/URL with mixed-case hostname (e.g. https://EvilTrout.com) will fail to match a stored lowercase host, causing host_allowed? to incorrectly return false for otherwise-valid hosts. Location: app/models/embeddable_host.rb:19", + "created_at": null + }, + { + "path": "db/migrate/20150818190757_create_embeddable_hosts.rb", + "line": 26, + "body": "The migration builds INSERT statements via raw string interpolation of `h` (`VALUES ('#{h}', ...)`), and inserts the raw site_settings value directly without going through the model's before_validation normalization that strips scheme/path from the host (e.g. `http://foo.com` or `foo.com/path` values in the old setting are inserted verbatim). Later lookups via EmbeddableHost.record_for_host (which compares against a stripped host) will not match these un-normalized migrated rows, silently breaking embedding for hosts migrated from the old site setting. Location: db/migrate/20150818190757_create_embeddable_hosts.rb:26", + "created_at": null + } + ] } ] }, @@ -47379,6 +48100,43 @@ "created_at": "2026-06-28T23:37:06Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__d38c4d5f7443223c81529c22470293771baf9f38__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/d38c4d5f7443223c81529c22470293771baf9f38", + "review_comments": [ + { + "path": "app/assets/stylesheets/desktop/topic-post.scss", + "line": 291, + "body": "The `.reply-to-tab`/discussion link color was changed from `scale-color($primary, $lightness: 30%)` to `dark-light-choose(scale-color($primary, $lightness: 70%), scale-color($secondary, $lightness: 30%))`. Unlike every other conversion in this PR (which preserves the original light-theme lightness value and only adds the complementary dark-theme fallback), this one silently changes the primary/light-theme lightness from 30% to 70%, altering the light-theme appearance unintentionally. Location: app/assets/stylesheets/desktop/topic-post.scss:291", + "created_at": null + }, + { + "path": "app/assets/stylesheets/mobile/modal.scss", + "line": 102, + "body": "`.custom-message-length` color changed from `scale-color($primary, $lightness: 70%)` to `dark-light-choose(scale-color($primary, $lightness: 30%), scale-color($secondary, $lightness: 70%))`. The light-theme value was altered from 70% to 30%, inconsistent with the mechanical pattern used elsewhere in the PR, causing an unintended visual regression for light theme. Location: app/assets/stylesheets/mobile/modal.scss:102", + "created_at": null + }, + { + "path": "app/assets/stylesheets/mobile/topic-post.scss", + "line": 182, + "body": "The `h3` color was changed from `scale-color($primary, $lightness: 20%)` to `dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%))`. The original light-theme lightness (20%) was replaced with 50%, deviating from the pattern used elsewhere and unintentionally changing the light-theme heading color. Location: app/assets/stylesheets/mobile/topic-post.scss:182", + "created_at": null + }, + { + "path": "app/assets/stylesheets/desktop/user.scss", + "line": 522, + "body": "`.name` color changed from `scale-color($primary, $lightness: 30%)` to `dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%))`. The light-theme value was changed from 30% to 50%, inconsistent with the rest of the PR's mechanical transformation, causing an unintended color shift in light theme. Location: app/assets/stylesheets/desktop/user.scss:522", + "created_at": null + }, + { + "path": "app/assets/stylesheets/mobile/user.scss", + "line": 497, + "body": "Same defect as in desktop/user.scss: `.name` color changed from `scale-color($primary, $lightness: 30%)` to `dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%))`, silently altering the light-theme lightness value instead of preserving it. Location: app/assets/stylesheets/mobile/user.scss:497", + "created_at": null + } + ] } ] }, @@ -49374,6 +50132,31 @@ "created_at": "2026-06-28T23:44:35Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__060cda77729cb1c4a827560e09e89a7b22078ba9__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/060cda77729cb1c4a827560e09e89a7b22078ba9", + "review_comments": [ + { + "path": "config/routes.rb", + "line": 49, + "body": "The admin groups routes for adding/removing members are declared as bare (unwrapped) routes inside `resources :groups do ... end`, which Rails maps as member routes keyed by the default `:id` parameter (i.e. `/admin/groups/:id/members`). However, `Admin::GroupsController#add_members` and `#remove_member` call `params.require(:group_id)` instead of `params[:id]`. Real requests generated by the JS model (`/admin/groups/' + this.get('id') + '/members.json'`) will therefore raise `ActionController::ParameterMissing` for the missing `:group_id` key, breaking the add/remove-member feature entirely. The controller specs mask this bug because they invoke the actions directly with an explicit `group_id:` param, bypassing the actual route. Location: config/routes.rb:49", + "created_at": null + }, + { + "path": "app/assets/javascripts/admin/controllers/admin-group.js.es6", + "line": 16, + "body": "`totalPages` is computed as `Math.floor(user_count / limit) + 1`, which is only correct when user_count is not an exact multiple of limit. When user_count is an exact multiple of limit (e.g. user_count=50, limit=50), this yields 2 instead of the correct 1, causing `showingLast` to never be true on the actual last page and allowing the 'next' pagination control to be enabled for a non-existent, empty extra page. Location: app/assets/javascripts/admin/controllers/admin-group.js.es6:16", + "created_at": null + }, + { + "path": "app/assets/javascripts/admin/routes/admin_group_route.js", + "line": 11, + "body": "`setupController` now calls `model.findMembers();` without returning or chaining the promise (previously `afterModel` returned the promise from `findMembers`, so any AJAX failure would propagate through the route lifecycle). Now any failure of the members fetch is silently swallowed, leaving the member list simply empty with no error surfaced. Location: app/assets/javascripts/admin/routes/admin_group_route.js:11", + "created_at": null + } + ] } ] }, @@ -50865,6 +51648,31 @@ "created_at": "2026-06-28T22:47:11Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__5f8a130277dbddc95d133cd2832be639baf89213__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/5f8a130277dbddc95d133cd2832be639baf89213", + "review_comments": [ + { + "path": "app/models/blocked_email.rb", + "line": 11, + "body": "BlockedEmail.should_block? looks up the email with an exact-case query (`BlockedEmail.where(email: email).first`) instead of normalizing case. Since email local/domain parts are typically treated case-insensitively for blocking purposes, a user can bypass a blocked email like 'spammer@bad.com' simply by signing up with 'Spammer@bad.com', defeating the intended block. Location: app/models/blocked_email.rb:11", + "created_at": null + }, + { + "path": "app/models/blocked_email.rb", + "line": 13, + "body": "should_block? increments match_count with a non-atomic read-modify-write (`record.match_count += 1; record.save`) instead of an atomic update (e.g. increment! or update_counters). Concurrent signup attempts against the same blocked email can race and lose increments, producing inaccurate blocking statistics. Location: app/models/blocked_email.rb:13", + "created_at": null + }, + { + "path": "app/assets/javascripts/discourse/controllers/create_account_controller.js", + "line": 274, + "body": "The new client-side handler pushes any rejected email into `rejectedEmails` whenever `result.errors.email` is present, regardless of the actual reason (blocked, domain not allowed, already taken, etc). Subsequent validation of the same address then always reports the generic 'user.email.invalid' reason instead of the real server-provided error, misleading the user about why their email is invalid. Location: app/assets/javascripts/discourse/controllers/create_account_controller.js:274", + "created_at": null + } + ] } ] }, @@ -52089,6 +52897,19 @@ "created_at": "2026-06-28T23:08:26Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__5b229316ee4c661836ed1161139692a3e8527444__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/5b229316ee4c661836ed1161139692a3e8527444", + "review_comments": [ + { + "path": "app/assets/stylesheets/common/foundation/mixins.scss", + "line": 126, + "body": "The new `align-items` mixin declares `-ms-align-items: $alignment;`, which is not a real CSS property (the correct IE10 'tweener' syntax property is `-ms-flex-align`, which is already declared in the same mixin). This line is a bogus/no-op declaration that will be silently ignored by all browsers, misleading anyone who assumes it provides IE cross-browser support. Location: app/assets/stylesheets/common/foundation/mixins.scss:126", + "created_at": null + } + ] } ] }, @@ -53315,6 +54136,19 @@ "created_at": "2026-06-28T23:23:17Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__267d8be1f556ed59639ced396c885bb44586da19__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/267d8be1f556ed59639ced396c885bb44586da19", + "review_comments": [ + { + "path": "app/serializers/user_serializer.rb", + "line": 140, + "body": "website_name compares URI host values directly without normalizing case: `URI(website.to_s).host` preserves whatever case the user entered (e.g. 'HTTP://Example.com' yields host 'Example.com'), while `Discourse.current_hostname` is typically lowercase. The equality/suffix checks (`website_host == discourse_host`, the stripped-label comparison, and `discourse_host.ends_with?(\".\" << website_host)`) are case-sensitive, so a website URL that is actually the same domain as the instance but differs only in case will incorrectly fall through to the 'different domain' branch and show only the bare host instead of the full path. Location: app/serializers/user_serializer.rb:140", + "created_at": null + } + ] } ] }, @@ -55740,6 +56574,37 @@ "created_at": "2026-06-28T23:03:36Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__4f8aed295a29954023b2849c060ef4fb299d1b5d__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/4f8aed295a29954023b2849c060ef4fb299d1b5d", + "review_comments": [ + { + "path": "app/models/post.rb", + "line": 131, + "body": "Post#cook now returns `raw` unmodified for posts with cook_method == raw_html, completely bypassing the markdown/sanitization pipeline (post_analyzer.cook). Combined with app/jobs/scheduled/poll_feed.rb, which stores RSS feed content (`CGI.unescapeHTML(i.content.scrub)`) directly via TopicEmbed.import with cook_method set to raw_html and no HTML sanitization, arbitrary HTML/JS from an external feed is persisted as `raw` and later rendered unescaped (e.g. `<%= raw post.cooked %>` in app/views/embed/best.html.erb). This is a stored XSS vector: content from an untrusted external feed is served to all viewers without sanitization. Location: app/models/post.rb:131", + "created_at": null + }, + { + "path": "lib/tasks/disqus.thor", + "line": 146, + "body": "lib/tasks/disqus.thor previously created imported topics with `created_at: Date.parse(t[:created_at])`, preserving the original Disqus comment/thread date. The new call `TopicEmbed.import_remote(user, t[:link], title: t[:title])` never passes a created_at, and TopicEmbed.import/import_remote have no created_at parameter, so all imported topics will now be timestamped with the current time instead of their true historical creation date, silently corrupting imported data. Location: lib/tasks/disqus.thor:146", + "created_at": null + }, + { + "path": "app/models/topic_embed.rb", + "line": 18, + "body": "TopicEmbed.import uses a check-then-act pattern (`embed = TopicEmbed.where(embed_url: url).first; ... if embed.blank? ... TopicEmbed.create!(...)`) with no locking, even though the embed_url column has a unique DB index (see db/migrate/20131217174004_create_topic_embeds.rb) and TopicRetriever's own comment acknowledges 'another process or job found the embed already'. Two concurrent imports for the same URL (e.g. PollFeed running while a direct TopicRetriever request for the same URL is in flight) can both pass the `embed.blank?` check and race to `create!`, causing an unhandled ActiveRecord::RecordNotUnique to propagate out of the job/transaction. Location: app/models/topic_embed.rb:18", + "created_at": null + }, + { + "path": "spec/jobs/poll_feed_spec.rb", + "line": 2, + "body": "The new spec requires an unrelated dependency (`jobs/regular/process_post`) instead of the job actually under test (`jobs/scheduled/poll_feed`), which is misleading and appears to be a copy-paste leftover in a newly added spec file. Location: spec/jobs/poll_feed_spec.rb:2", + "created_at": null + } + ] } ] }, @@ -57329,6 +58194,43 @@ "created_at": "2026-06-28T22:28:25Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__ffbaf8c54269df2ce510de91245760fddce09896__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/ffbaf8c54269df2ce510de91245760fddce09896", + "review_comments": [ + { + "path": "app/models/optimized_image.rb", + "line": 142, + "body": "self.downsize is defined twice back-to-back: first with signature (from, to, max_width, max_height, opts) delegating to optimize with a joined dimension string, then immediately redefined with signature (from, to, dimensions, opts). Ruby silently keeps only the second definition, making the first a dead, unreachable definition. Any existing caller elsewhere that still invokes downsize with separate width/height positional args (matching the old 5-arg signature) will now raise ArgumentError since the effective method only accepts 4 args. Location: app/models/optimized_image.rb:142", + "created_at": null + }, + { + "path": "app/assets/javascripts/discourse/lib/utilities.js", + "line": 182, + "body": "checkImageSize now hardcodes maxSizeKB to 10*1024 (10MB) instead of reading the per-type setting Discourse.SiteSettings['max_' + type + '_size_kb']. This removes the ability to enforce different limits for different upload types (e.g. attachments vs images vs avatars) and will incorrectly block valid uploads that are allowed to be larger than 10MB, or incorrectly allow files that should be limited to less than 10MB for a given type. Location: app/assets/javascripts/discourse/lib/utilities.js:182", + "created_at": null + }, + { + "path": "app/assets/javascripts/discourse/lib/utilities.js", + "line": 246, + "body": "The 413 (entity too large) error handler now hardcodes maxSizeKB to 10*1024 instead of reading Discourse.SiteSettings.max_image_size_kb, so the error message shown to the user no longer reflects the actual server-configured limit that caused the 413, producing a misleading message. Location: app/assets/javascripts/discourse/lib/utilities.js:246", + "created_at": null + }, + { + "path": "app/controllers/uploads_controller.rb", + "line": 55, + "body": "FileHelper.download's max size argument is now hardcoded to 10.megabytes instead of SiteSetting.max_image_size_kb.kilobytes, decoupling the URL-download size cap from the admin-configured max_image_size_kb used later in the same method for the downsize loop. This is inconsistent: an admin who lowers max_image_size_kb below 10MB no longer gets that limit enforced at download time, and an admin who raises it above 10MB will have downloads truncated at a lower, hardcoded value. Location: app/controllers/uploads_controller.rb:55", + "created_at": null + }, + { + "path": "app/controllers/uploads_controller.rb", + "line": 63, + "body": "After the downsize retry loop, there is no check confirming tempfile.size is actually within SiteSetting.max_image_size_kb. If OptimizedImage.downsize fails (e.g. ImageMagick error, unsupported format) or the 5 attempts at 80% still don't bring the file under the limit, the code silently proceeds to Upload.create_for with the still-oversized file, bypassing the max_image_size_kb size restriction without any error surfaced to the caller. Location: app/controllers/uploads_controller.rb:63", + "created_at": null + } + ] } ] }, @@ -58860,6 +59762,37 @@ "created_at": "2026-06-28T22:28:38Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "discourse__discourse__commit__6669a2d94d76eea3b99b8c476d12b1eb66726b07__shipwright__4701de31", + "pr_url": "https://github.com/discourse/discourse/commit/6669a2d94d76eea3b99b8c476d12b1eb66726b07", + "review_comments": [ + { + "path": "app/controllers/topics_controller.rb", + "line": 104, + "body": "`ensure_logged_in` before_filter now excludes `:unsubscribe`, but the action body immediately calls `current_user.id` (`TopicUser.find_by(user_id: current_user.id, ...)`). Since anonymous requests are now allowed to reach this action, `current_user` will be nil and this raises NoMethodError instead of gracefully handling unauthenticated access to the per-topic unsubscribe link. Location: app/controllers/topics_controller.rb:104", + "created_at": null + }, + { + "path": "app/controllers/topics_controller.rb", + "line": 106, + "body": "`tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])` can return nil if no TopicUser row exists for that user/topic. The following lines unconditionally call `tu.notification_level` and `tu.save!` with no nil check, causing a NoMethodError/500 instead of a handled response. Location: app/controllers/topics_controller.rb:106", + "created_at": null + }, + { + "path": "app/controllers/topics_controller.rb", + "line": 109, + "body": "The unsubscribe action mutates `tu.notification_level` directly and calls `tu.save!` instead of going through `TopicUser.change`, which is used by every other notification-level mutation path in this codebase to publish a `notification_level_change` MessageBus event. As a result, other open tabs/websocket clients of the same user won't be notified in real time that the subscription state changed via the unsubscribe link. Location: app/controllers/topics_controller.rb:109", + "created_at": null + }, + { + "path": "app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6", + "line": 6, + "body": "The new Ember controller property is misspelled `stopNotificiationsText` (should be `stopNotificationsText`). The typo is echoed in the template's `{{{stopNotificiationsText}}}` binding, so it still works, but it's a confusing, clearly-introduced naming defect in new public-facing code. Location: app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6:6", + "created_at": null + } + ] } ] }, @@ -60473,6 +61406,31 @@ "created_at": "2026-06-28T23:46:15Z" } ] + }, + { + "tool": "shipwright", + "repo_name": "calcom__cal.com__pull__22532__shipwright__4701de31", + "pr_url": "https://github.com/calcom/cal.com/pull/22532", + "review_comments": [ + { + "path": "packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts", + "line": 21, + "body": "deleteCache.handler.ts throws a plain `Error(\"Credential not found or access denied\")` instead of a `TRPCError` with an appropriate code (e.g. NOT_FOUND/FORBIDDEN). tRPC will wrap this as a generic INTERNAL_SERVER_ERROR, losing the correct HTTP/error semantics for an authorization failure and making it indistinguishable from unexpected server errors, unlike the rest of the tRPC handlers in this router which return typed errors. Location: packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts:21", + "created_at": null + }, + { + "path": "packages/features/apps/components/CredentialActionsDropdown.tsx", + "line": 91, + "body": "The cache status dropdown item uses `text-gray-900 dark:text-white` for the title and `text-gray-500 dark:text-white` for the 'last updated' subtitle. In dark mode both elements render as identical white text, eliminating the intended visual hierarchy between the label and the timestamp. Location: packages/features/apps/components/CredentialActionsDropdown.tsx:91", + "created_at": null + }, + { + "path": "packages/platform/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper.tsx", + "line": 70, + "body": "The actions container `