Skip to content

Commit 705aa70

Browse files
starhawkingEmma MayJeremy Udit
authored
Properly handle the new style of Node IDs in the GitHub GraphQL API (#914)
* Properly handle the new style of Node IDs in the GitHub GraphQL API https://github.blog/2021-02-10-new-global-id-format-coming-to-graphql/ * fixup! maintain compatibility with deprecated Global ID format Co-authored-by: Emma May <[email protected]> Co-authored-by: Jeremy Udit <[email protected]>
1 parent b413047 commit 705aa70

File tree

2 files changed

+246
-0
lines changed

2 files changed

+246
-0
lines changed

github/util_v4_repository.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ func getRepositoryID(name string, meta interface{}) (githubv4.ID, error) {
1616
return githubv4.ID(name), nil
1717
}
1818

19+
// Interpret `name` as a legacy node ID
20+
exists, _ = repositoryLegacyNodeIDExists(name, meta)
21+
if exists {
22+
return githubv4.ID(name), nil
23+
}
24+
1925
// Could not find repo by node ID, interpret `name` as repo name
2026
var query struct {
2127
Repository struct {
@@ -41,6 +47,29 @@ func getRepositoryID(name string, meta interface{}) (githubv4.ID, error) {
4147
}
4248

4349
func repositoryNodeIDExists(name string, meta interface{}) (bool, error) {
50+
51+
// API check if node ID exists
52+
var query struct {
53+
Node struct {
54+
ID githubv4.ID
55+
} `graphql:"node(id:$id)"`
56+
}
57+
variables := map[string]interface{}{
58+
"id": githubv4.ID(name),
59+
}
60+
ctx := context.Background()
61+
client := meta.(*Owner).v4client
62+
err := client.Query(ctx, &query, variables)
63+
if err != nil {
64+
return false, err
65+
}
66+
67+
return query.Node.ID.(string) == name, nil
68+
}
69+
70+
// Maintain compatibility with deprecated Global ID format
71+
// https://github.blog/2021-02-10-new-global-id-format-coming-to-graphql/
72+
func repositoryLegacyNodeIDExists(name string, meta interface{}) (bool, error) {
4473
// Check if the name is a base 64 encoded node ID
4574
_, err := base64.StdEncoding.DecodeString(name)
4675
if err != nil {

github/util_v4_repository_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package github
2+
3+
import (
4+
"bytes"
5+
"github.com/shurcooL/githubv4"
6+
"io"
7+
"io/ioutil"
8+
"net/http"
9+
"net/http/httptest"
10+
"strings"
11+
"testing"
12+
"text/template"
13+
)
14+
15+
// Heavily based on https://github.com/shurcooL/githubv4/blob/master/githubv4_test.go#L114-L144
16+
17+
const nodeMatchTmpl = `{
18+
"data": {
19+
"node": {
20+
"id": "{{.Provided}}"
21+
}
22+
}
23+
}`
24+
const nodeNoMatchTmpl = `{
25+
"data": {
26+
"node": null
27+
},
28+
"errors": [
29+
{
30+
"type": "NOT_FOUND",
31+
"path": [
32+
"node"
33+
],
34+
"locations": [
35+
{
36+
"line": 2,
37+
"column": 3
38+
}
39+
],
40+
"message": "Could not resolve to a node with the global id of '{{.Provided}}'"
41+
}
42+
]
43+
}`
44+
45+
const repoMatchTmpl = `{
46+
"data": {
47+
"repository": {
48+
"id": "{{.Expected}}"
49+
}
50+
}
51+
}`
52+
53+
const repoNoMatchTmpl = `{
54+
"data": {
55+
"repository": null
56+
},
57+
"errors": [
58+
{
59+
"type": "NOT_FOUND",
60+
"path": [
61+
"repository"
62+
],
63+
"locations": [
64+
{
65+
"line": 1,
66+
"column": 36
67+
}
68+
],
69+
"message": "Could not resolve to a Repository with the name '{{.Owner}}/{{.Provided}}'."
70+
}
71+
]
72+
}`
73+
74+
func TestGetRepositoryIDPositiveMatches(t *testing.T) {
75+
cases := []struct {
76+
Provided string
77+
Expected string
78+
Owner string
79+
}{
80+
{
81+
// Old style Node ID
82+
Provided: "MDEwOlJlcG9zaXRvcnk5MzQ0NjA5OQ==",
83+
Expected: "MDEwOlJlcG9zaXRvcnk5MzQ0NjA5OQ==",
84+
},
85+
{
86+
// Resolve a new style Node ID
87+
Provided: "terraform-provider-github",
88+
Expected: "MDEwOlJlcG9zaXRvcnk5MzQ0NjA5OQ==",
89+
Owner: "integrations",
90+
},
91+
{
92+
// New style Node ID
93+
Provided: "R_kgDOGGmaaw",
94+
Expected: "R_kgDOGGmaaw",
95+
},
96+
{
97+
// Resolve a new style Node ID
98+
Provided: "actions-docker-build",
99+
Expected: "R_kgDOGGmaaw",
100+
Owner: "hashicorp",
101+
},
102+
103+
// Ensure We don't get any unexpected results
104+
{
105+
Provided: "testrepo8",
106+
Owner: "testowner",
107+
},
108+
{
109+
Provided: "R_NOPE",
110+
},
111+
{
112+
Provided: "RkFJTCBIRVJFCg==",
113+
},
114+
}
115+
116+
responses := template.Must(template.New("node_match").Parse(nodeMatchTmpl))
117+
_, err := responses.New("node_no_match").Parse(nodeNoMatchTmpl)
118+
if err != nil {
119+
panic(err)
120+
}
121+
_, err = responses.New("repo_match").Parse(repoMatchTmpl)
122+
if err != nil {
123+
panic(err)
124+
}
125+
_, err = responses.New("repo_no_match").Parse(repoNoMatchTmpl)
126+
if err != nil {
127+
panic(err)
128+
}
129+
130+
mux := http.NewServeMux()
131+
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
132+
body := mustRead(req.Body)
133+
var action string
134+
for _, tc := range cases {
135+
if strings.Contains(body, tc.Provided) || strings.Contains(body, tc.Expected) {
136+
var out bytes.Buffer
137+
w.Header().Set("Content-Type", "application/json")
138+
if strings.Contains(body, "repository(owner:$owner, name:$name)") {
139+
if tc.Expected == tc.Provided {
140+
t.Fatalf("Attempted to use node_id=%s as a repo name", tc.Provided)
141+
} else if tc.Expected == "" {
142+
action = "repo_no_match"
143+
} else {
144+
action = "repo_match"
145+
}
146+
} else if strings.Contains(body, "node(id:$id)") {
147+
if tc.Expected == tc.Provided {
148+
action = "node_match"
149+
} else {
150+
action = "node_no_match"
151+
}
152+
} else {
153+
t.Fatalf("Unknown GraphQL Call on %s", tc.Provided)
154+
}
155+
err := responses.ExecuteTemplate(&out, action, tc)
156+
if err != nil {
157+
t.Fatalf("Failed Templating %s", err)
158+
}
159+
payload := out.String()
160+
mustWrite(w, payload)
161+
break
162+
}
163+
}
164+
if action == "" {
165+
t.Fatalf("Unknown query %s", body)
166+
}
167+
})
168+
169+
meta := Owner{
170+
v4client: githubv4.NewClient(&http.Client{Transport: localRoundTripper{handler: mux}}),
171+
name: "care-dot-com",
172+
}
173+
174+
for _, tc := range cases {
175+
got, err := getRepositoryID(tc.Provided, &meta)
176+
if err != nil {
177+
// We expect to error out on these repos
178+
if tc.Expected != "" {
179+
t.Fatalf("unexpected error(s): %s (%s)", err, tc.Provided)
180+
}
181+
t.Logf("Got expected error in %s: %s", tc.Provided, err)
182+
}
183+
if (tc.Expected != "") && (tc.Expected != got) {
184+
t.Fatalf("%s got %s expected %s", tc.Provided, got, tc.Expected)
185+
}
186+
if (tc.Expected == "") && (got != nil) {
187+
t.Fatalf("%s should have failed, instead got %s", tc.Provided, got)
188+
}
189+
}
190+
}
191+
192+
// localRoundTripper is an http.RoundTripper that executes HTTP transactions
193+
// by using handler directly, instead of going over an HTTP connection.
194+
type localRoundTripper struct {
195+
handler http.Handler
196+
}
197+
198+
func (l localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
199+
w := httptest.NewRecorder()
200+
l.handler.ServeHTTP(w, req)
201+
return w.Result(), nil
202+
}
203+
204+
func mustRead(r io.Reader) string {
205+
b, err := ioutil.ReadAll(r)
206+
if err != nil {
207+
panic(err)
208+
}
209+
return string(b)
210+
}
211+
212+
func mustWrite(w io.Writer, s string) {
213+
_, err := io.WriteString(w, s)
214+
if err != nil {
215+
panic(err)
216+
}
217+
}

0 commit comments

Comments
 (0)