Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/air_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ def self.configure

require_relative "air_test/version"
require_relative "air_test/configuration"
require_relative "air_test/notion_parser"
require_relative "air_test/ticket_parser"
require_relative "air_test/notion_ticket_parser"
require_relative "air_test/jira_ticket_parser"
require_relative "air_test/monday_ticket_parser"
require_relative "air_test/spec_generator"
require_relative "air_test/github_client"
require_relative "air_test/runner"
Expand Down
41 changes: 37 additions & 4 deletions lib/air_test/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,46 @@
module AirTest
# Handles configuration for AirTest, including API tokens and environment variables.
class Configuration
attr_accessor :notion_token, :notion_database_id, :github_token, :repo
attr_accessor :tool, :notion, :jira, :monday, :github, :repo

def initialize
@notion_token = ENV.fetch("NOTION_TOKEN", nil)
@notion_database_id = ENV.fetch("NOTION_DATABASE_ID", nil)
@github_token = ENV["GITHUB_BOT_TOKEN"] || ENV.fetch("GITHUB_TOKEN", nil)
@tool = ENV.fetch("AIRTEST_TOOL", "notion")
@notion = {
token: ENV.fetch("NOTION_TOKEN", nil),
database_id: ENV.fetch("NOTION_DATABASE_ID", nil)
}
@jira = {
token: ENV.fetch("JIRA_TOKEN", nil),
project_id: ENV.fetch("JIRA_PROJECT_ID", nil),
domain: ENV.fetch("JIRA_DOMAIN", nil),
email: ENV.fetch("JIRA_EMAIL", nil)
}
@monday = {
token: ENV.fetch("MONDAY_TOKEN", nil),
board_id: ENV.fetch("MONDAY_BOARD_ID", nil),
domain: ENV.fetch("MONDAY_DOMAIN", nil)
}
@github = {
token: ENV["GITHUB_BOT_TOKEN"] || ENV.fetch("GITHUB_TOKEN", nil)
}
@repo = ENV.fetch("REPO", nil)
end

def validate!
case tool.to_s.downcase
when "notion"
raise "Missing NOTION_TOKEN" unless notion[:token]
raise "Missing NOTION_DATABASE_ID" unless notion[:database_id]
when "jira"
raise "Missing JIRA_TOKEN" unless jira[:token]
raise "Missing JIRA_PROJECT_ID" unless jira[:project_id]
raise "Missing JIRA_DOMAIN" unless jira[:domain]
raise "Missing JIRA_EMAIL" unless jira[:email]
when "monday"
raise "Missing MONDAY_TOKEN" unless monday[:token]
raise "Missing MONDAY_BOARD_ID" unless monday[:board_id]
raise "Missing MONDAY_DOMAIN" unless monday[:domain]
end
end
end
end
8 changes: 4 additions & 4 deletions lib/air_test/github_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ module AirTest
# Handles GitHub API interactions for AirTest, such as commits and pull requests.
class GithubClient
def initialize(config = AirTest.configuration)
@github_token = config.github_token
@token = config.github[:token]
@repo = config.repo || detect_repo_from_git
@client = Octokit::Client.new(access_token: @github_token) if @github_token
@client = Octokit::Client.new(access_token: @token) if @token
end

def commit_and_push_branch(branch, files, commit_message)
Expand All @@ -21,9 +21,9 @@ def commit_and_push_branch(branch, files, commit_message)
system('git config user.name "air-test-bot"')
system('git config user.email "airtest.bot@gmail.com"')
# Set remote to use bot token if available
if @github_token
if @token
repo_url = "github.com/#{@repo}.git"
system("git remote set-url origin https://#{@github_token}@#{repo_url}")
system("git remote set-url origin https://#{@token}@#{repo_url}")
end
files.each { |f| system("git add -f #{f}") }
has_changes = !system("git diff --cached --quiet")
Expand Down
86 changes: 86 additions & 0 deletions lib/air_test/jira_ticket_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# frozen_string_literal: true

require "net/http"
require "json"
require "uri"
require_relative "ticket_parser"

module AirTest
class JiraTicketParser
include TicketParser
def initialize(config = AirTest.configuration)
@domain = config.jira[:domain]
@api_key = config.jira[:token]
@project_key = config.jira[:project_id]
@email = config.jira[:email]
end

def fetch_tickets(limit: 5)
# Try different status names (English and French)
statuses = ["To Do", "À faire", "Open", "New"]
all_issues = []

statuses.each do |status|
jql = "project = #{@project_key} AND status = '#{status}' ORDER BY created DESC"
uri = URI("#{@domain}/rest/api/3/search?jql=#{URI.encode_www_form_component(jql)}&maxResults=#{limit}")
request = Net::HTTP::Get.new(uri)
request.basic_auth(@email, @api_key)
request["Accept"] = "application/json"
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.request(request)

next unless response.code == "200"

data = JSON.parse(response.body)
issues = data["issues"] || []
all_issues.concat(issues)
puts "Found #{issues.length} issues with status '#{status}'" if issues.any?
end

all_issues.first(limit)
end

def parse_ticket_content(issue_id)
# Fetch issue details (description, etc.)
uri = URI("#{@domain}/rest/api/3/issue/#{issue_id}")
request = Net::HTTP::Get.new(uri)
request.basic_auth(@email, @api_key)
request["Accept"] = "application/json"
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.request(request)
return nil unless response.code == "200"

issue = JSON.parse(response.body)
# Example: parse description as feature, steps, etc. (customize as needed)
{
feature: issue.dig("fields", "summary") || "",
scenarios: [
{
title: "Scenario",
steps: [issue.dig("fields", "description", "content")&.map do |c|
c["content"]&.map do |t|
t["text"]
end&.join(" ")
end&.join(" ") || ""]
}
],
meta: { tags: [], priority: "", estimate: nil,
assignee: issue.dig("fields", "assignee", "displayName") || "" }
}
end

def extract_ticket_title(ticket)
ticket.dig("fields", "summary") || "No title"
end

def extract_ticket_id(ticket)
ticket["key"] || "No ID"
end

def extract_ticket_url(ticket)
"#{@domain}/browse/#{ticket["key"]}"
end
end
end
99 changes: 99 additions & 0 deletions lib/air_test/monday_ticket_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

require "net/http"
require "json"
require "uri"
require_relative "ticket_parser"

module AirTest
class MondayTicketParser
include TicketParser
def initialize(config = AirTest.configuration)
@api_token = config.monday[:token]
@board_id = config.monday[:board_id]
@domain = config.monday[:domain]
@base_url = "https://api.monday.com/v2"
end

def fetch_tickets(limit: 5)
# First, get all items from the board
query = <<~GRAPHQL
query {
boards(ids: "#{@board_id}") {
items_page {
items {
id
name
column_values {
id
text
value
}
}
}
}
}
GRAPHQL

response = make_graphql_request(query)
return [] unless response["data"]

items = response.dig("data", "boards", 0, "items_page", "items") || []

# Filter for items with "Not Started" status
not_started_items = items.select do |item|
status_column = item["column_values"].find { |cv| cv["id"] == "project_status" }
status_column && status_column["text"] == "Not Started"
end

not_started_items.first(limit)
end

def parse_ticket_content(item_id)
# For Monday, we'll use the item name as feature and create a simple scenario
# In the future, you could add a description column to Monday and parse it like Notion
{
feature: "Feature: #{extract_ticket_title({ "id" => item_id, "name" => "Loading..." })}",
scenarios: [
{
title: "Scenario",
steps: ["Implement the feature"]
}
],
meta: { tags: [], priority: "", estimate: nil, assignee: "" }
}
end

def extract_ticket_title(ticket)
ticket["name"] || "No title"
end

def extract_ticket_id(ticket)
ticket["id"] || "No ID"
end

def extract_ticket_url(ticket)
"https://#{@domain}/boards/#{@board_id}/pulses/#{ticket["id"]}"
end

private

def make_graphql_request(query)
uri = URI(@base_url)
request = Net::HTTP::Post.new(uri)
request["Authorization"] = @api_token
request["Content-Type"] = "application/json"
request.body = { query: query }.to_json

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.request(request)

return {} unless response.code == "200"

JSON.parse(response.body)
rescue JSON::ParserError
{}
end
end
end
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
# Parses Notion tickets and extracts relevant information for spec generation in AirTest.
# rubocop:disable Metrics/ClassLength
# frozen_string_literal: true

require "net/http"
require "json"
require "uri"
require_relative "ticket_parser"

module AirTest
# Parses Notion tickets and extracts relevant information for spec generation in AirTest.
class NotionParser
# Implements TicketParser for Notion integration.
class NotionTicketParser
include TicketParser
def initialize(config = AirTest.configuration)
@database_id = config.notion_database_id
@notion_token = config.notion_token
@database_id = config.notion[:database_id]
@notion_token = config.notion[:token]
@base_url = "https://api.notion.com/v1"
end

def fetch_tickets(limit: 5)
uri = URI("#{@base_url}/databases/#{@database_id}/query")
response = make_api_request(uri, { page_size: 100 })
# Add filter for 'Not started' status
request_body = {
page_size: 100,
filter: {
property: "Status",
select: {
equals: "Not started"
}
}
}
response = make_api_request(uri, request_body)
return [] unless response.code == "200"

data = JSON.parse(response.body)
Expand Down Expand Up @@ -254,5 +264,3 @@ def normalize_blocks(blocks)
end
end
end

# rubocop:enable Metrics/ClassLength
24 changes: 18 additions & 6 deletions lib/air_test/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,32 @@ module AirTest
# Runs the main automation workflow for AirTest, orchestrating Notion parsing and GitHub actions.
class Runner
def initialize(config = AirTest.configuration)
@notion = NotionParser.new(config)
@config = config
@parser = case config.tool.to_s.downcase
when "notion"
NotionTicketParser.new(config)
when "jira"
JiraTicketParser.new(config)
when "monday"
MondayTicketParser.new(config)
else
raise "Unknown tool: #{config.tool}"
end
@spec = SpecGenerator.new
@github = GithubClient.new(config)
end

def run(limit: 5)
tickets = @notion.fetch_tickets(limit: limit)
@config.validate!
tickets = @parser.fetch_tickets(limit: limit)
# Filter for 'Not started' tickets (assuming each parser returns only those, or filter here if needed)
puts "🔍 Found #{tickets.length} tickets"
tickets.each do |ticket|
ticket_id = @notion.extract_ticket_id(ticket)
title = @notion.extract_ticket_title(ticket)
url = @notion.extract_ticket_url(ticket)
ticket_id = @parser.extract_ticket_id(ticket)
title = @parser.extract_ticket_title(ticket)
url = @parser.extract_ticket_url(ticket)
puts "\n📋 Processing: FDR#{ticket_id} - #{title}"
parsed_data = @notion.parse_ticket_content(ticket["id"])
parsed_data = @parser.parse_ticket_content(ticket["id"])
unless parsed_data && parsed_data[:feature] && !parsed_data[:feature].empty?
puts "⚠️ Skipping ticket FDR#{ticket_id} due to missing or empty feature."
next
Expand Down
26 changes: 26 additions & 0 deletions lib/air_test/ticket_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module AirTest
# Interface for ticket parsers (Notion, Jira, Monday, etc.)
module TicketParser
def fetch_tickets(limit: 5)
raise NotImplementedError
end

def parse_ticket_content(page_id)
raise NotImplementedError
end

def extract_ticket_title(ticket)
raise NotImplementedError
end

def extract_ticket_id(ticket)
raise NotImplementedError
end

def extract_ticket_url(ticket)
raise NotImplementedError
end
end
end
Loading
Loading