diff --git a/assets/src/components/ai/AIPanel.tsx b/assets/src/components/ai/AIPanel.tsx index e517b569a..3d70a7579 100644 --- a/assets/src/components/ai/AIPanel.tsx +++ b/assets/src/components/ai/AIPanel.tsx @@ -20,6 +20,7 @@ const AIPanel = forwardRef( subheader, footer, children, + secondaryButton, ...props }: { open: boolean @@ -31,6 +32,7 @@ const AIPanel = forwardRef( subheader: string footer?: ReactNode children: ReactNode + secondaryButton?: ReactNode } & CardProps, ref: Ref ) => { @@ -108,13 +110,15 @@ const AIPanel = forwardRef( }, }} > - + {secondaryButton || ( + + )} {footer && footer} )} diff --git a/assets/src/components/ai/chatbot/AISuggestFix.tsx b/assets/src/components/ai/chatbot/AISuggestFix.tsx index 8f0dcde55..d475f66b7 100644 --- a/assets/src/components/ai/chatbot/AISuggestFix.tsx +++ b/assets/src/components/ai/chatbot/AISuggestFix.tsx @@ -1,18 +1,21 @@ -import { Markdown } from '@pluralsh/design-system' +import { Button, Markdown, PrOpenIcon, Toast } from '@pluralsh/design-system' import { Dispatch, ReactNode, SetStateAction, useCallback, + useEffect, useRef, useState, } from 'react' +import { useTheme } from 'styled-components' import { AiDelta, AiInsightFragment, AiRole, ChatMessage, useAiChatStreamSubscription, + useAiFixPrMutation, useAiSuggestedFixLazyQuery, } from '../../../generated/graphql.ts' import { GqlError } from '../../utils/Alert.tsx' @@ -20,6 +23,7 @@ import LoadingIndicator from '../../utils/LoadingIndicator.tsx' import AIPanel from '../AIPanel.tsx' import { AISuggestFixButton } from './AISuggestFixButton.tsx' import { ChatWithAIButton, insightMessage } from './ChatbotButton.tsx' +import { useDeploymentSettings } from 'components/contexts/DeploymentSettingsContext.tsx' interface AISuggestFixProps { insight: Nullable @@ -73,7 +77,71 @@ export function Loading({ ) } +function FixPr({ + insightId, + fix, +}: { + insightId: string + fix: string +}): ReactNode { + const [mutation, { data, loading, error }] = useAiFixPrMutation({ + variables: { insightId, messages: [{ role: AiRole.User, content: fix }] }, + }) + const theme = useTheme() + + // TEMP FIX, implement permanent solution in DS + const successToastRef = useRef(null) + const errorToastRef = useRef(null) + useEffect(() => { + if (successToastRef.current) successToastRef.current.style.zIndex = '10000' + if (errorToastRef.current) errorToastRef.current.style.zIndex = '10000' + }, [data?.aiFixPr]) + + return ( + <> + {data?.aiFixPr && ( + + + {data?.aiFixPr?.url} + + + )} + {error && ( + + {error.message} + + )} + + + ) +} + function AISuggestFix({ insight }: AISuggestFixProps): ReactNode { + const settings = useDeploymentSettings() const ref = useRef(null) const [streaming, setStreaming] = useState(false) const scrollToBottom = useCallback(() => { @@ -99,6 +167,18 @@ function AISuggestFix({ insight }: AISuggestFixProps): ReactNode { return null } + const tools = !!settings.ai?.toolsEnabled + const chatButton = ( + + ) + return (
+ tools && insight && data?.aiSuggestedFix ? ( + + ) : ( + chatButton + ) } > {data?.aiSuggestedFix && } diff --git a/assets/src/generated/graphql-kubernetes.ts b/assets/src/generated/graphql-kubernetes.ts index d1042cfb9..d29b0e287 100644 --- a/assets/src/generated/graphql-kubernetes.ts +++ b/assets/src/generated/graphql-kubernetes.ts @@ -1,4 +1,4 @@ -/* eslint-disable */ + /* prettier-ignore */ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; diff --git a/assets/src/generated/graphql-plural.ts b/assets/src/generated/graphql-plural.ts index 694f1a631..2a8b931af 100644 --- a/assets/src/generated/graphql-plural.ts +++ b/assets/src/generated/graphql-plural.ts @@ -1,4 +1,4 @@ -/* eslint-disable */ + /* prettier-ignore */ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; diff --git a/assets/src/generated/graphql.ts b/assets/src/generated/graphql.ts index 4852ef33e..72166d510 100644 --- a/assets/src/generated/graphql.ts +++ b/assets/src/generated/graphql.ts @@ -1,4 +1,4 @@ -/* eslint-disable */ + /* prettier-ignore */ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; @@ -261,6 +261,7 @@ export type AiSettings = { ollama?: Maybe; openai?: Maybe; provider?: Maybe; + toolsEnabled?: Maybe; vertex?: Maybe; }; @@ -272,6 +273,7 @@ export type AiSettingsAttributes = { ollama?: InputMaybe; openai?: InputMaybe; provider?: InputMaybe; + tools?: InputMaybe; vertex?: InputMaybe; }; @@ -1058,7 +1060,7 @@ export type Cluster = { /** an ai insight generated about issues discovered which might impact the health of this cluster */ insight?: Maybe; /** a set of kubernetes resources used to generate the ai insight for this cluster */ - insightComponents?: Maybe; + insightComponents?: Maybe>>; /** whether the deploy operator has been registered for this cluster */ installed?: Maybe; /** the url of the kas server you can access this cluster from */ @@ -1303,6 +1305,7 @@ export type ClusterInsightComponent = { cluster?: Maybe; group?: Maybe; id: Scalars['ID']['output']; + insight?: Maybe; kind: Scalars['String']['output']; name: Scalars['String']['output']; namespace?: Maybe; @@ -1888,6 +1891,11 @@ export type CostAnalysis = { totalCost?: Maybe; }; +export type CreatePrConfigAttributes = { + /** a scm connection id to use for pr automations */ + connectionId?: InputMaybe; +}; + export type CronJob = { __typename?: 'CronJob'; events?: Maybe>>; @@ -5280,6 +5288,7 @@ export type RollingUpdate = { export type RootMutationType = { __typename?: 'RootMutationType'; addRunLogs?: Maybe; + aiFixPr?: Maybe; approveBuild?: Maybe; /** approves an approval pipeline gate */ approveGate?: Maybe; @@ -5493,6 +5502,12 @@ export type RootMutationTypeAddRunLogsArgs = { }; +export type RootMutationTypeAiFixPrArgs = { + insightId: Scalars['ID']['input']; + messages?: InputMaybe>>; +}; + + export type RootMutationTypeApproveBuildArgs = { id: Scalars['ID']['input']; }; @@ -6316,6 +6331,7 @@ export type RootMutationTypeUpdatePullRequestArgs = { export type RootMutationTypeUpdateRbacArgs = { + catalogId?: InputMaybe; clusterId?: InputMaybe; pipelineId?: InputMaybe; projectId?: InputMaybe; @@ -7979,6 +7995,7 @@ export type ScmConnection = { apiUrl?: Maybe; /** base url for git clones for self-hosted versions */ baseUrl?: Maybe; + default?: Maybe; id: Scalars['ID']['output']; insertedAt?: Maybe; name: Scalars['String']['output']; @@ -7991,6 +8008,7 @@ export type ScmConnection = { export type ScmConnectionAttributes = { apiUrl?: InputMaybe; baseUrl?: InputMaybe; + default?: InputMaybe; github?: InputMaybe; name: Scalars['String']['input']; /** the owning entity in this scm provider, eg a github organization */ @@ -9110,6 +9128,10 @@ export enum Tool { Terraform = 'TERRAFORM' } +export type ToolConfigAttributes = { + createPr?: InputMaybe; +}; + /** How to enforce uniqueness for a field */ export type UniqByAttributes = { /** the scope this name is uniq w/in */ @@ -9776,6 +9798,14 @@ export type DeleteChatThreadMutationVariables = Exact<{ export type DeleteChatThreadMutation = { __typename?: 'RootMutationType', deleteThread?: { __typename?: 'ChatThread', id: string, default: boolean, summary: string, insertedAt?: string | null, updatedAt?: string | null, lastMessageAt?: string | null, chats?: { __typename?: 'ChatConnection', edges?: Array<{ __typename?: 'ChatEdge', node?: { __typename?: 'Chat', id: string, content: string, role: AiRole, seq: number, insertedAt?: string | null, updatedAt?: string | null } | null } | null> | null } | null, insight?: { __typename?: 'AiInsight', id: string, text?: string | null, summary?: string | null, sha?: string | null, freshness?: InsightFreshness | null, updatedAt?: string | null, insertedAt?: string | null, error?: Array<{ __typename?: 'ServiceError', message: string, source: string } | null> | null, cluster?: { __typename?: 'Cluster', id: string, name: string, distro?: ClusterDistro | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null, clusterInsightComponent?: { __typename?: 'ClusterInsightComponent', id: string, name: string } | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string, cluster?: { __typename?: 'Cluster', id: string, name: string, distro?: ClusterDistro | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null, serviceComponent?: { __typename?: 'ServiceComponent', id: string, name: string, service?: { __typename?: 'ServiceDeployment', id: string, name: string, cluster?: { __typename?: 'Cluster', id: string, name: string, distro?: ClusterDistro | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null } | null, stack?: { __typename?: 'InfrastructureStack', id?: string | null, name: string, type: StackType } | null, stackRun?: { __typename?: 'StackRun', id: string, message?: string | null, type: StackType, stack?: { __typename?: 'InfrastructureStack', id?: string | null, name: string } | null } | null } | null } | null }; +export type AiFixPrMutationVariables = Exact<{ + insightId: Scalars['ID']['input']; + messages?: InputMaybe> | InputMaybe>; +}>; + + +export type AiFixPrMutation = { __typename?: 'RootMutationType', aiFixPr?: { __typename?: 'PullRequest', id: string, title?: string | null, url: string, labels?: Array | null, creator?: string | null, status?: PrStatus | null, insertedAt?: string | null, updatedAt?: string | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string, protect?: boolean | null, deletedAt?: string | null } | null, cluster?: { __typename?: 'Cluster', handle?: string | null, protect?: boolean | null, deletedAt?: string | null, version?: string | null, currentVersion?: string | null, self?: boolean | null, virtual?: boolean | null, id: string, name: string, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null }; + export type AiChatStreamSubscriptionVariables = Exact<{ threadId?: InputMaybe; insightId?: InputMaybe; @@ -10455,21 +10485,21 @@ export type HttpConnectionFragment = { __typename?: 'HttpConnection', host: stri export type SmtpSettingsFragment = { __typename?: 'SmtpSettings', server: string, port: number, sender: string, user: string, ssl: boolean }; -export type AiSettingsFragment = { __typename?: 'AiSettings', enabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null }; +export type AiSettingsFragment = { __typename?: 'AiSettings', enabled?: boolean | null, toolsEnabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null }; -export type DeploymentSettingsFragment = { __typename?: 'DeploymentSettings', id: string, name: string, enabled: boolean, selfManaged?: boolean | null, insertedAt?: string | null, updatedAt?: string | null, agentHelmValues?: string | null, lokiConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, prometheusConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, artifactRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, deployerRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, smtp?: { __typename?: 'SmtpSettings', server: string, port: number, sender: string, user: string, ssl: boolean } | null, ai?: { __typename?: 'AiSettings', enabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, gitBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null }; +export type DeploymentSettingsFragment = { __typename?: 'DeploymentSettings', id: string, name: string, enabled: boolean, selfManaged?: boolean | null, insertedAt?: string | null, updatedAt?: string | null, agentHelmValues?: string | null, lokiConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, prometheusConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, artifactRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, deployerRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, smtp?: { __typename?: 'SmtpSettings', server: string, port: number, sender: string, user: string, ssl: boolean } | null, ai?: { __typename?: 'AiSettings', enabled?: boolean | null, toolsEnabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, gitBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null }; export type UpdateDeploymentSettingsMutationVariables = Exact<{ attributes: DeploymentSettingsAttributes; }>; -export type UpdateDeploymentSettingsMutation = { __typename?: 'RootMutationType', updateDeploymentSettings?: { __typename?: 'DeploymentSettings', id: string, name: string, enabled: boolean, selfManaged?: boolean | null, insertedAt?: string | null, updatedAt?: string | null, agentHelmValues?: string | null, lokiConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, prometheusConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, artifactRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, deployerRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, smtp?: { __typename?: 'SmtpSettings', server: string, port: number, sender: string, user: string, ssl: boolean } | null, ai?: { __typename?: 'AiSettings', enabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, gitBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; +export type UpdateDeploymentSettingsMutation = { __typename?: 'RootMutationType', updateDeploymentSettings?: { __typename?: 'DeploymentSettings', id: string, name: string, enabled: boolean, selfManaged?: boolean | null, insertedAt?: string | null, updatedAt?: string | null, agentHelmValues?: string | null, lokiConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, prometheusConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, artifactRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, deployerRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, smtp?: { __typename?: 'SmtpSettings', server: string, port: number, sender: string, user: string, ssl: boolean } | null, ai?: { __typename?: 'AiSettings', enabled?: boolean | null, toolsEnabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, gitBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; export type DeploymentSettingsQueryVariables = Exact<{ [key: string]: never; }>; -export type DeploymentSettingsQuery = { __typename?: 'RootQueryType', deploymentSettings?: { __typename?: 'DeploymentSettings', id: string, name: string, enabled: boolean, selfManaged?: boolean | null, insertedAt?: string | null, updatedAt?: string | null, agentHelmValues?: string | null, lokiConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, prometheusConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, artifactRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, deployerRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, smtp?: { __typename?: 'SmtpSettings', server: string, port: number, sender: string, user: string, ssl: boolean } | null, ai?: { __typename?: 'AiSettings', enabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, gitBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; +export type DeploymentSettingsQuery = { __typename?: 'RootQueryType', deploymentSettings?: { __typename?: 'DeploymentSettings', id: string, name: string, enabled: boolean, selfManaged?: boolean | null, insertedAt?: string | null, updatedAt?: string | null, agentHelmValues?: string | null, lokiConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, prometheusConnection?: { __typename?: 'HttpConnection', host: string, user?: string | null } | null, artifactRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, deployerRepository?: { __typename?: 'GitRepository', id: string, url: string, health?: GitHealth | null, authMethod?: AuthMethod | null, editable?: boolean | null, error?: string | null, insertedAt?: string | null, pulledAt?: string | null, updatedAt?: string | null, urlFormat?: string | null, httpsPath?: string | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, smtp?: { __typename?: 'SmtpSettings', server: string, port: number, sender: string, user: string, ssl: boolean } | null, ai?: { __typename?: 'AiSettings', enabled?: boolean | null, toolsEnabled?: boolean | null, provider?: AiProvider | null, anthropic?: { __typename?: 'AnthropicSettings', model?: string | null } | null, openai?: { __typename?: 'OpenaiSettings', model?: string | null } | null, azure?: { __typename?: 'AzureOpenaiSettings', apiVersion?: string | null, endpoint: string } | null, ollama?: { __typename?: 'OllamaSettings', model: string, url: string } | null, bedrock?: { __typename?: 'BedrockAiSettings', modelId: string, accessKeyId?: string | null } | null, vertex?: { __typename?: 'VertexAiSettings', model?: string | null, project: string, location: string } | null } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, gitBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; export type ObservabilityProviderFragment = { __typename?: 'ObservabilityProvider', id: string, name: string, type: ObservabilityProviderType, insertedAt?: string | null, updatedAt?: string | null }; @@ -12712,6 +12742,7 @@ export const AiSettingsFragmentDoc = gql` location } enabled + toolsEnabled provider } `; @@ -15452,6 +15483,40 @@ export function useDeleteChatThreadMutation(baseOptions?: Apollo.MutationHookOpt export type DeleteChatThreadMutationHookResult = ReturnType; export type DeleteChatThreadMutationResult = Apollo.MutationResult; export type DeleteChatThreadMutationOptions = Apollo.BaseMutationOptions; +export const AiFixPrDocument = gql` + mutation AiFixPr($insightId: ID!, $messages: [ChatMessage]) { + aiFixPr(insightId: $insightId, messages: $messages) { + ...PullRequest + } +} + ${PullRequestFragmentDoc}`; +export type AiFixPrMutationFn = Apollo.MutationFunction; + +/** + * __useAiFixPrMutation__ + * + * To run a mutation, you first call `useAiFixPrMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useAiFixPrMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [aiFixPrMutation, { data, loading, error }] = useAiFixPrMutation({ + * variables: { + * insightId: // value for 'insightId' + * messages: // value for 'messages' + * }, + * }); + */ +export function useAiFixPrMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(AiFixPrDocument, options); + } +export type AiFixPrMutationHookResult = ReturnType; +export type AiFixPrMutationResult = Apollo.MutationResult; +export type AiFixPrMutationOptions = Apollo.BaseMutationOptions; export const AiChatStreamDocument = gql` subscription AIChatStream($threadId: ID, $insightId: ID, $scopeId: String) { aiStream(threadId: $threadId, insightId: $insightId, scopeId: $scopeId) { @@ -24737,6 +24802,7 @@ export const namedOperations = { CreateChatThread: 'CreateChatThread', UpdateChatThread: 'UpdateChatThread', DeleteChatThread: 'DeleteChatThread', + AiFixPr: 'AiFixPr', CreatePrAutomation: 'CreatePrAutomation', UpdatePrAutomation: 'UpdatePrAutomation', DeletePrAutomation: 'DeletePrAutomation', diff --git a/assets/src/graph/ai.graphql b/assets/src/graph/ai.graphql index 96899e5b4..9b8915b10 100644 --- a/assets/src/graph/ai.graphql +++ b/assets/src/graph/ai.graphql @@ -235,6 +235,12 @@ mutation DeleteChatThread($id: ID!) { } } +mutation AiFixPr($insightId: ID!, $messages: [ChatMessage]) { + aiFixPr(insightId: $insightId, messages: $messages) { + ...PullRequest + } +} + subscription AIChatStream($threadId: ID, $insightId: ID, $scopeId: String) { aiStream(threadId: $threadId, insightId: $insightId, scopeId: $scopeId) { seq diff --git a/assets/src/graph/cdGlobalSettings.graphql b/assets/src/graph/cdGlobalSettings.graphql index 5414b83de..d4af231f2 100644 --- a/assets/src/graph/cdGlobalSettings.graphql +++ b/assets/src/graph/cdGlobalSettings.graphql @@ -36,6 +36,7 @@ fragment AiSettings on AiSettings { location } enabled + toolsEnabled provider } diff --git a/go/client/models_gen.go b/go/client/models_gen.go index 3a6d2dc0b..62e7f7cd0 100644 --- a/go/client/models_gen.go +++ b/go/client/models_gen.go @@ -184,18 +184,20 @@ type AiPinEdge struct { // Settings for configuring access to common LLM providers type AiSettings struct { - Enabled *bool `json:"enabled,omitempty"` - Provider *AiProvider `json:"provider,omitempty"` - Openai *OpenaiSettings `json:"openai,omitempty"` - Anthropic *AnthropicSettings `json:"anthropic,omitempty"` - Ollama *OllamaSettings `json:"ollama,omitempty"` - Azure *AzureOpenaiSettings `json:"azure,omitempty"` - Bedrock *BedrockAiSettings `json:"bedrock,omitempty"` - Vertex *VertexAiSettings `json:"vertex,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + ToolsEnabled *bool `json:"toolsEnabled,omitempty"` + Provider *AiProvider `json:"provider,omitempty"` + Openai *OpenaiSettings `json:"openai,omitempty"` + Anthropic *AnthropicSettings `json:"anthropic,omitempty"` + Ollama *OllamaSettings `json:"ollama,omitempty"` + Azure *AzureOpenaiSettings `json:"azure,omitempty"` + Bedrock *BedrockAiSettings `json:"bedrock,omitempty"` + Vertex *VertexAiSettings `json:"vertex,omitempty"` } type AiSettingsAttributes struct { Enabled *bool `json:"enabled,omitempty"` + Tools *ToolConfigAttributes `json:"tools,omitempty"` Provider *AiProvider `json:"provider,omitempty"` Openai *OpenaiSettingsAttributes `json:"openai,omitempty"` Anthropic *AnthropicSettingsAttributes `json:"anthropic,omitempty"` @@ -888,7 +890,7 @@ type Cluster struct { // an ai insight generated about issues discovered which might impact the health of this cluster Insight *AiInsight `json:"insight,omitempty"` // a set of kubernetes resources used to generate the ai insight for this cluster - InsightComponents *ClusterInsightComponent `json:"insightComponents,omitempty"` + InsightComponents []*ClusterInsightComponent `json:"insightComponents,omitempty"` // list cached nodes for a cluster, this can be stale up to 5m Nodes []*Node `json:"nodes,omitempty"` // list the cached node metrics for a cluster, can also be stale up to 5m @@ -1005,13 +1007,14 @@ type ClusterInfo struct { // A kubernetes object used in the course of generating a cluster insight type ClusterInsightComponent struct { - ID string `json:"id"` - Group *string `json:"group,omitempty"` - Version string `json:"version"` - Kind string `json:"kind"` - Namespace *string `json:"namespace,omitempty"` - Name string `json:"name"` - Cluster *Cluster `json:"cluster,omitempty"` + ID string `json:"id"` + Group *string `json:"group,omitempty"` + Version string `json:"version"` + Kind string `json:"kind"` + Namespace *string `json:"namespace,omitempty"` + Name string `json:"name"` + Cluster *Cluster `json:"cluster,omitempty"` + Insight *AiInsight `json:"insight,omitempty"` // the raw kubernetes resource itself, this is an expensive fetch and should be used sparingly Resource *KubernetesUnstructured `json:"resource,omitempty"` } @@ -1510,6 +1513,11 @@ type CostAnalysis struct { SharedCost *float64 `json:"sharedCost,omitempty"` } +type CreatePrConfigAttributes struct { + // a scm connection id to use for pr automations + ConnectionID *string `json:"connectionId,omitempty"` +} + type CronJob struct { Metadata Metadata `json:"metadata"` Status CronStatus `json:"status"` @@ -4601,6 +4609,7 @@ type ScmConnection struct { ID string `json:"id"` Name string `json:"name"` Type ScmType `json:"type"` + Default *bool `json:"default,omitempty"` Username *string `json:"username,omitempty"` // base url for git clones for self-hosted versions BaseURL *string `json:"baseUrl,omitempty"` @@ -4621,6 +4630,7 @@ type ScmConnectionAttributes struct { BaseURL *string `json:"baseUrl,omitempty"` APIURL *string `json:"apiUrl,omitempty"` Github *GithubAppAttributes `json:"github,omitempty"` + Default *bool `json:"default,omitempty"` // a ssh private key to be used for commit signing SigningPrivateKey *string `json:"signingPrivateKey,omitempty"` } @@ -5555,6 +5565,10 @@ type TerraformStateUrls struct { Unlock *string `json:"unlock,omitempty"` } +type ToolConfigAttributes struct { + CreatePr *CreatePrConfigAttributes `json:"createPr,omitempty"` +} + // How to enforce uniqueness for a field type UniqByAttributes struct { // the scope this name is uniq w/in diff --git a/lib/console.ex b/lib/console.ex index f77fdc60a..7059d1bb0 100644 --- a/lib/console.ex +++ b/lib/console.ex @@ -310,5 +310,7 @@ defmodule Console do def jitter(seconds), do: :rand.uniform(seconds * 2) - seconds + def priv_file!(name), do: Path.join([:code.priv_dir(:console), name]) |> File.read!() + def storage, do: Console.Storage.Git end diff --git a/lib/console/ai/fixer.ex b/lib/console/ai/fixer.ex index 43ab150bb..18d022d95 100644 --- a/lib/console/ai/fixer.ex +++ b/lib/console/ai/fixer.ex @@ -4,10 +4,10 @@ defmodule Console.AI.Fixer do """ use Console.Services.Base import Console.AI.Policy - alias Console.Schema.{AiInsight, Service, Stack, User} + alias Console.Schema.{AiInsight, Service, Stack, User, PullRequest} alias Console.AI.Fixer.Service, as: ServiceFixer alias Console.AI.Fixer.Stack, as: StackFixer - alias Console.AI.Provider + alias Console.AI.{Provider, Tools.Pr} @prompt """ Please provide the most straightforward code or configuration change available based on the information I've already provided above to fix this issue. @@ -15,6 +15,11 @@ defmodule Console.AI.Fixer do Be sure to explicitly state the Git repository and full file names that are needed to change, alongside the complete content of the files that need to be modified. """ + @tool """ + Please spawn a Pull Request to fix the issue described above. The code change should be the most direct + and straightforward way to fix the issue described, avoid any extraneous changes or modifying files not listed. + """ + @callback prompt(struct, binary) :: {:ok, Provider.history} | Console.error @doc """ @@ -33,6 +38,39 @@ defmodule Console.AI.Fixer do def fix(_), do: {:error, "ai fix recommendations not supported for this insight"} + @doc """ + Generate a fix recommendation from an ai insight struct + """ + @spec pr(AiInsight.t, Provider.history) :: {:ok, PullRequest.t} | Console.error + def pr(%AiInsight{service: %Service{} = svc, text: text}, history) do + pr_prompt(text, "service", history) + |> ask(@tool) + |> Provider.tool_call([Pr]) + |> handle_tool_call(%{service_id: svc.id}) + end + + def pr(%AiInsight{stack: %Stack{} = stack, text: text}, history) do + pr_prompt(text, "stack", history) + |> ask(@tool) + |> Provider.tool_call([Pr]) + |> handle_tool_call(%{stack_id: stack.id}) + end + + def pr(_, _), do: {:error, "ai fix recommendations not supported for this insight"} + + @doc """ + Spawns a pr given a fix recommendation + """ + @spec pr(binary, Provider.history, User.t) :: {:ok, PullRequest.t} | Console.error + def pr(id, history, %User{} = user) do + Console.AI.Tool.set_actor(user) + + Repo.get!(AiInsight, id) + |> Repo.preload([:service, :stack]) + |> allow(user, :read) + |> when_ok(&pr(&1, history)) + end + @doc """ Determines if a user has access to this insight, and generates a fix recommendation if so """ @@ -44,5 +82,26 @@ defmodule Console.AI.Fixer do |> when_ok(&fix/1) end - defp ask(prompt), do: prompt ++ [{:user, @prompt}] + defp handle_tool_call({:ok, [%{create_pr: %{result: pr_attrs}} | _]}, additional) do + %PullRequest{} + |> PullRequest.changeset(Map.merge(pr_attrs, additional)) + |> Repo.insert() + end + defp handle_tool_call({:ok, [%{create_pr: %{error: err}} | _]}, _), do: {:error, err} + defp handle_tool_call({:ok, msg}, _), do: {:error, msg} + defp handle_tool_call(err, _), do: err + + defp ask(prompt, task \\ @prompt), do: prompt ++ [{:user, task}] + + defp pr_prompt(insight, scope, history) when is_list(history) do + [ + {:user, """ + We've found an issue with a failing Plural #{scope}: + + #{insight} + + We've also found the appropriate fix. I'll list it below: + """} | history + ] + end end diff --git a/lib/console/ai/provider.ex b/lib/console/ai/provider.ex index 8e76787aa..3bb8d4ae9 100644 --- a/lib/console/ai/provider.ex +++ b/lib/console/ai/provider.ex @@ -1,9 +1,11 @@ defmodule Console.AI.Provider do + import Console.Services.Base, only: [ok: 1] alias Console.Schema.{DeploymentSettings, DeploymentSettings.AI} - alias Console.AI.{OpenAI, Anthropic, Ollama, Azure, Bedrock, Vertex} + alias Console.AI.{OpenAI, Anthropic, Ollama, Azure, Bedrock, Vertex, Tool} @type sender :: :system | :user | :assistant @type history :: [{sender, binary}] + @type tool_result :: [Tool.t] @preface {:system, """ You're a seasoned devops engineer with experience in Kubernetes, GitOps and Infrastructure As Code, and need to @@ -20,12 +22,32 @@ defmodule Console.AI.Provider do @callback completion(struct, history) :: {:ok, binary} | {:error, binary} + @callback tool_call(struct, history, [atom]) :: {:ok, binary | [tool_result]} | {:error, binary} + + @callback tools?() :: boolean + + def tools?() do + Console.Deployments.Settings.cached() + |> client() + |> case do + {:ok, %mod{}} -> mod.tools?() + _ -> false + end + end + def completion(history, opts \\ []) do settings = Console.Deployments.Settings.cached() with {:ok, %mod{} = client} <- client(settings), do: mod.completion(client, add_preface(history, opts)) end + def tool_call(history, tools, opts \\ []) do + settings = Console.Deployments.Settings.cached() + with {:ok, %mod{} = client} <- client(settings), + {:ok, result} <- mod.tool_call(client, add_preface(history, opts), tools), + do: handle_tool_calls(result, tools) + end + def summary(text), do: completion([{:user, text}], preface: @summary) @@ -43,6 +65,25 @@ defmodule Console.AI.Provider do do: {:ok, Vertex.new(vertex)} defp client(_), do: {:error, "ai not enabled for this Plural Console instance"} + defp handle_tool_calls([arg | _] = calls, tools) when is_map(arg) do + tools_by_name = Map.new(tools, & {"#{&1.name()}", &1}) + Enum.filter(calls, & Map.get(tools_by_name, &1.name)) + |> Enum.map(fn %Tool{name: n, arguments: args} -> + tool = tools_by_name[n] + with {:ok, struct} <- Tool.validate(tool, args), + {:ok, result} <- tool.implement(struct) do + %{tool.name() => %{result: result}} + else + {:error, res} when is_binary(res) -> %{tool.name() => %{error: res}} + {:error, [r | _] = errs} when is_binary(r) -> + %{tool.name() => %{error: Enum.join(errs, "\n")}} + err -> raise ArgumentError, message: "unknown tool error: #{inspect(err)}" + end + end) + |> ok() + end + defp handle_tool_calls(res, _) when is_binary(res), do: {:ok, res} + defp add_preface(history, opts) do case opts[:preface] do val when is_binary(val) -> [{:system, val} | history] diff --git a/lib/console/ai/provider/anthropic.ex b/lib/console/ai/provider/anthropic.ex index b1f64bd11..6e79dd29f 100644 --- a/lib/console/ai/provider/anthropic.ex +++ b/lib/console/ai/provider/anthropic.ex @@ -52,6 +52,10 @@ defmodule Console.AI.Anthropic do end end + def tool_call(_, _, _), do: {:error, "tool calling not implemented for this provider"} + + def tools?(), do: false + defp chat(%__MODULE__{access_key: token, model: model, stream: %Stream{} = stream}, history) do Stream.Exec.anthropic(fn -> {system, history} = split(history) diff --git a/lib/console/ai/provider/azure.ex b/lib/console/ai/provider/azure.ex index 65ed85b71..c4abf2590 100644 --- a/lib/console/ai/provider/azure.ex +++ b/lib/console/ai/provider/azure.ex @@ -32,4 +32,17 @@ defmodule Console.AI.Azure do |> Map.put(:model, model || OpenAI.default_model()) |> OpenAI.completion(messages) end + + @doc """ + Generate a openai completion from the azure openai credentials chain + """ + @spec tool_call(t(), Console.AI.Provider.history, [atom]) :: {:ok, binary} | {:ok, [Console.AI.Tool.t]} | Console.error + def tool_call(%__MODULE__{api_version: vsn, model: model} = azure, messages, tools) do + OpenAI.new(azure) + |> Map.put(:params, %{"api-version" => vsn || @api_vsn}) + |> Map.put(:model, model || OpenAI.default_model()) + |> OpenAI.tool_call(messages, tools) + end + + def tools?(), do: true end diff --git a/lib/console/ai/provider/bedrock.ex b/lib/console/ai/provider/bedrock.ex index 4bb81d046..ec59075d5 100644 --- a/lib/console/ai/provider/bedrock.ex +++ b/lib/console/ai/provider/bedrock.ex @@ -36,6 +36,10 @@ defmodule Console.AI.Bedrock do |> handle_response() end + def tool_call(_, _, _), do: {:error, "tool calling not implemented for this provider"} + + def tools?(), do: false + defp build_req([{:system, system} | rest]) do %{ system: [%{text: system}], diff --git a/lib/console/ai/provider/ollama.ex b/lib/console/ai/provider/ollama.ex index a9e9bd375..cfd1dd22e 100644 --- a/lib/console/ai/provider/ollama.ex +++ b/lib/console/ai/provider/ollama.ex @@ -48,6 +48,10 @@ defmodule Console.AI.Ollama do end end + def tool_call(_, _, _), do: {:error, "tool calling not implemented for this provider"} + + def tools?(), do: false + defp chat(%__MODULE__{url: url, model: model} = ollama, history) do body = Jason.encode!(%{ model: model, diff --git a/lib/console/ai/provider/openai.ex b/lib/console/ai/provider/openai.ex index a8eb02006..6b6865329 100644 --- a/lib/console/ai/provider/openai.ex +++ b/lib/console/ai/provider/openai.ex @@ -18,10 +18,19 @@ defmodule Console.AI.OpenAI do @options [recv_timeout: :infinity, timeout: :infinity] - defmodule Message do + defmodule ToolCall do @type t :: %__MODULE__{} - defstruct [:role, :content, :name] + defstruct [:id, :type, :function] + end + + defmodule Message do + alias Console.AI.OpenAI + @type t :: %__MODULE__{tool_calls: [OpenAI.ToolCall.t]} + + defstruct [:role, :content, :name, :tool_calls] + + def spec(), do: %__MODULE__{tool_calls: [%OpenAI.ToolCall{}]} end defmodule Choice do @@ -31,7 +40,7 @@ defmodule Console.AI.OpenAI do defstruct [:text, :index, :logprobs, :message] - def spec(), do: %__MODULE__{message: %OpenAI.Message{}} + def spec(), do: %__MODULE__{message: OpenAI.Message.spec()} end defmodule CompletionResponse do @@ -54,7 +63,7 @@ defmodule Console.AI.OpenAI do end @doc """ - Generate a openai completion from + Generate a openai completion """ @spec completion(t(), Console.AI.Provider.history) :: {:ok, binary} | Console.error def completion(%__MODULE__{} = openai, messages) do @@ -68,6 +77,25 @@ defmodule Console.AI.OpenAI do end end + @doc """ + Calls an openai tool call interface w/ strict mode + """ + @spec tool_call(t(), Console.AI.Provider.history, [atom]) :: {:ok, binary} | {:ok, [Console.AI.Tool.t]} | Console.error + def tool_call(%__MODULE__{} = openai, messages, tools) do + history = Enum.map(messages, fn {role, msg} -> %{role: role, content: msg} end) + case chat(%{openai | stream: nil}, history, tools) do + {:ok, %CompletionResponse{choices: [%Choice{message: %Message{tool_calls: [_ | _] = calls}} | _]}} -> + {:ok, gen_tools(calls)} + {:ok, %CompletionResponse{choices: [%Choice{message: %Message{content: content}} | _]}} -> + {:ok, content} + {:ok, content} when is_binary(content) -> {:ok, content} + {:ok, _} -> {:error, "could not generate an ai completion for this context"} + error -> error + end + end + + def tools?(), do: true + defp chat(%__MODULE__{access_key: token, model: model, stream: %Stream{} = stream} = openai, history) do Stream.Exec.openai(fn -> body = Jason.encode!(%{ @@ -81,11 +109,12 @@ defmodule Console.AI.OpenAI do end, stream) end - defp chat(%__MODULE__{access_key: token, model: model} = openai, history) do - body = Jason.encode!(%{ + defp chat(%__MODULE__{access_key: token, model: model} = openai, history, tools \\ nil) do + body = Console.drop_nils(%{ model: model || "gpt-4o-mini", messages: history, - }) + tools: tools && Enum.map(tools, &tool_args(&1)), + }) |> Jason.encode!() url(openai, "/chat/completions") |> HTTPoison.post(body, json_headers(token), @options) @@ -109,4 +138,24 @@ defmodule Console.AI.OpenAI do defp json_headers(token), do: headers([{"Content-Type", "application/json"}], token) defp headers(headers, token), do: [{"Authorization", "Bearer #{token}"} | headers] + + defp gen_tools(calls) do + Enum.map(calls, fn + %ToolCall{function: %{"name" => n, "arguments" => args}} -> + %Console.AI.Tool{name: n, arguments: Jason.decode!(args)} + _ -> nil + end) + |> Enum.filter(& &1) + end + + defp tool_args(tool) do + %{ + type: :function, + function: %{ + name: tool.name(), + description: tool.description(), + parameters: tool.json_schema() + } + } + end end diff --git a/lib/console/ai/provider/vertex.ex b/lib/console/ai/provider/vertex.ex index 0bb360ab3..5b3cbafe4 100644 --- a/lib/console/ai/provider/vertex.ex +++ b/lib/console/ai/provider/vertex.ex @@ -35,6 +35,19 @@ defmodule Console.AI.Vertex do end end + @doc """ + Generate a openai completion from the azure openai credentials chain + """ + @spec tool_call(t(), Console.AI.Provider.history, [atom]) :: {:ok, binary} | {:ok, [Console.AI.Tool.t]} | Console.error + def tool_call(%__MODULE__{} = vertex, messages, tools) do + with {:ok, %{token: token}} <- client(vertex) do + OpenAI.new(base_url: openai_url(vertex), access_token: token, model: openai_model(vertex)) + |> OpenAI.tool_call(messages, tools) + end + end + + def tools?(), do: true + defp openai_url(%__MODULE__{project: p, location: l} = c), do: "https://#{l}-aiplatform.googleapis.com/v1beta1/projects/#{p}/locations/#{l}/endpoints/#{ep(c)}" diff --git a/lib/console/ai/tool.ex b/lib/console/ai/tool.ex new file mode 100644 index 000000000..770bb2849 --- /dev/null +++ b/lib/console/ai/tool.ex @@ -0,0 +1,31 @@ +defmodule Console.AI.Tool do + alias Console.Schema.{DeploymentSettings, User} + alias Console.Deployments.{Git, Settings} + + @type t :: %__MODULE__{} + + defstruct [:name, :arguments] + + @callback json_schema() :: map + @callback name() :: atom + @callback description() :: binary + @callback changeset(struct, map) :: Ecto.Changeset.t + @callback implement(struct) :: {:ok, term} | Console.error + + def set_actor(%User{} = user), do: Process.put({__MODULE__, :actor}, user) + + def actor(), do: Process.get({__MODULE__, :actor}) + + def validate(tool, input) do + tool.changeset(struct(tool, %{}), input) + |> Ecto.Changeset.apply_action(:update) + end + + def scm_connection() do + case Settings.cached() do + %DeploymentSettings{ai: %{tools: %{create_pr: %{connection_id: id}}}} when is_binary(id) -> + Git.get_scm_connection(id) + _ -> Git.default_scm_connection() + end + end +end diff --git a/lib/console/ai/tools/pr.ex b/lib/console/ai/tools/pr.ex new file mode 100644 index 000000000..571a342bc --- /dev/null +++ b/lib/console/ai/tools/pr.ex @@ -0,0 +1,75 @@ +defmodule Console.AI.Tools.Pr do + use Ecto.Schema + import Ecto.Changeset + import Console.Deployments.Pr.Git + alias Console.Schema.{PrAutomation, ScmConnection} + alias Console.AI.Tool + alias Console.Deployments.Pr.Dispatcher + + embedded_schema do + field :repo_url, :string + field :branch_name, :string + field :commit_message, :string + field :pr_title, :string + field :pr_description, :string + + embeds_many :file_updates, FileUpdate, on_replace: :delete do + field :file_name, :string + field :content, :string + end + end + + @valid ~w(repo_url branch_name commit_message pr_title pr_description)a + + def changeset(model, attrs) do + model + |> cast(attrs, @valid) + |> cast_embed(:file_updates, with: &file_update_changeset/2) + |> validate_required(@valid) + end + + @json_schema Console.priv_file!("tools/pr.json") |> Jason.decode!() + + def json_schema(), do: @json_schema + def name(), do: :create_pr + def description(), do: "Creates a pull request or merge request against a configured Source Control Management provider" + + def implement(%__MODULE__{repo_url: url, branch_name: branch, commit_message: msg} = pr) do + with {:conn, %ScmConnection{} = conn} <- {:conn, Tool.scm_connection()}, + conn <- %{conn | author: Tool.actor()}, + url = to_http(conn, url), + {:ok, %ScmConnection{dir: d} = conn} <- setup(conn, url, branch), + :ok <- file_updates(pr, d), + {:ok, _} <- commit(conn, msg), + {:ok, _} <- push(conn, branch), + {:ok, identifier} <- slug(conn, url), + impl <- Dispatcher.dispatcher(conn) do + impl.create(%PrAutomation{ + connection: conn, + title: pr.pr_title, + message: pr.pr_description, + identifier: identifier, + }, branch, %{}) + else + {:conn, _} -> {:error, "no scm connection configured for AI yet"} + err -> err + end + end + + defp file_updates(%__MODULE__{file_updates: [_ | _] = updates}, dir) do + Enum.reduce_while(updates, :ok, fn %{file_name: f, content: c}, _ -> + case File.write(Path.join(dir, f), c) do + :ok -> {:cont, :ok} + {:error, err} -> + {:halt, {:error, "failed to write file #{f}, reason: #{inspect(err)}"}} + end + end) + end + defp file_updates(_, _), do: {:error, "no updates defined"} + + defp file_update_changeset(model, attrs) do + model + |> cast(attrs, ~w(file_name content)a) + |> validate_required(~w(file_name content)a) + end +end diff --git a/lib/console/deployments/git.ex b/lib/console/deployments/git.ex index fb6f1ab89..21e193577 100644 --- a/lib/console/deployments/git.ex +++ b/lib/console/deployments/git.ex @@ -50,6 +50,11 @@ defmodule Console.Deployments.Git do def get_scm_connection(id), do: Repo.get(ScmConnection, id) def get_scm_connection!(id), do: Repo.get!(ScmConnection, id) + def default_scm_connection() do + ScmConnection.default() + |> Repo.one() + end + def get_scm_connection_by_name(name), do: Repo.get_by(ScmConnection, name: name) @decorate cacheable(cache: @cache, key: {:scm_webhook, id}, opts: [ttl: @ttl]) @@ -417,7 +422,7 @@ defmodule Console.Deployments.Git do @spec upsert_catalog(map, User.t) :: catalog_resp def upsert_catalog(%{name: name} = attrs, %User{} = user) do case get_catalog_by_name(name) do - %Catalog{} = catalog -> Repo.preload(catalog, [:read_bindings, :write_bindings]) + %Catalog{} = catalog -> Repo.preload(catalog, [:read_bindings, :write_bindings, :create_bindings]) nil -> %Catalog{project_id: attrs[:project_id] || Settings.default_project!().id} end |> allow(user, :write) @@ -435,6 +440,18 @@ defmodule Console.Deployments.Git do |> when_ok(:delete) end + @doc """ + modifies rbac settings for this stack + """ + @spec catalog_rbac(map, binary, User.t) :: catalog_resp + def catalog_rbac(attrs, cat_id, %User{} = user) do + get_catalog!(cat_id) + |> Repo.preload([:read_bindings, :write_bindings, :create_bindings]) + |> allow(user, :write) + |> when_ok(&Catalog.rbac_changeset(&1, attrs)) + |> when_ok(:update) + end + @doc """ Upserts a new observer which can poll external registries and perform configurable actions as a result. diff --git a/lib/console/deployments/pr/dispatcher.ex b/lib/console/deployments/pr/dispatcher.ex index b9f92dd98..9c526cc35 100644 --- a/lib/console/deployments/pr/dispatcher.ex +++ b/lib/console/deployments/pr/dispatcher.ex @@ -29,6 +29,7 @@ defmodule Console.Deployments.Pr.Dispatcher do """ @callback review(conn :: ScmConnection.t, pr :: PullRequest.t, message :: binary) :: {:ok, binary} | Console.error + @doc """ Fully creates a pr against the working dispatcher implementation """ @@ -71,7 +72,7 @@ defmodule Console.Deployments.Pr.Dispatcher do end defp external_git(_), do: {:ok, nil} - defp dispatcher(%{type: :github}), do: Github - defp dispatcher(%{type: :gitlab}), do: Gitlab - defp dispatcher(%{type: :bitbucket}), do: BitBucket + def dispatcher(%{type: :github}), do: Github + def dispatcher(%{type: :gitlab}), do: Gitlab + def dispatcher(%{type: :bitbucket}), do: BitBucket end diff --git a/lib/console/deployments/pr/git.ex b/lib/console/deployments/pr/git.ex index 293f67639..bbc425025 100644 --- a/lib/console/deployments/pr/git.ex +++ b/lib/console/deployments/pr/git.ex @@ -38,6 +38,22 @@ defmodule Console.Deployments.Pr.Git do end end + def slug(%ScmConnection{} = conn, url) do + case Regex.scan(~r[^#{url(conn)}/(.*)\.git$], url) do + [[_, slug] | _] -> {:ok, slug} + _ -> {:error, "could not determine repo slug from #{url}"} + end + end + + def to_http(conn, "ssh://" <> rest), do: to_http(conn, rest) + def to_http(%ScmConnection{} = conn, "git@" <> _ = url) do + case String.split(url, ":") do + [_ | rest] -> Path.join(url(conn), Enum.join(rest, "")) + _ -> url + end + end + def to_http(_, "https://" <> _ = url), do: url + defp backfill_token(%ScmConnection{api_url: api_url, base_url: url, github: %{app_id: app_id, installation_id: inst_id, private_key: pk}} = conn) when is_binary(pk) do with {:ok, token} <- Github.app_token(api_url || url, app_id, inst_id, pk), do: {:ok, %{conn | token: token}} @@ -45,6 +61,12 @@ defmodule Console.Deployments.Pr.Git do defp backfill_token(%ScmConnection{} = conn), do: {:ok, conn} defp url(%ScmConnection{username: nil} = conn, id), do: url(%{conn | username: "apikey"}, id) + + defp url(%ScmConnection{username: username}, "https://" <> _ = url) do + uri = URI.parse(url) + URI.to_string(%{uri | userinfo: username}) + end + defp url(%ScmConnection{username: username} = conn, id) do base = url(conn) uri = URI.parse("#{base}/#{id}.git") diff --git a/lib/console/graphql/ai.ex b/lib/console/graphql/ai.ex index 9849f2b55..f50f12995 100644 --- a/lib/console/graphql/ai.ex +++ b/lib/console/graphql/ai.ex @@ -108,7 +108,8 @@ defmodule Console.GraphQl.AI do field :namespace, :string field :name, non_null(:string) - field :cluster, :cluster, resolve: dataloader(Deployments) + field :cluster, :cluster, resolve: dataloader(Deployments) + field :insight, :ai_insight, resolve: dataloader(Deployments) @desc "the raw kubernetes resource itself, this is an expensive fetch and should be used sparingly" field :resource, :kubernetes_unstructured do @@ -260,6 +261,14 @@ defmodule Console.GraphQl.AI do resolve &AI.delete_pin/2 end + + field :ai_fix_pr, :pull_request do + middleware Authenticated + arg :insight_id, non_null(:id) + arg :messages, list_of(:chat_message) + + resolve &AI.fix_pr/2 + end end object :ai_subscriptions do diff --git a/lib/console/graphql/deployments.ex b/lib/console/graphql/deployments.ex index d7a17eb09..790bdab98 100644 --- a/lib/console/graphql/deployments.ex +++ b/lib/console/graphql/deployments.ex @@ -98,6 +98,7 @@ arg :pipeline_id, :id arg :stack_id, :id arg :project_id, :id + arg :catalog_id, :id safe_resolve &Deployments.rbac/2 end diff --git a/lib/console/graphql/deployments/cluster.ex b/lib/console/graphql/deployments/cluster.ex index 2f04c0ef8..3c61ed0ed 100644 --- a/lib/console/graphql/deployments/cluster.ex +++ b/lib/console/graphql/deployments/cluster.ex @@ -335,7 +335,7 @@ defmodule Console.GraphQl.Deployments.Cluster do field :parent_cluster, :cluster, resolve: dataloader(Deployments), description: "the parent of this virtual cluster" field :insight, :ai_insight, resolve: dataloader(Deployments), description: "an ai insight generated about issues discovered which might impact the health of this cluster" - field :insight_components, :cluster_insight_component, resolve: dataloader(Deployments), description: "a set of kubernetes resources used to generate the ai insight for this cluster" + field :insight_components, list_of(:cluster_insight_component), resolve: dataloader(Deployments), description: "a set of kubernetes resources used to generate the ai insight for this cluster" field :nodes, list_of(:node), description: "list cached nodes for a cluster, this can be stale up to 5m", resolve: &Deployments.list_nodes/3 diff --git a/lib/console/graphql/deployments/git.ex b/lib/console/graphql/deployments/git.ex index 7200cff0e..aadb87d17 100644 --- a/lib/console/graphql/deployments/git.ex +++ b/lib/console/graphql/deployments/git.ex @@ -105,6 +105,7 @@ defmodule Console.GraphQl.Deployments.Git do field :base_url, :string field :api_url, :string field :github, :github_app_attributes + field :default, :boolean field :signing_private_key, :string, description: "a ssh private key to be used for commit signing" end @@ -420,6 +421,7 @@ defmodule Console.GraphQl.Deployments.Git do field :id, non_null(:id) field :name, non_null(:string) field :type, non_null(:scm_type) + field :default, :boolean field :username, :string field :base_url, :string, description: "base url for git clones for self-hosted versions" field :api_url, :string, description: "base url for HTTP apis for self-hosted versions if different from base url" diff --git a/lib/console/graphql/deployments/settings.ex b/lib/console/graphql/deployments/settings.ex index b8cc0ab9d..1f3bb945a 100644 --- a/lib/console/graphql/deployments/settings.ex +++ b/lib/console/graphql/deployments/settings.ex @@ -43,6 +43,7 @@ defmodule Console.GraphQl.Deployments.Settings do input_object :ai_settings_attributes do field :enabled, :boolean + field :tools, :tool_config_attributes field :provider, :ai_provider field :openai, :openai_settings_attributes field :anthropic, :anthropic_settings_attributes @@ -52,6 +53,14 @@ defmodule Console.GraphQl.Deployments.Settings do field :vertex, :vertex_ai_attributes end + input_object :tool_config_attributes do + field :create_pr, :create_pr_config_attributes + end + + input_object :create_pr_config_attributes do + field :connection_id, :id, description: "a scm connection id to use for pr automations" + end + input_object :openai_settings_attributes do field :base_url, :string field :access_token, :string @@ -172,14 +181,15 @@ defmodule Console.GraphQl.Deployments.Settings do @desc "Settings for configuring access to common LLM providers" object :ai_settings do - field :enabled, :boolean - field :provider, :ai_provider - field :openai, :openai_settings - field :anthropic, :anthropic_settings - field :ollama, :ollama_settings - field :azure, :azure_openai_settings - field :bedrock, :bedrock_ai_settings - field :vertex, :vertex_ai_settings + field :enabled, :boolean + field :tools_enabled, :boolean, resolve: fn _, _, _ -> {:ok, Console.AI.Provider.tools?()} end + field :provider, :ai_provider + field :openai, :openai_settings + field :anthropic, :anthropic_settings + field :ollama, :ollama_settings + field :azure, :azure_openai_settings + field :bedrock, :bedrock_ai_settings + field :vertex, :vertex_ai_settings end @desc "OpenAI connection information" diff --git a/lib/console/graphql/resolvers/ai.ex b/lib/console/graphql/resolvers/ai.ex index 6ee8cc2ea..10dbb012b 100644 --- a/lib/console/graphql/resolvers/ai.ex +++ b/lib/console/graphql/resolvers/ai.ex @@ -85,6 +85,10 @@ defmodule Console.GraphQl.Resolvers.AI do Fixer.fix(id, user) end + def fix_pr(%{insight_id: id, messages: chat}, %{context: %{current_user: user}}) do + Fixer.pr(id, Enum.map(chat || [], fn %{role: r, content: c} -> {r, c} end), user) + end + def save_chats(%{messages: msgs} = args, %{context: %{current_user: user}}), do: ChatSvc.save(msgs, args[:thread_id], user) diff --git a/lib/console/graphql/resolvers/deployments.ex b/lib/console/graphql/resolvers/deployments.ex index cbbfc02c6..fbbd570c5 100644 --- a/lib/console/graphql/resolvers/deployments.ex +++ b/lib/console/graphql/resolvers/deployments.ex @@ -2,7 +2,7 @@ defmodule Console.GraphQl.Resolvers.Deployments do use Console.GraphQl.Resolvers.Base, model: Console.Schema.Cluster import Console.Deployments.Policies, only: [allow: 3] import Console.GraphQl.Resolvers.Deployments.Base - alias Console.Deployments.{Clusters, Services, Pipelines, AddOns, Stacks, Settings} + alias Console.Deployments.{Clusters, Services, Pipelines, AddOns, Stacks, Settings, Git} alias Console.Schema.{ Cluster, ClusterNodePool, @@ -207,5 +207,6 @@ defmodule Console.GraphQl.Resolvers.Deployments do defp rbac_args(%{service_id: id}) when is_binary(id), do: {&Services.rbac/3, id} defp rbac_args(%{pipeline_id: id}) when is_binary(id), do: {&Pipelines.rbac/3, id} defp rbac_args(%{stack_id: id}) when is_binary(id), do: {&Stacks.rbac/3, id} + defp rbac_args(%{catalog_id: id}) when is_binary(id), do: {&Git.catalog_rbac/3, id} defp rbac_args(%{project_id: id}) when is_binary(id), do: {&Settings.project_rbac/3, id} end diff --git a/lib/console/schema/catalog.ex b/lib/console/schema/catalog.ex index 3b375b74c..5a6fc74a3 100644 --- a/lib/console/schema/catalog.ex +++ b/lib/console/schema/catalog.ex @@ -56,4 +56,12 @@ defmodule Console.Schema.Catalog do |> put_new_change(:read_policy_id, &Ecto.UUID.generate/0) |> put_new_change(:create_policy_id, &Ecto.UUID.generate/0) end + + def rbac_changeset(model, attrs \\ %{}) do + model + |> cast(attrs, []) + |> cast_assoc(:read_bindings) + |> cast_assoc(:write_bindings) + |> cast_assoc(:create_bindings) + end end diff --git a/lib/console/schema/deployment_settings.ex b/lib/console/schema/deployment_settings.ex index 8fb5d0648..a913ddc4e 100644 --- a/lib/console/schema/deployment_settings.ex +++ b/lib/console/schema/deployment_settings.ex @@ -58,6 +58,12 @@ defmodule Console.Schema.DeploymentSettings do field :enabled, :boolean, default: false field :provider, AIProvider, default: :openai + embeds_one :tools, ToolsConfig, on_replace: :update do + embeds_one :create_pr, PrToolConfig, on_replace: :update do + field :connection_id, :string + end + end + embeds_one :openai, OpenAi, on_replace: :update do field :base_url, :string field :access_token, EncryptedString @@ -161,6 +167,7 @@ defmodule Console.Schema.DeploymentSettings do defp ai_changeset(model, attrs) do model |> cast(attrs, ~w(enabled provider)a) + |> cast_embed(:tools, with: &tool_config_changeset/2) |> cast_embed(:openai, with: &ai_api_changeset/2) |> cast_embed(:anthropic, with: &ai_api_changeset/2) |> cast_embed(:ollama, with: &ollama_changeset/2) @@ -203,4 +210,16 @@ defmodule Console.Schema.DeploymentSettings do end end) end + + defp tool_config_changeset(model, attrs) do + model + |> cast(attrs, []) + |> cast_embed(:create_pr, with: &create_pr_changeset/2) + end + + defp create_pr_changeset(model, attrs) do + model + |> cast(attrs, [:connection_id]) + |> validate_required([:connection_id]) + end end diff --git a/lib/console/schema/scm_connection.ex b/lib/console/schema/scm_connection.ex index 4f584ca57..f5bc39714 100644 --- a/lib/console/schema/scm_connection.ex +++ b/lib/console/schema/scm_connection.ex @@ -7,6 +7,7 @@ defmodule Console.Schema.ScmConnection do schema "scm_connections" do field :name, :string + field :default, :boolean field :type, Type field :base_url, :string field :api_url, :string @@ -27,17 +28,20 @@ defmodule Console.Schema.ScmConnection do timestamps() end + def default(query \\ __MODULE__), do: from(scm in query, where: scm.default) + def ordered(query \\ __MODULE__, order \\ [asc: :name]) do from(scm in query, order_by: ^order) end - @valid ~w(name type base_url api_url username token signing_private_key)a + @valid ~w(name default type base_url api_url username token signing_private_key)a def changeset(model, attrs \\ %{}) do model |> cast(attrs, @valid) |> cast_embed(:github, with: &github_changeset/2) |> unique_constraint(:name) + |> unique_constraint(:default, message: "only one scm connection can be marked default at once") |> validate_required([:name, :type]) |> validate_private_key(:signing_private_key) end diff --git a/priv/repo/migrations/20241202210945_add_scm_connection_default.exs b/priv/repo/migrations/20241202210945_add_scm_connection_default.exs new file mode 100644 index 000000000..f6500775e --- /dev/null +++ b/priv/repo/migrations/20241202210945_add_scm_connection_default.exs @@ -0,0 +1,11 @@ +defmodule Console.Repo.Migrations.AddScmConnectionDefault do + use Ecto.Migration + + def change do + alter table(:scm_connections) do + add :default, :boolean, default: false + end + + create unique_index(:scm_connections, :default, where: "\"default\"") + end +end diff --git a/priv/tools/pr.json b/priv/tools/pr.json new file mode 100644 index 000000000..4b5b7533d --- /dev/null +++ b/priv/tools/pr.json @@ -0,0 +1,46 @@ +{ + "type": "object", + "properties": { + "repo_url": { + "type": "string", + "description": "The HTTPS url for the git repository you'll create a PR in" + }, + "branch_name": { + "type": "string", + "description": "A reasonable and concise branch name for this PR" + }, + "commit_message": { + "type": "string", + "description": "The commit message to use for this PR" + }, + "pr_title": { + "type": "string", + "description": "The title for this PR" + }, + "pr_description": { + "type": "string", + "description": "A longer-form description body for this PR, should allow users to understand the context and implications of the change" + }, + "file_updates": { + "type": "array", + "description": "A list of files to update in this PR", + "items": { + "type": "object", + "properties": { + "file_name": { + "description": "the relative file path name for this update", + "type": "string" + }, + "content": { + "description": "the full file content for this file", + "type": "string" + } + }, + "required": ["file_name", "content"], + "additionalProperties": false + } + } + }, + "required": ["repo_url", "branch_name", "file_updates", "commit_message", "pr_body", "pr_description"], + "additionalProperties": false +} \ No newline at end of file diff --git a/schema/schema.graphql b/schema/schema.graphql index 254ad578d..d704c6756 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -910,7 +910,7 @@ type RootMutationType { "a reusable mutation for updating rbac settings on core services" updateRbac( - rbac: RbacAttributes!, serviceId: ID, clusterId: ID, providerId: ID, pipelineId: ID, stackId: ID, projectId: ID + rbac: RbacAttributes!, serviceId: ID, clusterId: ID, providerId: ID, pipelineId: ID, stackId: ID, projectId: ID, catalogId: ID ): Boolean "saves a list of chat messages to your current chat history, can be used at any time" @@ -939,6 +939,8 @@ type RootMutationType { createPin(attributes: AiPinAttributes!): AiPin deletePin(id: ID!): AiPin + + aiFixPr(insightId: ID!, messages: [ChatMessage]): PullRequest } type RootSubscriptionType { @@ -1114,6 +1116,7 @@ input StackSettingsAttributes { input AiSettingsAttributes { enabled: Boolean + tools: ToolConfigAttributes provider: AiProvider openai: OpenaiSettingsAttributes anthropic: AnthropicSettingsAttributes @@ -1123,6 +1126,15 @@ input AiSettingsAttributes { vertex: VertexAiAttributes } +input ToolConfigAttributes { + createPr: CreatePrConfigAttributes +} + +input CreatePrConfigAttributes { + "a scm connection id to use for pr automations" + connectionId: ID +} + input OpenaiSettingsAttributes { baseUrl: String accessToken: String @@ -1305,6 +1317,7 @@ type SmtpSettings { "Settings for configuring access to common LLM providers" type AiSettings { enabled: Boolean + toolsEnabled: Boolean provider: AiProvider openai: OpenaiSettings anthropic: AnthropicSettings @@ -4591,7 +4604,7 @@ type Cluster { insight: AiInsight "a set of kubernetes resources used to generate the ai insight for this cluster" - insightComponents: ClusterInsightComponent + insightComponents: [ClusterInsightComponent] "list cached nodes for a cluster, this can be stale up to 5m" nodes: [Node] @@ -5268,6 +5281,8 @@ input ScmConnectionAttributes { github: GithubAppAttributes + default: Boolean + "a ssh private key to be used for commit signing" signingPrivateKey: String } @@ -5694,6 +5709,8 @@ type ScmConnection { type: ScmType! + default: Boolean + username: String "base url for git clones for self-hosted versions" @@ -6229,6 +6246,8 @@ type ClusterInsightComponent { cluster: Cluster + insight: AiInsight + "the raw kubernetes resource itself, this is an expensive fetch and should be used sparingly" resource: KubernetesUnstructured } diff --git a/test/console/ai/fixer_test.exs b/test/console/ai/fixer_test.exs new file mode 100644 index 000000000..981cde404 --- /dev/null +++ b/test/console/ai/fixer_test.exs @@ -0,0 +1,63 @@ +defmodule Console.AI.FixerTest do + use Console.DataCase, async: false + use Mimic + alias Console.AI.Fixer + + describe "#pr/2" do + test "it can spawn a fix pr" do + insert(:scm_connection, token: "some-pat", default: true) + expect(Tentacat.Pulls, :create, fn _, "pluralsh", "console", %{head: "pr-test"} -> + {:ok, %{"html_url" => "https://github.com/pr/url"}, %HTTPoison.Response{}} + end) + expect(Console.Deployments.Pr.Git, :setup, fn conn, "https://github.com/pluralsh/console.git", "pr-test" -> + {:ok, %{conn | dir: Briefly.create!(directory: true)}} + end) + expect(Console.Deployments.Pr.Git, :commit, fn _, _ -> {:ok, ""} end) + expect(Console.Deployments.Pr.Git, :push, fn _, "pr-test" -> {:ok, ""} end) + expect(File, :write, fn _, "first" -> :ok end) + expect(File, :write, fn _, "second" -> :ok end) + expect(HTTPoison, :post, fn _, _, _, _ -> + {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(%{choices: [ + %{ + message: %{ + tool_calls: [%{ + function: %{ + name: "create_pr", + arguments: Jason.encode!(%{ + repo_url: "git@github.com:pluralsh/console.git", + branch_name: "pr-test", + pr_description: "some pr", + pr_title: "some pr", + commit_message: "a commit", + file_updates: [%{file_name: "file.yaml", content: "first"}, %{file_name: "file2.yaml", content: "second"}] + }) + } + }] + } + } + ]})}} + end) + + user = insert(:user) + git = insert(:git_repository, url: "https://github.com/pluralsh/deployment-operator.git") + parent = insert(:service, + repository: git, + git: %{ref: "main", folder: "charts/deployment-operator"} + ) + + svc = insert(:service, + repository: git, + git: %{ref: "main", folder: "charts/deployment-operator"}, + write_bindings: [%{user_id: user.id}], + parent: parent + ) + insight = insert(:ai_insight, service: svc) + + deployment_settings(ai: %{enabled: true, provider: :openai, openai: %{access_token: "secret"}}) + + {:ok, pr} = Fixer.pr(insight.id, [], user) + + assert pr.url == "https://github.com/pr/url" + end + end +end diff --git a/test/console/graphql/queries/ai_queries_test.exs b/test/console/graphql/queries/ai_queries_test.exs index 4c34cbca4..6c4ce9b6d 100644 --- a/test/console/graphql/queries/ai_queries_test.exs +++ b/test/console/graphql/queries/ai_queries_test.exs @@ -166,11 +166,15 @@ defmodule Console.GraphQl.AiQueriesTest do {:ok, %{data: %{"clusterInsightComponent" => found}}} = run_query(""" query Comp($id: ID!) { - clusterInsightComponent(id: $id) { id } + clusterInsightComponent(id: $id) { + id + cluster { id } + } } """, %{"id" => comp.id}, %{current_user: user}) assert found["id"] == comp.id + assert found["cluster"]["id"] == comp.cluster_id end test "it cannot fetch w/o access" do diff --git a/test/console/graphql/queries/deployments/cluster_queries_test.exs b/test/console/graphql/queries/deployments/cluster_queries_test.exs index 1d9a5a580..e86ecb8db 100644 --- a/test/console/graphql/queries/deployments/cluster_queries_test.exs +++ b/test/console/graphql/queries/deployments/cluster_queries_test.exs @@ -138,6 +138,23 @@ defmodule Console.GraphQl.Deployments.ClusterQueriesTest do assert found["id"] == cluster.id end + test "it can sideload insight components" do + cluster = insert(:cluster) + components = insert_list(3, :cluster_insight_component, cluster: cluster) + + {:ok, %{data: %{"cluster" => found}}} = run_query(""" + query cluster($id: ID!) { + cluster(id: $id) { + id + insightComponents { id } + } + } + """, %{"id" => cluster.id}, %{current_user: admin_user()}) + + assert found["id"] == cluster.id + assert ids_equal(found["insightComponents"], components) + end + test "writers can query deploy tokens" do user = insert(:user) cluster = insert(:cluster, write_bindings: [%{user_id: user.id}])