Skip to content

Commit 0c11f07

Browse files
Merge pull request #4747 from linuxfoundation/unicron-make-co-autors-support-conditional
Make co-authors support configurable per repo, or repo pattern or org-wide on github-org level
2 parents 50515b1 + bb1817b commit 0c11f07

File tree

12 files changed

+435
-31
lines changed

12 files changed

+435
-31
lines changed

CO_AUTHORS.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
## Co-Authors support
2+
3+
You can allow co-authors support on a specific repository, set of repositories matching regular expression pattern, or for all repositories in a GitHub organization.
4+
5+
This can be done on the GitHub organization level by setting the `enable_co_authors` property on `cla-{stage}-github-orgs` DynamoDB table.
6+
7+
Replace `{stage}` with either `dev` or `prod`.
8+
9+
This property is a map attribute that contains mapping from repository pattern to co-authors support enabled or disabled.
10+
11+
So the format is like `"repository_pattern": true|false`.
12+
13+
There can be multiple entries under one Github Organization DynamoDB entry.
14+
15+
Example:
16+
```
17+
{
18+
(...)
19+
"organization_name": {
20+
"S": "linuxfoundation"
21+
},
22+
"enable_co_authors": {
23+
"M": {
24+
"*": {
25+
"BOOL": true,
26+
},
27+
"re:(?i)^repo[0-9]+$": {
28+
"BOOL": false,
29+
}
30+
}
31+
},
32+
(...)
33+
}
34+
```
35+
36+
This enables co-authors support on all repos under "linuxfoundation", except for those matching the regular expression `re:(?i)^repo[0-9]+$`.
37+
38+
Algorithm to match pattern is as follows:
39+
- First we check repository name for exact match. Repository name is without the organization name, so for `https://github.com/linuxfoundation/easycla` it is just `easycla`. If we find an entry in `enable_co_authors` for `easycla` that entry is used and we stop searching.
40+
- If no exact match is found, we check for regular expression match. Only keys starting with `re:` are considered. If we find a match, we use that entry and stop searching.
41+
- If no match is found, we check for `*` entry. If it exists, we use that entry and stop searching.
42+
- If no match is found, we don't support co-authors for that repository. Default is no co-author support.
43+
44+
45+
There is a script that allows you to update the `enable_co_authors` property in the DynamoDB table. It is located in `utils/enable_co_authors_entry.sh`. You can run it like this:
46+
- `` MODE=mode ./utils/enable_co_authors_entry.sh 'org-name' 'repo-pattern' t ``.
47+
- `` MODE=add-key ./utils/enable_co_authors_entry.sh 'sun-test-org' '*' f ``.
48+
49+
`MODE` can be one of:
50+
- `put-item`: Overwrites/adds the entire `enable_co_authors` property. Needs all 3 arguments org, repo, and pattern.
51+
- `add-key`: Adds or updates a key/value inside the `enable_co_authors` map (preserves other keys). Needs all 3 args.
52+
- `delete-key`: Removes a key from the `enable_co_authors` map. Needs 2 arguments: org and repo.
53+
- `delete-item`: Deletes the entire `enable_co_authors` from the item. Needs 1 argument: org.
54+
55+
56+
You can also use AWS CLI to update the `enable_co_authors` property. Here is an example command:
57+
58+
To add a new `enable_co_authors` entry:
59+
60+
```
61+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item \
62+
--table-name "cla-prod-github-orgs" \
63+
--key '{"organization_name": {"S": "linuxfoundation"}}' \
64+
--update-expression 'SET enable_co_authors = :val' \
65+
--expression-attribute-values '{":val": {"M": {"re:^easycla":{"BOOL": true}}}}'
66+
```
67+
68+
To add a new key to an existing `enable_co_authors` entry (or replace the existing key):
69+
70+
```
71+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item \
72+
--table-name "cla-prod-github-orgs" \
73+
--key '{"organization_name": {"S": "linuxfoundation"}}' \
74+
--update-expression "SET enable_co_authors.#repo = :val" \
75+
--expression-attribute-names '{"#repo": "re:^easycla"}' \
76+
--expression-attribute-values '{":val": {"BOOL": false}}'
77+
```
78+
79+
To delete a key from an existing `enable_co_authors` entry:
80+
81+
```
82+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item \
83+
--table-name "cla-prod-github-orgs" \
84+
--key '{"organization_name": {"S": "linuxfoundation"}}' \
85+
--update-expression "REMOVE enable_co_authors.#repo" \
86+
--expression-attribute-names '{"#repo": "re:^easycla"}'
87+
```
88+
89+
To delete the entire `enable_co_authors` entry:
90+
91+
```
92+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item \
93+
--table-name "cla-prod-github-orgs" \
94+
--key '{"organization_name": {"S": "linuxfoundation"}}' \
95+
--update-expression "REMOVE enable_co_authors"
96+
```
97+
98+
To see given organization's entry: `./utils/scan.sh github-orgs organization_name sun-test-org`.
99+
100+
Or using AWS CLI:
101+
102+
```
103+
aws --profile "lfproduct-prod" dynamodb scan --table-name "cla-prod-github-orgs" --filter-expression "contains(organization_name,:v)" --expression-attribute-values "{\":v\":{\"S\":\"linuxfoundation\"}}" --max-items 100 | jq -r '.Items'
104+
```
105+
106+
To check for log entries related to skipping CLA check, you can use the following command: `` STAGE=dev DTFROM='1 hour ago' DTTO='1 second ago' ./utils/search_aws_log_group.sh 'cla-backend-dev-githubactivity' 'enable_co_authors' ``.
107+
108+
# Example setup on prod
109+
110+
To add first `enable_co_authors` value for an organization:
111+
```
112+
aws --profile lfproduct-prod --region us-east-1 dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "open-telemetry"}}' --update-expression 'SET enable_co_authors = :val' --expression-attribute-values '{":val": {"M": {"otel-arrow":{"BOOL":true}}}}'
113+
aws --profile lfproduct-prod --region us-east-1 dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "openfga"}}' --update-expression 'SET enable_co_authors = :val' --expression-attribute-values '{":val": {"M": {"vscode-ext":{"BOOL":true}}}}'
114+
```
115+
116+
To add additional repositories entries without overwriting the existing `enable_co_authors` value:
117+
```
118+
aws --profile lfproduct-prod --region us-east-1 dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "open-telemetry"}}' --update-expression 'SET enable_co_authors.#repo = :val' --expression-attribute-names '{"#repo": "*"}' --expression-attribute-values '{":val": {"BOOL": true}}'
119+
aws --profile lfproduct-prod --region us-east-1 dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "openfga"}}' --update-expression 'SET enable_co_authors.#repo = :val' --expression-attribute-names '{"#repo": "*"}' --expression-attribute-values '{":val": {"BOOL": true}}'
120+
```
121+
122+
To delete a specific repo entry from `enable_co_authors`:
123+
```
124+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "open-telemetry"}}' --update-expression 'REMOVE enable_co_authors.#repo' --expression-attribute-names '{"#repo": "*"}'
125+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "openfga"}}' --update-expression 'REMOVE enable_co_authors.#repo' --expression-attribute-names '{"#repo": "*"}'
126+
```
127+
128+
To delete the entire `enable_co_authors` attribute:
129+
```
130+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "open-telemetry"}}' --update-expression 'REMOVE enable_co_authors'
131+
aws --profile "lfproduct-prod" --region "us-east-1" dynamodb update-item --table-name "cla-prod-github-orgs" --key '{"organization_name": {"S": "openfga"}}' --update-expression 'REMOVE enable_co_authors'
132+
```
133+
134+
To check values:
135+
```
136+
aws --profile "lfproduct-prod" dynamodb scan --table-name "cla-prod-github-orgs" --filter-expression "contains(organization_name,:v)" --expression-attribute-values "{\":v\":{\"S\":\"open-telemetry\"}}" --max-items 100 | jq -r '.Items'
137+
aws --profile "lfproduct-prod" dynamodb scan --table-name "cla-prod-github-orgs" --filter-expression "contains(organization_name,:v)" --expression-attribute-values "{\":v\":{\"S\":\"openfga\"}}" --max-items 100 | jq -r '.Items'
138+
aws --profile "lfproduct-prod" dynamodb scan --table-name "cla-prod-github-orgs" --filter-expression "contains(organization_name,:v)" --expression-attribute-values "{\":v\":{\"S\":\"open-telemetry\"}}" --max-items 100 | jq -r '.Items[0].enable_co_authors.M["otel-arrow"]["BOOL"]'
139+
aws --profile "lfproduct-prod" dynamodb scan --table-name "cla-prod-github-orgs" --filter-expression "contains(organization_name,:v)" --expression-attribute-values "{\":v\":{\"S\":\"openfga\"}}" --max-items 100 | jq -r '.Items[0].enable_co_authors.M["vscode-ext"]["BOOL"]'
140+
```
141+
142+
Typical adding a new entry for an organization:
143+
```
144+
STAGE=prod MODE=add-key DEBUG=1 ./utils/enable_co_authors_entry.sh 'open-telemetry' 'opentelemetry-rust' t
145+
```
146+
147+
148+
# How co-authors are processed
149+
150+
When a commit is made to a repository that has co-authors support enabled, the backend will check if the commit message contains `co-authored-by:` lines/commit trailers (case insensitive).
151+
152+
If it does, the backend will process the co-authors as follows, assume trailer value is `name <email>` like `Lukasz Gryglicki <[email protected]>`:
153+
154+
- First we check if email is in format `[email protected]`. If it is we use number part as GitHub user ID and fetch the user from GitHub API. If the user is found, we use that user as co-author.
155+
156+
- Second we check if email is in format `[email protected]`. If it is we use username part as GitHub username/login and fetch the user from GitHub API. If the user is found, we use that user as co-author.
157+
158+
- Thirs we lookup for email using GitHub API. If the user is found, we use that user as co-author.
159+
160+
- Finally we use the name part for `name <email>` and lookup using GitHub API assuming that this name is GitHub username/login (this is the case for some bots). If the user is found, we use that user as co-author.
161+
162+
We use internal caching while doing all those lookups with cache key `name` and `email` and TTL 24 hours. We even cache by `(name, email)` when nothing is found because this is the most time consuming option. It will have a chance to be found in the future (up to 24 hours from lookup).

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ The following diagram explains the EasyCLA architecture.
6565

6666
See [BOT_ALLOWLIST.md](BOT_ALLOWLIST.md) for information on configuring bots that are exempt from CLA checks.
6767

68+
## Co-authors support
69+
70+
See [CO_AUTHORS.md](CO_AUTHORS.md) for information on configuring co-authors support.
71+
6872
## EasyCLA Release Process
6973

7074
The following diagram illustrates the EasyCLA release process:

cla-backend-go/github/bots.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,54 @@ func parseConfigPatterns(config string) []string {
127127
return []string{config}
128128
}
129129

130+
// IsCoAuthorsEnabledForRepo returns whether co-authors are enabled for this repo
131+
func IsCoAuthorsEnabledForRepo(enableCoAuthors map[string]bool, orgRepo string) bool {
132+
repo := stripOrg(orgRepo)
133+
f := logrus.Fields{
134+
"functionName": "github.IsCoAuthorsEnabledForRepo",
135+
"orgRepo": orgRepo,
136+
"repo": repo,
137+
}
138+
if enableCoAuthors == nil {
139+
log.WithFields(f).Debugf("enable_co_authors is not set on '%s', skipping co-authors", orgRepo)
140+
return false
141+
}
142+
143+
// 1. Exact match
144+
if v, ok := enableCoAuthors[repo]; ok {
145+
log.WithFields(f).Debugf("enable_co_authors found for repo %s: %t (exact hit)", orgRepo, v)
146+
return v
147+
}
148+
149+
// 2. Regex pattern
150+
log.WithFields(f).Debugf("No enable_co_authors found for repo %s, checking regex patterns", orgRepo)
151+
for k, v := range enableCoAuthors {
152+
if !strings.HasPrefix(k, "re:") {
153+
continue
154+
}
155+
pattern := k[3:]
156+
re, err := regexp.Compile(pattern)
157+
if err != nil {
158+
log.WithFields(f).Debugf("Invalid regex in enable_co_authors: %s (%v) for repo: %s", k, err, orgRepo)
159+
continue
160+
}
161+
if re.MatchString(repo) {
162+
log.WithFields(f).Debugf("Found enable_co_authors for repo %s: %t via regex pattern: %s", orgRepo, v, pattern)
163+
return v
164+
}
165+
}
166+
167+
// 3. Wildcard fallback
168+
if v, ok := enableCoAuthors["*"]; ok {
169+
log.WithFields(f).Debugf("No enable_co_authors found for repo %s, using wildcard: %t", orgRepo, v)
170+
return v
171+
}
172+
173+
// 4. No match
174+
log.WithFields(f).Debugf("No enable_co_authors found for repo %s, skipping co-authors", orgRepo)
175+
return false
176+
}
177+
130178
// SkipAllowlistedBots- check if the actors are allowlisted based on the skip_cla configuration.
131179
// Returns two lists:
132180
// - actors still missing cla: actors who still need to sign the CLA after checking skip_cla

cla-backend-go/github/github_repository.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ func (u UserCommitSummary) getUserInfo(tagUser bool) string {
225225
tagValue = "@"
226226
}
227227
if u.CommitAuthor != nil {
228-
if *u.CommitAuthor.Login != "" {
228+
if u.CommitAuthor.Login != nil && *u.CommitAuthor.Login != "" {
229229
sb.WriteString(fmt.Sprintf("login: %s%s / ", tagValue, *u.CommitAuthor.Login))
230230
}
231231

@@ -501,10 +501,11 @@ func GetCoAuthorCommits(
501501
return summary
502502
}
503503

504-
func GetPullRequestCommitAuthors(ctx context.Context, installationID int64, pullRequestID int, owner, repo string) ([]*UserCommitSummary, *string, error) {
504+
func GetPullRequestCommitAuthors(ctx context.Context, installationID int64, pullRequestID int, owner, repo string, withCoAuthors bool) ([]*UserCommitSummary, *string, error) {
505505
f := logrus.Fields{
506506
"functionName": "github.github_repository.GetPullRequestCommitAuthors",
507507
"pullRequestID": pullRequestID,
508+
"withCoAuthors": withCoAuthors,
508509
}
509510
var userCommitSummary []*UserCommitSummary
510511

@@ -554,7 +555,9 @@ func GetPullRequestCommitAuthors(ctx context.Context, installationID int64, pull
554555
Affiliated: false,
555556
Authorized: false,
556557
})
557-
ExpandWithCoAuthors(ctx, client, commit, pullRequestID, installationID, &userCommitSummary)
558+
if withCoAuthors {
559+
ExpandWithCoAuthors(ctx, client, commit, pullRequestID, installationID, &userCommitSummary)
560+
}
558561
}
559562

560563
// get latest commit SHA

cla-backend-go/github_organizations/models.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type GithubOrganization struct {
2020
AutoEnabledClaGroupID string `json:"auto_enabled_cla_group_id,omitempty"`
2121
Version string `json:"version,omitempty"`
2222
SkipCLA map[string]string `json:"skip_cla,omitempty"`
23+
EnableCoAuthors map[string]bool `json:"enable_co_authors,omitempty"`
2324
}
2425

2526
// ToModel converts to models.GithubOrganization
@@ -37,6 +38,7 @@ func ToModel(in *GithubOrganization) *models.GithubOrganization {
3738
BranchProtectionEnabled: in.BranchProtectionEnabled,
3839
ProjectSFID: in.ProjectSFID,
3940
SkipCla: in.SkipCLA,
41+
EnableCoAuthors: in.EnableCoAuthors,
4042
}
4143
}
4244

cla-backend-go/signatures/service.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1021,8 +1021,9 @@ func (s service) updateChangeRequest(ctx context.Context, ghOrg *models.GithubOr
10211021
gitHubRepoName := utils.StringValue(githubRepository.Name)
10221022

10231023
// Fetch committers
1024+
withCoAuthors := github.IsCoAuthorsEnabledForRepo(ghOrg.EnableCoAuthors, gitHubRepoName)
10241025
log.WithFields(f).Debugf("fetching commit authors for PR: %d using repository owner: %s, repo: %s", pullRequestID, gitHubOrgName, gitHubRepoName)
1025-
authors, latestSHA, authorsErr := github.GetPullRequestCommitAuthors(ctx, ghOrg.OrganizationInstallationID, int(pullRequestID), gitHubOrgName, gitHubRepoName)
1026+
authors, latestSHA, authorsErr := github.GetPullRequestCommitAuthors(ctx, ghOrg.OrganizationInstallationID, int(pullRequestID), gitHubOrgName, gitHubRepoName, withCoAuthors)
10261027
if authorsErr != nil {
10271028
log.WithFields(f).WithError(authorsErr).Warnf("unable to get commit authors for %s/%s for PR: %d", gitHubOrgName, gitHubRepoName, pullRequestID)
10281029
return authorsErr

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,20 @@ properties:
9696
'*': 'some-bot-login;*;*'
9797
'repo1': 're:vee?rendra;*;*'
9898
're:(?i)^repo[0-9]+$': '[re:(?i)^l(ukasz)?gryglicki$;re:(?i)^l(ukasz)?gryglicki@;*||;re:^\d+\+Copilot@users\.noreply\.github\.com$;copilot-swe-agent[bot]]'
99+
enableCoAuthors:
100+
type: object
101+
additionalProperties:
102+
type: boolean
103+
description: |
104+
Map of repository name or pattern (e.g. 'repo1', '*', 're:pattern') to a bool value (true/false) specifying co-authors support for given repo(s).
105+
Patterns can be:
106+
- An exact match (e.g. 'repo1').
107+
- A regular expression prefixed with 're:' (e.g. 're:(?i)^repo[0-9]+$').
108+
- A wildcard '*' to match all.
109+
example:
110+
'*': true
111+
'repo1': false
112+
're:(?i)^repo[0-9]+$': true
99113

100114
repositories:
101115
type: object

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
)
1717

1818
// updateChangeRequest is a helper function that updates PR - typically after the docusign is completed
19+
//
20+
//nolint:gocyclo
1921
func (s service) updateChangeRequest(ctx context.Context, installationID, repositoryID, pullRequestID int64, projectID string) error {
2022
f := logrus.Fields{
2123
"functionName": "v1.signatures.service.updateChangeRequest",
@@ -54,8 +56,12 @@ func (s service) updateChangeRequest(ctx context.Context, installationID, reposi
5456
gitHubRepoName := utils.StringValue(githubRepository.Name)
5557

5658
// Fetch committers
59+
withCoAuthors := false
60+
if ghOrg != nil {
61+
withCoAuthors = github.IsCoAuthorsEnabledForRepo(ghOrg.EnableCoAuthors, gitHubRepoName)
62+
}
5763
log.WithFields(f).Debugf("fetching commit authors for PR: %d using repository owner: %s, repo: %s", pullRequestID, gitHubOrgName, gitHubRepoName)
58-
authors, latestSHA, authorsErr := github.GetPullRequestCommitAuthors(ctx, installationID, int(pullRequestID), gitHubOrgName, gitHubRepoName)
64+
authors, latestSHA, authorsErr := github.GetPullRequestCommitAuthors(ctx, installationID, int(pullRequestID), gitHubOrgName, gitHubRepoName, withCoAuthors)
5965
if authorsErr != nil {
6066
log.WithFields(f).WithError(authorsErr).Warnf("unable to get commit authors for %s/%s for PR: %d", gitHubOrgName, gitHubRepoName, pullRequestID)
6167
return authorsErr

cla-backend/cla/models/dynamo_models.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3913,6 +3913,7 @@ class Meta:
39133913
enabled = BooleanAttribute(null=True)
39143914
note = UnicodeAttribute(null=True)
39153915
skip_cla = MapAttribute(of=UnicodeAttribute, null=True)
3916+
enable_co_authors = MapAttribute(of=BooleanAttribute, null=True)
39163917

39173918

39183919
class GitHubOrg(model_interfaces.GitHubOrg): # pylint: disable=too-many-public-methods
@@ -3922,7 +3923,8 @@ class GitHubOrg(model_interfaces.GitHubOrg): # pylint: disable=too-many-public-
39223923

39233924
def __init__(
39243925
self, organization_name=None, organization_installation_id=None, organization_sfid=None,
3925-
auto_enabled=False, branch_protection_enabled=False, note=None, enabled=True, skip_cla=None,
3926+
auto_enabled=False, branch_protection_enabled=False, note=None, enabled=True,
3927+
skip_cla=None, enable_co_authors=None,
39263928
):
39273929
super(GitHubOrg).__init__()
39283930
self.model = GitHubOrgModel()
@@ -3936,6 +3938,7 @@ def __init__(
39363938
self.model.note = note
39373939
self.model.enabled = enabled
39383940
self.model.skip_cla = skip_cla
3941+
self.model.enable_co_authors = enable_co_authors
39393942

39403943
def __str__(self):
39413944
return (
@@ -3948,7 +3951,8 @@ def __str__(self):
39483951
f'branch_protection_enabled: {self.model.branch_protection_enabled},'
39493952
f'note: {self.model.note},'
39503953
f'enabled: {self.model.enabled},'
3951-
f'skip_cla: {self.model.skip_cla}'
3954+
f'skip_cla: {self.model.skip_cla},'
3955+
f'enable_co_authors: {self.model.enable_co_authors}'
39523956
)
39533957

39543958
def to_dict(self):
@@ -3997,6 +4001,9 @@ def get_branch_protection_enabled(self):
39974001
def get_skip_cla(self):
39984002
return self.model.skip_cla
39994003

4004+
def get_enable_co_authors(self):
4005+
return self.model.enable_co_authors
4006+
40004007
def get_note(self):
40014008
"""
40024009
Getter for the note.
@@ -4037,6 +4044,9 @@ def set_branch_protection_enabled(self, branch_protection_enabled):
40374044
def set_skip_cla(self, skip_cla):
40384045
self.model.skip_cla = skip_cla
40394046

4047+
def set_enable_co_authors(self, enable_co_authors):
4048+
self.model.enable_co_authors = enable_co_authors
4049+
40404050
def set_note(self, note):
40414051
self.model.note = note
40424052

0 commit comments

Comments
 (0)