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
46 changes: 46 additions & 0 deletions src-tauri/src/engine/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,52 @@ fn parse_scan_output(output: &str) -> Result<Vec<ScannedTask>, String> {
))
}

/// Extract the raw JSON array substring from text that may contain surrounding prose.
/// Returns the raw JSON string (including brackets).
pub fn extract_json_array_raw(text: &str) -> Option<String> {
let start = text.find('[')?;
let mut depth = 0;
let mut end = None;
let mut in_string = false;
let mut escape_next = false;

for (i, ch) in text[start..].char_indices() {
if escape_next {
escape_next = false;
continue;
}

if ch == '\\' && in_string {
escape_next = true;
continue;
}

if ch == '"' {
in_string = !in_string;
continue;
}

if in_string {
continue;
}

match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
end = Some(start + i + 1);
break;
}
}
_ => {}
}
}

let end = end?;
Some(text[start..end].to_string())
}

/// Extract a JSON array from text that may contain surrounding prose.
/// String-literal-aware: skips brackets inside JSON string values.
fn extract_json_array(text: &str) -> Option<Vec<ScannedTask>> {
Expand Down
86 changes: 86 additions & 0 deletions src-tauri/src/engine_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,92 @@ end tell"#,
Ok(())
}

/// Augment imported tasks with codebase context using Claude CLI.
/// Accepts a batch of tasks and returns enriched metadata for each.
#[tauri::command]
pub async fn engine_augment_tasks(
repo_path: String,
tasks: Vec<AugmentTaskInput>,
) -> Result<Vec<AugmentTaskResult>, String> {
println!(
"[engine_augment_tasks] augmenting {} tasks for repo={}",
tasks.len(),
repo_path
);

// Collect source files for context
let context = scanner::collect_source_files(&repo_path)?;

// Build prompt with all tasks
let mut task_list = String::new();
for (i, t) in tasks.iter().enumerate() {
task_list.push_str(&format!(
"Task {}: {}\nDescription: {}\n\n",
i + 1,
t.title,
t.description.as_deref().unwrap_or("(no description)")
));
}

let prompt = format!(
r#"You are analyzing tasks imported from an issue tracker in the context of a codebase.
For each task below, analyze the codebase and return enriched metadata.

{}

Output ONLY a JSON array (one entry per task, same order) with no markdown formatting:
[{{
"files_involved": ["path/to/file.ts"],
"estimated_effort": "low" | "medium" | "high",
"enriched_description": "Enhanced description with codebase context...",
"category": "feature" | "tech_debt" | "tests" | "docs" | "security" | "performance" | "dx" | "observability" | "general"
}}]"#,
task_list
);

let result = engine::invoke_claude_cli(
&repo_path,
&prompt,
300, // 5 min timeout
Some(&context),
None,
None,
)
.await?;

if !result.success {
return Err(format!(
"Claude CLI failed: {}",
result.stderr
));
}

// Parse the JSON array from stdout
let json_str = scanner::extract_json_array_raw(&result.stdout)
.ok_or_else(|| "Failed to extract JSON array from augmentation response".to_string())?;

let results: Vec<AugmentTaskResult> = serde_json::from_str(&json_str)
.map_err(|e| format!("Failed to parse augmentation results: {e}"))?;

Ok(results)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AugmentTaskInput {
pub title: String,
pub description: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AugmentTaskResult {
pub files_involved: Vec<String>,
pub estimated_effort: String,
pub enriched_description: String,
pub category: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EngineStatusResponse {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ pub fn run() {
engine_commands::engine_get_diff,
engine_commands::engine_get_diff_stat,
engine_commands::engine_create_pr,
engine_commands::engine_augment_tasks,
engine_commands::run_terminal_command,
command::set_dock_badge,
])
Expand Down
41 changes: 41 additions & 0 deletions src-tauri/src/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,5 +251,46 @@ pub fn migrations() -> Vec<Migration> {
"#,
kind: MigrationKind::Up,
},
Migration {
version: 13,
description: "add Linear integration columns and sync config table",
sql: r#"
ALTER TABLE tasks ADD COLUMN linear_issue_id TEXT;
ALTER TABLE tasks ADD COLUMN linear_identifier TEXT;
ALTER TABLE tasks ADD COLUMN linear_url TEXT;

CREATE INDEX IF NOT EXISTS idx_tasks_linear_issue
ON tasks(linear_issue_id) WHERE linear_issue_id IS NOT NULL;

CREATE TABLE IF NOT EXISTS linear_sync_config (
id TEXT PRIMARY KEY NOT NULL,
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
linear_team_id TEXT NOT NULL,
linear_team_name TEXT NOT NULL,
linear_project_id TEXT,
linear_project_name TEXT,
auto_sync INTEGER NOT NULL DEFAULT 0,
filter_labels TEXT,
last_sync_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_linear_sync_repo
ON linear_sync_config(repository_id);

INSERT OR IGNORE INTO global_settings (key, value) VALUES
('linear_api_key', ''),
('linear_enabled', 'false');
"#,
kind: MigrationKind::Up,
},
Migration {
version: 14,
description: "add sync_schedule to linear_sync_config",
sql: r#"
ALTER TABLE linear_sync_config ADD COLUMN sync_schedule TEXT NOT NULL DEFAULT 'manual';
"#,
kind: MigrationKind::Up,
},
]
}
24 changes: 24 additions & 0 deletions src/core/api/useEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
updateTask as dbUpdateTask,
} from "@core/db/tasks";
import { listRepositories } from "@core/db/repositories";
import { addComment as addLinearComment } from "@core/services/linear";
import type {
BudgetConfig,
BudgetStatus,
Expand Down Expand Up @@ -447,6 +448,29 @@ async function handleTaskResult(
}
}

// Link PR back to Linear if this is a Linear-sourced task
if (prUrl) {
try {
const task = await getTask(variables.taskId);
if (task?.linearIssueId && settings.linearApiKey) {
await addLinearComment(
settings.linearApiKey,
task.linearIssueId,
`PR created by [SUSTN](https://sustn.app): ${prUrl}`,
);
console.log(
"[handleTaskResult] linked PR to Linear issue:",
task.linearIdentifier,
);
}
} catch (linearErr) {
console.error(
"[handleTaskResult] Linear link-back failed:",
linearErr,
);
}
}

await dbUpdateTaskWithRetry(variables.taskId, {
state: prUrl ? ("done" as const) : ("review" as const),
baseBranch: variables.baseBranch,
Expand Down
Loading
Loading