feat: Add a tool to search Linear Issues in copilot (#11518)
This PR adds a tool to search Linear issues. If the integration is enabled for the account, the tool will return results as expected. Also introduces support for an `active?` method, which allows third-party Copilot tools to be conditionally enabled based on the status of the integration on the account.
This commit is contained in:
@@ -9,6 +9,8 @@ class Captain::ToolRegistryService
|
||||
|
||||
def register_tool(tool_class)
|
||||
tool = tool_class.new(@assistant)
|
||||
return unless tool.active?
|
||||
|
||||
@tools[tool.name] = tool
|
||||
@registered_tools << tool.to_registry_format
|
||||
end
|
||||
|
||||
@@ -31,4 +31,8 @@ class Captain::Tools::BaseService
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def active?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_linear_issues'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search Linear issues based on a search term'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
description: 'The search term to find Linear issues'
|
||||
}
|
||||
},
|
||||
required: %w[term]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
return 'Linear integration is not enabled' unless active?
|
||||
|
||||
term = arguments['term']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Service called with the search term #{term}"
|
||||
|
||||
return 'Missing required parameters' if term.blank?
|
||||
|
||||
linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account)
|
||||
result = linear_service.search_issue(term)
|
||||
|
||||
return result[:error] if result[:error]
|
||||
|
||||
issues = result[:data]
|
||||
return 'No issues found, I should try another similar search term' if issues.blank?
|
||||
|
||||
total_count = issues.length
|
||||
|
||||
<<~RESPONSE
|
||||
Total number of issues: #{total_count}
|
||||
#{issues.map { |issue| format_issue(issue) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
def active?
|
||||
@assistant.account.hooks.find_by(app_id: 'linear').present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_issue(issue)
|
||||
<<~ISSUE
|
||||
Title: #{issue['title']}
|
||||
ID: #{issue['id']}
|
||||
State: #{issue['state']['name']}
|
||||
Priority: #{format_priority(issue['priority'])}
|
||||
#{issue['assignee'] ? "Assignee: #{issue['assignee']['name']}" : 'Assignee: Unassigned'}
|
||||
#{issue['description'].present? ? "\nDescription: #{issue['description']}" : ''}
|
||||
ISSUE
|
||||
end
|
||||
|
||||
def format_priority(priority)
|
||||
return 'No priority' if priority.nil?
|
||||
|
||||
case priority
|
||||
when 0 then 'No priority'
|
||||
when 1 then 'Urgent'
|
||||
when 2 then 'High'
|
||||
when 3 then 'Medium'
|
||||
when 4 then 'Low'
|
||||
else 'Unknown'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,13 @@ require 'rails_helper'
|
||||
|
||||
# Test tool implementation
|
||||
class TestTool < Captain::Tools::BaseService
|
||||
attr_accessor :tool_active
|
||||
|
||||
def initialize(*args)
|
||||
super
|
||||
@tool_active = true
|
||||
end
|
||||
|
||||
def name
|
||||
'test_tool'
|
||||
end
|
||||
@@ -24,6 +31,10 @@ class TestTool < Captain::Tools::BaseService
|
||||
def execute(*args)
|
||||
args
|
||||
end
|
||||
|
||||
def active?
|
||||
@tool_active
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe Captain::ToolRegistryService do
|
||||
@@ -40,27 +51,41 @@ RSpec.describe Captain::ToolRegistryService do
|
||||
describe '#register_tool' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
it 'registers a new tool' do
|
||||
service.register_tool(tool_class)
|
||||
|
||||
expect(service.tools['test_tool']).to be_a(TestTool)
|
||||
expect(service.registered_tools).to include(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'A test tool for specs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
context 'when tool is active' do
|
||||
it 'registers a new tool' do
|
||||
service.register_tool(tool_class)
|
||||
expect(service.tools['test_tool']).to be_a(TestTool)
|
||||
expect(service.registered_tools).to include(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'A test tool for specs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tool is inactive' do
|
||||
it 'does not register the tool' do
|
||||
tool = tool_class.new(assistant)
|
||||
tool.tool_active = false
|
||||
allow(tool_class).to receive(:new).and_return(tool)
|
||||
|
||||
service.register_tool(tool_class)
|
||||
|
||||
expect(service.tools['test_tool']).to be_nil
|
||||
expect(service.registered_tools).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_linear_issues')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search Linear issues based on a search term')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
description: 'The search term to find Linear issues'
|
||||
}
|
||||
},
|
||||
required: %w[term]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when Linear integration is enabled' do
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear integration is not enabled' do
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when Linear integration is not enabled' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear integration is enabled' do
|
||||
let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service)
|
||||
end
|
||||
|
||||
context 'when term is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({ 'term' => '' })).to eq('Missing required parameters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns error' do
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' })
|
||||
end
|
||||
|
||||
it 'returns the error message' do
|
||||
expect(service.execute({ 'term' => 'test' })).to eq('API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns no issues' do
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ data: [] })
|
||||
end
|
||||
|
||||
it 'returns no issues found message' do
|
||||
expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns issues' do
|
||||
let(:issues) do
|
||||
[{
|
||||
'title' => 'Test Issue',
|
||||
'id' => 'TEST-123',
|
||||
'state' => { 'name' => 'In Progress' },
|
||||
'priority' => 4,
|
||||
'assignee' => { 'name' => 'John Doe' },
|
||||
'description' => 'Test description'
|
||||
}]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ data: issues })
|
||||
end
|
||||
|
||||
it 'returns formatted issues' do
|
||||
result = service.execute({ 'term' => 'test' })
|
||||
expect(result).to include('Total number of issues: 1')
|
||||
expect(result).to include('Title: Test Issue')
|
||||
expect(result).to include('ID: TEST-123')
|
||||
expect(result).to include('State: In Progress')
|
||||
expect(result).to include('Priority: Low')
|
||||
expect(result).to include('Assignee: John Doe')
|
||||
expect(result).to include('Description: Test description')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user