chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+439
View File
@@ -0,0 +1,439 @@
package github
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"testing"
"github.com/github/github-mcp-server/internal/githubv4mock"
gogithub "github.com/google/go-github/v89/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Test_IssueRequest_EmptyFieldValues_OmittedByJSON pins the omitempty
// behaviour that makes the DELETE fallback necessary. If go-github ever drops
// the tag, the REST PATCH alone could clear field values and this test would
// fail to remind us.
func Test_IssueRequest_EmptyFieldValues_OmittedByJSON(t *testing.T) {
t.Parallel()
req := &gogithub.IssueRequest{
Title: gogithub.Ptr("still here"),
IssueFieldValues: []*gogithub.IssueRequestFieldValue{},
}
body, err := json.Marshal(req)
require.NoError(t, err)
assert.NotContains(t, string(body), "issue_field_values",
"empty IssueFieldValues should be dropped by omitempty — this is why the REST PATCH alone can't clear field values when the merged list ends up empty, and why we fall back to the dedicated DELETE endpoint")
assert.Contains(t, string(body), `"title":"still here"`,
"sanity check: other fields still serialise")
}
// Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint covers the bug fix:
// when the kept set ends up empty, the PATCH alone can't clear the field
// (omitempty strips the empty slice), so UpdateIssue follows up with a DELETE
// to the dedicated endpoint.
func Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
capturedPatchBody []byte
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
capturedPatchBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(mockIssue)
},
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
},
}))
// Existing field values for the merge step. Returning the field we're
// about to delete makes the kept list empty, triggering the fallback DELETE.
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{
"fullDatabaseId": "101",
"name": "Priority",
},
"value": "P1",
},
},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101},
"", "", 0,
)
require.NoError(t, err)
if result.IsError {
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
}
mu.Lock()
defer mu.Unlock()
require.NotContains(t, string(capturedPatchBody), "issue_field_values",
"REST PATCH body must not carry issue_field_values when the kept set is empty (PATCH body was: %s)", string(capturedPatchBody))
require.Equal(t, []string{"/repos/owner/repo/issues/42/issue-field-values/101"}, deletePaths,
"expected exactly one DELETE call to the dedicated endpoint for field id 101")
}
// Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics: when the kept set is
// non-empty, set semantics handle the deletion implicitly via the PATCH — no
// DELETE follow-up needed.
func Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"issue_field_values": []any{
map[string]any{"field_id": float64(202), "value": "High"},
},
}).andThen(
mockResponse(t, http.StatusOK, mockIssue),
),
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
},
}))
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "101", "name": "Priority"},
"value": "P1",
},
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "202", "name": "Impact"},
"value": "High",
},
},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101},
"", "", 0,
)
require.NoError(t, err)
if result.IsError {
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
}
mu.Lock()
defer mu.Unlock()
require.Empty(t, deletePaths,
"no DELETE call should fire when the kept set is non-empty — the PATCH's set semantics clear the deleted field on the server side")
}
// Test_UpdateIssue_DeleteAbsentFieldIsNoOp: deleting a field that isn't set
// must not fire a DELETE (the endpoint would 404), preserving the pre-fix
// silent-no-op behaviour so idempotent delete:true callers don't break on
// retry.
func Test_UpdateIssue_DeleteAbsentFieldIsNoOp(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
capturedPatchBody []byte
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
capturedPatchBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(mockIssue)
},
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
// Fail loudly: if we get here, the fix is wrong.
w.WriteHeader(http.StatusNotFound)
},
}))
// Issue has no field values at all.
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101}, // ask to delete a field that isn't set
"", "", 0,
)
require.NoError(t, err)
if result.IsError {
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
}
mu.Lock()
defer mu.Unlock()
require.NotContains(t, string(capturedPatchBody), "issue_field_values",
"PATCH body must not carry issue_field_values when nothing changed")
require.Empty(t, deletePaths,
"no DELETE call should fire for a field that isn't present on the issue — preserves the pre-fix silent-no-op behaviour and avoids a guaranteed 404")
}
// Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure: a failing DELETE
// must not short-circuit subsequent ones, and the error must name which IDs
// succeeded and which failed so callers can retry the right ones.
func Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure(t *testing.T) {
t.Parallel()
mockIssue := &gogithub.Issue{
Number: gogithub.Ptr(42),
Title: gogithub.Ptr("Test issue"),
State: gogithub.Ptr("open"),
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
}
var (
mu sync.Mutex
deletePaths []string
)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(mockIssue)
},
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
deletePaths = append(deletePaths, r.URL.Path)
mu.Unlock()
// Field 202 fails; 101 and 303 succeed. All three should fire and
// the error must name 202 as failed and 101/303 as cleared.
if strings.HasSuffix(r.URL.Path, "/202") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"simulated failure"}`))
return
}
w.WriteHeader(http.StatusNoContent)
},
}))
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "101", "name": "Priority"},
"value": "P1",
},
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "202", "name": "Visibility"},
"value": "High",
},
map[string]any{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"fullDatabaseId": "303", "name": "Impact"},
"value": "Critical",
},
},
},
},
},
})
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(42),
},
existingFieldsResponse,
),
))
result, err := UpdateIssue(
context.Background(),
restClient,
gqlClient,
"owner", "repo", 42,
"", "", nil, nil, 0, "",
nil,
[]int64{101, 202, 303},
"", "", 0,
)
require.NoError(t, err)
require.True(t, result.IsError, "expected an error result because field 202 failed")
mu.Lock()
defer mu.Unlock()
// All three DELETEs must have fired — the middle failure must not short-circuit the third.
require.Len(t, deletePaths, 3,
"all three DELETE calls should fire even though one fails; got paths: %v", deletePaths)
resultText := getTextResult(t, result).Text
require.Contains(t, resultText, "failed=[202]",
"error must name the failed field ID so the caller can retry it; got: %s", resultText)
require.Contains(t, resultText, "cleared=[101 303]",
"error must name the cleared field IDs so the caller knows what's already done; got: %s", resultText)
}