WIP add sourcegraph

This commit is contained in:
2026-02-03 19:14:07 -10:00
parent 65d93d819b
commit dfff777d04
19 changed files with 1367 additions and 3 deletions
+1
View File
@@ -217,6 +217,7 @@ func LoadSettings() {
loadProjectFrom(CfgProvider)
loadMimeTypeMapFrom(CfgProvider)
loadFederationFrom(CfgProvider)
loadSourcegraphFrom(CfgProvider)
}
// LoadSettingsForInstall initializes the settings for install
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
// Sourcegraph settings for code intelligence integration
var Sourcegraph = struct {
Enabled bool
URL string
AccessToken string
SkipTLSVerify bool
Timeout int // request timeout in seconds
CacheTTL int // cache time-to-live in seconds
SyncOnPush bool
}{
Enabled: false,
Timeout: 5,
CacheTTL: 300,
SyncOnPush: true,
SkipTLSVerify: false,
}
func loadSourcegraphFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("sourcegraph")
Sourcegraph.Enabled = sec.Key("ENABLED").MustBool(false)
Sourcegraph.URL = sec.Key("URL").MustString("")
Sourcegraph.AccessToken = sec.Key("ACCESS_TOKEN").MustString("")
Sourcegraph.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool(false)
Sourcegraph.Timeout = sec.Key("TIMEOUT").MustInt(5)
Sourcegraph.CacheTTL = sec.Key("CACHE_TTL").MustInt(300)
Sourcegraph.SyncOnPush = sec.Key("SYNC_ON_PUSH").MustBool(true)
// Validate: if enabled, URL must be set
if Sourcegraph.Enabled && Sourcegraph.URL == "" {
Sourcegraph.Enabled = false
}
}
+5
View File
@@ -1443,6 +1443,11 @@ func Routes() *web.Router {
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
m.Get("/licenses", reqRepoReader(unit.TypeCode), repo.GetLicenses)
m.Group("/sourcegraph", func() {
m.Get("/hover", repo.SourcegraphHover)
m.Get("/definition", repo.SourcegraphDefinition)
m.Get("/references", repo.SourcegraphReferences)
}, reqRepoReader(unit.TypeCode))
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed)
m.Group("/avatar", func() {
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
sourcegraph_service "code.gitea.io/gitea/services/sourcegraph"
)
// SourcegraphHover returns hover information at a position
func SourcegraphHover(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/sourcegraph/hover repository repoSourcegraphHover
// ---
// summary: Get code intelligence hover info
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: path
// in: query
// description: file path
// type: string
// required: true
// - name: line
// in: query
// description: line number (0-indexed)
// type: integer
// required: true
// - name: character
// in: query
// description: character position (0-indexed)
// type: integer
// required: true
// - name: ref
// in: query
// description: git ref (commit SHA, branch, tag)
// type: string
// required: true
// responses:
// "200":
// description: hover information
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "503":
// description: Sourcegraph integration not available
if !setting.Sourcegraph.Enabled {
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph integration is not enabled")
return
}
path := ctx.FormString("path")
line := ctx.FormInt("line")
char := ctx.FormInt("character")
ref := ctx.FormString("ref")
if path == "" || ref == "" {
ctx.APIError(http.StatusBadRequest, "path and ref are required")
return
}
client := sourcegraph_service.GetClient()
if client == nil {
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph client not initialized")
return
}
result, err := client.Hover(ctx, ctx.Repo.Repository.FullName(), ref, path, line, char)
if err != nil {
ctx.APIError(http.StatusBadGateway, err)
return
}
if result == nil {
ctx.JSON(http.StatusOK, map[string]any{})
return
}
ctx.JSON(http.StatusOK, result)
}
// SourcegraphDefinition returns definition locations for a symbol
func SourcegraphDefinition(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/sourcegraph/definition repository repoSourcegraphDefinition
// ---
// summary: Get code intelligence definition locations
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: path
// in: query
// description: file path
// type: string
// required: true
// - name: line
// in: query
// description: line number (0-indexed)
// type: integer
// required: true
// - name: character
// in: query
// description: character position (0-indexed)
// type: integer
// required: true
// - name: ref
// in: query
// description: git ref (commit SHA, branch, tag)
// type: string
// required: true
// responses:
// "200":
// description: definition locations
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "503":
// description: Sourcegraph integration not available
if !setting.Sourcegraph.Enabled {
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph integration is not enabled")
return
}
path := ctx.FormString("path")
line := ctx.FormInt("line")
char := ctx.FormInt("character")
ref := ctx.FormString("ref")
if path == "" || ref == "" {
ctx.APIError(http.StatusBadRequest, "path and ref are required")
return
}
client := sourcegraph_service.GetClient()
if client == nil {
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph client not initialized")
return
}
result, err := client.Definition(ctx, ctx.Repo.Repository.FullName(), ref, path, line, char)
if err != nil {
ctx.APIError(http.StatusBadGateway, err)
return
}
if result == nil {
result = []sourcegraph_service.Location{}
}
ctx.JSON(http.StatusOK, result)
}
// SourcegraphReferences returns reference locations for a symbol
func SourcegraphReferences(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/sourcegraph/references repository repoSourcegraphReferences
// ---
// summary: Get code intelligence reference locations
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: path
// in: query
// description: file path
// type: string
// required: true
// - name: line
// in: query
// description: line number (0-indexed)
// type: integer
// required: true
// - name: character
// in: query
// description: character position (0-indexed)
// type: integer
// required: true
// - name: ref
// in: query
// description: git ref (commit SHA, branch, tag)
// type: string
// required: true
// responses:
// "200":
// description: reference locations
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "503":
// description: Sourcegraph integration not available
if !setting.Sourcegraph.Enabled {
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph integration is not enabled")
return
}
path := ctx.FormString("path")
line := ctx.FormInt("line")
char := ctx.FormInt("character")
ref := ctx.FormString("ref")
if path == "" || ref == "" {
ctx.APIError(http.StatusBadRequest, "path and ref are required")
return
}
client := sourcegraph_service.GetClient()
if client == nil {
ctx.APIError(http.StatusServiceUnavailable, "Sourcegraph client not initialized")
return
}
result, err := client.References(ctx, ctx.Repo.Repository.FullName(), ref, path, line, char)
if err != nil {
ctx.APIError(http.StatusBadGateway, err)
return
}
if result == nil {
result = []sourcegraph_service.Location{}
}
ctx.JSON(http.StatusOK, result)
}
+2
View File
@@ -51,6 +51,7 @@ import (
release_service "code.gitea.io/gitea/services/release"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/services/repository/archiver"
sourcegraph_service "code.gitea.io/gitea/services/sourcegraph"
"code.gitea.io/gitea/services/task"
"code.gitea.io/gitea/services/uinotification"
"code.gitea.io/gitea/services/webhook"
@@ -172,6 +173,7 @@ func InitWebInstalled(ctx context.Context) {
mustInitCtx(ctx, actions_service.Init)
mustInit(repo_service.InitLicenseClassifier)
mustInit(sourcegraph_service.Init)
// Finally start up the cron
cron.Init(ctx)
+10
View File
@@ -281,4 +281,14 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa
ctx.Data["EscapeStatus"] = escapeStatus
ctx.Data["BlameRows"] = rows
ctx.Data["LexerName"] = lexerName
// Sourcegraph code intelligence configuration
if setting.Sourcegraph.Enabled {
ctx.Data["SourcegraphEnabled"] = true
ctx.Data["SourcegraphConfig"] = map[string]any{
"enabled": true,
"repoFullName": ctx.Repo.Repository.FullName(),
"commitID": ctx.Repo.CommitID,
}
}
}
+10
View File
@@ -87,6 +87,16 @@ func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner
setPathsCompareContext(ctx, before, head, headOwner, headName)
setImageCompareContext(ctx)
setCsvCompareContext(ctx)
// Sourcegraph code intelligence configuration
if setting.Sourcegraph.Enabled {
ctx.Data["SourcegraphEnabled"] = true
ctx.Data["SourcegraphConfig"] = map[string]any{
"enabled": true,
"repoFullName": headOwner + "/" + headName,
"commitID": head.ID.String(),
}
}
}
// SourceCommitURL creates a relative URL for a commit in the given repository
+10
View File
@@ -256,6 +256,16 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
default:
// unable to render anything, show the "view raw" or let frontend handle it
}
// Sourcegraph code intelligence configuration
if setting.Sourcegraph.Enabled {
ctx.Data["SourcegraphEnabled"] = true
ctx.Data["SourcegraphConfig"] = map[string]any{
"enabled": true,
"repoFullName": ctx.Repo.Repository.FullName(),
"commitID": ctx.Repo.CommitID,
}
}
}
func prepareFileViewEditorButtons(ctx *context.Context) bool {
+260
View File
@@ -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
}
+237
View File
@@ -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
}
+29
View File
@@ -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
}
+47
View File
@@ -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)
}
+2 -1
View File
@@ -29,7 +29,8 @@
</div>
</h4>
<div class="ui bottom attached table unstackable segment">
<div class="file-view code-view unicode-escaped">
<div class="file-view code-view unicode-escaped"
{{if .SourcegraphEnabled}}data-global-init="initSourcegraph" data-sourcegraph="{{JsonUtils.EncodeToString .SourcegraphConfig}}" data-sg-path="{{.FileTreePath}}"{{end}}>
{{if .IsFileTooLarge}}
{{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}}
{{else if not .FileSize}}
+2 -1
View File
@@ -81,7 +81,8 @@
{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}}
{{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}}
{{$isReviewFile := and $.IsSigned $.PageIsPullFiles (not $.Repository.IsArchived) $.IsShowingAllCommits}}
<div class="diff-file-box file-content {{TabSizeClass $.Editorconfig $file.Name}} tw-mt-0" id="diff-{{$file.NameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if or ($file.ShouldBeHidden) (not $isExpandable)}}data-folded="true"{{end}}>
<div class="diff-file-box file-content {{TabSizeClass $.Editorconfig $file.Name}} tw-mt-0" id="diff-{{$file.NameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if or ($file.ShouldBeHidden) (not $isExpandable)}}data-folded="true"{{end}}
{{if $.SourcegraphEnabled}}data-global-init="initSourcegraph" data-sourcegraph="{{JsonUtils.EncodeToString $.SourcegraphConfig}}" data-sg-path="{{$file.Name}}"{{end}}>
<div class="diff-file-header sticky-2nd-row ui top attached header">
<div class="diff-file-name tw-flex tw-flex-1 tw-items-center tw-gap-1 tw-flex-wrap">
<div class="flex-text-block">
+2 -1
View File
@@ -1,5 +1,6 @@
<div {{if .ReadmeInList}}id="readme"{{end}} class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content"
data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}">
data-global-init="initRepoFileView{{if .SourcegraphEnabled}} initSourcegraph{{end}}" data-raw-file-link="{{.RawFileLink}}"
{{if .SourcegraphEnabled}}data-sourcegraph="{{JsonUtils.EncodeToString .SourcegraphConfig}}" data-sg-path="{{.TreePath}}"{{end}}>
{{- if .FileError}}
<div class="ui error message">
+117
View File
@@ -0,0 +1,117 @@
/* Sourcegraph code intelligence styles */
.sourcegraph-hover {
max-width: 600px;
max-height: 400px;
overflow: auto;
}
.sourcegraph-hover .sg-content {
padding: 8px;
font-size: 13px;
}
.sourcegraph-hover .sg-content pre {
margin: 0;
padding: 8px;
background: var(--color-code-bg);
border-radius: 4px;
white-space: pre-wrap;
word-wrap: break-word;
font-family: var(--fonts-monospace);
font-size: 12px;
}
.sourcegraph-hover .sg-actions {
display: flex;
gap: 8px;
padding: 8px;
border-top: 1px solid var(--color-secondary);
}
/* References panel */
.sg-refs-panel {
position: fixed;
right: 16px;
top: 100px;
width: 400px;
max-height: 60vh;
background: var(--color-body);
border: 1px solid var(--color-secondary);
border-radius: 6px;
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 1000;
display: flex;
flex-direction: column;
}
.sg-refs-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-secondary);
background: var(--color-box-header);
}
.sg-refs-title {
font-weight: 600;
font-size: 14px;
}
.sg-refs-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--color-text-light);
padding: 0;
line-height: 1;
}
.sg-refs-close:hover {
color: var(--color-text);
}
.sg-refs-list {
overflow-y: auto;
flex: 1;
}
.sg-refs-item {
display: flex;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid var(--color-secondary);
color: var(--color-text);
text-decoration: none;
font-family: var(--fonts-monospace);
font-size: 12px;
}
.sg-refs-item:hover {
background: var(--color-hover);
}
.sg-refs-item:last-child {
border-bottom: none;
}
.sg-refs-path {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sg-refs-line {
color: var(--color-text-light);
margin-left: 8px;
}
/* Highlight hoverable tokens on hover */
.code-view .code-inner span:hover {
background: var(--color-hover);
cursor: pointer;
border-radius: 2px;
}
+1
View File
@@ -44,6 +44,7 @@
@import "./features/cropper.css";
@import "./features/console.css";
@import "./features/captcha.css";
@import "./features/sourcegraph.css";
@import "./markup/content.css";
@import "./markup/codecopy.css";
+334
View File
@@ -0,0 +1,334 @@
import {createTippy} from '../modules/tippy.ts';
import {GET} from '../modules/fetch.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import type {Instance as TippyInstance} from 'tippy.js';
interface SourcegraphConfig {
enabled: boolean;
repoFullName: string;
commitID: string;
}
interface HoverResult {
contents?: string;
range?: {
start: {line: number; character: number};
end: {line: number; character: number};
};
}
interface Location {
repo: string;
path: string;
line: number;
character: number;
}
let activeTippy: TippyInstance | null = null;
let hoverTimeout: number | null = null;
let refsPanel: HTMLElement | null = null;
async function fetchHover(config: SourcegraphConfig, path: string, line: number, char: number): Promise<HoverResult | null> {
const params = new URLSearchParams({
path,
line: String(line),
character: String(char),
ref: config.commitID,
});
try {
const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/hover?${params}`);
if (!resp.ok) return null;
const data = await resp.json();
return data.contents ? data : null;
} catch {
return null;
}
}
async function fetchDefinition(config: SourcegraphConfig, path: string, line: number, char: number): Promise<Location[]> {
const params = new URLSearchParams({
path,
line: String(line),
character: String(char),
ref: config.commitID,
});
try {
const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/definition?${params}`);
if (!resp.ok) return [];
return await resp.json();
} catch {
return [];
}
}
async function fetchReferences(config: SourcegraphConfig, path: string, line: number, char: number): Promise<Location[]> {
const params = new URLSearchParams({
path,
line: String(line),
character: String(char),
ref: config.commitID,
});
try {
const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/references?${params}`);
if (!resp.ok) return [];
return await resp.json();
} catch {
return [];
}
}
function hideActiveTippy(): void {
if (activeTippy) {
activeTippy.destroy();
activeTippy = null;
}
}
function hideRefsPanel(): void {
if (refsPanel) {
refsPanel.remove();
refsPanel = null;
}
}
function createHoverContent(
contents: string,
config: SourcegraphConfig,
path: string,
line: number,
char: number,
): HTMLElement {
const el = document.createElement('div');
el.className = 'sourcegraph-hover';
// Render markdown content as pre-formatted text (simple approach)
// In production, you might want to use a proper markdown renderer
const contentDiv = document.createElement('div');
contentDiv.className = 'sg-content';
contentDiv.innerHTML = `<pre>${escapeHtml(contents)}</pre>`;
el.appendChild(contentDiv);
// Action buttons
const actionsDiv = document.createElement('div');
actionsDiv.className = 'sg-actions';
const goToDefBtn = document.createElement('button');
goToDefBtn.className = 'ui mini basic button';
goToDefBtn.textContent = 'Go to definition';
goToDefBtn.addEventListener('click', async () => {
hideActiveTippy();
const locations = await fetchDefinition(config, path, line, char);
if (locations.length === 1) {
navigateToLocation(locations[0]);
} else if (locations.length > 1) {
showLocationsPanel('Definitions', locations);
}
});
actionsDiv.appendChild(goToDefBtn);
const findRefsBtn = document.createElement('button');
findRefsBtn.className = 'ui mini basic button';
findRefsBtn.textContent = 'Find references';
findRefsBtn.addEventListener('click', async () => {
hideActiveTippy();
const locations = await fetchReferences(config, path, line, char);
if (locations.length > 0) {
showLocationsPanel('References', locations);
}
});
actionsDiv.appendChild(findRefsBtn);
el.appendChild(actionsDiv);
return el;
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function navigateToLocation(loc: Location): void {
// Build URL to the location
// If it's in the same repo, navigate to file
// If it's in another repo, we need to construct the full URL
const currentPath = window.location.pathname;
const repoMatch = currentPath.match(/^\/([^/]+\/[^/]+)/);
const currentRepo = repoMatch ? repoMatch[1] : '';
let url: string;
if (loc.repo === currentRepo || !loc.repo) {
// Same repo - construct relative URL
const basePath = currentPath.replace(/\/src\/.*$/, '');
url = `${basePath}/src/branch/main/${loc.path}#L${loc.line + 1}`;
} else {
// Different repo
url = `/${loc.repo}/src/branch/main/${loc.path}#L${loc.line + 1}`;
}
window.location.href = url;
}
function showLocationsPanel(title: string, locations: Location[]): void {
hideRefsPanel();
const panel = document.createElement('div');
panel.className = 'sg-refs-panel';
const header = document.createElement('div');
header.className = 'sg-refs-header';
header.innerHTML = `
<span class="sg-refs-title">${escapeHtml(title)} (${locations.length})</span>
<button class="sg-refs-close">&times;</button>
`;
panel.appendChild(header);
header.querySelector('.sg-refs-close')?.addEventListener('click', hideRefsPanel);
const list = document.createElement('div');
list.className = 'sg-refs-list';
for (const loc of locations) {
const item = document.createElement('a');
item.className = 'sg-refs-item';
item.href = '#';
item.innerHTML = `
<span class="sg-refs-path">${escapeHtml(loc.path)}</span>
<span class="sg-refs-line">:${loc.line + 1}</span>
`;
item.addEventListener('click', (e) => {
e.preventDefault();
navigateToLocation(loc);
});
list.appendChild(item);
}
panel.appendChild(list);
document.body.appendChild(panel);
refsPanel = panel;
}
function getTokenPosition(container: HTMLElement, token: Element, path: string): {path: string; line: number; char: number} | null {
const row = token.closest('tr');
if (!row) return null;
// Support both data-line-number (file view, blame) and data-line-num (diff view)
let lineEl = row.querySelector('[data-line-number]') as HTMLElement | null;
let line: number;
if (lineEl) {
line = parseInt(lineEl.dataset.lineNumber || '0', 10);
} else {
// For diff views, prefer the "new" line number (right side) for code intelligence
lineEl = row.querySelector('.lines-num-new[data-line-num]') as HTMLElement | null;
if (!lineEl) {
lineEl = row.querySelector('[data-line-num]') as HTMLElement | null;
}
if (!lineEl) return null;
line = parseInt(lineEl.dataset.lineNum || '0', 10);
}
if (isNaN(line) || line <= 0) return null;
// Calculate character offset within the line
const codeCell = row.querySelector('.lines-code .code-inner');
if (!codeCell) return null;
let char = 0;
const walker = document.createTreeWalker(codeCell, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (token.contains(node)) {
// Found the text node containing or preceding our token
break;
}
char += node.textContent?.length || 0;
}
// Sourcegraph uses 0-indexed lines
return {path, line: line - 1, char};
}
function isHoverableToken(el: Element): boolean {
// Check if it's a span inside code-inner (syntax highlighted token)
if (el.tagName !== 'SPAN') return false;
const codeInner = el.closest('.code-inner');
return codeInner !== null;
}
export function initSourcegraph(): void {
registerGlobalInitFunc('initSourcegraph', (container: HTMLElement) => {
const configStr = container.dataset.sourcegraph;
if (!configStr) return;
let config: SourcegraphConfig;
try {
config = JSON.parse(configStr);
} catch {
return;
}
if (!config.enabled) return;
const path = container.dataset.sgPath || '';
if (!path) return;
// Set up hover listener with debouncing
container.addEventListener('mouseover', (e) => {
const target = e.target as HTMLElement;
if (!isHoverableToken(target)) return;
// Clear any existing timeout
if (hoverTimeout) {
window.clearTimeout(hoverTimeout);
}
hoverTimeout = window.setTimeout(async () => {
const pos = getTokenPosition(container, target, path);
if (!pos) return;
const hover = await fetchHover(config, pos.path, pos.line, pos.char);
if (!hover?.contents) return;
// Hide any existing tippy
hideActiveTippy();
// Create and show new tippy
const content = createHoverContent(hover.contents, config, pos.path, pos.line, pos.char);
activeTippy = createTippy(target, {
content,
theme: 'default',
interactive: true,
trigger: 'manual',
placement: 'top',
maxWidth: 600,
onHide: () => {
activeTippy = null;
},
});
activeTippy.show();
}, 300);
});
container.addEventListener('mouseout', (e) => {
const target = e.target as HTMLElement;
if (!isHoverableToken(target)) return;
if (hoverTimeout) {
window.clearTimeout(hoverTimeout);
hoverTimeout = null;
}
});
// Close refs panel when clicking outside
document.addEventListener('click', (e) => {
if (refsPanel && !refsPanel.contains(e.target as Node)) {
hideRefsPanel();
}
});
});
}
+2
View File
@@ -19,6 +19,7 @@ import {initStopwatch} from './features/stopwatch.ts';
import {initRepoFileSearch} from './features/repo-findfile.ts';
import {initMarkupContent} from './markup/content.ts';
import {initRepoFileView} from './features/file-view.ts';
import {initSourcegraph} from './features/sourcegraph.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
@@ -158,6 +159,7 @@ const initPerformanceTracer = callInitFunctions([
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
initSourcegraph,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.