Skip to content

Commit cb86a00

Browse files
committed
wip
1 parent eef247f commit cb86a00

10 files changed

+445
-18
lines changed

CONTINUITY.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# CONTINUITY.md
2+
3+
## GitHub Projects V2 Tools Continuity
4+
5+
### Objective
6+
Refine and robustly test the owner resolution logic for GitHub Projects V2 tools, ensuring correct handling of organizations, users, and ambiguous cases, and maintain strict TDD and modularity.
7+
8+
### Key Accomplishments
9+
- Refactored owner resolution logic into a reusable `resolveOwnerID` function.
10+
- All tools (ListOrganizationProjectsTool, ListUserProjectsTool, GetProjectTool, CreateProjectTool) now use this logic.
11+
- Updated and extended tests for all relevant scenarios, including ambiguous and error cases.
12+
- Fixed test panics due to mock exhaustion by queuing multiple mock responses where needed.
13+
- Ensured all GraphQL queries and mutations use the correct owner ID (user or org) as required by GitHub's API.
14+
- All tests for repository resource content and owner resolution now pass except for a single edge case (`owner_is_user`), which is under investigation.
15+
16+
### Outstanding Issues
17+
- `TestOwnerResolutionInCreateProject/owner_is_user` fails, likely due to a mismatch between the expected and actual mutation input or mock handler logic.
18+
- Need to confirm mutation input for `createProjectV2` uses the resolved user ID and matches the expected GraphQL structure.
19+
- All other tests, including ambiguous and negative owner cases, pass.
20+
21+
### Next Steps
22+
1. Add debug output to the test or handler to reveal the actual error message and request body for the failing case.
23+
2. Inspect the mutation input struct and marshaling to ensure the correct field (`ownerId`) is sent.
24+
3. Once all tests pass, refactor Projects V2 logic and tool factories into a single `projects.go` file for convention compliance and PR acceptance.
25+
26+
### Design and Implementation Notes
27+
- Owner resolution is always attempted for both org and user; org is preferred if both exist.
28+
- Mock handlers in tests must return the correct GraphQL response structure to avoid unmarshalling errors.
29+
- All code changes follow TDD, modular, and DRY principles as per user rules.
30+
- Security: GitHub tokens are handled via env var and not hardcoded.
31+
32+
### User Preferences
33+
- Strict TDD and validation using specs/references.
34+
- Modular, minimal, and DRY Go code.
35+
- All logic and factories to be colocated for each feature.
36+
37+
---
38+
_Last updated: 2025-04-21 03:48:58-04:00_

TODO.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# TODO.md
2+
3+
## GitHub Projects V2 Tools: Next Steps
4+
5+
### Immediate
6+
- [ ] Debug and fix `TestOwnerResolutionInCreateProject/owner_is_user` failure:
7+
- [ ] Add debug output in the test handler to print the actual error and request body for this case.
8+
- [ ] Confirm that `createProjectV2` mutation receives the resolved user ID as `ownerId`.
9+
- [ ] Inspect struct tags and marshaling for mutation input.
10+
- [ ] Adjust either test or code until all cases pass.
11+
- [ ] Run full test suite and validate all tests pass with no regressions.
12+
13+
### After All Tests Pass
14+
- [ ] Refactor Projects V2 business logic and MCP tool factories into a single `projects.go` file, matching codebase conventions.
15+
- [ ] Validate that all tool registrations and integrations remain functional after refactor.
16+
- [ ] Update/validate documentation and CONTINUITY.md as needed.
17+
18+
### Ongoing
19+
- [ ] Maintain strict TDD and minimal/DRY Go code.
20+
- [ ] Ensure all GraphQL interactions match GitHub's documented API structure.
21+
- [ ] Keep security best practices for token management.
22+
23+
---
24+
_Last updated: 2025-04-21 03:49:21-04:00_

pkg/github/is_graphql_not_found.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package github
2+
3+
import "strings"
4+
5+
// isGraphQLNotFound returns true if the error is a GraphQL 'not found' error.
6+
func isGraphQLNotFound(err error) bool {
7+
if err == nil {
8+
return false
9+
}
10+
msg := err.Error()
11+
return strings.Contains(msg, "could not resolve to a User") ||
12+
strings.Contains(msg, "could not resolve to an Organization") ||
13+
strings.Contains(msg, "non-200 OK status code: 400") ||
14+
strings.Contains(msg, "non-200 OK status code: 404")
15+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package github
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func TestIsGraphQLNotFound(t *testing.T) {
9+
cases := []struct {
10+
name string
11+
err error
12+
expects bool
13+
}{
14+
{"nil error", nil, false},
15+
{"empty error", errors.New(""), false},
16+
{"user not found", errors.New("could not resolve to a User"), true},
17+
{"org not found", errors.New("could not resolve to an Organization"), true},
18+
{"400 code", errors.New("non-200 OK status code: 400"), true},
19+
{"404 code", errors.New("non-200 OK status code: 404"), true},
20+
{"other error", errors.New("some other error"), false},
21+
}
22+
for _, c := range cases {
23+
t.Run(c.name, func(t *testing.T) {
24+
result := isGraphQLNotFound(c.err)
25+
if result != c.expects {
26+
t.Errorf("expected %v, got %v for input %v", c.expects, result, c.err)
27+
}
28+
})
29+
}
30+
}

pkg/github/owner_resolution.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
ghv4 "github.com/shurcooL/githubv4"
9+
)
10+
11+
// resolveOwnerID resolves an owner login (org or user) to a GraphQL ID, preferring org if both exist.
12+
// Returns the ID or an error ("owner not found" if neither found).
13+
type GraphQLClient interface {
14+
Query(ctx context.Context, q interface{}, vars map[string]interface{}) error
15+
}
16+
17+
func resolveOwnerID(ctx context.Context, client GraphQLClient, owner string) (ghv4.ID, error) {
18+
var orgQ struct {
19+
Organization *struct{ ID ghv4.ID } `graphql:"organization(login: $login)"`
20+
}
21+
orgVars := map[string]interface{}{"login": ghv4.String(owner)}
22+
orgErr := client.Query(ctx, &orgQ, orgVars)
23+
orgNotFound := orgErr != nil && isGraphQLNotFound(orgErr)
24+
if orgErr != nil && !orgNotFound {
25+
return "", fmt.Errorf("organization lookup failed: %w", orgErr)
26+
}
27+
if orgQ.Organization != nil {
28+
return orgQ.Organization.ID, nil
29+
}
30+
31+
var userQ struct {
32+
User *struct{ ID ghv4.ID } `graphql:"user(login: $login)"`
33+
}
34+
userVars := map[string]interface{}{"login": ghv4.String(owner)}
35+
userErr := client.Query(ctx, &userQ, userVars)
36+
userNotFound := userErr != nil && isGraphQLNotFound(userErr)
37+
if userErr != nil && !userNotFound {
38+
return "", fmt.Errorf("user lookup failed: %w", userErr)
39+
}
40+
if userQ.User != nil {
41+
return userQ.User.ID, nil
42+
}
43+
if orgNotFound && userNotFound {
44+
return "", errors.New("owner not found")
45+
}
46+
return "", errors.New("owner not found") // Defensive fallback
47+
}

pkg/github/owner_resolution_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
ghv4 "github.com/shurcooL/githubv4"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
13+
type fakeGraphQLClient struct {
14+
orgID ghv4.ID
15+
userID ghv4.ID
16+
orgErr error
17+
userErr error
18+
}
19+
20+
func (f *fakeGraphQLClient) Query(ctx context.Context, q interface{}, vars map[string]interface{}) error {
21+
if oq, ok := q.(*struct {
22+
Organization *struct{ ID ghv4.ID } `graphql:"organization(login: $login)"`
23+
}); ok {
24+
if f.orgErr != nil {
25+
return f.orgErr
26+
}
27+
if f.orgID != "" {
28+
*oq = struct {
29+
Organization *struct{ ID ghv4.ID } `graphql:"organization(login: $login)"`
30+
}{Organization: &struct{ ID ghv4.ID }{ID: f.orgID}}
31+
}
32+
return nil
33+
}
34+
if uq, ok := q.(*struct {
35+
User *struct{ ID ghv4.ID } `graphql:"user(login: $login)"`
36+
}); ok {
37+
if f.userErr != nil {
38+
return f.userErr
39+
}
40+
if f.userID != "" {
41+
*uq = struct {
42+
User *struct{ ID ghv4.ID } `graphql:"user(login: $login)"`
43+
}{User: &struct{ ID ghv4.ID }{ID: f.userID}}
44+
}
45+
return nil
46+
}
47+
return errors.New("unexpected query type")
48+
}
49+
50+
func (f *fakeGraphQLClient) Mutate(ctx context.Context, m interface{}, input interface{}, v map[string]interface{}) error {
51+
return errors.New("not implemented")
52+
}
53+
54+
func TestResolveOwnerID(t *testing.T) {
55+
ctx := context.Background()
56+
owner := "testowner"
57+
58+
tests := []struct {
59+
name string
60+
orgID ghv4.ID
61+
userID ghv4.ID
62+
orgErr error
63+
userErr error
64+
expectID ghv4.ID
65+
expectErr string
66+
}{
67+
{
68+
name: "org exists",
69+
orgID: "ORGID",
70+
userID: "",
71+
expectID: "ORGID",
72+
},
73+
{
74+
name: "user exists",
75+
orgID: "",
76+
userID: "USERID",
77+
expectID: "USERID",
78+
},
79+
{
80+
name: "both org and user exist (prefer org)",
81+
orgID: "ORGID",
82+
userID: "USERID",
83+
expectID: "ORGID",
84+
},
85+
{
86+
name: "neither org nor user exist",
87+
orgID: "",
88+
userID: "",
89+
orgErr: errors.New("non-200 OK status code: 404"),
90+
userErr: errors.New("non-200 OK status code: 404"),
91+
expectErr: "owner not found",
92+
},
93+
{
94+
name: "org fatal error",
95+
orgID: "",
96+
userID: "USERID",
97+
orgErr: errors.New("fatal org error"),
98+
expectErr: "organization lookup failed",
99+
},
100+
{
101+
name: "user fatal error",
102+
orgID: "",
103+
userID: "",
104+
orgErr: errors.New("non-200 OK status code: 404"),
105+
userErr: errors.New("fatal user error"),
106+
expectErr: "user lookup failed",
107+
},
108+
}
109+
110+
for _, tc := range tests {
111+
t.Run(tc.name, func(t *testing.T) {
112+
client := &fakeGraphQLClient{
113+
orgID: tc.orgID,
114+
userID: tc.userID,
115+
orgErr: tc.orgErr,
116+
userErr: tc.userErr,
117+
}
118+
id, err := resolveOwnerID(ctx, client, owner)
119+
if tc.expectErr != "" {
120+
assert.Error(t, err)
121+
assert.Contains(t, err.Error(), tc.expectErr)
122+
} else {
123+
assert.NoError(t, err)
124+
assert.Equal(t, tc.expectID, id)
125+
}
126+
})
127+
}
128+
}

pkg/github/projects.go

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ func GetProjectItems(ctx context.Context, in *GetProjectItemsInput, client *ghv4
354354

355355
// CreateProject creates a new project using the provided githubv4.Client.
356356
// If client is nil, a default client is created using GITHUB_TOKEN from environment.
357+
// CreateProject creates a new project using the provided githubv4.Client.
358+
// Resolves the owner (organization or user) to a GraphQL ID and uses it in the mutation input.
357359
func CreateProject(ctx context.Context, in *CreateProjectInput, client *ghv4.Client) (*Project, error) {
358360
if in.Owner == "" || in.Title == "" {
359361
return nil, errors.New("owner and title are required")
@@ -367,22 +369,10 @@ func CreateProject(ctx context.Context, in *CreateProjectInput, client *ghv4.Cli
367369
client = ghv4.NewClient(&http.Client{Transport: &authTransport{token: token}})
368370
}
369371

370-
// Lookup owner ID (org or user)
371-
var ownerQ struct {
372-
Organization *struct{ ID ghv4.ID } `graphql:"organization(login: $login)"`
373-
User *struct{ ID ghv4.ID } `graphql:"user(login: $login)"`
374-
}
375-
ownerVars := map[string]interface{}{"login": ghv4.String(in.Owner)}
376-
if err := client.Query(ctx, &ownerQ, ownerVars); err != nil {
377-
return nil, fmt.Errorf("owner lookup failed: %w", err)
378-
}
379-
var ownerID ghv4.ID
380-
if ownerQ.Organization != nil {
381-
ownerID = ownerQ.Organization.ID
382-
} else if ownerQ.User != nil {
383-
ownerID = ownerQ.User.ID
384-
} else {
385-
return nil, errors.New("owner not found")
372+
// Always resolve the owner to a GraphQL ID (works for both orgs and users)
373+
ownerID, err := resolveOwnerID(ctx, client, in.Owner)
374+
if err != nil {
375+
return nil, err
386376
}
387377

388378
type createProjectInput struct {

0 commit comments

Comments
 (0)