Skip to content
Open
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
21 changes: 21 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(cat:*)",
"Bash(find:*)",
"Bash(git checkout:*)",
"Bash(cargo check:*)",
"Bash(cargo test:*)",
"Bash(git remote add:*)",
"Bash(git add:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat: add Bitbucket Server support for PR operations\n\nAdd support for Bitbucket Server \\(on-premise\\) alongside existing GitHub\nintegration using a VCS provider abstraction layer.\n\nNew features:\n- VcsProvider trait for unified VCS operations\n- VcsProviderRegistry for auto-detection from remote URL\n- BitbucketService implementing create PR, get status, list PRs, fetch comments\n- Secure credential storage \\(file-based with macOS Keychain integration\\)\n- Bitbucket REST API v1.0 client with retry logic\n\nThe system auto-detects whether a repository uses GitHub or Bitbucket\nbased on the remote URL and routes operations accordingly.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <[email protected]>\nEOF\n\\)\")",
"Bash(git push:*)",
"Bash(curl:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix: resolve merge conflicts with main branch\n\n- Add missing GitHubRepoInfo import in git.rs\n- Add GitHubRepoInfo::from_remote_url\\(\\) method for URL parsing\n- Update get_pr_status to use new single-argument update_pr_status signature\n- Fix Bitbucket test to avoid TLS provider requirement\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <[email protected]>\nEOF\n\\)\")",
"Bash(gh pr checks:*)",
"WebFetch(domain:api.vibekanban.com)",
"Bash(gh api repos/BloopAI/vibe-kanban/pulls/1842/reviews)",
"Bash(git commit -m \"$\\(cat <<''EOF''\ntest: add comprehensive tests for Bitbucket integration\n\nAdd extensive test coverage for:\n- Bitbucket models: PR state conversion, comment conversion, serialization\n- Credentials: storage, loading, saving, invalid JSON handling\n- VcsProvider: URL parsing edge cases, error display, type display\n\n41 total tests for the Bitbucket/VcsProvider functionality.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <[email protected]>\nEOF\n\\)\")"
]
}
}
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,18 @@ When running Vibe Kanban on a remote server (e.g., via systemctl, Docker, or clo
When configured, the "Open in VSCode" buttons will generate URLs like `vscode://vscode-remote/ssh-remote+user@host/path` that open your local editor and connect to the remote server.

See the [documentation](https://vibekanban.com/docs/configuration-customisation/global-settings#remote-ssh-configuration) for detailed setup instructions.

### Jira Integration

Vibe Kanban can import your assigned Jira tickets directly into new tasks, including the full ticket description so your coding agents have all the context they need.

**Prerequisites:**
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
- Atlassian MCP plugin configured in Claude

**Setup:**
1. Install the Atlassian MCP plugin for Claude Code following the [official instructions](https://github.com/anthropics/claude-code/tree/main/plugins/atlassian)
2. Authenticate with your Atlassian account when prompted
3. In Vibe Kanban, create a new task and click "Load Jira tickets" to see your assigned issues

When you select a Jira ticket, the task title and description are automatically populated with the ticket details.
15 changes: 15 additions & 0 deletions crates/db/migrations/20260117000000_add_jira_cache.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Add jira_cache table for caching Jira ticket responses
-- Cache entries expire after 5 minutes (TTL managed in application code)

CREATE TABLE jira_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT NOT NULL UNIQUE,
data TEXT NOT NULL, -- JSON serialized JiraIssuesResponse
cached_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec'))
);

-- Index for fast lookup by cache key
CREATE INDEX idx_jira_cache_cache_key ON jira_cache(cache_key);

-- Index for cleaning up stale entries by timestamp
CREATE INDEX idx_jira_cache_cached_at ON jira_cache(cached_at);
206 changes: 206 additions & 0 deletions crates/db/src/models/jira_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};

/// Cache TTL in minutes
const CACHE_TTL_MINUTES: i64 = 5;

/// A cached Jira response entry (internal row representation)
#[derive(Debug, Clone, FromRow)]
struct JiraCacheRow {
pub cache_key: String,
pub data: String,
pub cached_at: String,
}

/// Cached Jira issues response with parsed data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraCache<T> {
pub cache_key: String,
pub data: T,
pub cached_at: DateTime<Utc>,
}

impl<T: for<'de> Deserialize<'de>> JiraCache<T> {
/// Check if the cache entry is still valid (within TTL)
pub fn is_valid(&self) -> bool {
let now = Utc::now();
let expiry = self.cached_at + Duration::minutes(CACHE_TTL_MINUTES);
now < expiry
}

/// Get the remaining TTL in seconds
pub fn remaining_ttl_secs(&self) -> i64 {
let expiry = self.cached_at + Duration::minutes(CACHE_TTL_MINUTES);
let remaining = expiry - Utc::now();
remaining.num_seconds().max(0)
}
}

/// Database operations for Jira cache
pub struct JiraCacheRepo;

impl JiraCacheRepo {
/// Get a cached entry by key if it exists and is valid
pub async fn get<T: for<'de> Deserialize<'de>>(
pool: &SqlitePool,
cache_key: &str,
) -> Result<Option<JiraCache<T>>, JiraCacheError> {
let row: Option<JiraCacheRow> = sqlx::query_as(
r#"
SELECT cache_key, data, cached_at
FROM jira_cache
WHERE cache_key = $1
"#,
)
.bind(cache_key)
.fetch_optional(pool)
.await?;

match row {
Some(row) => {
let data: T = serde_json::from_str(&row.data)?;
let cached_at = parse_sqlite_datetime(&row.cached_at)?;
let cache = JiraCache {
cache_key: row.cache_key,
data,
cached_at,
};

if cache.is_valid() {
Ok(Some(cache))
} else {
// Cache expired, delete it
Self::delete(pool, cache_key).await?;
Ok(None)
}
}
None => Ok(None),
}
}

/// Store data in the cache (upsert)
pub async fn set<T: Serialize>(
pool: &SqlitePool,
cache_key: &str,
data: &T,
) -> Result<(), JiraCacheError> {
let data_json = serde_json::to_string(data)?;

sqlx::query(
r#"
INSERT INTO jira_cache (cache_key, data)
VALUES ($1, $2)
ON CONFLICT(cache_key) DO UPDATE SET
data = excluded.data,
cached_at = datetime('now', 'subsec')
"#,
)
.bind(cache_key)
.bind(data_json)
.execute(pool)
.await?;

Ok(())
}

/// Delete a cache entry by key
pub async fn delete(pool: &SqlitePool, cache_key: &str) -> Result<u64, JiraCacheError> {
let result = sqlx::query("DELETE FROM jira_cache WHERE cache_key = $1")
.bind(cache_key)
.execute(pool)
.await?;
Ok(result.rows_affected())
}

/// Delete all expired cache entries
pub async fn cleanup_expired(pool: &SqlitePool) -> Result<u64, JiraCacheError> {
let cutoff = Utc::now() - Duration::minutes(CACHE_TTL_MINUTES);
let cutoff_str = cutoff.format("%Y-%m-%d %H:%M:%S%.f").to_string();

let result = sqlx::query("DELETE FROM jira_cache WHERE cached_at < $1")
.bind(cutoff_str)
.execute(pool)
.await?;
Ok(result.rows_affected())
}

/// Invalidate all cache entries (force refresh)
pub async fn invalidate_all(pool: &SqlitePool) -> Result<u64, JiraCacheError> {
let result = sqlx::query("DELETE FROM jira_cache")
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}

/// Parse SQLite datetime string to DateTime<Utc>
fn parse_sqlite_datetime(s: &str) -> Result<DateTime<Utc>, JiraCacheError> {
// SQLite stores datetime with subsecond precision as "2024-01-17 12:34:56.789"
// Try multiple formats to be flexible
let formats = [
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%dT%H:%M:%S",
];

for fmt in formats {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
return Ok(DateTime::from_naive_utc_and_offset(naive, Utc));
}
}

Err(JiraCacheError::ParseError(format!(
"Failed to parse datetime: {}",
s
)))
}

#[derive(Debug, thiserror::Error)]
pub enum JiraCacheError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),

#[error("JSON serialization error: {0}")]
Serialization(#[from] serde_json::Error),

#[error("Parse error: {0}")]
ParseError(String),
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_cache_validity() {
let cache = JiraCache {
cache_key: "test".to_string(),
data: "test data".to_string(),
cached_at: Utc::now(),
};
assert!(cache.is_valid());
assert!(cache.remaining_ttl_secs() > 0);
}

#[test]
fn test_cache_expired() {
let cache = JiraCache {
cache_key: "test".to_string(),
data: "test data".to_string(),
cached_at: Utc::now() - Duration::minutes(10),
};
assert!(!cache.is_valid());
assert_eq!(cache.remaining_ttl_secs(), 0);
}

#[test]
fn test_parse_sqlite_datetime() {
let result = parse_sqlite_datetime("2024-01-17 12:34:56.789");
assert!(result.is_ok());

let result = parse_sqlite_datetime("2024-01-17 12:34:56");
assert!(result.is_ok());
}
}
1 change: 1 addition & 0 deletions crates/db/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod execution_process;
pub mod execution_process_logs;
pub mod execution_process_repo_state;
pub mod image;
pub mod jira_cache;
pub mod merge;
pub mod project;
pub mod project_repo;
Expand Down
2 changes: 2 additions & 0 deletions crates/server/src/bin/generate_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ fn generate_types_content() -> String {
services::services::queued_message::QueuedMessage::decl(),
services::services::queued_message::QueueStatus::decl(),
services::services::git::ConflictOp::decl(),
services::services::jira::JiraIssue::decl(),
services::services::jira::JiraIssuesResponse::decl(),
executors::actions::ExecutorAction::decl(),
executors::mcp_config::McpConfig::decl(),
executors::actions::ExecutorActionType::decl(),
Expand Down
94 changes: 94 additions & 0 deletions crates/server/src/routes/jira.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use axum::{
Router,
extract::State,
response::Json as ResponseJson,
routing::{get, post},
};
use deployment::Deployment;
use services::services::jira::{JiraError, JiraIssuesResponse, JiraService};
use utils::response::ApiResponse;

use crate::DeploymentImpl;

/// Error response type for Jira API
#[derive(Debug, serde::Serialize)]
struct JiraErrorInfo {
code: &'static str,
details: String,
}

pub fn router() -> Router<DeploymentImpl> {
Router::new()
.route("/jira/my-issues", get(fetch_my_jira_issues))
.route("/jira/refresh", post(refresh_jira_issues))
}

/// Fetch Jira issues (uses 5-minute cache)
#[axum::debug_handler]
async fn fetch_my_jira_issues(
State(deployment): State<DeploymentImpl>,
) -> ResponseJson<ApiResponse<JiraIssuesResponse, JiraErrorInfo>> {
handle_jira_result(JiraService::fetch_my_issues(&deployment.db().pool).await)
}

/// Force refresh Jira issues (bypasses cache)
#[axum::debug_handler]
async fn refresh_jira_issues(
State(deployment): State<DeploymentImpl>,
) -> ResponseJson<ApiResponse<JiraIssuesResponse, JiraErrorInfo>> {
handle_jira_result(JiraService::refresh_my_issues(&deployment.db().pool).await)
}

/// Convert JiraService result to API response
fn handle_jira_result(
result: Result<JiraIssuesResponse, JiraError>,
) -> ResponseJson<ApiResponse<JiraIssuesResponse, JiraErrorInfo>> {
match result {
Ok(response) => {
tracing::info!("Successfully fetched {} Jira issues", response.total);
ResponseJson(ApiResponse::success(response))
}
Err(JiraError::NotConfigured(msg)) => {
tracing::warn!("Claude MCP not configured: {}", msg);
ResponseJson(ApiResponse::error_with_data(JiraErrorInfo {
code: "NOT_CONFIGURED",
details: msg,
}))
}
Err(JiraError::ExecutionError(msg)) => {
tracing::error!("Failed to execute Claude CLI: {}", msg);
ResponseJson(ApiResponse::error_with_data(JiraErrorInfo {
code: "EXECUTION_ERROR",
details: msg,
}))
}
Err(JiraError::ParseError(msg)) => {
tracing::error!("Failed to parse Jira response: {}", msg);
ResponseJson(ApiResponse::error_with_data(JiraErrorInfo {
code: "PARSE_ERROR",
details: msg,
}))
}
Err(JiraError::ClaudeError(msg)) => {
tracing::error!("Claude returned an error: {}", msg);
ResponseJson(ApiResponse::error_with_data(JiraErrorInfo {
code: "CLAUDE_ERROR",
details: msg,
}))
}
Err(JiraError::Timeout(secs)) => {
tracing::error!("Jira fetch timed out after {} seconds", secs);
ResponseJson(ApiResponse::error_with_data(JiraErrorInfo {
code: "TIMEOUT",
details: format!("Request timed out after {} seconds. Please try again.", secs),
}))
}
Err(JiraError::CacheError(e)) => {
tracing::error!("Jira cache error: {}", e);
ResponseJson(ApiResponse::error_with_data(JiraErrorInfo {
code: "CACHE_ERROR",
details: format!("Cache error: {}", e),
}))
}
}
}
2 changes: 2 additions & 0 deletions crates/server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod execution_processes;
pub mod frontend;
pub mod health;
pub mod images;
pub mod jira;
pub mod oauth;
pub mod organizations;
pub mod projects;
Expand Down Expand Up @@ -44,6 +45,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService<Router> {
.merge(repo::router())
.merge(events::router(&deployment))
.merge(approvals::router())
.merge(jira::router())
.merge(scratch::router(&deployment))
.merge(sessions::router(&deployment))
.nest("/images", images::routes())
Expand Down
Loading