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)
|
def register_tool(tool_class)
|
||||||
tool = tool_class.new(@assistant)
|
tool = tool_class.new(@assistant)
|
||||||
|
return unless tool.active?
|
||||||
|
|
||||||
@tools[tool.name] = tool
|
@tools[tool.name] = tool
|
||||||
@registered_tools << tool.to_registry_format
|
@registered_tools << tool.to_registry_format
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,4 +31,8 @@ class Captain::Tools::BaseService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
true
|
||||||
|
end
|
||||||
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
|
# Test tool implementation
|
||||||
class TestTool < Captain::Tools::BaseService
|
class TestTool < Captain::Tools::BaseService
|
||||||
|
attr_accessor :tool_active
|
||||||
|
|
||||||
|
def initialize(*args)
|
||||||
|
super
|
||||||
|
@tool_active = true
|
||||||
|
end
|
||||||
|
|
||||||
def name
|
def name
|
||||||
'test_tool'
|
'test_tool'
|
||||||
end
|
end
|
||||||
@@ -24,6 +31,10 @@ class TestTool < Captain::Tools::BaseService
|
|||||||
def execute(*args)
|
def execute(*args)
|
||||||
args
|
args
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
@tool_active
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
RSpec.describe Captain::ToolRegistryService do
|
RSpec.describe Captain::ToolRegistryService do
|
||||||
@@ -40,27 +51,41 @@ RSpec.describe Captain::ToolRegistryService do
|
|||||||
describe '#register_tool' do
|
describe '#register_tool' do
|
||||||
let(:tool_class) { TestTool }
|
let(:tool_class) { TestTool }
|
||||||
|
|
||||||
it 'registers a new tool' do
|
context 'when tool is active' do
|
||||||
service.register_tool(tool_class)
|
it 'registers a new tool' do
|
||||||
|
service.register_tool(tool_class)
|
||||||
expect(service.tools['test_tool']).to be_a(TestTool)
|
expect(service.tools['test_tool']).to be_a(TestTool)
|
||||||
expect(service.registered_tools).to include(
|
expect(service.registered_tools).to include(
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'test_tool',
|
name: 'test_tool',
|
||||||
description: 'A test tool for specs',
|
description: 'A test tool for specs',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
test_param: {
|
test_param: {
|
||||||
type: 'string'
|
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
|
||||||
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