From dfff777d04af40f4aaac023c141b7b3696deb42c Mon Sep 17 00:00:00 2001 From: ajet Date: Tue, 3 Feb 2026 19:14:07 -1000 Subject: [PATCH] WIP add sourcegraph --- modules/setting/setting.go | 1 + modules/setting/sourcegraph.go | 37 +++ routers/api/v1/api.go | 5 + routers/api/v1/repo/sourcegraph.go | 259 +++++++++++++++++++++ routers/init.go | 2 + routers/web/repo/blame.go | 10 + routers/web/repo/compare.go | 10 + routers/web/repo/view_file.go | 10 + services/sourcegraph/client.go | 260 +++++++++++++++++++++ services/sourcegraph/graphql.go | 237 +++++++++++++++++++ services/sourcegraph/init.go | 29 +++ services/sourcegraph/notifier.go | 47 ++++ templates/repo/blame.tmpl | 3 +- templates/repo/diff/box.tmpl | 3 +- templates/repo/view_file.tmpl | 3 +- web_src/css/features/sourcegraph.css | 117 ++++++++++ web_src/css/index.css | 1 + web_src/js/features/sourcegraph.ts | 334 +++++++++++++++++++++++++++ web_src/js/index-domready.ts | 2 + 19 files changed, 1367 insertions(+), 3 deletions(-) create mode 100644 modules/setting/sourcegraph.go create mode 100644 routers/api/v1/repo/sourcegraph.go create mode 100644 services/sourcegraph/client.go create mode 100644 services/sourcegraph/graphql.go create mode 100644 services/sourcegraph/init.go create mode 100644 services/sourcegraph/notifier.go create mode 100644 web_src/css/features/sourcegraph.css create mode 100644 web_src/js/features/sourcegraph.ts diff --git a/modules/setting/setting.go b/modules/setting/setting.go index dc60d99bd6..ab06dbe5c4 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -217,6 +217,7 @@ func LoadSettings() { loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadFederationFrom(CfgProvider) + loadSourcegraphFrom(CfgProvider) } // LoadSettingsForInstall initializes the settings for install diff --git a/modules/setting/sourcegraph.go b/modules/setting/sourcegraph.go new file mode 100644 index 0000000000..86d07b9c5f --- /dev/null +++ b/modules/setting/sourcegraph.go @@ -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 + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 359d5af4c4..c38eb98a18 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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() { diff --git a/routers/api/v1/repo/sourcegraph.go b/routers/api/v1/repo/sourcegraph.go new file mode 100644 index 0000000000..db5275b852 --- /dev/null +++ b/routers/api/v1/repo/sourcegraph.go @@ -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) +} diff --git a/routers/init.go b/routers/init.go index 82a5378263..f69a45aad5 100644 --- a/routers/init.go +++ b/routers/init.go @@ -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) diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 25eb88eefc..ea77183b0d 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -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, + } + } } diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 150a8583c8..b168253260 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -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 diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 44bc8543b0..7c6f998e80 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -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 { diff --git a/services/sourcegraph/client.go b/services/sourcegraph/client.go new file mode 100644 index 0000000000..d6711c388c --- /dev/null +++ b/services/sourcegraph/client.go @@ -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 +} diff --git a/services/sourcegraph/graphql.go b/services/sourcegraph/graphql.go new file mode 100644 index 0000000000..ae5dea21a2 --- /dev/null +++ b/services/sourcegraph/graphql.go @@ -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 +} diff --git a/services/sourcegraph/init.go b/services/sourcegraph/init.go new file mode 100644 index 0000000000..7b47aa6000 --- /dev/null +++ b/services/sourcegraph/init.go @@ -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 +} diff --git a/services/sourcegraph/notifier.go b/services/sourcegraph/notifier.go new file mode 100644 index 0000000000..be55ab3507 --- /dev/null +++ b/services/sourcegraph/notifier.go @@ -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) +} diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 9cd4b2a122..34970d5a86 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -29,7 +29,8 @@
-
+
{{if .IsFileTooLarge}} {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} {{else if not .FileSize}} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 41a8268cb3..dec7e8bb18 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -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}} -
+
diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 809b1e9677..452ce49ebf 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -1,5 +1,6 @@
+ 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}}
diff --git a/web_src/css/features/sourcegraph.css b/web_src/css/features/sourcegraph.css new file mode 100644 index 0000000000..ff2cc5a059 --- /dev/null +++ b/web_src/css/features/sourcegraph.css @@ -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; +} diff --git a/web_src/css/index.css b/web_src/css/index.css index 6bfbddeacc..40082d079b 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -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"; diff --git a/web_src/js/features/sourcegraph.ts b/web_src/js/features/sourcegraph.ts new file mode 100644 index 0000000000..cd4c8226ca --- /dev/null +++ b/web_src/js/features/sourcegraph.ts @@ -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 { + 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 { + 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 { + 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 = `
${escapeHtml(contents)}
`; + 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 = ` + ${escapeHtml(title)} (${locations.length}) + + `; + 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 = ` + ${escapeHtml(loc.path)} + :${loc.line + 1} + `; + 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(); + } + }); + }); +} diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index 660e5c0989..50f968c402 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -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.