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 @@