diff --git a/lib/air_test.rb b/lib/air_test.rb index 450e3ae..20a0a3f 100644 --- a/lib/air_test.rb +++ b/lib/air_test.rb @@ -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" diff --git a/lib/air_test/configuration.rb b/lib/air_test/configuration.rb index bb31661..8b7e80b 100644 --- a/lib/air_test/configuration.rb +++ b/lib/air_test/configuration.rb @@ -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 diff --git a/lib/air_test/github_client.rb b/lib/air_test/github_client.rb index 26af3e1..460581a 100644 --- a/lib/air_test/github_client.rb +++ b/lib/air_test/github_client.rb @@ -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) @@ -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") diff --git a/lib/air_test/jira_ticket_parser.rb b/lib/air_test/jira_ticket_parser.rb new file mode 100644 index 0000000..4c1255b --- /dev/null +++ b/lib/air_test/jira_ticket_parser.rb @@ -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 diff --git a/lib/air_test/monday_ticket_parser.rb b/lib/air_test/monday_ticket_parser.rb new file mode 100644 index 0000000..43b558a --- /dev/null +++ b/lib/air_test/monday_ticket_parser.rb @@ -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 diff --git a/lib/air_test/notion_parser.rb b/lib/air_test/notion_ticket_parser.rb similarity index 94% rename from lib/air_test/notion_parser.rb rename to lib/air_test/notion_ticket_parser.rb index 2468c68..6687e51 100644 --- a/lib/air_test/notion_parser.rb +++ b/lib/air_test/notion_ticket_parser.rb @@ -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) @@ -254,5 +264,3 @@ def normalize_blocks(blocks) end end end - -# rubocop:enable Metrics/ClassLength diff --git a/lib/air_test/runner.rb b/lib/air_test/runner.rb index 350e909..771fcf5 100644 --- a/lib/air_test/runner.rb +++ b/lib/air_test/runner.rb @@ -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 diff --git a/lib/air_test/ticket_parser.rb b/lib/air_test/ticket_parser.rb new file mode 100644 index 0000000..d7c6d1f --- /dev/null +++ b/lib/air_test/ticket_parser.rb @@ -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 diff --git a/test_jira_correct_email.rb b/test_jira_correct_email.rb new file mode 100755 index 0000000..cd92c51 --- /dev/null +++ b/test_jira_correct_email.rb @@ -0,0 +1,104 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "net/http" +require "json" +require "uri" + +# Your credentials with correct email +email = "bouland.julien@gmail.com" +api_key = "ATATT3xFfGF0H5a0-ZcFh-ZVXAwe-r00b-FDkDsy1ZjxWJz4FVLgmwT_fN5qnQWsHHQKG5-cWwjkWRmMAaOYbpUjz2RYMwJSOVST8rIOWINY3GggDy73l-xd-_IrNXmEmxQg3nH2jnrWrcwFqzJKcbzOXrTvVWGEUv753J-OYvlLrWXA0yegYc4=0247F6BE" +domain = "https://boulandjulien.atlassian.net" + +puts "šŸ” Testing Jira with correct email: #{email}" + +# Test 1: Check authentication +puts "\n1ļøāƒ£ Testing authentication..." +uri = URI("#{domain}/rest/api/3/myself") +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) + +if response.code == "200" + user_data = JSON.parse(response.body) + puts "āœ… Authentication successful!" + puts " User: #{user_data["displayName"]} (#{user_data["emailAddress"]})" + puts " Account ID: #{user_data["accountId"]}" +else + puts "āŒ Authentication failed: #{response.code}" + puts " Response: #{response.body}" + exit 1 +end + +# Test 2: Get accessible projects +puts "\n2ļøāƒ£ Getting accessible projects..." +uri = URI("#{domain}/rest/api/3/project") +request = Net::HTTP::Get.new(uri) +request.basic_auth(email, api_key) +request["Accept"] = "application/json" + +response = http.request(request) + +if response.code == "200" + projects = JSON.parse(response.body) + puts "āœ… Found #{projects.length} accessible projects:" + projects.each do |project| + puts " - Key: #{project["key"]}, Name: #{project["name"]}, ID: #{project["id"]}" + end +else + puts "āŒ Failed to get projects: #{response.code}" + puts " Response: #{response.body}" +end + +# Test 3: Try to get project details for SCRUM +puts "\n3ļøāƒ£ Testing SCRUM project access..." +uri = URI("#{domain}/rest/api/3/project/SCRUM") +request = Net::HTTP::Get.new(uri) +request.basic_auth(email, api_key) +request["Accept"] = "application/json" + +response = http.request(request) + +if response.code == "200" + project_data = JSON.parse(response.body) + puts "āœ… SCRUM project found!" + puts " Key: #{project_data["key"]}" + puts " Name: #{project_data["name"]}" + puts " ID: #{project_data["id"]}" +else + puts "āŒ SCRUM project not found: #{response.code}" + puts " Response: #{response.body}" +end + +# Test 4: Try to get issues from any available project +if projects&.any? + test_project = projects.first + puts "\n4ļøāƒ£ Testing issues from #{test_project["key"]} project..." + + jql = "project = #{test_project["key"]} ORDER BY created DESC" + uri = URI("#{domain}/rest/api/3/search?jql=#{URI.encode_www_form_component(jql)}&maxResults=5") + request = Net::HTTP::Get.new(uri) + request.basic_auth(email, api_key) + request["Accept"] = "application/json" + + response = http.request(request) + + if response.code == "200" + data = JSON.parse(response.body) + issues = data["issues"] || [] + puts "āœ… Found #{issues.length} issues in #{test_project["key"]}:" + issues.each do |issue| + status = issue.dig("fields", "status", "name") || "Unknown" + puts " - #{issue["key"]}: #{issue.dig("fields", "summary")} (Status: #{status})" + end + else + puts "āŒ Failed to get issues: #{response.code}" + puts " Response: #{response.body}" + end +end + +puts "\nšŸŽ‰ Jira test completed!"