Skip to content

Commit 6ca6b2e

Browse files
Add golang version
Signed-off-by: Lukasz Gryglicki <[email protected]>
1 parent e918790 commit 6ca6b2e

File tree

9 files changed

+281
-16
lines changed

9 files changed

+281
-16
lines changed

cla-backend-go/events/event_data.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,23 @@ type CorporateSignatureSignedEventData struct {
457457
SignatoryName string
458458
}
459459

460+
// BypassCLAEventData event data model
461+
type BypassCLAEventData struct {
462+
Repo string
463+
Config string
464+
Actor string
465+
}
466+
467+
func (ed *BypassCLAEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) {
468+
data := fmt.Sprintf("repo='%s', config='%s', actor='%s'", ed.Repo, ed.Config, ed.Actor)
469+
return data, true
470+
}
471+
472+
func (ed *BypassCLAEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) {
473+
data := fmt.Sprintf("repo='%s', config='%s', actor='%s'", ed.Repo, ed.Config, ed.Actor)
474+
return data, true
475+
}
476+
460477
func (ed *CorporateSignatureSignedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) {
461478
data := fmt.Sprintf("The signature was signed for the project %s and company %s by %s", args.ProjectName, ed.CompanyName, ed.SignatoryName)
462479
if args.UserName != "" {

cla-backend-go/events/event_types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,6 @@ const (
9999

100100
IndividualSignatureSigned = "individual.signature.signed"
101101
CorporateSignatureSigned = "corporate.signature.signed"
102+
103+
BypassCLA = "Bypass CLA"
102104
)

cla-backend-go/github/bots.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright The Linux Foundation and each contributor to CommunityBridge.
2+
// SPDX-License-Identifier: MIT
3+
4+
package github
5+
6+
import (
7+
"fmt"
8+
"github.com/linuxfoundation/easycla/cla-backend-go/events"
9+
"github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models"
10+
log "github.com/linuxfoundation/easycla/cla-backend-go/logging"
11+
"github.com/sirupsen/logrus"
12+
"regexp"
13+
"strings"
14+
)
15+
16+
// propertyMatches returns true if value matches the pattern.
17+
// - "*" matches anything
18+
// - "re:..." matches regex (value must be non-empty)
19+
// - otherwise, exact match
20+
func propertyMatches(pattern, value string) bool {
21+
f := logrus.Fields{
22+
"functionName": "github.propertyMatches",
23+
"pattern": pattern,
24+
"value": value,
25+
}
26+
if pattern == "*" {
27+
return true
28+
}
29+
if value == "" {
30+
return false
31+
}
32+
if strings.HasPrefix(pattern, "re:") {
33+
regex := pattern[3:]
34+
re, err := regexp.Compile(regex)
35+
if err != nil {
36+
log.WithFields(f).Debugf("Error in propertyMatches: bad regexp: %s, error: %v", regex, err)
37+
return false
38+
}
39+
return re.MatchString(value)
40+
}
41+
return value == pattern
42+
}
43+
44+
// stripOrg removes the organization part from the repository name.
45+
// If input is "org/repo", returns "repo". If no "/", returns input unchanged.
46+
func stripOrg(repoFull string) string {
47+
idx := strings.Index(repoFull, "/")
48+
if idx >= 0 && idx+1 < len(repoFull) {
49+
return repoFull[idx+1:]
50+
}
51+
return repoFull
52+
}
53+
54+
// isActorSkipped returns true if the given actor should be skipped according to the skip_cla config pattern.
55+
// config format: "<username_pattern>;<email_pattern>"
56+
// Actor.CommitAuthor.Login and Actor.CommitAuthor.Email should be *string, can be nil.
57+
func isActorSkipped(actor *UserCommitSummary, config string) bool {
58+
f := logrus.Fields{
59+
"functionName": "github.isActorSkipped",
60+
"config": config,
61+
}
62+
// Defensive: must have exactly one ';'
63+
if !strings.Contains(config, ";") {
64+
log.WithFields(f).Debugf("Invalid skip_cla config format: %s, expected '<username_pattern>;<email_pattern>'", config)
65+
return false
66+
}
67+
parts := strings.SplitN(config, ";", 2)
68+
if len(parts) != 2 {
69+
return false
70+
}
71+
usernamePattern := parts[0]
72+
emailPattern := parts[1]
73+
var (
74+
username string
75+
email string
76+
)
77+
if actor.CommitAuthor != nil && actor.CommitAuthor.Login != nil {
78+
username = *actor.CommitAuthor.Login
79+
}
80+
if actor.CommitAuthor != nil && actor.CommitAuthor.Email != nil {
81+
email = *actor.CommitAuthor.Email
82+
}
83+
84+
return propertyMatches(usernamePattern, username) && propertyMatches(emailPattern, email)
85+
}
86+
87+
// SkipWhitelistedBots- check if the actors are whitelisted based on the skip_cla configuration.
88+
// Returns two lists:
89+
// - actors still missing cla: actors who still need to sign the CLA after checking skip_cla
90+
// - whitelisted actors: actors who are skipped due to skip_cla configuration
91+
// :param orgModel: The GitHub organization model instance.
92+
// :param orgRepo: The repository name in the format 'org/repo'.
93+
// :param actorsMissingCla: List of UserCommitSummary objects representing actors who are missing CLA.
94+
// :return: two arrays (actors still missing CLA, whitelisted actors)
95+
// : in cla-{stage}-github-orgs table there can be a skip_cla field which is a dict with the following structure:
96+
//
97+
// {
98+
// "repo-name": "<username_pattern>;<email_pattern>",
99+
// "re:repo-regexp": "<username_pattern>;<email_pattern>",
100+
// "*": "<username_pattern>;<email_pattern>"
101+
// }
102+
//
103+
// where:
104+
// - repo-name is the exact repository name under given org (e.g., "my-repo" not "my-org/my-repo")
105+
// - re:repo-regexp is a regex pattern to match repository names
106+
// - * is a wildcard that applies to all repositories
107+
// - <username_pattern> is a GitHub username pattern (exact match or regex prefixed by re: or match all '*')
108+
// - <email_pattern> is a GitHub email pattern (exact match or regex prefixed by re: or match all '*')
109+
// The username and email patterns are separated by a semicolon (;).
110+
// If the skip_cla is not set, it will skip the whitelisted bots check.
111+
func SkipWhitelistedBots(ev events.Service, orgModel *models.GithubOrganization, orgRepo, projectID string, actorsMissingCLA []*UserCommitSummary) ([]*UserCommitSummary, []*UserCommitSummary) {
112+
repo := stripOrg(orgRepo)
113+
f := logrus.Fields{
114+
"functionName": "github.SkipWhitelistedBots",
115+
"orgRepo": orgRepo,
116+
"repo": repo,
117+
"projectID": projectID,
118+
}
119+
outActorsMissingCLA := []*UserCommitSummary{}
120+
whitelistedActors := []*UserCommitSummary{}
121+
122+
skipCLA := orgModel.SkipCla
123+
if skipCLA == nil {
124+
log.WithFields(f).Debug("skip_cla is not set, skipping whitelisted bots check")
125+
return actorsMissingCLA, []*UserCommitSummary{}
126+
}
127+
128+
var config string
129+
130+
// 1. Exact match
131+
if val, ok := skipCLA[repo]; ok {
132+
config = val
133+
log.WithFields(f).Debugf("skip_cla config found for repo (exact hit): '%s'", config)
134+
}
135+
136+
// 2. Regex match (if no exact hit)
137+
if config == "" {
138+
log.WithFields(f).Debug("No skip_cla config found for repo, checking regex patterns")
139+
for k, v := range skipCLA {
140+
if !strings.HasPrefix(k, "re:") {
141+
continue
142+
}
143+
pattern := k[3:]
144+
re, err := regexp.Compile(pattern)
145+
if err != nil {
146+
log.WithFields(f).Warnf("Invalid regex in skip_cla: '%s': %+v", pattern, err)
147+
continue
148+
}
149+
if re.MatchString(repo) {
150+
config = v
151+
log.WithFields(f).Debugf("Found skip_cla config for repo via regex pattern: '%s'", config)
152+
break
153+
}
154+
}
155+
}
156+
157+
// 3. Wildcard fallback
158+
if config == "" {
159+
if val, ok := skipCLA["*"]; ok {
160+
config = val
161+
log.WithFields(f).Debugf("No skip_cla config found for repo, using wildcard config: '%s'", config)
162+
}
163+
}
164+
165+
// 4. No match
166+
if config == "" {
167+
log.WithFields(f).Debug("No skip_cla config found for repo, skipping whitelisted bots check")
168+
return actorsMissingCLA, []*UserCommitSummary{}
169+
}
170+
171+
for _, actor := range actorsMissingCLA {
172+
if isActorSkipped(actor, config) {
173+
id, login, username, email := "(null)", "(null)", "(null)", "(null)"
174+
if actor.CommitAuthor != nil && actor.CommitAuthor.ID != nil {
175+
id = fmt.Sprintf("%v", *actor.CommitAuthor.ID)
176+
}
177+
if actor.CommitAuthor != nil && actor.CommitAuthor.Login != nil {
178+
login = *actor.CommitAuthor.Login
179+
}
180+
if actor.CommitAuthor != nil && actor.CommitAuthor.Name != nil {
181+
username = *actor.CommitAuthor.Name
182+
}
183+
if actor.CommitAuthor != nil && actor.CommitAuthor.Email != nil {
184+
email = *actor.CommitAuthor.Email
185+
}
186+
actorData := fmt.Sprintf("id='%v',login='%v',username='%v',email='%v'", id, login, username, email)
187+
msg := fmt.Sprintf(
188+
"Skipping CLA check for repo='%s', actor: %s due to skip_cla config: '%s'",
189+
orgRepo, actorData, config,
190+
)
191+
log.WithFields(f).Info(msg)
192+
eventData := events.BypassCLAEventData{
193+
Repo: orgRepo,
194+
Config: config,
195+
Actor: actorData,
196+
}
197+
ev.LogEvent(&events.LogEventArgs{
198+
EventType: events.BypassCLA,
199+
EventData: &eventData,
200+
UserID: id,
201+
UserName: login,
202+
ProjectID: projectID,
203+
})
204+
log.WithFields(f).Debugf("event logged")
205+
actor.Authorized = true
206+
whitelistedActors = append(whitelistedActors, actor)
207+
} else {
208+
outActorsMissingCLA = append(outActorsMissingCLA, actor)
209+
}
210+
}
211+
212+
return outActorsMissingCLA, whitelistedActors
213+
}

cla-backend-go/github_organizations/models.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,19 @@ import "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models"
77

88
// GithubOrganization is data model for github organizations
99
type GithubOrganization struct {
10-
DateCreated string `json:"date_created,omitempty"`
11-
DateModified string `json:"date_modified,omitempty"`
12-
OrganizationInstallationID int64 `json:"organization_installation_id,omitempty"`
13-
OrganizationName string `json:"organization_name,omitempty"`
14-
OrganizationNameLower string `json:"organization_name_lower,omitempty"`
15-
OrganizationSFID string `json:"organization_sfid,omitempty"`
16-
ProjectSFID string `json:"project_sfid"`
17-
Enabled bool `json:"enabled"`
18-
AutoEnabled bool `json:"auto_enabled"`
19-
BranchProtectionEnabled bool `json:"branch_protection_enabled"`
20-
AutoEnabledClaGroupID string `json:"auto_enabled_cla_group_id,omitempty"`
21-
Version string `json:"version,omitempty"`
10+
DateCreated string `json:"date_created,omitempty"`
11+
DateModified string `json:"date_modified,omitempty"`
12+
OrganizationInstallationID int64 `json:"organization_installation_id,omitempty"`
13+
OrganizationName string `json:"organization_name,omitempty"`
14+
OrganizationNameLower string `json:"organization_name_lower,omitempty"`
15+
OrganizationSFID string `json:"organization_sfid,omitempty"`
16+
ProjectSFID string `json:"project_sfid"`
17+
Enabled bool `json:"enabled"`
18+
AutoEnabled bool `json:"auto_enabled"`
19+
BranchProtectionEnabled bool `json:"branch_protection_enabled"`
20+
AutoEnabledClaGroupID string `json:"auto_enabled_cla_group_id,omitempty"`
21+
Version string `json:"version,omitempty"`
22+
SkipCLA map[string]string `json:"skip_cla,omitempty"`
2223
}
2324

2425
// ToModel converts to models.GithubOrganization
@@ -35,6 +36,7 @@ func ToModel(in *GithubOrganization) *models.GithubOrganization {
3536
AutoEnabledClaGroupID: in.AutoEnabledClaGroupID,
3637
BranchProtectionEnabled: in.BranchProtectionEnabled,
3738
ProjectSFID: in.ProjectSFID,
39+
SkipCla: in.SkipCLA,
3840
}
3941
}
4042

cla-backend-go/signatures/service.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,8 @@ func (s service) updateChangeRequest(ctx context.Context, ghOrg *models.GithubOr
11141114
}
11151115

11161116
log.WithFields(f).Debugf("commit authors status => signed: %+v and missing: %+v", signed, unsigned)
1117+
unsigned, signed = github.SkipWhitelistedBots(s.eventsService, ghOrg, gitHubRepoName, projectID, unsigned)
1118+
log.WithFields(f).Debugf("commit authors status after whitelisting bots => signed: %+v and missing: %+v", signed, unsigned)
11171119

11181120
// update pull request
11191121
updateErr := github.UpdatePullRequest(ctx, ghOrg.OrganizationInstallationID, int(pullRequestID), gitHubOrgName, gitHubRepoName, githubRepository.ID, *latestSHA, signed, unsigned, s.claBaseAPIURL, s.claLandingPage, s.claLogoURL)
@@ -1132,7 +1134,7 @@ func (s service) updateChangeRequest(ctx context.Context, ghOrg *models.GithubOr
11321134
// true, true, nil if user has an ECLA (authorized, with company affiliation, no error)
11331135
func (s service) HasUserSigned(ctx context.Context, user *models.User, projectID string) (*bool, *bool, error) {
11341136
f := logrus.Fields{
1135-
"functionName": "v1.signatures.service.updateChangeRequest",
1137+
"functionName": "v1.signatures.service.HasUserSigned",
11361138
"projectID": projectID,
11371139
"user": user,
11381140
}

cla-backend-go/swagger/common/github-organization.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ properties:
6969
x-nullable: true
7070
example: "https://github.com/organizations/deal-test-org-2/settings/installations/1235464"
7171
format: uri
72+
skipCla:
73+
type: object
74+
additionalProperties:
75+
type: string
76+
description: |
77+
Map of repository name or pattern (e.g. 'repo1', '*', 're:pattern') to a string in the form '<username_pattern>;<email_pattern>' for skipping CLA checks for certain bots. Patterns can be exact, wildcard '*', or regexp prefixed with 're:'.
78+
example:
79+
"*": "copilot-swe-agent[bot];*"
80+
"repo1": "re:vee?rendra;*"
7281

7382
repositories:
7483
type: object

cla-backend-go/v2/sign/helpers.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ func (s service) updateChangeRequest(ctx context.Context, installationID, reposi
4141
return errors.New(msg)
4242
}
4343

44+
var ghOrg *models.GithubOrganization
45+
if githubRepository.Owner.Login != nil {
46+
var ghOrgErr error
47+
ghOrg, ghOrgErr = s.githubOrgService.GetGitHubOrganizationByName(ctx, *githubRepository.Owner.Login)
48+
if ghOrgErr != nil {
49+
log.WithFields(f).WithError(ghOrgErr).Warnf("unable to lookup GitHub organization by name: %s - unable to update GitHub status", *githubRepository.Owner.Login)
50+
}
51+
}
52+
4453
gitHubOrgName := utils.StringValue(githubRepository.Owner.Login)
4554
gitHubRepoName := utils.StringValue(githubRepository.Name)
4655

@@ -138,6 +147,10 @@ func (s service) updateChangeRequest(ctx context.Context, installationID, reposi
138147
}
139148

140149
log.WithFields(f).Debugf("commit authors status => signed: %+v and missing: %+v", signed, unsigned)
150+
if ghOrg != nil {
151+
unsigned, signed = github.SkipWhitelistedBots(s.eventsService, ghOrg, gitHubRepoName, projectID, unsigned)
152+
log.WithFields(f).Debugf("commit authors status after whitelisting bots => signed: %+v and missing: %+v", signed, unsigned)
153+
}
141154

142155
// update pull request
143156
updateErr := github.UpdatePullRequest(ctx, installationID, int(pullRequestID), gitHubOrgName, gitHubRepoName, githubRepository.ID, *latestSHA, signed, unsigned, s.ClaV1ApiURL, s.claLandingPage, s.claLogoURL)
@@ -156,7 +169,7 @@ func (s service) updateChangeRequest(ctx context.Context, installationID, reposi
156169
// true, true, nil if user has an ECLA (authorized, with company affiliation, no error)
157170
func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID string) (*bool, *bool, error) {
158171
f := logrus.Fields{
159-
"functionName": "v1.signatures.service.updateChangeRequest",
172+
"functionName": "v1.signatures.service.hasUserSigned",
160173
"projectID": projectID,
161174
"user": user,
162175
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2847,6 +2847,10 @@ func (s *service) GetUserActiveSignature(ctx context.Context, userID string) (*m
28472847
utils.XREQUESTID: ctx.Value(utils.XREQUESTID),
28482848
"userID": userID,
28492849
}
2850+
// LG:
2851+
// _ = s.updateChangeRequest(ctx, 35275118, 614349032, 215, "01af041c-fa69-4052-a23c-fb8c1d3bef24")
2852+
// return nil, nil
2853+
// LG:
28502854
activeSignatureMetadata, err := s.storeRepository.GetActiveSignatureMetaData(ctx, userID)
28512855
if err != nil {
28522856
log.WithFields(f).WithError(err).Warnf("unable to get active signature meta data for user: %s", userID)

cla-backend/cla/models/github_models.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -936,9 +936,11 @@ def property_matches(self, pattern, value):
936936
try:
937937
if pattern == '*':
938938
return True
939+
if value is None or value == '':
940+
return False
939941
if pattern.startswith('re:'):
940942
regex = pattern[3:]
941-
return value is not None and re.search(regex, value) is not None
943+
return re.search(regex, value) is not None
942944
return value == pattern
943945
except Exception as exc:
944946
cla.log.warning("Error in property_matches: pattern=%s, value=%s, exc=%s", pattern, value, exc)
@@ -951,6 +953,7 @@ def is_actor_skipped(self, actor, config):
951953
"""
952954
try:
953955
if ';' not in config:
956+
cla.log.warning("Invalid skip_cla config format: %s, expected '<username_pattern>;<email_pattern>'", config)
954957
return False
955958
username_pattern, email_pattern = config.split(';', 1)
956959
username = getattr(actor, "author_login", None)
@@ -1051,7 +1054,7 @@ def skip_whitelisted_bots(self, org_model, org_repo, actors_missing_cla) -> Tupl
10511054
config,
10521055
)
10531056
cla.log.info(msg)
1054-
ev = Event.create_event(
1057+
Event.create_event(
10551058
event_type=EventType.BypassCLA,
10561059
event_data=msg,
10571060
event_summary=msg,

0 commit comments

Comments
 (0)