98bf278472
- backend/handlers.go - data.cpp - data.h - main.cpp - panel_board.cpp - panel_filters.cpp - appicon.ico - backend/kanban_cpp_backend.exe Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
317 lines
9.7 KiB
C++
317 lines
9.7 KiB
C++
#include "data.h"
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <memory>
|
|
#include <thread>
|
|
|
|
#include "core/http_request.h"
|
|
#include "core/sse_client.h"
|
|
#include "core/logger.h"
|
|
#include <nlohmann/json.hpp>
|
|
|
|
namespace kanban {
|
|
|
|
using json = nlohmann::json;
|
|
|
|
State& state() {
|
|
static State s;
|
|
return s;
|
|
}
|
|
|
|
static std::unique_ptr<fn_sse::Client> g_sse;
|
|
|
|
static std::vector<std::string> j_arr(const json& j, const char* key) {
|
|
std::vector<std::string> out;
|
|
if (!j.contains(key) || !j[key].is_array()) return out;
|
|
for (const auto& v : j[key]) {
|
|
if (v.is_string()) out.push_back(v.get<std::string>());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
static std::string j_str(const json& j, const char* key) {
|
|
if (!j.contains(key) || !j[key].is_string()) return "";
|
|
return j[key].get<std::string>();
|
|
}
|
|
|
|
static Issue parse_issue(const json& j) {
|
|
Issue i;
|
|
i.id = j_str(j, "id");
|
|
i.title = j_str(j, "title");
|
|
i.status = j_str(j, "status");
|
|
i.type = j_str(j, "type");
|
|
i.scope = j_str(j, "scope");
|
|
i.priority = j_str(j, "priority");
|
|
i.domain = j_arr(j, "domain");
|
|
i.tags = j_arr(j, "tags");
|
|
i.depends = j_arr(j, "depends");
|
|
i.blocks = j_arr(j, "blocks");
|
|
i.related = j_arr(j, "related");
|
|
i.flow = j_str(j, "flow");
|
|
i.file_path = j_str(j, "file_path");
|
|
if (j.contains("completed") && j["completed"].is_boolean())
|
|
i.completed = j["completed"].get<bool>();
|
|
i.body = j_str(j, "body");
|
|
return i;
|
|
}
|
|
|
|
static Flow parse_flow(const json& j) {
|
|
Flow f;
|
|
f.id = j_str(j, "id");
|
|
f.title = j_str(j, "title");
|
|
f.status = j_str(j, "status");
|
|
f.kind = j_str(j, "kind");
|
|
f.tags = j_arr(j, "tags");
|
|
f.file_path = j_str(j, "file_path");
|
|
f.body = j_str(j, "body");
|
|
return f;
|
|
}
|
|
|
|
static fn_http::Response http_get(const std::string& path) {
|
|
fn_http::Request req;
|
|
req.method = "GET";
|
|
req.url = state().backend_url + path;
|
|
req.timeout_ms = 5000;
|
|
return fn_http::request(req);
|
|
}
|
|
|
|
static fn_http::Response http_patch_json(const std::string& path, const std::string& body) {
|
|
fn_http::Request req;
|
|
req.method = "PATCH";
|
|
req.url = state().backend_url + path;
|
|
req.timeout_ms = 5000;
|
|
req.headers.push_back({"Content-Type", "application/json"});
|
|
req.body = body;
|
|
return fn_http::request(req);
|
|
}
|
|
|
|
static fn_http::Response http_post_json(const std::string& path, const std::string& body) {
|
|
fn_http::Request req;
|
|
req.method = "POST";
|
|
req.url = state().backend_url + path;
|
|
req.timeout_ms = 10000;
|
|
req.headers.push_back({"Content-Type", "application/json"});
|
|
req.body = body;
|
|
return fn_http::request(req);
|
|
}
|
|
|
|
bool refresh_issues() {
|
|
auto resp = http_get("/api/issues");
|
|
if (resp.status != 200) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_error = "issues fetch: " + std::to_string(resp.status) + " " + resp.error;
|
|
return false;
|
|
}
|
|
auto j = json::parse(resp.body, nullptr, false);
|
|
if (j.is_discarded() || !j.is_array()) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_error = "issues: invalid JSON";
|
|
return false;
|
|
}
|
|
std::vector<Issue> out;
|
|
out.reserve(j.size());
|
|
for (const auto& it : j) out.push_back(parse_issue(it));
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().issues = std::move(out);
|
|
state().last_refresh_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
state().last_error.clear();
|
|
return true;
|
|
}
|
|
|
|
bool refresh_flows() {
|
|
auto resp = http_get("/api/flows");
|
|
if (resp.status != 200) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_error = "flows fetch: " + std::to_string(resp.status);
|
|
return false;
|
|
}
|
|
auto j = json::parse(resp.body, nullptr, false);
|
|
if (j.is_discarded() || !j.is_array()) return false;
|
|
std::vector<Flow> out;
|
|
out.reserve(j.size());
|
|
for (const auto& it : j) out.push_back(parse_flow(it));
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().flows = std::move(out);
|
|
return true;
|
|
}
|
|
|
|
bool refresh_meta() {
|
|
auto resp = http_get("/api/meta");
|
|
if (resp.status != 200) return false;
|
|
auto j = json::parse(resp.body, nullptr, false);
|
|
if (j.is_discarded()) return false;
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().meta.statuses = j_arr(j, "statuses");
|
|
state().meta.priorities = j_arr(j, "priorities");
|
|
state().meta.scopes = j_arr(j, "scopes");
|
|
state().meta.types = j_arr(j, "types");
|
|
return true;
|
|
}
|
|
|
|
bool refresh_issue_detail(const std::string& id) {
|
|
auto resp = http_get("/api/issues/" + id);
|
|
if (resp.status != 200) return false;
|
|
auto j = json::parse(resp.body, nullptr, false);
|
|
if (j.is_discarded()) return false;
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().selected_issue_id = id;
|
|
state().selected_issue_detail = parse_issue(j);
|
|
return true;
|
|
}
|
|
|
|
static std::string json_escape(const std::string& s) {
|
|
json j = s;
|
|
return j.dump();
|
|
}
|
|
|
|
bool patch_issue_status(const std::string& id, const std::string& new_status) {
|
|
std::string body = "{\"status\":" + json_escape(new_status) + "}";
|
|
auto resp = http_patch_json("/api/issues/" + id, body);
|
|
if (resp.status != 200) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_error = "PATCH status " + id + ": " + std::to_string(resp.status);
|
|
return false;
|
|
}
|
|
// Update local cache without full refresh.
|
|
auto j = json::parse(resp.body, nullptr, false);
|
|
if (!j.is_discarded()) {
|
|
Issue updated = parse_issue(j);
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
for (auto& i : state().issues) {
|
|
if (i.id == id) {
|
|
i = updated;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool refresh_agent_status() {
|
|
auto resp = http_get("/api/agent_status");
|
|
if (resp.status != 200) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().agent_runner_up = false;
|
|
state().agent_active.clear();
|
|
return false;
|
|
}
|
|
auto j = json::parse(resp.body, nullptr, false);
|
|
if (j.is_discarded()) return false;
|
|
std::map<std::string, std::string> active;
|
|
if (j.contains("active") && j["active"].is_object()) {
|
|
for (auto it = j["active"].begin(); it != j["active"].end(); ++it) {
|
|
if (it.value().is_string()) active[it.key()] = it.value().get<std::string>();
|
|
}
|
|
}
|
|
bool up = j.value("available", false);
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().agent_runner_up = up;
|
|
state().agent_active = std::move(active);
|
|
return true;
|
|
}
|
|
|
|
bool launch_agent(const std::string& issue_id) {
|
|
std::string body = "{\"issue_id\":" + json_escape(issue_id) + ",\"mode\":\"fix-issue\"}";
|
|
auto resp = http_post_json("/api/agent_launch", body);
|
|
if (resp.status < 200 || resp.status >= 300) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_launch_msg = "launch failed (" + std::to_string(resp.status) + "): " + resp.body;
|
|
return false;
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_launch_msg = "launched agent on " + issue_id;
|
|
// Optimistically mark as active so the dot shows up immediately.
|
|
state().agent_active[issue_id] = "pending";
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool is_agent_active(const std::string& issue_id) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
return state().agent_active.find(issue_id) != state().agent_active.end();
|
|
}
|
|
|
|
bool patch_issue_fields(const std::string& id, const std::string& json_partial) {
|
|
auto resp = http_patch_json("/api/issues/" + id, json_partial);
|
|
if (resp.status != 200) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_error = "PATCH " + id + ": " + std::to_string(resp.status);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void start_sse() {
|
|
if (g_sse) return;
|
|
fn_sse::Config cfg;
|
|
cfg.url = state().backend_url + "/api/sse";
|
|
g_sse = std::make_unique<fn_sse::Client>();
|
|
g_sse->start(
|
|
cfg,
|
|
[](const fn_sse::Event& /*ev*/) {
|
|
std::lock_guard<std::mutex> g(state().mu);
|
|
state().last_refresh_ns = 0;
|
|
},
|
|
nullptr);
|
|
}
|
|
|
|
void stop_sse() {
|
|
if (g_sse) {
|
|
g_sse->stop();
|
|
g_sse.reset();
|
|
}
|
|
}
|
|
|
|
static bool contains(const std::set<std::string>& s, const std::string& v) {
|
|
return s.find(v) != s.end();
|
|
}
|
|
|
|
bool passes_filters(const Issue& iss) {
|
|
const auto& f = state().filters;
|
|
if (!f.include_completed && iss.completed) return false;
|
|
if (!f.priorities.empty() && !contains(f.priorities, iss.priority)) return false;
|
|
if (!f.scopes.empty() && !contains(f.scopes, iss.scope)) return false;
|
|
if (!f.domains.empty()) {
|
|
bool match = false;
|
|
for (const auto& d : iss.domain) {
|
|
if (contains(f.domains, d)) {
|
|
match = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!match) return false;
|
|
}
|
|
if (!f.tags.empty()) {
|
|
bool match = false;
|
|
for (const auto& t : iss.tags) {
|
|
if (contains(f.tags, t)) {
|
|
match = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!match) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::vector<std::string> collect_domains() {
|
|
std::set<std::string> uniq;
|
|
for (const auto& i : state().issues) {
|
|
for (const auto& d : i.domain) uniq.insert(d);
|
|
}
|
|
return {uniq.begin(), uniq.end()};
|
|
}
|
|
|
|
std::vector<std::string> collect_tags() {
|
|
std::set<std::string> uniq;
|
|
for (const auto& i : state().issues) {
|
|
for (const auto& t : i.tags) uniq.insert(t);
|
|
}
|
|
return {uniq.begin(), uniq.end()};
|
|
}
|
|
|
|
} // namespace kanban
|