mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5040ab6fca
Adds a `diff_url:` term to the `q` search parameter on `GET /api/experimental/chats` so callers can look up the chat associated with a particular pull request, merge request, or any other URL persisted on the chat's diff status. ``` q=diff_url:"https://github.com/coder/coder/pull/123" ``` Match is case-insensitive. When the URL lives on a delegated sub-agent's diff status, the parent chat is returned so the relationship surfaces from a single lookup. <details> <summary>Design notes</summary> - **Forge-agnostic.** Reuses the existing `chat_diff_statuses.url` column rather than introducing a `pr:` vocabulary, since the SDK already documents the URL as "may point to a pull request or a branch page depending on whether a PR has been opened." Works for GitHub PRs, GitLab MRs, branch pages, etc. - **Composes with `archived:`.** The two terms can be combined: `q=archived:true diff_url:"..."`. - **Case handling.** The parser used to lowercase the entire `q` string up front, which would mangle URL path segments. Switched to lowercasing only the field key inside `searchTerms` (already happens there) and keeping the value as the caller typed it. The SQL comparison lowercases on both sides. - **Validation.** `diff_url` must be a syntactically valid HTTP(S) URL with a non-empty host. No forge-specific validation. - **Index.** Adds `idx_chat_diff_statuses_url_lower` on `LOWER(url)` so the lookup is cheap even on large datasets. - **Sub-agent fan-in.** `EXISTS` clause matches when the URL lives on the chat itself or any chat with `root_chat_id` equal to the chat's id, so a delegated sub-agent's PR pulls in its parent. - **Deferred.** Sentinels like `pr:any` / `pr:none` and a forge-agnostic state filter (`diff_state:open|merged|closed`) were intentionally left out of this change. They couple cleanly to a second forge or a clearer product call, and shipping them now would lock in vocabulary we may want to revisit. </details> ## Tests - `coderd/searchquery`: parser tests for valid URLs, case handling (key insensitive, value preserved), composition with `archived:`, and validation errors (non-HTTP scheme, missing host, malformed URL). - `coderd/exp_chats_test.go`: end-to-end coverage hitting `ListChats`. Verifies a root chat matches its own URL, a parent chat surfaces when only a sub-agent has the URL, lookups are case-insensitive, non-matching URLs return empty, and invalid URLs return `400`. --- _This PR was authored by a Coder Agent on behalf of @kylecarbs._
795 lines
29 KiB
Go
795 lines
29 KiB
Go
package searchquery
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// AuditLogs requires the database to fetch an organization by name
|
|
// to convert to organization uuid.
|
|
//
|
|
// Supported query parameters:
|
|
//
|
|
// - request_id: UUID (can be used to search for associated audits e.g. connect/disconnect or open/close)
|
|
// - resource_id: UUID
|
|
// - resource_target: string
|
|
// - username: string
|
|
// - email: string
|
|
// - date_from: string (date in format "2006-01-02")
|
|
// - date_to: string (date in format "2006-01-02")
|
|
// - organization: string (organization UUID or name)
|
|
// - resource_type: string (enum)
|
|
// - action: string (enum)
|
|
// - build_reason: string (enum)
|
|
func AuditLogs(ctx context.Context, db database.Store, query string) (database.GetAuditLogsOffsetParams,
|
|
database.CountAuditLogsParams, []codersdk.ValidationError,
|
|
) {
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
values.Add("resource_type", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
|
|
return database.GetAuditLogsOffsetParams{}, database.CountAuditLogsParams{}, errors
|
|
}
|
|
|
|
const dateLayout = "2006-01-02"
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetAuditLogsOffsetParams{
|
|
RequestID: parser.UUID(values, uuid.Nil, "request_id"),
|
|
ResourceID: parser.UUID(values, uuid.Nil, "resource_id"),
|
|
ResourceTarget: parser.String(values, "", "resource_target"),
|
|
Username: parser.String(values, "", "username"),
|
|
Email: parser.String(values, "", "email"),
|
|
DateFrom: parser.Time(values, time.Time{}, "date_from", dateLayout),
|
|
DateTo: parser.Time(values, time.Time{}, "date_to", dateLayout),
|
|
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
|
|
ResourceType: string(httpapi.ParseCustom(parser, values, "", "resource_type", httpapi.ParseEnum[database.ResourceType])),
|
|
Action: string(httpapi.ParseCustom(parser, values, "", "action", httpapi.ParseEnum[database.AuditAction])),
|
|
BuildReason: string(httpapi.ParseCustom(parser, values, "", "build_reason", httpapi.ParseEnum[database.BuildReason])),
|
|
}
|
|
if !filter.DateTo.IsZero() {
|
|
filter.DateTo = filter.DateTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
|
}
|
|
|
|
// Prepare the count filter, which uses the same parameters as the GetAuditLogsOffsetParams.
|
|
// nolint:exhaustruct // UserID and CountCap are not obtained from the query parameters.
|
|
countFilter := database.CountAuditLogsParams{
|
|
RequestID: filter.RequestID,
|
|
ResourceID: filter.ResourceID,
|
|
ResourceTarget: filter.ResourceTarget,
|
|
Username: filter.Username,
|
|
Email: filter.Email,
|
|
DateFrom: filter.DateFrom,
|
|
DateTo: filter.DateTo,
|
|
OrganizationID: filter.OrganizationID,
|
|
ResourceType: filter.ResourceType,
|
|
Action: filter.Action,
|
|
BuildReason: filter.BuildReason,
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, countFilter, parser.Errors
|
|
}
|
|
|
|
func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, database.CountConnectionLogsParams, []codersdk.ValidationError) {
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
values.Add("search", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
|
|
return database.GetConnectionLogsOffsetParams{}, database.CountConnectionLogsParams{}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetConnectionLogsOffsetParams{
|
|
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
|
|
WorkspaceOwner: parser.String(values, "", "workspace_owner"),
|
|
WorkspaceOwnerEmail: parser.String(values, "", "workspace_owner_email"),
|
|
Type: string(httpapi.ParseCustom(parser, values, "", "type", httpapi.ParseEnum[database.ConnectionType])),
|
|
Username: parser.String(values, "", "username"),
|
|
UserEmail: parser.String(values, "", "user_email"),
|
|
ConnectedAfter: parser.Time3339Nano(values, time.Time{}, "connected_after"),
|
|
ConnectedBefore: parser.Time3339Nano(values, time.Time{}, "connected_before"),
|
|
WorkspaceID: parser.UUID(values, uuid.Nil, "workspace_id"),
|
|
ConnectionID: parser.UUID(values, uuid.Nil, "connection_id"),
|
|
Status: string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[codersdk.ConnectionLogStatus])),
|
|
}
|
|
|
|
if filter.Username == "me" {
|
|
filter.UserID = apiKey.UserID
|
|
filter.Username = ""
|
|
}
|
|
|
|
if filter.WorkspaceOwner == "me" {
|
|
filter.WorkspaceOwnerID = apiKey.UserID
|
|
filter.WorkspaceOwner = ""
|
|
}
|
|
|
|
// This MUST be kept in sync with the above
|
|
// nolint:exhaustruct // CountCap is not obtained from the query parameters.
|
|
countFilter := database.CountConnectionLogsParams{
|
|
OrganizationID: filter.OrganizationID,
|
|
WorkspaceOwner: filter.WorkspaceOwner,
|
|
WorkspaceOwnerID: filter.WorkspaceOwnerID,
|
|
WorkspaceOwnerEmail: filter.WorkspaceOwnerEmail,
|
|
Type: filter.Type,
|
|
UserID: filter.UserID,
|
|
Username: filter.Username,
|
|
UserEmail: filter.UserEmail,
|
|
ConnectedAfter: filter.ConnectedAfter,
|
|
ConnectedBefore: filter.ConnectedBefore,
|
|
WorkspaceID: filter.WorkspaceID,
|
|
ConnectionID: filter.ConnectionID,
|
|
Status: filter.Status,
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
return filter, countFilter, parser.Errors
|
|
}
|
|
|
|
func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
values.Add("search", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return database.GetUsersParams{}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetUsersParams{
|
|
Search: parser.String(values, "", "search"),
|
|
Name: parser.String(values, "", "name"),
|
|
Status: httpapi.ParseCustomList(parser, values, []database.UserStatus{}, "status", httpapi.ParseEnum[database.UserStatus]),
|
|
IsServiceAccount: parser.NullableBoolean(values, sql.NullBool{}, "service_account"),
|
|
RbacRole: parser.Strings(values, []string{}, "role"),
|
|
LastSeenAfter: parser.Time3339Nano(values, time.Time{}, "last_seen_after"),
|
|
LastSeenBefore: parser.Time3339Nano(values, time.Time{}, "last_seen_before"),
|
|
CreatedAfter: parser.Time3339Nano(values, time.Time{}, "created_after"),
|
|
CreatedBefore: parser.Time3339Nano(values, time.Time{}, "created_before"),
|
|
GithubComUserID: parser.Int64(values, 0, "github_com_user_id"),
|
|
LoginType: httpapi.ParseCustomList(parser, values, []database.LoginType{}, "login_type", httpapi.ParseEnum[database.LoginType]),
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return database.OrganizationMembersParams{
|
|
OrganizationID: organizationID,
|
|
UserID: uuid.Nil,
|
|
IncludeSystem: false,
|
|
GithubUserID: 0,
|
|
}, nil
|
|
}
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
switch term {
|
|
case "user_id":
|
|
values.Set("user_id", "")
|
|
case "github_user_id":
|
|
values.Set("github_user_id", "")
|
|
case "include_system":
|
|
values.Set("include_system", "")
|
|
default:
|
|
return xerrors.Errorf("invalid search term: %s", term)
|
|
}
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return database.OrganizationMembersParams{
|
|
OrganizationID: organizationID,
|
|
UserID: uuid.Nil,
|
|
IncludeSystem: false,
|
|
GithubUserID: 0,
|
|
}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
params := database.OrganizationMembersParams{
|
|
OrganizationID: organizationID,
|
|
UserID: parser.UUID(values, uuid.Nil, "user_id"),
|
|
IncludeSystem: parser.Boolean(values, false, "include_system"),
|
|
GithubUserID: parser.Int64(values, 0, "github_user_id"),
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
|
|
return params, parser.Errors
|
|
}
|
|
|
|
func Workspaces(ctx context.Context, db database.Store, query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) {
|
|
filter := database.GetWorkspacesParams{
|
|
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),
|
|
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
Limit: int32(page.Limit),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// It is a workspace name, and maybe includes an owner
|
|
parts := splitQueryParameterByDelimiter(term, '/', false)
|
|
switch len(parts) {
|
|
case 1:
|
|
values.Add("name", parts[0])
|
|
case 2:
|
|
values.Add("owner", parts[0])
|
|
values.Add("name", parts[1])
|
|
default:
|
|
return xerrors.Errorf("Query element %q can only contain 1 '/'", term)
|
|
}
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.WorkspaceIds = parser.UUIDs(values, []uuid.UUID{}, "id")
|
|
filter.OwnerUsername = parser.String(values, "", "owner")
|
|
filter.TemplateName = parser.String(values, "", "template")
|
|
filter.Name = parser.String(values, "", "name")
|
|
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
|
|
filter.HasAgentStatuses = parser.Strings(values, []string{}, "has-agent")
|
|
filter.Dormant = parser.Boolean(values, false, "dormant")
|
|
filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after")
|
|
filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before")
|
|
filter.UsingActive = sql.NullBool{
|
|
// Invert the value of the query parameter to get the correct value.
|
|
// UsingActive returns if the workspace is on the latest template active version.
|
|
Bool: !parser.Boolean(values, true, "outdated"),
|
|
// Only include this search term if it was provided. Otherwise default to omitting it
|
|
// which will return all workspaces.
|
|
Valid: values.Has("outdated"),
|
|
}
|
|
filter.HasAITask = parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task")
|
|
filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent")
|
|
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
|
|
filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared")
|
|
// TODO: support "me" by passing in the actorID
|
|
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", uuid.Nil)
|
|
filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
|
|
// Translate healthy filter to has-agent statuses
|
|
// healthy:true = connected, healthy:false = disconnected or timeout
|
|
if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid {
|
|
if healthy.Bool {
|
|
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "connected")
|
|
} else {
|
|
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "disconnected", "timeout")
|
|
}
|
|
}
|
|
|
|
type paramMatch struct {
|
|
name string
|
|
value *string
|
|
}
|
|
// parameter matching takes the form of:
|
|
// `param:<name>[=<value>]`
|
|
// If the value is omitted, then we match on the presence of the parameter.
|
|
// If the value is provided, then we match on the parameter and value.
|
|
params := httpapi.ParseCustomList(parser, values, []paramMatch{}, "param", func(v string) (paramMatch, error) {
|
|
// Ignore excess spaces
|
|
v = strings.TrimSpace(v)
|
|
parts := strings.Split(v, "=")
|
|
if len(parts) == 1 {
|
|
// Only match on the presence of the parameter
|
|
return paramMatch{name: parts[0], value: nil}, nil
|
|
}
|
|
if len(parts) == 2 {
|
|
if parts[1] == "" {
|
|
return paramMatch{}, xerrors.Errorf("query element %q has an empty value. omit the '=' to match just on the parameter name", v)
|
|
}
|
|
// Match on the parameter and value
|
|
return paramMatch{name: parts[0], value: &parts[1]}, nil
|
|
}
|
|
return paramMatch{}, xerrors.Errorf("query element %q can only contain 1 '='", v)
|
|
})
|
|
for _, p := range params {
|
|
if p.value == nil {
|
|
filter.HasParam = append(filter.HasParam, p.name)
|
|
continue
|
|
}
|
|
filter.ParamNames = append(filter.ParamNames, p.name)
|
|
filter.ParamValues = append(filter.ParamValues, *p.value)
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) {
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// Default to the display name
|
|
values.Add("display_name", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return database.GetTemplatesWithFilterParams{}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetTemplatesWithFilterParams{
|
|
Deleted: parser.Boolean(values, false, "deleted"),
|
|
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
|
|
ExactName: parser.String(values, "", "exact_name"),
|
|
ExactDisplayName: parser.String(values, "", "exact_display_name"),
|
|
FuzzyName: parser.String(values, "", "name"),
|
|
FuzzyDisplayName: parser.String(values, "", "display_name"),
|
|
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
|
|
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
|
|
HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"),
|
|
AuthorID: parser.UUID(values, uuid.Nil, "author_id"),
|
|
AuthorUsername: parser.String(values, "", "author"),
|
|
HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"),
|
|
}
|
|
|
|
if filter.AuthorUsername == codersdk.Me {
|
|
filter.AuthorID = actorID
|
|
filter.AuthorUsername = ""
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func AIBridgeInterceptions(ctx context.Context, db database.Store, query string, page codersdk.Pagination, actorID uuid.UUID) (database.ListAIBridgeInterceptionsParams, []codersdk.ValidationError) {
|
|
// nolint:exhaustruct // Empty values just means "don't filter by that field".
|
|
filter := database.ListAIBridgeInterceptionsParams{
|
|
AfterID: page.AfterID,
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
Limit: int32(page.Limit),
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// Default to the initiating user
|
|
values.Add("initiator", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
|
|
filter.Provider = parser.String(values, "", "provider")
|
|
filter.Model = parser.String(values, "", "model")
|
|
filter.Client = parser.String(values, "", "client")
|
|
|
|
// Time must be between started_after and started_before.
|
|
filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
|
|
filter.StartedBefore = parser.Time3339Nano(values, time.Time{}, "started_before")
|
|
if !filter.StartedBefore.IsZero() && !filter.StartedAfter.IsZero() && !filter.StartedBefore.After(filter.StartedAfter) {
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: "started_before",
|
|
Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
|
|
})
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func AIBridgeSessions(ctx context.Context, db database.Store, query string, page codersdk.Pagination, actorID uuid.UUID, afterSessionID string) (database.ListAIBridgeSessionsParams, []codersdk.ValidationError) {
|
|
// nolint:exhaustruct // Empty values just means "don't filter by that field".
|
|
filter := database.ListAIBridgeSessionsParams{
|
|
AfterSessionID: afterSessionID,
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
Limit: int32(page.Limit),
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
values, errors := searchTerms(query, func(string, url.Values) error {
|
|
// Do not specify a default search key; let's be explicit to prevent user confusion.
|
|
return xerrors.New("no search key specified")
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
|
|
filter.Provider = parser.String(values, "", "provider")
|
|
filter.Model = parser.String(values, "", "model")
|
|
filter.Client = parser.String(values, "", "client")
|
|
filter.SessionID = parser.String(values, "", "session_id")
|
|
|
|
// Time must be between started_after and started_before.
|
|
filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
|
|
filter.StartedBefore = parser.Time3339Nano(values, time.Time{}, "started_before")
|
|
if !filter.StartedBefore.IsZero() && !filter.StartedAfter.IsZero() && !filter.StartedBefore.After(filter.StartedAfter) {
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: "started_before",
|
|
Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
|
|
})
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func AIBridgeModels(query string, page codersdk.Pagination) (database.ListAIBridgeModelsParams, []codersdk.ValidationError) {
|
|
// nolint:exhaustruct // Empty values just means "don't filter by that field".
|
|
filter := database.ListAIBridgeModelsParams{
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
Limit: int32(page.Limit),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// Defaults to the `model` if no `key:value` pair is provided.
|
|
values.Add("model", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.Model = parser.String(values, "", "model")
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func AIBridgeClients(query string, page codersdk.Pagination) (database.ListAIBridgeClientsParams, []codersdk.ValidationError) {
|
|
// nolint:exhaustruct // Empty values just means "don't filter by that field".
|
|
filter := database.ListAIBridgeClientsParams{
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
Limit: int32(page.Limit),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
values.Add("client", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.Client = parser.String(values, "", "client")
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
// Tasks parses a search query for tasks.
|
|
//
|
|
// Supported query parameters:
|
|
// - owner: string (username, UUID, or 'me' for current user)
|
|
// - organization: string (organization UUID or name)
|
|
// - status: string (pending, initializing, active, paused, error, unknown)
|
|
func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UUID) (database.ListTasksParams, []codersdk.ValidationError) {
|
|
filter := database.ListTasksParams{
|
|
OwnerID: uuid.Nil,
|
|
OrganizationID: uuid.Nil,
|
|
Status: "",
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// Default unqualified terms to owner
|
|
values.Add("owner", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.OwnerID = parseUser(ctx, db, parser, values, "owner", actorID)
|
|
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
|
|
filter.Status = parser.String(values, "", "status")
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
// Chats parses a search query for chats.
|
|
//
|
|
// Supported query parameters:
|
|
// - archived: boolean (default: false, excludes archived chats unless explicitly set)
|
|
// - diff_url: string (matches chats whose linked diff URL equals the
|
|
// given value, case-insensitively; URLs typically contain ':' so
|
|
// they must be quoted, e.g. q=diff_url:"https://github.com/o/r/pull/1")
|
|
func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
|
|
filter := database.GetChatsParams{
|
|
// Default to hiding archived chats.
|
|
Archived: sql.NullBool{Bool: false, Valid: true},
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
// Lowercase the keys so they match regardless of how the caller
|
|
// types them, but preserve value casing because some filters
|
|
// (e.g. diff_url) may include URL path segments where case is
|
|
// meaningful.
|
|
values, errors := searchTerms(query, func(term string, _ url.Values) error {
|
|
return xerrors.Errorf("unsupported search term: %q", term)
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.Archived = parser.NullableBoolean(values, filter.Archived, "archived")
|
|
if diffURL := parser.String(values, "", "diff_url"); diffURL != "" {
|
|
if err := validateDiffURL(diffURL); err != nil {
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: "diff_url",
|
|
Detail: err.Error(),
|
|
})
|
|
} else {
|
|
filter.DiffURL = sql.NullString{String: diffURL, Valid: true}
|
|
}
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
// validateDiffURL checks that the value is a syntactically valid HTTP(S)
|
|
// URL. The check is intentionally forge-agnostic because the diff URL on
|
|
// a chat may point to a pull request, merge request, branch page, etc.
|
|
func validateDiffURL(raw string) error {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return xerrors.Errorf("diff_url is not a valid URL: %w", err)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return xerrors.Errorf("diff_url must use http or https scheme, got %q", u.Scheme)
|
|
}
|
|
if u.Host == "" {
|
|
return xerrors.New("diff_url must include a host")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
|
|
searchValues := make(url.Values)
|
|
|
|
// Because we do this in 2 passes, we want to maintain quotes on the first
|
|
// pass. Further splitting occurs on the second pass and quotes will be
|
|
// dropped.
|
|
tokens := splitQueryParameterByDelimiter(query, ' ', true)
|
|
elements := processTokens(tokens)
|
|
for _, element := range elements {
|
|
if strings.HasPrefix(element, ":") || strings.HasSuffix(element, ":") {
|
|
return nil, []codersdk.ValidationError{
|
|
{
|
|
Field: "q",
|
|
Detail: fmt.Sprintf("Query element %q cannot start or end with ':'", element),
|
|
},
|
|
}
|
|
}
|
|
parts := splitQueryParameterByDelimiter(element, ':', false)
|
|
switch len(parts) {
|
|
case 1:
|
|
// No key:value pair. Use default behavior.
|
|
err := defaultKey(element, searchValues)
|
|
if err != nil {
|
|
return nil, []codersdk.ValidationError{
|
|
{Field: "q", Detail: err.Error()},
|
|
}
|
|
}
|
|
case 2:
|
|
searchValues.Add(strings.ToLower(parts[0]), parts[1])
|
|
default:
|
|
return nil, []codersdk.ValidationError{
|
|
{
|
|
Field: "q",
|
|
Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
return searchValues, nil
|
|
}
|
|
|
|
func parseOrganization(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
|
|
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
|
|
if v == "" {
|
|
return uuid.Nil, nil
|
|
}
|
|
organizationID, err := uuid.Parse(v)
|
|
if err == nil {
|
|
return organizationID, nil
|
|
}
|
|
organization, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
|
|
Name: v, Deleted: false,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", v)
|
|
}
|
|
return organization.ID, nil
|
|
})
|
|
}
|
|
|
|
func parseUser(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string, actorID uuid.UUID) uuid.UUID {
|
|
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
|
|
if v == "" {
|
|
return uuid.Nil, nil
|
|
}
|
|
if v == codersdk.Me && actorID != uuid.Nil {
|
|
return actorID, nil
|
|
}
|
|
userID, err := uuid.Parse(v)
|
|
if err == nil {
|
|
return userID, nil
|
|
}
|
|
user, err := db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
|
Username: v,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("user %q either does not exist, or you are unauthorized to view them", v)
|
|
}
|
|
return user.ID, nil
|
|
})
|
|
}
|
|
|
|
// Parse a group filter value into a group UUID.
|
|
// Supported formats:
|
|
// - <group-uuid>
|
|
// - <organization-name>/<group-name>
|
|
// - <group-name> (resolved in the default organization)
|
|
func parseGroup(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
|
|
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
|
|
if v == "" {
|
|
return uuid.Nil, nil
|
|
}
|
|
groupID, err := uuid.Parse(v)
|
|
if err == nil {
|
|
return groupID, nil
|
|
}
|
|
|
|
var groupName string
|
|
var org database.Organization
|
|
parts := strings.Split(v, "/")
|
|
switch len(parts) {
|
|
case 1:
|
|
dbOrg, err := db.GetDefaultOrganization(ctx)
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.New("fetching default organization")
|
|
}
|
|
org = dbOrg
|
|
groupName = parts[0]
|
|
case 2:
|
|
orgName := parts[0]
|
|
if err := codersdk.NameValid(orgName); err != nil {
|
|
return uuid.Nil, xerrors.Errorf("invalid organization name %w", err)
|
|
}
|
|
dbOrg, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
|
|
Name: orgName,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", orgName)
|
|
}
|
|
org = dbOrg
|
|
|
|
groupName = parts[1]
|
|
|
|
default:
|
|
return uuid.Nil, xerrors.New("invalid organization or group name, the filter must be in the pattern of <organization name>/<group name>")
|
|
}
|
|
|
|
if err := codersdk.GroupNameValid(groupName); err != nil {
|
|
return uuid.Nil, xerrors.Errorf("invalid group name %w", err)
|
|
}
|
|
|
|
group, err := db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{
|
|
OrganizationID: org.ID,
|
|
Name: groupName,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("group %q either does not exist, does not belong to the organization %q, or you are unauthorized to view it", groupName, org.Name)
|
|
}
|
|
return group.ID, nil
|
|
})
|
|
}
|
|
|
|
// splitQueryParameterByDelimiter takes a query string and splits it into the individual elements
|
|
// of the query. Each element is separated by a delimiter. All quoted strings are
|
|
// kept as a single element.
|
|
//
|
|
// Although all our names cannot have spaces, that is a validation error.
|
|
// We should still parse the quoted string as a single value so that validation
|
|
// can properly fail on the space. If we do not, a value of `template:"my name"`
|
|
// will search `template:"my name:name"`, which produces an empty list instead of
|
|
// an error.
|
|
// nolint:revive
|
|
func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes bool) []string {
|
|
quoted := false
|
|
parts := strings.FieldsFunc(query, func(r rune) bool {
|
|
if r == '"' {
|
|
quoted = !quoted
|
|
}
|
|
return !quoted && r == delimiter
|
|
})
|
|
if !maintainQuotes {
|
|
for i, part := range parts {
|
|
parts[i] = strings.Trim(part, "\"")
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
// processTokens takes the split tokens and groups them based on a delimiter (':').
|
|
// Tokens without a delimiter present are joined to support searching with spaces.
|
|
//
|
|
// Example Input: ['deprecated:false', 'test', 'template']
|
|
// Example Output: ['deprecated:false', 'test template']
|
|
func processTokens(tokens []string) []string {
|
|
var results []string
|
|
var nonFieldTerms []string
|
|
for _, token := range tokens {
|
|
if strings.Contains(token, string(':')) {
|
|
results = append(results, token)
|
|
} else {
|
|
nonFieldTerms = append(nonFieldTerms, token)
|
|
}
|
|
}
|
|
if len(nonFieldTerms) > 0 {
|
|
results = append(results, strings.Join(nonFieldTerms, " "))
|
|
}
|
|
return results
|
|
}
|