WIP add sourcegraph
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/cache"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// Client wraps Sourcegraph GraphQL API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
accessToken string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Range represents a range in source code
|
||||
type Range struct {
|
||||
Start Position `json:"start"`
|
||||
End Position `json:"end"`
|
||||
}
|
||||
|
||||
// Position represents a position in source code
|
||||
type Position struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
}
|
||||
|
||||
// HoverResult represents hover documentation response
|
||||
type HoverResult struct {
|
||||
Contents string `json:"contents"` // Markdown content
|
||||
Range *Range `json:"range,omitempty"`
|
||||
}
|
||||
|
||||
// Location represents a code location
|
||||
type Location struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
var defaultClient *Client
|
||||
|
||||
// GetClient returns the singleton Sourcegraph client
|
||||
func GetClient() *Client {
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// NewClient creates a new Sourcegraph client with current settings
|
||||
func NewClient() *Client {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: setting.Sourcegraph.SkipTLSVerify, //nolint:gosec
|
||||
},
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: setting.Sourcegraph.URL,
|
||||
accessToken: setting.Sourcegraph.AccessToken,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(setting.Sourcegraph.Timeout) * time.Second,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// graphqlRequest performs a GraphQL request to Sourcegraph
|
||||
func (c *Client) graphqlRequest(ctx context.Context, query string, variables map[string]any) (json.RawMessage, error) {
|
||||
body := map[string]any{
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
}
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/.api/graphql", bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "token "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
return nil, fmt.Errorf("graphql error: %s", result.Errors[0].Message)
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// cacheKey generates a cache key for code intelligence queries
|
||||
func cacheKey(queryType, repo, rev, path string, line, char int) string {
|
||||
return fmt.Sprintf("sg:%s:%s:%s:%s:%d:%d", queryType, repo, rev, path, line, char)
|
||||
}
|
||||
|
||||
// Hover queries documentation at a position
|
||||
func (c *Client) Hover(ctx context.Context, repo, rev, path string, line, char int) (*HoverResult, error) {
|
||||
key := cacheKey("hover", repo, rev, path, line, char)
|
||||
|
||||
// Try cache first
|
||||
if cached, hit := cache.GetCache().Get(key); hit {
|
||||
var result HoverResult
|
||||
if err := json.Unmarshal([]byte(cached), &result); err == nil {
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := c.graphqlRequest(ctx, hoverQuery, map[string]any{
|
||||
"repo": repo,
|
||||
"rev": rev,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"char": char,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := parseHoverResponse(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if result != nil {
|
||||
if resultBytes, err := json.Marshal(result); err == nil {
|
||||
_ = cache.GetCache().Put(key, string(resultBytes), int64(setting.Sourcegraph.CacheTTL))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Definition queries for symbol definition
|
||||
func (c *Client) Definition(ctx context.Context, repo, rev, path string, line, char int) ([]Location, error) {
|
||||
key := cacheKey("def", repo, rev, path, line, char)
|
||||
|
||||
// Try cache first
|
||||
if cached, hit := cache.GetCache().Get(key); hit {
|
||||
var result []Location
|
||||
if err := json.Unmarshal([]byte(cached), &result); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := c.graphqlRequest(ctx, definitionQuery, map[string]any{
|
||||
"repo": repo,
|
||||
"rev": rev,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"char": char,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := parseLocationsResponse(data, "definitions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if resultBytes, err := json.Marshal(result); err == nil {
|
||||
_ = cache.GetCache().Put(key, string(resultBytes), int64(setting.Sourcegraph.CacheTTL))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// References queries for all references to a symbol
|
||||
func (c *Client) References(ctx context.Context, repo, rev, path string, line, char int) ([]Location, error) {
|
||||
key := cacheKey("refs", repo, rev, path, line, char)
|
||||
|
||||
// Try cache first
|
||||
if cached, hit := cache.GetCache().Get(key); hit {
|
||||
var result []Location
|
||||
if err := json.Unmarshal([]byte(cached), &result); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := c.graphqlRequest(ctx, referencesQuery, map[string]any{
|
||||
"repo": repo,
|
||||
"rev": rev,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"char": char,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := parseLocationsResponse(data, "references")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if resultBytes, err := json.Marshal(result); err == nil {
|
||||
_ = cache.GetCache().Put(key, string(resultBytes), int64(setting.Sourcegraph.CacheTTL))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SyncRepository notifies Sourcegraph to re-sync a repository
|
||||
func (c *Client) SyncRepository(ctx context.Context, repoName string) error {
|
||||
// Use the repository sync mutation
|
||||
_, err := c.graphqlRequest(ctx, syncRepoMutation, map[string]any{
|
||||
"repo": repoName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("Sourcegraph sync failed for %s: %v", repoName, err)
|
||||
return err
|
||||
}
|
||||
log.Debug("Sourcegraph sync triggered for %s", repoName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GraphQL queries for Sourcegraph API
|
||||
|
||||
const hoverQuery = `
|
||||
query Hover($repo: String!, $rev: String!, $path: String!, $line: Int!, $char: Int!) {
|
||||
repository(name: $repo) {
|
||||
commit(rev: $rev) {
|
||||
blob(path: $path) {
|
||||
lsif {
|
||||
hover(line: $line, character: $char) {
|
||||
markdown {
|
||||
text
|
||||
}
|
||||
range {
|
||||
start { line character }
|
||||
end { line character }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const definitionQuery = `
|
||||
query Definition($repo: String!, $rev: String!, $path: String!, $line: Int!, $char: Int!) {
|
||||
repository(name: $repo) {
|
||||
commit(rev: $rev) {
|
||||
blob(path: $path) {
|
||||
lsif {
|
||||
definitions(line: $line, character: $char) {
|
||||
nodes {
|
||||
resource {
|
||||
path
|
||||
repository {
|
||||
name
|
||||
}
|
||||
commit {
|
||||
oid
|
||||
}
|
||||
}
|
||||
range {
|
||||
start { line character }
|
||||
end { line character }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const referencesQuery = `
|
||||
query References($repo: String!, $rev: String!, $path: String!, $line: Int!, $char: Int!) {
|
||||
repository(name: $repo) {
|
||||
commit(rev: $rev) {
|
||||
blob(path: $path) {
|
||||
lsif {
|
||||
references(line: $line, character: $char, first: 100) {
|
||||
nodes {
|
||||
resource {
|
||||
path
|
||||
repository {
|
||||
name
|
||||
}
|
||||
commit {
|
||||
oid
|
||||
}
|
||||
}
|
||||
range {
|
||||
start { line character }
|
||||
end { line character }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const syncRepoMutation = `
|
||||
mutation SyncRepo($repo: String!) {
|
||||
scheduleRepositoryPermissionsSync(repository: $repo) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Response parsing structures
|
||||
|
||||
type hoverResponse struct {
|
||||
Repository *struct {
|
||||
Commit *struct {
|
||||
Blob *struct {
|
||||
LSIF *struct {
|
||||
Hover *struct {
|
||||
Markdown *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"markdown"`
|
||||
Range *struct {
|
||||
Start struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"start"`
|
||||
End struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"end"`
|
||||
} `json:"range"`
|
||||
} `json:"hover"`
|
||||
} `json:"lsif"`
|
||||
} `json:"blob"`
|
||||
} `json:"commit"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
|
||||
type locationsResponse struct {
|
||||
Repository *struct {
|
||||
Commit *struct {
|
||||
Blob *struct {
|
||||
LSIF *struct {
|
||||
Definitions *locationNodes `json:"definitions,omitempty"`
|
||||
References *locationNodes `json:"references,omitempty"`
|
||||
} `json:"lsif"`
|
||||
} `json:"blob"`
|
||||
} `json:"commit"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
|
||||
type locationNodes struct {
|
||||
Nodes []struct {
|
||||
Resource struct {
|
||||
Path string `json:"path"`
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"repository"`
|
||||
Commit struct {
|
||||
OID string `json:"oid"`
|
||||
} `json:"commit"`
|
||||
} `json:"resource"`
|
||||
Range struct {
|
||||
Start struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"start"`
|
||||
End struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"end"`
|
||||
} `json:"range"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
|
||||
func parseHoverResponse(data json.RawMessage) (*HoverResult, error) {
|
||||
var resp hoverResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse hover response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Repository == nil || resp.Repository.Commit == nil ||
|
||||
resp.Repository.Commit.Blob == nil || resp.Repository.Commit.Blob.LSIF == nil ||
|
||||
resp.Repository.Commit.Blob.LSIF.Hover == nil {
|
||||
return nil, nil // No hover info available
|
||||
}
|
||||
|
||||
hover := resp.Repository.Commit.Blob.LSIF.Hover
|
||||
result := &HoverResult{}
|
||||
|
||||
if hover.Markdown != nil {
|
||||
result.Contents = hover.Markdown.Text
|
||||
}
|
||||
|
||||
if hover.Range != nil {
|
||||
result.Range = &Range{
|
||||
Start: Position{
|
||||
Line: hover.Range.Start.Line,
|
||||
Character: hover.Range.Start.Character,
|
||||
},
|
||||
End: Position{
|
||||
Line: hover.Range.End.Line,
|
||||
Character: hover.Range.End.Character,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseLocationsResponse(data json.RawMessage, field string) ([]Location, error) {
|
||||
var resp locationsResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse locations response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Repository == nil || resp.Repository.Commit == nil ||
|
||||
resp.Repository.Commit.Blob == nil || resp.Repository.Commit.Blob.LSIF == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var nodes *locationNodes
|
||||
switch field {
|
||||
case "definitions":
|
||||
nodes = resp.Repository.Commit.Blob.LSIF.Definitions
|
||||
case "references":
|
||||
nodes = resp.Repository.Commit.Blob.LSIF.References
|
||||
}
|
||||
|
||||
if nodes == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
locations := make([]Location, 0, len(nodes.Nodes))
|
||||
for _, node := range nodes.Nodes {
|
||||
locations = append(locations, Location{
|
||||
Repo: node.Resource.Repository.Name,
|
||||
Path: node.Resource.Path,
|
||||
Line: node.Range.Start.Line,
|
||||
Character: node.Range.Start.Character,
|
||||
})
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
||||
// Init initializes the Sourcegraph service
|
||||
func Init() error {
|
||||
if !setting.Sourcegraph.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultClient = NewClient()
|
||||
notify_service.RegisterNotifier(NewNotifier())
|
||||
|
||||
log.Info("Sourcegraph integration enabled: %s", setting.Sourcegraph.URL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEnabled returns whether Sourcegraph integration is enabled
|
||||
func IsEnabled() bool {
|
||||
return setting.Sourcegraph.Enabled && defaultClient != nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sourcegraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
||||
type sourcegraphNotifier struct {
|
||||
notify_service.NullNotifier
|
||||
}
|
||||
|
||||
var _ notify_service.Notifier = &sourcegraphNotifier{}
|
||||
|
||||
// NewNotifier creates a new Sourcegraph notifier
|
||||
func NewNotifier() notify_service.Notifier {
|
||||
return &sourcegraphNotifier{}
|
||||
}
|
||||
|
||||
func (n *sourcegraphNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
||||
if !setting.Sourcegraph.Enabled || !setting.Sourcegraph.SyncOnPush {
|
||||
return
|
||||
}
|
||||
|
||||
client := GetClient()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger Sourcegraph to re-sync the repository
|
||||
if err := client.SyncRepository(ctx, repo.FullName()); err != nil {
|
||||
log.Warn("Sourcegraph sync failed for %s: %v", repo.FullName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *sourcegraphNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
||||
// Also sync on mirror push
|
||||
n.PushCommits(ctx, pusher, repo, opts, commits)
|
||||
}
|
||||
Reference in New Issue
Block a user