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
103 changes: 103 additions & 0 deletions cmd/ghweb/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package main

import (
"crypto/tls"
"encoding/xml"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"

"github.com/BorisTyshkevich/github-semantic-search/internal/click"
"github.com/BorisTyshkevich/github-semantic-search/internal/embed"
"github.com/BorisTyshkevich/github-semantic-search/internal/server"
)

var searchFn = click.Search

Check failure on line 18 in cmd/ghweb/main.go

View workflow job for this annotation

GitHub Actions / Perform SAST analysis (golangci-lint)

var searchFn is unused (unused)

func main() {
var (
listen = flag.String("listen", ":8080", "http listen address")
connName = flag.String("connection", "", "name of connection from ~/.clickhouse-client/config.xml")
host = flag.String("host", "github.demo.altinity.cloud", "ClickHouse host")
port = flag.Int("port", 9440, "ClickHouse port")
user = flag.String("user", "demo", "ClickHouse user")
pass = flag.String("pass", "demo", "ClickHouse password")
secure = flag.Bool("secure", true, "use TLS")
chTab = flag.String("table", "clickcomments", "ClickHouse table")
debug = flag.Bool("debug", false, "debug mode")
)
flag.Parse()

var (
clickHost string
clickUser string
clickPass string
clickDB string
useSecure bool
)
if *connName != "" {
var err error
clickHost, clickUser, clickPass, clickDB, useSecure, err = loadConnection(*connName)
if err != nil {
log.Fatalf("load connection: %v", err)
}
} else {
clickHost = fmt.Sprintf("%s:%d", *host, *port)
clickUser = *user
clickPass = *pass
clickDB = "default"
useSecure = *secure
}
tlsCfg := &tls.Config{InsecureSkipVerify: true}

Check failure on line 54 in cmd/ghweb/main.go

View workflow job for this annotation

GitHub Actions / Perform SAST analysis (golangci-lint)

G402: TLS InsecureSkipVerify set true. (gosec)
if !useSecure {
tlsCfg = nil
}

emb := embed.OpenAI{}
handler := server.Handler(emb, click.Options{
Host: clickHost,
User: clickUser,
Password: clickPass,
DB: clickDB,
Table: *chTab,
TLS: tlsCfg,
}, *debug)

log.Printf("listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, handler))

Check failure on line 70 in cmd/ghweb/main.go

View workflow job for this annotation

GitHub Actions / Perform SAST analysis (golangci-lint)

G114: Use of net/http serve function that has no support for setting timeouts (gosec)
}

type clickhouseConfig struct {
Connections struct {
Connections []struct {
Name string `xml:"name"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
User string `xml:"user"`
Password string `xml:"password"`
Database string `xml:"database"`
Secure int `xml:"secure"`
} `xml:"connection"`
} `xml:"connections_credentials"`
}

func loadConnection(name string) (host, user, pass, db string, secure bool, err error) {
path := filepath.Join(os.Getenv("HOME"), ".clickhouse-client", "config.xml")
raw, err := os.ReadFile(path)
if err != nil {
return "", "", "", "", false, err
}
var cfg clickhouseConfig
if err := xml.Unmarshal(raw, &cfg); err != nil {
return "", "", "", "", false, err
}
for _, c := range cfg.Connections.Connections {
if c.Name == name {
return fmt.Sprintf("%s:%d", c.Hostname, c.Port), c.User, c.Password, c.Database, c.Secure == 1, nil
}
}
return "", "", "", "", false, fmt.Errorf("connection %q not found", name)
}
5 changes: 5 additions & 0 deletions internal/embed/embedder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package embed

type Embedder interface {
Vector(text string, debug bool) ([]float32, error)
}
11 changes: 11 additions & 0 deletions internal/embed/mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package embed

// Mock is a simple embedder used for tests.
type Mock struct{}

func (Mock) Vector(text string, debug bool) ([]float32, error) {
// Return small vector for deterministic tests
return []float32{0.1, 0.2, 0.3, 0.4}, nil
}

var _ Embedder = (*Mock)(nil)
8 changes: 8 additions & 0 deletions internal/embed/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,11 @@ func Vector(text string, debug bool) ([]float32, error) {
return out.Data[0].Embedding, nil
}

// OpenAI implements Embedder using the OpenAI API.
type OpenAI struct{}

func (OpenAI) Vector(text string, debug bool) ([]float32, error) {
return Vector(text, debug)
}

var _ Embedder = (*OpenAI)(nil)
43 changes: 43 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package server

import (
"encoding/json"
"net/http"

"github.com/BorisTyshkevich/github-semantic-search/internal/click"
"github.com/BorisTyshkevich/github-semantic-search/internal/embed"
"github.com/BorisTyshkevich/github-semantic-search/web"
)

var content = web.FS

var SearchFn = click.Search

func Handler(emb embed.Embedder, opt click.Options, debug bool) http.Handler {
mux := http.NewServeMux()
fs := http.FS(content)
mux.Handle("/", http.FileServer(fs))
mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("query")
if q == "" {
http.Error(w, "missing query", http.StatusBadRequest)
return
}
state := r.URL.Query().Get("state")
labels := r.URL.Query().Get("labels")

vec, err := emb.Vector(q, debug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rows, err := SearchFn(vec, state, labels, opt, debug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rows)
})
return mux
}
37 changes: 37 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package server

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/BorisTyshkevich/github-semantic-search/internal/click"
"github.com/BorisTyshkevich/github-semantic-search/internal/embed"
)

func TestHandler(t *testing.T) {
SearchFn = func(vec []float32, state, labels string, opt click.Options, debug bool) ([]click.Row, error) {
return []click.Row{{Number: 1, Title: "Test", State: "open"}}, nil
}
defer func() { SearchFn = click.Search }()
h := Handler(embed.Mock{}, click.Options{}, false)
ts := httptest.NewServer(h)
defer ts.Close()

resp, err := http.Get(ts.URL + "/api/search?query=foo")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d", resp.StatusCode)
}
var rows []click.Row
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Number != 1 {
t.Fatalf("unexpected response: %#v", rows)
}
}
6 changes: 6 additions & 0 deletions web/fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package web

import "embed"

//go:embed *
var FS embed.FS
42 changes: 42 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>GitHub Issue Search</title>
<style>
body{font-family:Arial,sans-serif;margin:40px;background:#f7f7f7;}
#results{margin-top:20px;}
.issue{padding:10px;border-bottom:1px solid #ddd;background:#fff;}
.issue a{font-weight:bold;text-decoration:none;color:#0366d6;}
</style>
</head>
<body>
<h1>GitHub Issue Search</h1>
<form id="searchForm">
<input type="text" id="query" placeholder="search text" size="40" required />
<label>State:<input type="text" id="state" placeholder="open"/></label>
<label>Labels:<input type="text" id="labels" placeholder="bug,performance"/></label>
<button type="submit">Search</button>
</form>
<div id="results"></div>
<script>
document.getElementById('searchForm').addEventListener('submit', async (e) => {
e.preventDefault();
const q = document.getElementById('query').value;
const st = document.getElementById('state').value;
const lbl = document.getElementById('labels').value;
const params = new URLSearchParams({query:q,state:st,labels:lbl});
const res = await fetch('/api/search?' + params.toString());
const data = await res.json();
const container = document.getElementById('results');
container.innerHTML = '';
data.forEach(item => {
const div = document.createElement('div');
div.className = 'issue';
div.innerHTML = `<a href="https://github.com/ClickHouse/ClickHouse/issues/${item.number}" target="_blank">#${item.number}</a> ${item.title}`;
container.appendChild(div);
});
});
</script>
</body>
</html>
6 changes: 6 additions & 0 deletions webfs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package webfs

import "embed"

//go:embed web/*
var FS embed.FS
Loading