Compare commits

..

1 Commits

Author SHA1 Message Date
Lunny Xiao 6694ec6873 fix(main): only trust build version when major matches module path
debug.ReadBuildInfo() can report a version whose major version does
not match the module path's major version suffix (e.g. a v2 tag on a
module without a /v2 path), which is unreliable per Go's module
versioning rules. Extract the decision into resolveVersion() and only
accept the build version when its major matches the module path's
implied major version, otherwise fall back to the dev version.

Fixes #231.

Co-Authored-By: Codet <codet@commitgo.dev> (GPT-5-Codex)
2026-08-23 23:35:02 -07:00
5 changed files with 124 additions and 65 deletions
+57 -2
View File
@@ -1,7 +1,10 @@
package main
import (
"path"
"runtime/debug"
"strconv"
"strings"
"gitea.com/gitea/gitea-mcp/cmd"
"gitea.com/gitea/gitea-mcp/pkg/flag"
@@ -11,13 +14,65 @@ var Version = "dev"
func init() {
if Version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
Version = info.Main.Version
if info, ok := debug.ReadBuildInfo(); ok {
Version = resolveVersion(Version, info)
}
}
flag.Version = Version
}
// resolveVersion returns the version reported by debug.ReadBuildInfo when its
// major version matches the major version encoded in the module path (e.g.
// "/v2" suffix). Otherwise it falls back to devVersion, since Go's module
// versioning rules make a mismatched major version untrustworthy (see #231).
func resolveVersion(devVersion string, info *debug.BuildInfo) string {
if info == nil {
return devVersion
}
buildVersion := info.Main.Version
if buildVersion == "" || buildVersion == "(devel)" {
return devVersion
}
buildMajor := majorVersionOf(buildVersion)
pathMajor := majorVersionFromModulePath(info.Main.Path)
if buildMajor != pathMajor {
return devVersion
}
return buildVersion
}
// majorVersionOf extracts the numeric major version from a semver-like
// string such as "v1.2.3", returning 0 if it cannot be parsed.
func majorVersionOf(version string) int {
version = strings.TrimPrefix(version, "v")
dot := strings.IndexByte(version, '.')
if dot >= 0 {
version = version[:dot]
}
major, err := strconv.Atoi(version)
if err != nil {
return 0
}
return major
}
// majorVersionFromModulePath returns the major version encoded in a module
// path's "/vN" suffix, or 1 if the module path has no such suffix (as is the
// case for v0 and v1 modules).
func majorVersionFromModulePath(modulePath string) int {
suffix := path.Base(modulePath)
if len(suffix) < 2 || suffix[0] != 'v' {
return 1
}
major, err := strconv.Atoi(suffix[1:])
if err != nil {
return 1
}
return major
}
func main() {
cmd.Execute()
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"runtime/debug"
"testing"
)
func TestResolveVersion(t *testing.T) {
cases := []struct {
name string
dev string
info *debug.BuildInfo
want string
}{
{
name: "nil build info falls back to dev version",
dev: "dev",
info: nil,
want: "dev",
},
{
name: "devel version falls back to dev version",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "(devel)"}},
want: "dev",
},
{
name: "empty version falls back to dev version",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: ""}},
want: "dev",
},
{
name: "v1 version accepted for module path without major suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "v1.2.3"}},
want: "v1.2.3",
},
{
name: "v2 version rejected when module path has no /v2 suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "v2.0.0"}},
want: "dev",
},
{
name: "v2 version accepted when module path has /v2 suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp/v2", Version: "v2.0.0"}},
want: "v2.0.0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveVersion(tc.dev, tc.info)
if got != tc.want {
t.Errorf("resolveVersion(%q, %+v) = %q, want %q", tc.dev, tc.info, got, tc.want)
}
})
}
}
-4
View File
@@ -49,7 +49,6 @@ var (
tool.Array("milestones", tool.Description("milestone name or ID filter"), tool.Items(map[string]any{"type": "string"})),
tool.String("since", tool.Description("updated after ISO 8601")),
tool.String("before", tool.Description("updated before ISO 8601")),
tool.String("assigned_by", tool.Description("filter by the user who assigned the issue")),
tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
)
@@ -205,9 +204,6 @@ func listRepoIssuesFn(ctx context.Context, args map[string]any) (*mcp.CallToolRe
if t := params.GetOptionalTime(args, "before"); t != nil {
opt.Before = *t
}
if assignedBy, ok := args["assigned_by"].(string); ok {
opt.AssignedBy = assignedBy
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
+6 -56
View File
@@ -61,13 +61,12 @@ func Test_listRepoIssuesFn_filters(t *testing.T) {
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"type": "issues",
"labels": []any{"bug", "enhancement"},
"milestones": []any{"v1.0", "2"},
"since": "2026-01-01T00:00:00Z",
"assigned_by": "octocat",
"owner": owner,
"repo": repo,
"type": "issues",
"labels": []any{"bug", "enhancement"},
"milestones": []any{"v1.0", "2"},
"since": "2026-01-01T00:00:00Z",
}
_, err := listRepoIssuesFn(context.Background(), args)
@@ -90,9 +89,6 @@ func Test_listRepoIssuesFn_filters(t *testing.T) {
if !strings.Contains(gotQuery, "type=issues") {
t.Fatalf("expected type query param, got %s", gotQuery)
}
if !strings.Contains(gotQuery, "assigned_by=octocat") {
t.Fatalf("expected assigned_by query param, got %s", gotQuery)
}
}
func Test_listRepoIssuesFn_includesMilestone(t *testing.T) {
@@ -142,52 +138,6 @@ func Test_listRepoIssuesFn_includesMilestone(t *testing.T) {
}
}
func Test_listRepoIssuesFn_includesAssignees(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"number": 1, "title": "with assignees", "state": "open", "assignees": [{"login": "octocat"}]}
]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
args := map[string]any{
"owner": owner, "repo": repo,
}
res, err := listRepoIssuesFn(context.Background(), args)
if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"assignees"`) || !strings.Contains(body, `"octocat"`) {
t.Fatalf("expected assignees in list output, got: %s", body)
}
}
func Test_createIssueFn_labels(t *testing.T) {
const (
owner = "octo"
-3
View File
@@ -63,9 +63,6 @@ func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
if len(i.Labels) > 0 {
m["labels"] = slim.LabelNames(i.Labels)
}
if len(i.Assignees) > 0 {
m["assignees"] = slim.UserLogins(i.Assignees)
}
if i.Milestone != nil {
m["milestone"] = map[string]any{
"id": i.Milestone.ID,