Skip to content

Commit 7318f58

Browse files
Merge pull request #4826 from linuxfoundation/unicron-4803-4820
Unicron 4803 4820
2 parents 9a4b426 + ff947c7 commit 7318f58

File tree

7 files changed

+211
-12
lines changed

7 files changed

+211
-12
lines changed

cla-backend-go/github/github_repository.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,35 @@ type gqlRequest struct {
7878
}
7979

8080
type gqlError struct {
81-
Message string `json:"message"`
82-
Type string `json:"type,omitempty"` // sometimes "RATE_LIMITED"
81+
Message string `json:"message"`
82+
Type string `json:"type,omitempty"` // sometimes "RATE_LIMITED"
83+
Path []interface{} `json:"path,omitempty"`
84+
Extensions map[string]any `json:"extensions,omitempty"`
8385
}
8486

8587
type gqlResponse struct {
8688
Data json.RawMessage `json:"data"`
8789
Errors []gqlError `json:"errors,omitempty"`
8890
}
8991

92+
type GraphQLError struct {
93+
Errs []gqlError
94+
}
95+
96+
func (e *GraphQLError) Error() string {
97+
if len(e.Errs) == 0 {
98+
return "graphql: unknown error"
99+
}
100+
msg := "graphql: "
101+
for i, ge := range e.Errs {
102+
msg += fmt.Sprintf("#%d: %s (type=%s path=%v)", i+1, ge.Message, ge.Type, ge.Path)
103+
if i < len(e.Errs)-1 {
104+
msg += "; "
105+
}
106+
}
107+
return msg
108+
}
109+
90110
// doGraphQL posts to /graphql using v3 client and unmarshals the "data" field into v.
91111
// No retries; if GraphQL returns "errors", returns an error.
92112
func doGraphQL(ctx context.Context, c *github.Client, query string, variables map[string]interface{}, v any) (*github.Response, error) {
@@ -95,14 +115,16 @@ func doGraphQL(ctx context.Context, c *github.Client, query string, variables ma
95115
if err != nil {
96116
return nil, err
97117
}
118+
req.Header.Set("Accept", "application/vnd.github+json")
119+
req.Header.Set("Content-Type", "application/json")
120+
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
98121
var gr gqlResponse
99122
resp, err := c.Do(ctx, req, &gr)
100123
if err != nil {
101124
return resp, err
102125
}
103126
if len(gr.Errors) > 0 {
104-
first := gr.Errors[0]
105-
return resp, fmt.Errorf("graphql error: %s", first.Message)
127+
return resp, &GraphQLError{Errs: gr.Errors}
106128
}
107129
if v != nil && len(gr.Data) > 0 {
108130
if err := json.Unmarshal(gr.Data, v); err != nil {

cla-backend-go/v2/cla_manager/emails.go

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,8 @@ func (s *service) SendDesigneeEmailToUserWithNoLFID(ctx context.Context, input D
313313
// Parse the provided user's name
314314
userFirstName, userLastName := utils.GetFirstAndLastName(input.userWithNoLFIDName)
315315

316-
return acsClient.SendUserInvite(ctx, &v2AcsService.SendUserInviteInput{
316+
// Send invitation for the primary CLA role (e.g., cla-manager-designee)
317+
inviteErr := acsClient.SendUserInvite(ctx, &v2AcsService.SendUserInviteInput{
317318
InviteUserFirstName: userFirstName,
318319
InviteUserLastName: userLastName,
319320
InviteUserEmail: input.userWithNoLFIDEmail,
@@ -326,6 +327,48 @@ func (s *service) SendDesigneeEmailToUserWithNoLFID(ctx context.Context, input D
326327
EmailContent: body,
327328
Automate: false,
328329
})
330+
if inviteErr != nil {
331+
log.WithFields(f).WithError(inviteErr).Warnf("failed to send primary role invitation")
332+
return inviteErr
333+
}
334+
335+
// Also send invitation for the "contact" role which is required to access Corporate Console
336+
// This is sent at organization scope (not project|organization)
337+
contactSubject := fmt.Sprintf("EasyCLA: Invitation to access Corporate Console for %s", input.companyName)
338+
contactBody, contactBodyErr := emails.RenderV2ToCLAManagerDesigneeTemplate(s.emailTemplateService, input.projectSFIDs,
339+
emails.V2ToCLAManagerDesigneeTemplateParams{
340+
CommonEmailParams: emails.CommonEmailParams{
341+
RecipientName: input.userWithNoLFIDName,
342+
CompanyName: input.companyName,
343+
},
344+
Contributor: input.contributorModel,
345+
}, emails.V2DesigneeToUserWithNoLFIDTemplate, emails.V2DesigneeToUserWithNoLFIDTemplateName)
346+
347+
if contactBodyErr != nil {
348+
log.WithFields(f).WithError(contactBodyErr).Warnf("failed to render contact role invitation email template")
349+
// Don't return error, as the primary invitation was successful
350+
} else {
351+
log.WithFields(f).Debug("sending contact role invite request...")
352+
contactInviteErr := acsClient.SendUserInvite(ctx, &v2AcsService.SendUserInviteInput{
353+
InviteUserFirstName: userFirstName,
354+
InviteUserLastName: userLastName,
355+
InviteUserEmail: input.userWithNoLFIDEmail,
356+
RoleName: utils.ContactRole,
357+
Scope: "organization", // Contact role is at organization scope only
358+
ProjectSFID: "", // Not applicable for organization scope
359+
OrganizationSFID: input.organizationID,
360+
InviteType: "userinvite",
361+
Subject: contactSubject,
362+
EmailContent: contactBody,
363+
Automate: false,
364+
})
365+
if contactInviteErr != nil {
366+
log.WithFields(f).WithError(contactInviteErr).Warnf("failed to send contact role invitation, but primary invitation succeeded")
367+
// Don't return error, as the primary invitation was successful
368+
}
369+
}
370+
371+
return nil
329372
}
330373

331374
// sendEmailToUserWithNoLFID helper function to send email to a given user with no LFID
@@ -364,8 +407,9 @@ func (s *service) SendEmailToUserWithNoLFID(ctx context.Context, input EmailToUs
364407
userFirstName, userLastName := utils.GetFirstAndLastName(input.userWithNoLFIDName)
365408

366409
log.WithFields(f).Debug("sending user invite request...")
367-
//return acsClient.SendUserInvite(ctx, &userWithNoLFIDEmail, role, utils.ProjectOrgScope, projectID, organizationID, "userinvite", &subject, &body, automate)
368-
return acsClient.SendUserInvite(ctx, &v2AcsService.SendUserInviteInput{
410+
411+
// Send invitation for the primary CLA role (e.g., cla-manager-designee)
412+
inviteErr := acsClient.SendUserInvite(ctx, &v2AcsService.SendUserInviteInput{
369413
InviteUserFirstName: userFirstName,
370414
InviteUserLastName: userLastName,
371415
InviteUserEmail: input.userWithNoLFIDEmail,
@@ -378,4 +422,45 @@ func (s *service) SendEmailToUserWithNoLFID(ctx context.Context, input EmailToUs
378422
EmailContent: body,
379423
Automate: false,
380424
})
425+
if inviteErr != nil {
426+
log.WithFields(f).WithError(inviteErr).Warnf("failed to send primary role invitation")
427+
return inviteErr
428+
}
429+
430+
// Also send invitation for the "contact" role which is required to access Corporate Console
431+
// This is sent at organization scope (not project|organization)
432+
contactSubject := fmt.Sprintf("EasyCLA: Invitation to access Corporate Console for %s", input.companyName)
433+
contactBody, contactBodyErr := emails.RenderV2CLAManagerToUserWithNoLFIDTemplate(s.emailTemplateService, input.projectID, emails.V2CLAManagerToUserWithNoLFIDTemplateParams{
434+
CommonEmailParams: emails.CommonEmailParams{
435+
RecipientName: input.userWithNoLFIDName,
436+
CompanyName: input.companyName,
437+
},
438+
RequesterUserName: input.requesterUsername,
439+
RequesterEmail: input.requesterEmail,
440+
})
441+
if contactBodyErr != nil {
442+
log.WithFields(f).WithError(contactBodyErr).Warnf("failed to render contact role invitation email template")
443+
// Don't return error, as the primary invitation was successful
444+
} else {
445+
log.WithFields(f).Debug("sending contact role invite request...")
446+
contactInviteErr := acsClient.SendUserInvite(ctx, &v2AcsService.SendUserInviteInput{
447+
InviteUserFirstName: userFirstName,
448+
InviteUserLastName: userLastName,
449+
InviteUserEmail: input.userWithNoLFIDEmail,
450+
RoleName: utils.ContactRole,
451+
Scope: "organization", // Contact role is at organization scope only
452+
ProjectSFID: "", // Not applicable for organization scope
453+
OrganizationSFID: input.organizationID,
454+
InviteType: "userinvite",
455+
Subject: contactSubject,
456+
EmailContent: contactBody,
457+
Automate: false,
458+
})
459+
if contactInviteErr != nil {
460+
log.WithFields(f).WithError(contactInviteErr).Warnf("failed to send contact role invitation, but primary invitation succeeded")
461+
// Don't return error, as the primary invitation was successful
462+
}
463+
}
464+
465+
return nil
381466
}

cla-backend-go/v2/cla_manager/service.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,28 @@ func (s *service) CreateCLAManagerDesignee(ctx context.Context, companyID string
414414
log.WithFields(f).Debugf("created user role organization scope for user: %s, with role: %s with role ID: %s using project|org: %s|%s...",
415415
userEmail, utils.CLADesigneeRole, roleID, projectSFID, v1CompanyModel.CompanyExternalID)
416416

417+
// Also assign the "contact" role which is required to access the Corporate Console
418+
log.WithFields(f).Debugf("loading role ID for %s...", utils.ContactRole)
419+
contactRoleID, contactErr := acServiceClient.GetRoleID(utils.ContactRole)
420+
if contactErr != nil {
421+
log.WithFields(f).Warnf("Problem getting role ID for contact role, error: %+v - continuing without contact role", contactErr)
422+
} else {
423+
log.WithFields(f).Debugf("creating contact role organization scope for user: %s, with role: %s with role ID: %s using org: %s...",
424+
userEmail, utils.ContactRole, contactRoleID, v1CompanyModel.CompanyExternalID)
425+
contactScopeErr := orgClient.CreateOrgUserRoleOrgScope(ctx, userEmail, v1CompanyModel.CompanyExternalID, contactRoleID)
426+
if contactScopeErr != nil {
427+
// Ignore conflict - role has already been assigned - otherwise, log error but don't fail
428+
if _, ok := contactScopeErr.(*organizations.CreateOrgUsrRoleScopesConflict); !ok {
429+
log.WithFields(f).Warnf("problem creating contact role org scope for email: %s, companySFID: %s, error: %+v - continuing without contact role", userEmail, v1CompanyModel.CompanyExternalID, contactScopeErr)
430+
} else {
431+
log.WithFields(f).Debugf("contact role already assigned for user: %s, companySFID: %s", userEmail, v1CompanyModel.CompanyExternalID)
432+
}
433+
} else {
434+
log.WithFields(f).Debugf("successfully created contact role organization scope for user: %s, with role: %s with role ID: %s using org: %s",
435+
userEmail, utils.ContactRole, contactRoleID, v1CompanyModel.CompanyExternalID)
436+
}
437+
}
438+
417439
// Log Event
418440
s.eventService.LogEventWithContext(ctx,
419441
&events.LogEventArgs{

cla-backend/cla/models/github_models.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@ def update_merge_group_status(
707707

708708
# Create the commit status on the merge commit
709709
if self.client is None:
710-
self.client = self._get_github_client(installation_id)
710+
self.client = get_github_integration_client(installation_id)
711711

712712
# Get repository
713713
cla.log.debug(f"{fn} - Getting repository by ID: {repository_id}")
@@ -2148,14 +2148,24 @@ def pygithub_graphql(g, query: str, variables: dict | None = None):
21482148
try:
21492149
# LG: note that this uses internal PyGithub API - may break in future versions:
21502150
# g._Github__requester.requestJsonAndCheck
2151-
headers, data = g._Github__requester.requestJsonAndCheck(
2151+
if hasattr(g, "graphql"):
2152+
return g.graphql(query, variables or {})
2153+
2154+
headers = {
2155+
"Accept": "application/vnd.github+json",
2156+
"Content-Type": "application/json",
2157+
}
2158+
_, data = g._Github__requester.requestJsonAndCheck(
21522159
"POST",
21532160
"/graphql",
21542161
input={"query": query, "variables": variables or {}},
2162+
headers=headers,
21552163
)
21562164
if isinstance(data, dict) and data.get("errors"):
2157-
msg = data["errors"][0].get("message", "GraphQL error")
2158-
cla.log.error(f"GraphQL errors: {msg} (all={data['errors']!r})")
2165+
errs = data["errors"]
2166+
paths = [e.get("path") for e in errs]
2167+
msgs = [e.get("message") for e in errs]
2168+
cla.log.error(f"GraphQL errors: {msgs} (paths={paths}, all={errs!r})")
21592169
return None
21602170
return data.get("data")
21612171
except Exception as exc:
@@ -2288,7 +2298,16 @@ def get_pr_commit_count_gql(g, owner: str, repo: str, number: int) -> int | None
22882298
if data is None:
22892299
cla.log.debug(f"get_pr_commit_count_gql: no data returned")
22902300
return None
2291-
return data["repository"]["pullRequest"]["commits"]["totalCount"]
2301+
repo_obj = data.get("repository")
2302+
if not repo_obj:
2303+
cla.log.debug("get_pr_commit_count_gql: repository null (no access?)")
2304+
return None
2305+
pr = repo_obj.get("pullRequest")
2306+
if not pr:
2307+
cla.log.debug("get_pr_commit_count_gql: pullRequest null (bad number or no access?)")
2308+
return None
2309+
commits = pr.get("commits") or {}
2310+
return commits.get("totalCount")
22922311
except Exception as e:
22932312
cla.log.debug(f"get_pr_commit_count_gql: failed to fetch count: {e}")
22942313
return None

set-easycla-github-token.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/bash
2+
export GITHUB_OAUTH_TOKEN="$(cat ./easycla-github-oauth-token.secret)"

utils/copy_prod_case_7.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/bash
2+
repo_id=$(STAGE=prod ./utils/scan.sh repositories repository_name 'fluxnova-modeler' | jq -r '.[0].repository_id.S')
3+
./utils/copy_prod_to_dev.sh repositories repository_id "${repo_id}"
4+
STAGE=dev ./utils/scan.sh repositories repository_name 'fluxnova-modeler'

utils/github_pat_check.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/bin/bash
2+
# Quick test for GitHub PAT GraphQL permissions
3+
# ./utils/github_pat_check.sh "$(cat ./easycla-github-oauth-token.secret)" finos fluxnova-modeler 85
4+
# ./utils/github_pat_check.sh "$(cat /etc/github/oauth)" cncf devstats 114
5+
6+
if ( [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ] || [ -z "$4" ] )
7+
then
8+
echo "Usage: $0 YOUR_GITHUB_TOKEN org repo pr-number"
9+
echo "Example: $0 ghp_xxxxxxxxxxxxxxxxxxxx cncf devstats 114"
10+
exit 1
11+
fi
12+
13+
TOKEN="$1"
14+
ORG="$2"
15+
REPO="$3"
16+
PR="$4"
17+
echo "Testing GitHub PAT GraphQL with token: ${TOKEN:0:4} for $ORG/$REPO PR:$PR..."
18+
19+
echo -e "\n1. Testing Simple GraphQL Query:"
20+
echo "--------------------------------"
21+
curl -s -H "Authorization: Bearer $TOKEN" \
22+
-H "Content-Type: application/json" \
23+
-d '{"query":"query { viewer { login } }"}' \
24+
https://api.github.com/graphql
25+
26+
echo -e "\n\n2. Checking Token Scopes:"
27+
echo "------------------------"
28+
curl -s -I -H "Authorization: Bearer $TOKEN" \
29+
https://api.github.com/user | grep -i "x-oauth-scopes" || echo "No scopes header found"
30+
31+
echo -e "\n\n3. Testing Repository Access:"
32+
echo "----------------------------"
33+
curl -s -H "Authorization: Bearer $TOKEN" \
34+
-H "Content-Type: application/json" \
35+
-d '{"query":"query { repository(owner:\"'"$ORG"'\", name:\"'"$REPO"'\") { name } }"}' \
36+
https://api.github.com/graphql
37+
38+
echo -e "\n\n4. Testing PR Commits (the failing query):"
39+
echo "------------------------------------------"
40+
curl -s -H "Authorization: Bearer $TOKEN" \
41+
-H "Content-Type: application/json" \
42+
-d '{"query":"query { repository(owner:\"'"$ORG"'\", name:\"'"$REPO"'\") { pullRequest(number:'$PR') { commits(first:1) { nodes { commit { oid } } } } } }"}' \
43+
https://api.github.com/graphql
44+
45+
echo -e "\n"

0 commit comments

Comments
 (0)