Feat/llm api key create#633
Conversation
Summary of ChangesHello @x22x22, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 此拉取请求旨在通过引入一个新功能,允许服务账号安全地为其项目生成专用于 LLM 调用的 API Key。这解决了之前服务账号无法创建受限用途密钥的问题,从而显著提升了系统的安全态势,实现了对 LLM 调用的最小权限控制和生命周期管理,并为运维和集成提供了标准化的接口。 Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. 📝 WalkthroughWalkthroughAdds end-to-end support for creating LLM-scoped API keys: new handler wiring, business logic to create LLM API keys, GraphQL mutation and resolver, GraphQL-specific middleware permitting service-account API keys for the createLLMAPIKey mutation, scope/privacy rule changes, and tests for service and middleware behavior. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Router as "API Router (/admin/graphql)"
participant Middleware as "WithGraphQLAuthForLLMAPIKey"
participant Auth as "AuthService"
participant APIKeySvc as "APIKeyService"
participant DB as "Database/Ent"
participant Resolver as "GraphQL Resolver (CreateLLMAPIKey)"
Client->>Router: POST /admin/graphql (createLLMAPIKey {name})
Router->>Middleware: pass request
Middleware->>Auth: try JWT auth (optional)
Auth-->>Middleware: JWT authenticated? (if yes -> inject user)
alt JWT not present/valid
Middleware->>Middleware: parse GraphQL payload
Middleware->>APIKeySvc: validate provided API key (service account + write scope)
APIKeySvc-->>Middleware: API key+project validated
Middleware->>Router: inject api key & project into context
end
Router->>Resolver: call CreateLLMAPIKey(ctx, name)
Resolver->>APIKeySvc: CreateLLMAPIKey(ctx, ownerKey, name)
APIKeySvc->>DB: generate & persist new user-scoped API key (scopes: ReadChannels, WriteRequests)
DB-->>APIKeySvc: new APIKey entity
APIKeySvc-->>Resolver: return created APIKey
Resolver-->>Client: HTTP 200 + APIKey payload
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Comment |
| return nil, ErrAPIKeyNameRequired | ||
| } | ||
|
|
||
| ctx = privacy.DecisionContext(ctx, privacy.Allow) |
b757ad9 to
f5ae8ec
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds the ability for service account API keys to create LLM-specific API keys with restricted scopes for safer distribution and usage. The feature enables service accounts with write_api_keys permission to generate limited-scope API keys (with only read_channels and write_requests scopes) for LLM calls, supporting better security practices and lifecycle management.
Changes:
- Added new API endpoint
POST /admin/llm/api-keyswith API key authentication middleware - Implemented
CreateLLMAPIKeybusiness logic with validation for service account type and required scopes - Added comprehensive test coverage for all validation scenarios and success cases
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| internal/server/routes.go | Added new route group /admin/llm with API key authentication and registered the CreateLLMAPIKey handler |
| internal/server/biz/errors.go | Defined new error constants for API key validation (owner required, service account required, scope required, name required) |
| internal/server/biz/api_key.go | Implemented CreateLLMAPIKey method with security validations and LLM-specific scope assignment |
| internal/server/biz/api_key_test.go | Added comprehensive test suite covering success case and all error scenarios (nil owner, non-service account, missing scope, empty name) |
| internal/server/api/api_key.go | Created new API handler with request/response models and proper error mapping to HTTP status codes |
| internal/server/api/fx_module.go | Registered APIKeyHandlers in the dependency injection module |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@internal/server/api/api_key.go`:
- Around line 84-86: The default branch currently returns the raw err in the 500
response (JSONError(c, http.StatusInternalServerError, err)), which can leak
internal details; change it to return a generic error message to the client
(e.g., "internal server error") while logging the original err server-side;
update the default case in the same function handling API key errors to call
JSONError with the generic message and send the detailed err to your logger (or
c.Error/internal log) instead of returning err to the response.
🧹 Nitpick comments (2)
internal/server/biz/api_key_test.go (1)
972-1064: Good test coverage for the new CreateLLMAPIKey functionality.The test suite covers:
- Successful creation with name trimming verification
- All four error conditions (nil owner, non-service account, missing scope, empty name)
- Proper field assertions including type, scopes, and ownership inheritance
Consider adding a test case for verifying the generated key format (starts with "ah-") similar to
TestAPIKeyService_CreateAPIKey_Typeat line 947-969.🧪 Optional: Add key format verification
t.Run("creates llm api key", func(t *testing.T) { apiKey, err := apiKeyService.CreateLLMAPIKey(ctx, ownerAPIKey, " LLM Key ") require.NoError(t, err) require.NotNil(t, apiKey) require.Equal(t, "LLM Key", apiKey.Name) require.Equal(t, ownerUser.ID, apiKey.UserID) require.Equal(t, ownerProject.ID, apiKey.ProjectID) require.Equal(t, apikey.TypeUser, apiKey.Type) require.ElementsMatch(t, []string{string(scopes.ScopeReadChannels), string(scopes.ScopeWriteRequests)}, apiKey.Scopes) require.NotEmpty(t, apiKey.Key) + require.True(t, len(apiKey.Key) > 3) + require.Equal(t, "ah-", apiKey.Key[:3]) })internal/server/api/api_key.go (1)
63-66: Consider returning the actual binding error for better debugging.The generic "Invalid request format" message doesn't help clients understand what's wrong with their request. The actual binding error would provide more useful feedback.
♻️ Suggested improvement
var req CreateLLMAPIKeyRequest if err := c.ShouldBindJSON(&req); err != nil { - JSONError(c, http.StatusBadRequest, errors.New("Invalid request format")) + JSONError(c, http.StatusBadRequest, err) return }
| default: | ||
| JSONError(c, http.StatusInternalServerError, err) | ||
| } |
There was a problem hiding this comment.
Avoid exposing internal error details in 500 responses.
The default case passes the raw error to the response, which might leak implementation details (database errors, internal paths, etc.) to clients.
🔒 Suggested fix
default:
- JSONError(c, http.StatusInternalServerError, err)
+ JSONError(c, http.StatusInternalServerError, biz.ErrInternal)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| default: | |
| JSONError(c, http.StatusInternalServerError, err) | |
| } | |
| default: | |
| JSONError(c, http.StatusInternalServerError, biz.ErrInternal) | |
| } |
🤖 Prompt for AI Agents
In `@internal/server/api/api_key.go` around lines 84 - 86, The default branch
currently returns the raw err in the 500 response (JSONError(c,
http.StatusInternalServerError, err)), which can leak internal details; change
it to return a generic error message to the client (e.g., "internal server
error") while logging the original err server-side; update the default case in
the same function handling API key errors to call JSONError with the generic
message and send the detailed err to your logger (or c.Error/internal log)
instead of returning err to the response.
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
|
现在的 service account 是个半成品,其实没起作用,做了一半才发现没想清楚,所以就没继续了。 然后,预期给 service account 暴露的 API 也是 graphql,完全复用 Admin 的 API,和 Admin API 一样的能力。 |
|
我思考过是否复用admin的api,发现是不行的,因为这样就可能存在越权申请apikey的情况。即我们用一个仅包含创建apikey scope的key去创建了一个更高权限的apikey。基于这个想法,我才没复用 admin的api。 我的业务场景本身其实只是为了让集成方可以在它自己的项目下创建一个仅仅可以进行大模型调用的apikey。 |
|
PermissionValidator 可以看下这个,现在是有权限校验的,不能越过 user/api key 的权限。 长期来看,应该是提供统一的 OpenAPI。 |
我们这边是期望这个 service account apikey是项目下的。而不是全局的。这样创建的llm调用apikey也就自然属于这个项目下的。这样来说对于第三方就不需要知道自己属于什么项目。 |
我先按你的要求添加一个新的 createLLMAPIKey mutation |
|
已按建议统一走 GraphQL,并确保不越权:
|
PR 说明:新增 LLM API Key 创建能力背景当前系统需要支持服务账号在不暴露主密钥的情况下,为同一项目生成用于 LLM 调用的 API Key,以便更安全地分发与使用。 没有该功能之前的问题
新增功能后如何使用
GraphQL 示例: curl -X POST "https://<your-host>/admin/graphql" \
-H "Authorization: Bearer <SERVICE_ACCOUNT_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"query":"mutation { createLLMAPIKey(name: \"llm-key-for-app\") { id projectID name key type status scopes } }"}'响应示例: {
"data": {
"createLLMAPIKey": {
"id": "gid://axonhub/APIKey/8",
"projectID": "gid://axonhub/Project/2",
"name": "llm-key-for-app",
"key": "ah-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"type": "user",
"status": "enabled",
"scopes": [
"read_channels",
"write_requests"
]
}
}
}说明: 权限约束:
对应效果
单测覆盖
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/server/biz/api_key_test.go (1)
55-77: Fix pipeline failure: missingprivacy.DecisionContextfor test setup.The pipeline failure indicates
TestAPIKeyService_GetAPIKeyfails at line 76 with "no user in context: ent/privacy: deny rule". The test setup creates entities without the privacy bypass that other tests use.🐛 Proposed fix to restore privacy bypass for entity creation
func TestAPIKeyService_GetAPIKey(t *testing.T) { // Test with noop cache (no cache configured) cacheConfig := xcache.Config{} // Empty config = noop cache apiKeyService, client := setupTestAPIKeyService(t, cacheConfig) defer client.Close() ctx := context.Background() ctx = ent.NewContext(ctx, client) + ctx = privacy.DecisionContext(ctx, privacy.Allow) // Create a test user hashedPassword, err := HashPassword("test-password")
🤖 Fix all issues with AI agents
In `@internal/server/middleware/auth.go`:
- Around line 181-197: The current loop in auth.go (iterating doc.Operations and
checking payload.OperationName, ast.Mutation and field.Name ==
"createLLMAPIKey") returns true as soon as it sees createLLMAPIKey, allowing
additional mutations in the same request; change the logic to verify the entire
request contains only that single mutation: inspect doc.Operations and their
SelectionSets to ensure there is exactly one operation (or if
payload.OperationName is set, that the named operation exists) and that this
operation is of type ast.Mutation and its SelectionSet contains exactly one
top-level field whose Name == "createLLMAPIKey"; if any other operations or
other top-level fields are present, do not authorize. Ensure checks reference
doc.Operations, payload.OperationName, ast.Mutation, SelectionSet and field.Name
== "createLLMAPIKey".
In `@internal/server/middleware/graphql_auth_test.go`:
- Around line 40-42: The test-initialized AuthService is missing SystemService
and UserService, causing tryJWTAuth -> AuthenticateJWTToken to panic when it
calls s.SystemService.SecretKey() and s.UserService.GetUserByID(); fix by
updating the test setup that creates authService to also provide simple stub
implementations for SystemService and UserService (implementing SecretKey() and
GetUserByID(...) respectively) or replace with appropriate mocks so
AuthenticateJWTToken can run without nil derefs; reference AuthService,
tryJWTAuth, AuthenticateJWTToken, SystemService.SecretKey, and
UserService.GetUserByID when locating where to add the stubs/mocks.
In `@internal/server/routes.go`:
- Line 28: The Handlers struct contains an unused field APIKey that is never
referenced by SetupRoutes; either remove the APIKey field from the Handlers
struct (and any places that construct/populate it) or keep it but add a clear
TODO comment on the APIKey field in the Handlers definition explaining its
intended future use and why it is currently unused (e.g., "TODO: reserved for
future API key routes"). Update any constructors or tests that populate
Handlers.APIKey accordingly so the code and comments remain consistent.
| for _, op := range doc.Operations { | ||
| if payload.OperationName != "" && op.Name != payload.OperationName { | ||
| continue | ||
| } | ||
| if op.Operation != ast.Mutation { | ||
| continue | ||
| } | ||
| for _, selection := range op.SelectionSet { | ||
| field, ok := selection.(*ast.Field) | ||
| if !ok { | ||
| continue | ||
| } | ||
| if field.Name == "createLLMAPIKey" { | ||
| return true, nil | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Security issue: Multiple mutations in a single request could bypass authorization.
The current logic returns true as soon as it finds createLLMAPIKey in any selection set. This allows a malicious request containing multiple mutations to pass authorization:
mutation {
createLLMAPIKey(name: "x") { id }
deleteUser(id: 1) # This would also be executed!
}The check should verify that the request contains only the createLLMAPIKey mutation and nothing else.
🔒 Proposed fix to ensure only createLLMAPIKey is allowed
func isCreateLLMAPIKeyMutation(c *gin.Context) (bool, error) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return false, err
}
if len(body) == 0 {
return false, errors.New("empty GraphQL request body")
}
defer func() {
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
}()
var payload graphQLPayload
if err := json.Unmarshal(body, &payload); err != nil {
return false, err
}
if payload.Query == "" {
return false, errors.New("missing GraphQL query")
}
doc, err := parser.ParseQuery(&ast.Source{Input: payload.Query})
if err != nil {
return false, err
}
+ foundCreateLLMAPIKey := false
for _, op := range doc.Operations {
if payload.OperationName != "" && op.Name != payload.OperationName {
continue
}
- if op.Operation != ast.Mutation {
- continue
- }
- for _, selection := range op.SelectionSet {
- field, ok := selection.(*ast.Field)
- if !ok {
- continue
- }
- if field.Name == "createLLMAPIKey" {
- return true, nil
+ // Reject queries - only mutations allowed for API key auth
+ if op.Operation == ast.Query {
+ return false, nil
+ }
+ if op.Operation == ast.Mutation {
+ for _, selection := range op.SelectionSet {
+ field, ok := selection.(*ast.Field)
+ if !ok {
+ continue
+ }
+ if field.Name == "createLLMAPIKey" {
+ foundCreateLLMAPIKey = true
+ } else {
+ // Any other mutation field disqualifies
+ return false, nil
+ }
}
}
}
- return false, nil
+ return foundCreateLLMAPIKey, nil
}🤖 Prompt for AI Agents
In `@internal/server/middleware/auth.go` around lines 181 - 197, The current loop
in auth.go (iterating doc.Operations and checking payload.OperationName,
ast.Mutation and field.Name == "createLLMAPIKey") returns true as soon as it
sees createLLMAPIKey, allowing additional mutations in the same request; change
the logic to verify the entire request contains only that single mutation:
inspect doc.Operations and their SelectionSets to ensure there is exactly one
operation (or if payload.OperationName is set, that the named operation exists)
and that this operation is of type ast.Mutation and its SelectionSet contains
exactly one top-level field whose Name == "createLLMAPIKey"; if any other
operations or other top-level fields are present, do not authorize. Ensure
checks reference doc.Operations, payload.OperationName, ast.Mutation,
SelectionSet and field.Name == "createLLMAPIKey".
| authService := &biz.AuthService{ | ||
| APIKeyService: apiKeyService, | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the test file
find . -type f -name "graphql_auth_test.go"Repository: looplj/axonhub
Length of output: 107
🏁 Script executed:
#!/bin/bash
# Examine the test file to see the setup and AuthService initialization
cat -n internal/server/middleware/graphql_auth_test.go | head -120Repository: looplj/axonhub
Length of output: 4553
🏁 Script executed:
#!/bin/bash
# Find AuthService struct definition
rg "type AuthService struct" -A 20Repository: looplj/axonhub
Length of output: 1283
🏁 Script executed:
#!/bin/bash
# Examine the middleware implementation
cat -n internal/server/middleware/graphql_auth.go | head -150Repository: looplj/axonhub
Length of output: 132
🏁 Script executed:
#!/bin/bash
# List all files in middleware directory
ls -la internal/server/middleware/Repository: looplj/axonhub
Length of output: 1311
🏁 Script executed:
#!/bin/bash
# Find the middleware implementation
rg "WithGraphQLAuthForLLMAPIKey" --type go -B 2 -A 20Repository: looplj/axonhub
Length of output: 6136
🏁 Script executed:
#!/bin/bash
# Look for auth middleware functions
rg "func.*Auth" internal/server/middleware/ --type go -A 10Repository: looplj/axonhub
Length of output: 7001
🏁 Script executed:
#!/bin/bash
# Get the complete middleware auth.go
cat -n internal/server/middleware/auth.goRepository: looplj/axonhub
Length of output: 8045
🏁 Script executed:
#!/bin/bash
# Check AuthService methods
rg "func \(\w+ \*AuthService\)" -A 10 internal/server/biz/auth.goRepository: looplj/axonhub
Length of output: 1711
🏁 Script executed:
#!/bin/bash
# Get full AuthenticateJWTToken implementation
rg "func \(s \*AuthService\) AuthenticateJWTToken" -A 35 internal/server/biz/auth.goRepository: looplj/axonhub
Length of output: 1241
🏁 Script executed:
#!/bin/bash
# Get GenerateJWTToken for reference
rg "func \(s \*AuthService\) GenerateJWTToken" -A 15 internal/server/biz/auth.goRepository: looplj/axonhub
Length of output: 632
🏁 Script executed:
#!/bin/bash
# Full AnthenticateAPIKey
rg "func \(s \*AuthService\) AnthenticateAPIKey" -A 25 internal/server/biz/auth.goRepository: looplj/axonhub
Length of output: 685
AuthService is incompletely initialized; JWT authentication will crash at runtime.
The AuthService in the test setup (lines 40-42) only initializes APIKeyService, leaving SystemService and UserService as nil. The middleware's tryJWTAuth function calls auth.AuthenticateJWTToken, which unconditionally accesses s.SystemService.SecretKey() and s.UserService.GetUserByID(), causing nil pointer dereferences if JWT authentication is attempted.
The current tests pass because they only send API key headers (not JWT tokens), avoiding the JWT auth path. Initialize the missing services or add explicit nil guards in the test to reflect the actual expected behavior.
🤖 Prompt for AI Agents
In `@internal/server/middleware/graphql_auth_test.go` around lines 40 - 42, The
test-initialized AuthService is missing SystemService and UserService, causing
tryJWTAuth -> AuthenticateJWTToken to panic when it calls
s.SystemService.SecretKey() and s.UserService.GetUserByID(); fix by updating the
test setup that creates authService to also provide simple stub implementations
for SystemService and UserService (implementing SecretKey() and GetUserByID(...)
respectively) or replace with appropriate mocks so AuthenticateJWTToken can run
without nil derefs; reference AuthService, tryJWTAuth, AuthenticateJWTToken,
SystemService.SecretKey, and UserService.GetUserByID when locating where to add
the stubs/mocks.
| Playground *api.PlaygroundHandlers | ||
| System *api.SystemHandlers | ||
| Auth *api.AuthHandlers | ||
| APIKey *api.APIKeyHandlers |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if APIKey handler is used anywhere in the codebase
echo "=== Searching for handlers.APIKey usage ==="
rg -n 'handlers\.APIKey' .
echo -e "\n=== Searching for APIKey field usage in routes.go ==="
rg -n 'APIKey' internal/server/routes.go
echo -e "\n=== Searching for APIKey references in entire internal/server directory ==="
rg -n 'APIKey' internal/server/Repository: looplj/axonhub
Length of output: 50371
🏁 Script executed:
#!/bin/bash
# View the Handlers struct and SetupRoutes function to understand APIKey field usage
echo "=== Viewing routes.go around line 28 (struct definition) ==="
sed -n '1,50p' internal/server/routes.go
echo -e "\n=== Searching for SetupRoutes function ==="
rg -n "func SetupRoutes" internal/server/routes.go
echo -e "\n=== Viewing SetupRoutes function ==="
sed -n '60,130p' internal/server/routes.goRepository: looplj/axonhub
Length of output: 4199
🏁 Script executed:
#!/bin/bash
# Check if handlers.APIKey is referenced anywhere in routes.go
echo "=== Complete search for APIKey in routes.go ==="
rg -n "APIKey" internal/server/routes.go
echo -e "\n=== Total lines in routes.go ==="
wc -l internal/server/routes.go
echo -e "\n=== Viewing end of routes.go to see if there are more routes ==="
tail -30 internal/server/routes.goRepository: looplj/axonhub
Length of output: 1337
Remove unused APIKey handler field or add a TODO comment if reserved for future use.
The APIKey field is injected into the Handlers struct but never referenced in SetupRoutes. Either remove it or document its intended purpose with a TODO comment.
🤖 Prompt for AI Agents
In `@internal/server/routes.go` at line 28, The Handlers struct contains an unused
field APIKey that is never referenced by SetupRoutes; either remove the APIKey
field from the Handlers struct (and any places that construct/populate it) or
keep it but add a clear TODO comment on the APIKey field in the Handlers
definition explaining its intended future use and why it is currently unused
(e.g., "TODO: reserved for future API key routes"). Update any constructors or
tests that populate Handlers.APIKey accordingly so the code and comments remain
consistent.
|
代码我先合并了,我会做一些调整。 |
PR 说明:新增 LLM API Key 创建能力
背景
当前系统需要支持服务账号在不暴露主密钥的情况下,为同一项目生成用于 LLM 调用的 API Key,以便更安全地分发与使用。
没有该功能之前的问题
新增功能后如何使用
createLLMAPIKey(name: String!)GraphQL 示例:
响应示例:
{ "data": { "createLLMAPIKey": { "id": "gid://axonhub/APIKey/8", "projectID": "gid://axonhub/Project/2", "name": "llm-key-for-app", "key": "ah-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "type": "user", "status": "enabled", "scopes": [ "read_channels", "write_requests" ] } } }说明:
/admin/graphql仅createLLMAPIKey支持 API key,其余 GraphQL 操作仍需 JWT。权限约束:
write:api_keys权限对应效果
read:channels与write:requests。单测覆盖
internal/server/biz/api_key_test.go(CreateLLMAPIKey 成功/失败场景)internal/server/middleware/graphql_auth_test.go(仅 createLLMAPIKey 允许 API key)Summary by CodeRabbit
New Features
Bug Fixes & Improvements
Tests
✏️ Tip: You can customize this high-level summary in your review settings.