diff --git a/.agents/skills/pull-request/SKILL.md b/.agents/skills/pull-request/SKILL.md new file mode 100644 index 0000000..83ea65b --- /dev/null +++ b/.agents/skills/pull-request/SKILL.md @@ -0,0 +1,44 @@ +--- +name: pull-request +description: 自动对比当前分支相对于 master 的差异,推送代码,并使用 `gh` 命令以英文编写标题和描述创建 Pull Request。在用户请求提交 PR、总结更改或推送分支到主库时使用。 +--- + +# Pull Request Creation Workflow + +This skill automates the process of creating a Pull Request from the current branch to `master` (or the default base branch) using the GitHub CLI (`gh`). + +## Core Objectives + +1. **Analyze Diffs**: Compare the current branch with the target base branch (`master` by default) to understand what has changed. +2. **Generate Content**: Write a high-quality PR title and description in **English**, following standard engineering practices (Overview, Key Changes, Verification). +3. **Ensure Sync**: Always push the local branch to the remote origin before creating the PR. +4. **Create PR**: Use `gh pr create` to finalize the process. + +## Step-by-Step Instructions + +### 1. Research & Analysis +- Determine the current branch name: `git branch --show-current`. +- Verify the status and recent history: `git status && git log -n 5`. +- Analyze the functional changes against the base branch: `git diff master..HEAD --stat`. If the base branch is different, ask the user or check repo defaults. + +### 2. Strategy & Preparation +- If there are uncommitted changes, ask the user to commit or stash them first. +- Ensure the branch is pushed: `git push origin `. + +### 3. Generate PR Content (English Only) +Draft the title and body in English: +- **Title**: Use conventional commit style, e.g., `feat(ui): add new dashboard`, `fix(auth): resolve session timeout`. +- **Body**: + - `## Overview`: A brief summary of the purpose. + - `## Key Changes`: Bullet points describing specific implementation details. + - `## Verification`: How the changes were tested. + +### 4. Execution +- Call `gh pr create` with the generated title and body. +- If `gh` is not in the `PATH`, look for it in common locations like `/opt/homebrew/bin/gh` or `/usr/local/bin/gh`. +- Default flags: `--base master --head --web=false`. + +## Example Command +```bash +gh pr create --title "feat(observability): implement tracing" --body "## Overview..." --base master --head feat/branch-name +``` diff --git a/backend/main.go b/backend/main.go index b563d6b..0284b12 100644 --- a/backend/main.go +++ b/backend/main.go @@ -10,6 +10,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "vstable-engine/internal/ast" @@ -125,13 +126,24 @@ func UnaryInterceptor( } }() + traceID := "unknown" + if md, ok := metadata.FromIncomingContext(ctx); ok { + if vals := md.Get("x-trace-id"); len(vals) > 0 { + traceID = vals[0] + } + } + + log.Printf("[%s] gRPC Request: %s | payload: %+v", traceID, info.FullMethod, req) + resp, err = handler(ctx, req) if err != nil { // Just ensure it's a grpc status error if _, ok := status.FromError(err); !ok { err = status.Errorf(codes.Unknown, "%v", err) } - log.Printf("[gRPC Error] %s: %v", info.FullMethod, err) + log.Printf("[%s] gRPC Response: %s | error: %v", traceID, info.FullMethod, err) + } else { + log.Printf("[%s] gRPC Response: %s | success", traceID, info.FullMethod) } return resp, err } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d3dbce9..e3b4e9d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,16 +1,17 @@ { "name": "vstable", - "version": "0.4.0", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vstable", - "version": "0.4.0", + "version": "0.5.3", "license": "ISC", "dependencies": { "@monaco-editor/react": "^4.7.0", "@tailwindcss/postcss": "^4.1.18", + "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-shell": "^2.2.0", "@tauri-apps/plugin-store": "^2.2.0", "lucide-react": "^0.563.0", @@ -1876,6 +1877,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-log": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz", + "integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-shell": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index c8aecb9..5597bdd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,6 +55,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@tailwindcss/postcss": "^4.1.18", + "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-shell": "^2.2.0", "@tauri-apps/plugin-store": "^2.2.0", "lucide-react": "^0.563.0", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 103bef3..7547d07 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { LazyStore } from '@tauri-apps/plugin-store'; +import { info } from '@tauri-apps/plugin-log'; import type { ConnectionConfig, PersistedWorkspace, QueryResult } from '../types/session'; /** @@ -9,6 +10,14 @@ import type { ConnectionConfig, PersistedWorkspace, QueryResult } from '../types const store = new LazyStore('settings.json'); +const callApi = async (command: string, args: Record = {}): Promise => { + const traceId = crypto.randomUUID(); + const payload = { ...args, traceId }; + // Log the outgoing request to the unified log file + info(`[${traceId}] IPC Call: ${command} | args: ${JSON.stringify(args)}`); + return invoke(command, payload); +}; + export const apiClient = { // Database Operations connect: async (id: string, config: ConnectionConfig): Promise => { @@ -19,23 +28,23 @@ export const apiClient = { } else { dsn = `postgres://${user}:${password}@${host}:${port}/${database}?sslmode=disable`; } - return invoke('db_connect', { id, dialect, dsn }); + return callApi('db_connect', { id, dialect, dsn }); }, query: async (id: string, sql: string, params?: any[]): Promise => - invoke('db_query', { id, sql, params }), + callApi('db_query', { id, sql, params }), - disconnect: async (id: string): Promise => invoke('db_disconnect', { id }), + disconnect: async (id: string): Promise => callApi('db_disconnect', { id }), - enginePing: async (): Promise => invoke('engine_ping'), + enginePing: async (): Promise => callApi('engine_ping'), generateAlterSql: async (req: any): Promise => { - const res: any = await invoke('sql_generate_alter', { req }); + const res: any = await callApi('sql_generate_alter', { req }); return res.sqls || []; }, generateCreateSql: async (req: any): Promise => { - const res: any = await invoke('sql_generate_create', { req }); + const res: any = await callApi('sql_generate_create', { req }); return res.sqls || []; }, diff --git a/frontend/tauri/src/commands.rs b/frontend/tauri/src/commands.rs index 1a66ea2..19698d2 100644 --- a/frontend/tauri/src/commands.rs +++ b/frontend/tauri/src/commands.rs @@ -4,13 +4,22 @@ use crate::vstable::{ConnectRequest, DisconnectRequest, QueryRequest, PingReques use crate::vstable::engine_service_client::EngineServiceClient; use crate::utils::{json_to_prost_value, prost_struct_to_json, json_to_diff_request}; +fn inject_trace_id(mut req: tonic::Request, trace_id: &str) -> tonic::Request { + if let Ok(meta_value) = trace_id.parse() { + req.metadata_mut().insert("x-trace-id", meta_value); + } + req +} + #[tauri::command] pub async fn db_connect( state: State<'_, GrpcState>, + trace_id: String, id: String, dialect: String, dsn: String, ) -> Result { + log::info!("[{}] Received IPC: db_connect | id: {}, dialect: {}", trace_id, id, dialect); let mut client_lock = state.client.lock().await; let addr = format!("http://127.0.0.1:{}", state.port); let channel = tonic::transport::Endpoint::from_shared(addr) @@ -20,7 +29,7 @@ pub async fn db_connect( .map_err(|e| e.to_string())?; let mut client = EngineServiceClient::new(channel); - let request = tonic::Request::new(ConnectRequest { id, dialect, dsn }); + let request = inject_trace_id(tonic::Request::new(ConnectRequest { id, dialect, dsn }), &trace_id); let response = client.db_connect(request).await.map_err(|e| e.to_string())?; let inner = response.into_inner(); @@ -31,10 +40,12 @@ pub async fn db_connect( #[tauri::command] pub async fn db_query( state: State<'_, GrpcState>, + trace_id: String, id: String, sql: String, params: Option>, ) -> Result { + log::info!("[{}] Received IPC: db_query | id: {}, sql length: {}", trace_id, id, sql.len()); let mut client_lock = state.client.lock().await; let client = client_lock.as_mut().ok_or("Not connected")?; @@ -42,7 +53,7 @@ pub async fn db_query( values: p.into_iter().map(json_to_prost_value).collect(), }); - let request = tonic::Request::new(QueryRequest { id, sql, params: pb_params }); + let request = inject_trace_id(tonic::Request::new(QueryRequest { id, sql, params: pb_params }), &trace_id); let response = client.query(request).await.map_err(|e| e.to_string())?; let inner = response.into_inner(); @@ -54,16 +65,18 @@ pub async fn db_query( } #[tauri::command] -pub async fn db_disconnect(state: State<'_, GrpcState>, id: String) -> Result<(), String> { +pub async fn db_disconnect(state: State<'_, GrpcState>, trace_id: String, id: String) -> Result<(), String> { + log::info!("[{}] Received IPC: db_disconnect | id: {}", trace_id, id); let mut client_lock = state.client.lock().await; let client = client_lock.as_mut().ok_or("Not connected")?; - let request = tonic::Request::new(DisconnectRequest { id }); + let request = inject_trace_id(tonic::Request::new(DisconnectRequest { id }), &trace_id); client.disconnect(request).await.map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] -pub async fn engine_ping(state: State<'_, GrpcState>) -> Result { +pub async fn engine_ping(state: State<'_, GrpcState>, trace_id: String) -> Result { + log::info!("[{}] Received IPC: engine_ping", trace_id); let mut client_lock = state.client.lock().await; let addr = format!("http://127.0.0.1:{}", state.port); let channel = tonic::transport::Endpoint::from_shared(addr) @@ -73,26 +86,31 @@ pub async fn engine_ping(state: State<'_, GrpcState>) -> Result { .map_err(|e| e.to_string())?; let mut client = EngineServiceClient::new(channel); - let result = client.ping(tonic::Request::new(PingRequest {})).await.is_ok(); + let request = inject_trace_id(tonic::Request::new(PingRequest {}), &trace_id); + let result = client.ping(request).await.is_ok(); *client_lock = Some(client); Ok(result) } #[tauri::command] -pub async fn sql_generate_alter(state: State<'_, GrpcState>, req: serde_json::Value) -> Result { +pub async fn sql_generate_alter(state: State<'_, GrpcState>, trace_id: String, req: serde_json::Value) -> Result { + log::info!("[{}] Received IPC: sql_generate_alter | req: {}", trace_id, req); let mut client_lock = state.client.lock().await; let client = client_lock.as_mut().ok_or("Not connected")?; - let request = tonic::Request::new(json_to_diff_request(req)); + let diff_req = json_to_diff_request(req)?; + let request = inject_trace_id(tonic::Request::new(diff_req), &trace_id); let response = client.generate_alter_table(request).await.map_err(|e| e.to_string())?; let inner = response.into_inner(); Ok(serde_json::json!({ "success": inner.success, "sqls": inner.sqls })) } #[tauri::command] -pub async fn sql_generate_create(state: State<'_, GrpcState>, req: serde_json::Value) -> Result { +pub async fn sql_generate_create(state: State<'_, GrpcState>, trace_id: String, req: serde_json::Value) -> Result { + log::info!("[{}] Received IPC: sql_generate_create | req: {}", trace_id, req); let mut client_lock = state.client.lock().await; let client = client_lock.as_mut().ok_or("Not connected")?; - let request = tonic::Request::new(json_to_diff_request(req)); + let diff_req = json_to_diff_request(req)?; + let request = inject_trace_id(tonic::Request::new(diff_req), &trace_id); let response = client.generate_create_table(request).await.map_err(|e| e.to_string())?; let inner = response.into_inner(); Ok(serde_json::json!({ "success": inner.success, "sqls": inner.sqls })) diff --git a/frontend/tauri/src/lib.rs b/frontend/tauri/src/lib.rs index 1da0947..d7e51f5 100644 --- a/frontend/tauri/src/lib.rs +++ b/frontend/tauri/src/lib.rs @@ -10,10 +10,27 @@ use tauri_plugin_shell::ShellExt; use std::sync::Arc; use tokio::sync::Mutex; use grpc::GrpcState; +use tauri_plugin_log::{Target, TargetKind, RotationStrategy}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let log_dir = std::env::current_exe().unwrap().parent().unwrap().join("logs"); + tauri::Builder::default() + .plugin( + tauri_plugin_log::Builder::new() + .targets([ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::Folder { + path: log_dir, + file_name: Some("vstable".to_string()), + }), + Target::new(TargetKind::Webview), + ]) + .rotation_strategy(RotationStrategy::KeepAll) + .max_file_size(50 * 1024 * 1024) + .build() + ) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_store::Builder::default().build()) .manage(GrpcState { client: Arc::new(Mutex::new(None)), port: 39082 }) @@ -34,9 +51,9 @@ pub fn run() { tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { if let tauri_plugin_shell::process::CommandEvent::Stdout(line) = event { - println!("Sidecar: {}", String::from_utf8_lossy(&line)); + log::info!("Sidecar: {}", String::from_utf8_lossy(&line)); } else if let tauri_plugin_shell::process::CommandEvent::Stderr(line) = event { - eprintln!("Sidecar Error: {}", String::from_utf8_lossy(&line)); + log::error!("Sidecar Error: {}", String::from_utf8_lossy(&line)); } } }); diff --git a/frontend/tauri/src/utils.rs b/frontend/tauri/src/utils.rs index aa31e0e..3edc5cb 100644 --- a/frontend/tauri/src/utils.rs +++ b/frontend/tauri/src/utils.rs @@ -65,77 +65,129 @@ pub fn prost_value_to_json(v: prost_types::Value) -> serde_json::Value { } } -pub fn json_to_diff_request(v: serde_json::Value) -> DiffRequest { - DiffRequest { - dialect: v["dialect"].as_str().unwrap_or("").to_string(), - schema: v["schema"].as_str().unwrap_or("").to_string(), - table_name: v["table"].as_str().unwrap_or("").to_string(), - old_table_name: v["original_name"].as_str().unwrap_or("").to_string(), - columns: json_to_vec_column(v["columns"].clone()), - deleted_columns: json_to_vec_column(v["deleted_columns"].clone()), - indexes: json_to_vec_index(v["indexes"].clone()), - deleted_indexes: json_to_vec_index(v["deleted_indexes"].clone()), - foreign_keys: vec![], - deleted_foreign_keys: vec![], - check_constraints: vec![], - deleted_checks: vec![], - views: vec![], - deleted_views: vec![], - triggers: vec![], - deleted_triggers: vec![], - routines: vec![], - deleted_routines: vec![], - config: None, - } +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DiffRequestDto { + #[serde(default)] + pub dialect: String, + #[serde(default)] + pub schema: String, + #[serde(default)] + pub table_name: String, + #[serde(default)] + pub old_table_name: String, + #[serde(default)] + pub columns: Vec, + #[serde(default)] + pub deleted_columns: Vec, + #[serde(default)] + pub indexes: Vec, + #[serde(default)] + pub deleted_indexes: Vec, } -fn json_to_vec_column(v: serde_json::Value) -> Vec { - v.as_array() - .unwrap_or(&vec![]) - .iter() - .map(|c| ColumnDefinition { - name: c["name"].as_str().unwrap_or("").to_string(), - r#type: c["type"].as_str().unwrap_or("").to_string(), - nullable: c["nullable"].as_bool().unwrap_or(true), - default_value: c["default_value"].as_str().map(|s| s.to_string()), - is_primary_key: c["primary_key"].as_bool().unwrap_or(false), - is_auto_increment: c["auto_increment"].as_bool().unwrap_or(false), - comment: c["comment"].as_str().unwrap_or("").to_string(), - length: c["length"] - .as_i64() - .map(|i| json_to_prost_value(serde_json::Value::Number(i.into()))), - precision: c["precision"] - .as_i64() - .map(|i| json_to_prost_value(serde_json::Value::Number(i.into()))), - scale: c["scale"] - .as_i64() - .map(|i| json_to_prost_value(serde_json::Value::Number(i.into()))), +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ColumnDefinitionDto { + #[serde(default)] + pub name: String, + #[serde(default)] + pub r#type: String, + #[serde(default = "default_true")] + pub nullable: bool, + pub default_value: Option, + #[serde(default)] + pub is_primary_key: bool, + #[serde(default)] + pub is_auto_increment: bool, + #[serde(default)] + pub comment: String, + pub length: Option, + pub precision: Option, + pub scale: Option, + #[serde(default = "default_original_index")] + pub original_index: i32, +} + +fn default_true() -> bool { true } +fn default_original_index() -> i32 { -1 } + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct IndexDefinitionDto { + #[serde(default)] + pub name: String, + #[serde(default)] + pub columns: Vec, + #[serde(default)] + pub is_unique: bool, +} + +impl From for ColumnDefinition { + fn from(dto: ColumnDefinitionDto) -> Self { + ColumnDefinition { + name: dto.name, + r#type: dto.r#type, + nullable: dto.nullable, + default_value: dto.default_value, + is_primary_key: dto.is_primary_key, + is_auto_increment: dto.is_auto_increment, + comment: dto.comment, + length: dto.length.map(|i| json_to_prost_value(serde_json::Value::Number(i.into()))), + precision: dto.precision.map(|i| json_to_prost_value(serde_json::Value::Number(i.into()))), + scale: dto.scale.map(|i| json_to_prost_value(serde_json::Value::Number(i.into()))), enum_values: vec![], id: "".to_string(), is_default_expression: false, is_identity: false, original: None, - original_index: c["original_index"].as_i64().map(|i| i as i32).unwrap_or(-1), + original_index: dto.original_index, pk_constraint_name: "".to_string(), - }) - .collect() + } + } } -fn json_to_vec_index(v: serde_json::Value) -> Vec { - v.as_array() - .unwrap_or(&vec![]) - .iter() - .map(|i| IndexDefinition { - name: i["name"].as_str().unwrap_or("").to_string(), - columns: i["columns"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .map(|s| s.as_str().unwrap_or("").to_string()) - .collect(), - is_unique: i["unique"].as_bool().unwrap_or(false), +impl From for IndexDefinition { + fn from(dto: IndexDefinitionDto) -> Self { + IndexDefinition { + name: dto.name, + columns: dto.columns, + is_unique: dto.is_unique, id: "".to_string(), original: None, - }) - .collect() + } + } +} + +impl From for DiffRequest { + fn from(dto: DiffRequestDto) -> Self { + DiffRequest { + dialect: dto.dialect, + schema: dto.schema, + table_name: dto.table_name, + old_table_name: dto.old_table_name, + columns: dto.columns.into_iter().map(|c| c.into()).collect(), + deleted_columns: dto.deleted_columns.into_iter().map(|c| c.into()).collect(), + indexes: dto.indexes.into_iter().map(|i| i.into()).collect(), + deleted_indexes: dto.deleted_indexes.into_iter().map(|i| i.into()).collect(), + foreign_keys: vec![], + deleted_foreign_keys: vec![], + check_constraints: vec![], + deleted_checks: vec![], + views: vec![], + deleted_views: vec![], + triggers: vec![], + deleted_triggers: vec![], + routines: vec![], + deleted_routines: vec![], + config: None, + } + } +} + +pub fn json_to_diff_request(v: serde_json::Value) -> Result { + let dto: DiffRequestDto = serde_json::from_value(v).map_err(|e| format!("Invalid DiffRequest format: {}", e))?; + Ok(dto.into()) }