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
125 changes: 59 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,66 @@
# ActiveProject Gem

A standardized Ruby interface for multiple project management APIs (Jira, Basecamp, Trello, etc.).
A standardized Ruby interface for multiple project-management APIs
(Jira, Basecamp, Trello, GitHub Projects, …).

## Problem

Integrating with various project management platforms like Jira, Basecamp, and Trello often requires writing separate, complex clients for each API. Developers face challenges in handling different authentication methods, error formats, data structures, and workflow concepts across these platforms.
Every platform—Jira, Basecamp, Trello, GitHub—ships its **own** authentication flow, error vocabulary, data model, and workflow quirks.
Teams end up maintaining a grab-bag of fragile, bespoke clients.

## Solution

The ActiveProject gem aims to solve this by providing a unified, opinionated interface built on the **Adapter pattern**. It abstracts away the complexities of individual APIs, offering:
ActiveProject wraps those APIs behind a single, opinionated interface:

* **Normalized Data Models:** Common Ruby objects for core concepts like `Project`, `Issue` (Issue/Task/Card/To-do), `Comment`, and `User`.
* **Standardized Operations:** Consistent methods for creating, reading, updating, and transitioning issues (e.g., `issue.close!`, `issue.reopen!`).
* **Unified Error Handling:** A common set of exceptions (`AuthenticationError`, `NotFoundError`, `RateLimitError`, etc.) regardless of the underlying platform.
* **Co-operative Concurrency:** Optional fiber-based I/O (powered by the [`async`](https://github.com/socketry/async) ecosystem) for bulk operations without threads.
| Feature | What you get |
|---------|--------------|
| **Normalized models** | `Project`, `Issue` (Task/Card/To-do), `Comment`, `User`—same Ruby objects everywhere. |
| **Standard CRUD** | `issue.close!`, `issue.reopen!`, `project.list_issues`, etc. |
| **Unified errors** | `AuthenticationError`, `NotFoundError`, `RateLimitError`, … regardless of the backend. |
| **Co-operative concurrency** | Fiber-based I/O (via [`async`](https://github.com/socketry/async)) for painless parallel fan-out. |

## Supported Platforms

The initial focus is on integrating with platforms primarily via their **REST APIs**:
## Supported Platforms (initial wave)

* **Jira (Cloud & Server):** REST API (v3)
* **Basecamp (v3+):** REST API
* **Trello:** REST API
| Platform | API | Notes |
|---------------------------|------------|------------------------------|
| **Jira** (Cloud & Server) | REST v3 | Full issue & project support |
| **Basecamp** | REST v3+ | Maps To-dos ↔ Issues |
| **Trello** | REST | Cards ↔ Issues |
| **GitHub Projects V2** | GraphQL v4 | |

Future integrations might include platforms like Asana (REST), Monday.com (GraphQL), and Linear (GraphQL). For GraphQL-based APIs, the adapter will encapsulate the query logic, maintaining a consistent interface for the gem user.
_Planned next_: Asana, Monday.com, Linear, etc.

## Core Concepts

* **Project:** Represents a Jira Project, Basecamp Project, or Trello Board.
* **Task:** A unified representation of a Jira Issue, Basecamp To-do, or Trello Card. Includes normalized fields like `title`, `description`, `assignees`, `status`, and `priority`.
* **Status Normalization:** Maps platform-specific statuses (Jira statuses, Basecamp completion, Trello lists) to a common set like `:open`, `:in_progress`, `:closed`.
* **Priority Normalization:** Maps priorities (where available, like in Jira) to a standard scale (e.g., `:low`, `:medium`, `:high`).
* **Project** – Jira Project, Basecamp Project, Trello Board, or GitHub ProjectV2.
* **Issue** – Unified wrapper around Jira Issue, Basecamp To-do, Trello Card, GitHub Issue/PR.
*GitHub DraftIssues intentionally omitted for now.*
* **Status** – Normalized to `:open`, `:in_progress`, `:closed`.
* **Priority** – Normalized to `:low`, `:medium`, `:high` (where supported).

---

## Architecture

The gem uses an **Adapter pattern**, with specific adapters (`Adapters::JiraAdapter`, `Adapters::BasecampAdapter`, etc.) implementing a common interface. This allows for easy extension to new platforms.
```

ActiveProject
└── Adapters
├── JiraAdapter
├── BasecampAdapter
├── TrelloAdapter
└── GithubProjectAdapter

````
Add a new platform by subclassing and conforming to the common contract.
---

## Planned Features

* CRUD operations for Projects and Tasks.
* Unified status transitions.
* Comment management.
* Standardized error handling and reporting.
* Webhook support for real-time updates from platforms.
* Configuration management for API credentials.
* Utilization of **Mermaid diagrams** to visualize workflows and integration logic within documentation.
* Webhook helpers for real-time updates
* Centralised credential/config store
* Mermaid diagrams for docs & SDK flow-charts

## Installation

Expand Down Expand Up @@ -74,46 +90,26 @@ Configure multiple adapters, optionally with named instances (default is `:prima

```ruby
ActiveProject.configure do |config|
# Primary Jira instance (default name :primary)
config.add_adapter(:jira,
site_url: ENV.fetch('JIRA_SITE_URL'),
username: ENV.fetch('JIRA_USERNAME'),
api_token: ENV.fetch('JIRA_API_TOKEN')
)

# Secondary Jira instance
config.add_adapter(:jira, :secondary,
site_url: ENV.fetch('JIRA_SECOND_SITE_URL'),
username: ENV.fetch('JIRA_SECOND_USERNAME'),
api_token: ENV.fetch('JIRA_SECOND_API_TOKEN')
)

# Basecamp primary instance
config.add_adapter(:basecamp,
account_id: ENV.fetch('BASECAMP_ACCOUNT_ID'),
access_token: ENV.fetch('BASECAMP_ACCESS_TOKEN')
)

# Trello primary instance
config.add_adapter(:trello,
key: ENV.fetch('TRELLO_KEY'),
token: ENV.fetch('TRELLO_TOKEN')
)
config.add_adapter :jira,
site_url: ENV["JIRA_SITE_URL"],
username: ENV["JIRA_USERNAME"],
api_token: ENV["JIRA_API_TOKEN"]

config.add_adapter :basecamp,
account_id: ENV["BASECAMP_ACCOUNT_ID"],
access_token: ENV["BASECAMP_ACCESS_TOKEN"]

config.add_adapter :trello,
key: ENV["TRELLO_KEY"],
token: ENV["TRELLO_TOKEN"]

# GitHub Projects – real Issues/PRs only
config.add_adapter :github_project,
access_token: ENV["GITHUB_TOKEN"]
end
```

### Accessing adapters

Fetch a specific adapter instance:

```ruby
jira_primary = ActiveProject.adapter(:jira) # defaults to :primary
jira_secondary = ActiveProject.adapter(:jira, :secondary)
basecamp = ActiveProject.adapter(:basecamp) # defaults to :primary
trello = ActiveProject.adapter(:trello) # defaults to :primary
```

### Basic Usage (Jira Example)
### Create & attach a GitHub Issue (draft-free)

```ruby
# Get the configured Jira adapter instance
Expand Down Expand Up @@ -175,8 +171,7 @@ end

```ruby
# Get the configured Basecamp adapter instance
basecamp_config = ActiveProject.configuration.adapter_config(:basecamp)
basecamp_adapter = ActiveProject::Adapters::BasecampAdapter.new(**basecamp_config)
basecamp_adapter = ActiveProject.adapter(:basecamp)

begin
# List projects
Expand Down Expand Up @@ -245,8 +240,6 @@ Enable the non-blocking adapter by setting an ENV var **before** your process bo
AP_DEFAULT_ADAPTER=async_http
```

### Parallel fan-out example

```ruby
ActiveProject::Async.run do |task|
jira = ActiveProject.adapter(:jira)
Expand Down
4 changes: 2 additions & 2 deletions activeproject.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ Gem::Specification.new do |spec|
end

spec.add_dependency 'activesupport', '>= 8.0', '< 9.0'
spec.add_dependency 'faraday', '>= 2.0'
spec.add_dependency 'faraday-retry'
spec.add_dependency 'async'
spec.add_dependency 'async-http'
spec.add_dependency 'async-http-faraday'
spec.add_dependency 'faraday', '>= 2.0'
spec.add_dependency 'faraday-retry'
spec.add_development_dependency 'mocha'
end
125 changes: 111 additions & 14 deletions lib/active_project/adapters/base.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
# frozen_string_literal: true

require_relative "../status_mapper"

module ActiveProject
module Adapters
# Base abstract class defining the interface for all adapters.
# Concrete adapters should inherit from this class and implement its abstract methods.
class Base
include ErrorMapper

# ─────────────────── Central HTTP-status → exception map ────────────
rescue_status 401..403, with: ActiveProject::AuthenticationError
rescue_status 404, with: ActiveProject::NotFoundError
rescue_status 429, with: ActiveProject::RateLimitError
rescue_status 400, 422, with: ActiveProject::ValidationError

attr_reader :config

def initialize(config:)
@config = config
@status_mapper = StatusMapper.from_config(adapter_type, config)
end

# Lists projects accessible by the configured credentials.
# @return [Array<ActiveProject::Project>]
def list_projects
Expand Down Expand Up @@ -52,8 +69,13 @@ def list_issues(project_id, options = {})

# Finds a specific issue by its ID or key.
# @param id [String, Integer] The ID or key of the issue.
# @param context [Hash] Optional context hash (e.g., { project_id: '...' } for Basecamp).
# @param context [Hash] Optional context hash. Platform-specific requirements:
# - Basecamp: REQUIRES { project_id: '...' }
# - Jira: Optional { fields: '...' }
# - Trello: Optional { fields: '...' }
# - GitHub: Ignored
# @return [ActiveProject::Issue, nil] The issue object or nil if not found.
# @raise [ArgumentError] if required context is missing (platform-specific).
def find_issue(id, context = {})
raise NotImplementedError, "#{self.class.name} must implement #find_issue"
end
Expand All @@ -69,36 +91,67 @@ def create_issue(project_id, attributes)
# Updates an existing issue.
# @param id [String, Integer] The ID or key of the issue to update.
# @param attributes [Hash] Issue attributes to update.
# @param context [Hash] Optional context hash (e.g., { project_id: '...' } for Basecamp).
# @param context [Hash] Optional context hash. Platform-specific requirements:
# - Basecamp: REQUIRES { project_id: '...' }
# - Jira: Optional { fields: '...' }
# - Trello: Optional { fields: '...' }
# - GitHub: Uses different signature: update_issue(project_id, item_id, attrs)
# @return [ActiveProject::Issue] The updated issue object.
# @raise [ArgumentError] if required context is missing (platform-specific).
# @note GitHub adapter overrides this with update_issue(project_id, item_id, attrs)
# due to GraphQL API requirements for project-specific field operations.
def update_issue(id, attributes, context = {})
raise NotImplementedError, "#{self.class.name} must implement #update_issue"
end

# Base implementation of delete_issue that raises NotImplementedError
# This will be included in the base adapter class and overridden by specific adapters
# Deletes an issue from a project.
# @param id [String, Integer] The ID or key of the issue to delete.
# @param context [Hash] Optional context hash. Platform-specific requirements:
# - Basecamp: REQUIRES { project_id: '...' }
# - Jira: Optional { delete_subtasks: true/false }
# - Trello: Ignored
# - GitHub: Uses different signature: delete_issue(project_id, item_id)
# @return [Boolean] true if deletion was successful.
# @raise [ArgumentError] if required context is missing (platform-specific).
# @note GitHub adapter overrides this with delete_issue(project_id, item_id)
# due to GraphQL API requirements.
def delete_issue(id, context = {})
raise NotImplementedError, "The #{self.class.name} adapter does not implement delete_issue"
end

# Adds a comment to an issue.
# @param issue_id [String, Integer] The ID or key of the issue.
# @param comment_body [String] The text of the comment.
# @param context [Hash] Optional context hash (e.g., { project_id: '...' } for Basecamp).
# @param context [Hash] Optional context hash. Platform-specific requirements:
# - Basecamp: REQUIRES { project_id: '...' }
# - Jira: Ignored
# - Trello: Ignored
# - GitHub: Optional { content_node_id: '...' } for optimization
# @return [ActiveProject::Comment] The created comment object.
# @raise [ArgumentError] if required context is missing (platform-specific).
def add_comment(issue_id, comment_body, context = {})
raise NotImplementedError, "#{self.class.name} must implement #add_comment"
end

# Checks if the adapter supports webhook processing.
# @return [Boolean] true if the adapter can process webhooks
def supports_webhooks?
respond_to?(:parse_webhook, true) &&
!method(:parse_webhook).source_location.nil? &&
method(:parse_webhook).source_location[0] != __FILE__
end

# Verifies the signature of an incoming webhook request, if supported by the platform.
# @param _request_body [String] The raw request body.
# @param _signature_header [String] The value of the platform-specific signature header (e.g., 'X-Trello-Webhook').
# @return [Boolean] true if the signature is valid or verification is not supported/needed, false otherwise.
# @raise [NotImplementedError] if verification is applicable but not implemented by a subclass.
def verify_webhook_signature(_request_body, _signature_header)
# Default implementation assumes no verification needed or supported.
# Adapters supporting verification should override this.
true
# @param request_body [String] The raw request body.
# @param signature_header [String] The value of the platform-specific signature header.
# @param webhook_secret [String] Optional webhook secret for verification.
# @return [Boolean] true if the signature is valid or verification is not supported, false otherwise.
# @note Override this method in adapter subclasses to implement platform-specific verification.
def verify_webhook_signature(request_body, signature_header, webhook_secret: nil)
# Default implementation assumes no verification needed.
# Adapters supporting verification should override this method.
return true unless supports_webhooks? # Allow non-webhook flows by default
false # Adapters must override this method to implement verification
end

# Parses an incoming webhook payload into a standardized WebhookEvent struct.
Expand All @@ -107,7 +160,9 @@ def verify_webhook_signature(_request_body, _signature_header)
# @return [ActiveProject::WebhookEvent, nil] The parsed event object or nil if the payload is irrelevant/unparseable.
# @raise [NotImplementedError] if webhook parsing is not implemented for the adapter.
def parse_webhook(request_body, headers = {})
raise NotImplementedError, "#{self.class.name} must implement #parse_webhook"
raise NotImplementedError,
"#{self.class.name} does not support webhook parsing. " \
"Webhook support is optional. Check #supports_webhooks? before calling this method."
end

# Retrieves details for the currently authenticated user.
Expand All @@ -124,6 +179,48 @@ def get_current_user
def connected?
raise NotImplementedError, "#{self.class.name} must implement #connected?"
end

# Adapters that do **not** support a custom “status” field can simply rely
# on this default implementation. Adapters that _do_ care (e.g. the
# GitHub project adapter which knows its single-select options) already
# override it.
#
# @return [Boolean] _true_ if the symbol is safe to pass through.
def status_known?(project_id, status_sym)
@status_mapper.status_known?(status_sym, project_id: project_id)
end

# Returns all valid statuses for the given project context.
# @param project_id [String, Integer] The project context
# @return [Array<Symbol>] Array of valid status symbols
def valid_statuses(project_id = nil)
@status_mapper.valid_statuses(project_id: project_id)
end

# Normalizes a platform-specific status to a standard symbol.
# @param platform_status [String, Symbol] Platform-specific status
# @param project_id [String, Integer] Optional project context
# @return [Symbol] Normalized status symbol
def normalize_status(platform_status, project_id: nil)
@status_mapper.normalize_status(platform_status, project_id: project_id)
end

# Converts a normalized status back to platform-specific format.
# @param normalized_status [Symbol] Normalized status symbol
# @param project_id [String, Integer] Optional project context
# @return [String, Symbol] Platform-specific status
def denormalize_status(normalized_status, project_id: nil)
@status_mapper.denormalize_status(normalized_status, project_id: project_id)
end

protected

# Returns the adapter type symbol for status mapping.
# Override in subclasses if the adapter type differs from class name pattern.
# @return [Symbol] The adapter type
def adapter_type
self.class.name.split("::").last.gsub("Adapter", "").downcase.to_sym
end
end
end
end
Loading
Loading