Skip to content

Commit

Permalink
feat: add support for external CODEOWNERS (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
shini4i authored Nov 21, 2024
1 parent d0b8e1b commit 9b1ddee
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 63 deletions.
41 changes: 34 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,58 @@
</div>

> [!WARNING]
> This project is in the early stages of development and is not yet ready for production use. Some breaking changes may occur.
> This project is in the early stages of development. Some breaking changes may occur.
## General information

`atlantis-emoji-gate` is a tool designed to work with Atlantis on GitLab Community Edition (CE) to ensure that a specific emoji reaction is present on a GitLab merge request.

This acts as a replacement for mandatory MR approval (which does not work on CE), and can be used to ensure that a specific person has reviewed the MR before `atlantis apply` is allowed to run.
`atlantis-emoji-gate` is a tool designed to work with Atlantis on GitLab Community Edition (CE) to ensure that a
specific emoji reaction is present on a GitLab merge request.

This acts as a replacement for mandatory MR approval (which does not work on CE), and can be used to ensure that a
specific person has reviewed the MR before `atlantis apply` is allowed to run.

```mermaid
graph LR
A[MR is Opened] --> B[Atlantis plan is triggered]
B --> C[Atlantis adds comment to MR with details]
C --> D[User validates the result]
D --> E[User adds comment: atlantis apply]
E --> F[Atlantis runs atlantis-emoji-gate]
F --> G{Code owner added required emoji?}
G -->|Yes| H[Apply happens]
G -->|No| I[Apply fails]
%% Style links
linkStyle 6 stroke:green,stroke-width:2px
linkStyle 7 stroke:red,stroke-width:2px
```

## Configuration

`atlantis-emoji-gate` is configured using environment variables. The following variables are available:

- `APPROVE_EMOJI` - The emoji that must be present on the MR for `atlantis apply` to be allowed to run (default: `thumbsup`)
- `CODEOWNERS_PATH` - The path to the CODEOWNERS file in the repository (default: `CODEOWNERS`)
- `INSECURE` - If MR author is allowed to approve their own MR (default: `false`)
| Variable | Description | Default | Optional |
|-------------------|------------------------------------------------------------------------------------|--------------|----------|
| `APPROVE_EMOJI` | The emoji that must be present on the MR for `atlantis apply` to be allowed to run | `thumbsup` | No |
| `CODEOWNERS_PATH` | The path to the CODEOWNERS file in the repository | `CODEOWNERS` | No |
| `CODEOWNERS_REPO` | The repository to check for CODEOWNERS file | | Yes |
| `INSECURE` | If MR author is allowed to approve their own MR | `false` | No |

The remaining environment variables are set dynamically by Atlantis and should not be set manually.

At this early stage, only owners of the whole repository are supported.

CODEOWNERS file example:

```
* @username @username2
```

> [!NOTE]
> CODEOWNERS file is required to be present in the default branch of the repository.
Workflow example:

```yaml
workflows:
default:
Expand All @@ -53,4 +79,5 @@ workflows:
```
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
1 change: 1 addition & 0 deletions cmd/emoji-gate/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type GitlabConfig struct {
BaseRepoName string `env:"BASE_REPO_NAME,required,notEmpty"`
PullRequestID int `env:"PULL_NUM,required,notEmpty"`
CodeOwnersPath string `env:"CODEOWNERS_PATH,notEmpty" envDefault:"CODEOWNERS"`
CodeOwnersRepo string `env:"CODEOWNERS_REPO"` // Optional, if not provided, will use BaseRepoOwner/BaseRepoName
MrAuthor string `env:"PULL_AUTHOR,required,notEmpty"`
Insecure bool `env:"INSECURE,notEmpty" envDefault:"false"` // If MR author allowed to approve his own MR
}
Expand Down
22 changes: 10 additions & 12 deletions cmd/emoji-gate/gitlabClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ import (
// GitlabClientInterface defines the methods that must be implemented by a GitLab client.
type GitlabClientInterface interface {
GetProject(projectPath string) (*Project, error)
ListAwardEmojis(mrID int) ([]*AwardEmoji, error)
GetFileContent(branch, filePath string) (string, error)
ListAwardEmojis(projectID, mrID int) ([]*AwardEmoji, error)
GetFileContent(projectID int, branch, filePath string) (string, error)
}

type GitlabClient struct {
Scheme string
BaseURL string
Token string
ProjectID int
client *http.Client
Scheme string
BaseURL string
Token string
client *http.Client
}

type Project struct {
Expand Down Expand Up @@ -74,21 +73,20 @@ func (g *GitlabClient) GetProject(projectPath string) (*Project, error) {
escapedPath := url.PathEscape(projectPath)
var project Project
err := g.get(fmt.Sprintf("/projects/%s", escapedPath), &project)
g.ProjectID = project.ID
return &project, err
}

func (g *GitlabClient) ListAwardEmojis(mrID int) ([]*AwardEmoji, error) {
func (g *GitlabClient) ListAwardEmojis(projectID, mrID int) ([]*AwardEmoji, error) {
var emojis []*AwardEmoji
err := g.get(fmt.Sprintf("/projects/%d/merge_requests/%d/award_emoji", g.ProjectID, mrID), &emojis)
err := g.get(fmt.Sprintf("/projects/%d/merge_requests/%d/award_emoji", projectID, mrID), &emojis)
return emojis, err
}

func (g *GitlabClient) GetFileContent(branch, filePath string) (string, error) {
func (g *GitlabClient) GetFileContent(projectID int, branch, filePath string) (string, error) {
var content struct {
Content string `json:"content"`
}
err := g.get(fmt.Sprintf("/projects/%d/repository/files/%s?ref=%s", g.ProjectID, filePath, branch), &content)
err := g.get(fmt.Sprintf("/projects/%d/repository/files/%s?ref=%s", projectID, filePath, branch), &content)
if err != nil {
return "", err
}
Expand Down
6 changes: 2 additions & 4 deletions cmd/emoji-gate/gitlabClient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ func TestGitlabClient_ListAwardEmojis(t *testing.T) {

client := NewGitlabClient(server.URL[7:], "dummyToken")
client.Scheme = "http" // Use HTTP scheme for the test server
client.ProjectID = 1

emojis, err := client.ListAwardEmojis(1)
emojis, err := client.ListAwardEmojis(1, 1)
assert.NoError(t, err)
assert.Len(t, emojis, 1)
assert.Equal(t, "thumbsup", emojis[0].Name)
Expand All @@ -88,9 +87,8 @@ func TestGitlabClient_GetFileContent(t *testing.T) {

client := NewGitlabClient(server.URL[7:], "dummyToken")
client.Scheme = "http" // Use HTTP scheme for the test server
client.ProjectID = 1

content, err := client.GetFileContent("main", "CODEOWNERS")
content, err := client.GetFileContent(1, "main", "CODEOWNERS")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
Expand Down
79 changes: 42 additions & 37 deletions cmd/emoji-gate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,24 @@ package main
import (
"bufio"
"fmt"
"log"
"os"
"slices"
"strings"
)

// ParseCodeOwners parses the content of a CODEOWNERS file and extracts owners for the global pattern '*'.
// ParseCodeOwners extracts owners for the global pattern '*' from the CODEOWNERS content.
func ParseCodeOwners(content string) ([]string, error) {
reader := strings.NewReader(content)
scanner := bufio.NewScanner(reader)

var owners []string
scanner := bufio.NewScanner(strings.NewReader(content))

for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}

parts := strings.Fields(line)
if len(parts) < 2 {
continue
}

// Check for the global '*' pattern.
if parts[0] == "*" {
if len(parts) >= 2 && parts[0] == "*" {
for _, owner := range parts[1:] {
owners = append(owners, strings.TrimPrefix(owner, "@"))
}
Expand All @@ -37,84 +30,96 @@ func ParseCodeOwners(content string) ([]string, error) {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading CODEOWNERS content: %w", err)
}

return owners, nil
}

// CheckMandatoryApproval verifies if mandatory approvals are present based on CODEOWNERS.
func CheckMandatoryApproval(client GitlabClientInterface, cfg GitlabConfig, codeOwnersContent string) (bool, error) {
// fetchCodeOwnersContent retrieves the CODEOWNERS file content based on configuration.
func fetchCodeOwnersContent(client GitlabClientInterface, cfg GitlabConfig, project *Project) (string, error) {
if cfg.CodeOwnersRepo != "" {
codeOwnersRepo, err := client.GetProject(cfg.CodeOwnersRepo)
if err != nil {
return "", fmt.Errorf("failed to get codeowners project: %w", err)
}
return client.GetFileContent(codeOwnersRepo.ID, project.DefaultBranch, cfg.CodeOwnersPath)
}
return client.GetFileContent(project.ID, project.DefaultBranch, cfg.CodeOwnersPath)
}

// CheckMandatoryApproval validates approvals against CODEOWNERS.
func CheckMandatoryApproval(client GitlabClientInterface, cfg GitlabConfig, projectID int, codeOwnersContent string) (bool, error) {
owners, err := ParseCodeOwners(codeOwnersContent)
if err != nil {
return false, fmt.Errorf("failed to parse CODEOWNERS: %w", err)
}

reactions, err := client.ListAwardEmojis(cfg.PullRequestID)
reactions, err := client.ListAwardEmojis(projectID, cfg.PullRequestID)
if err != nil {
return false, fmt.Errorf("failed to fetch reactions: %w", err)
}

approvedBy := filterApprovals(owners, reactions, cfg)
if len(approvedBy) > 0 {
fmt.Printf("Mandatory approval provided by: %v", approvedBy)
return true, nil
}

fmt.Println("Mandatory approval not found")
return false, nil
}

// filterApprovals identifies valid approvers from reactions.
func filterApprovals(owners []string, reactions []*AwardEmoji, cfg GitlabConfig) []string {
var approvedBy []string

for _, reaction := range reactions {
if slices.Contains(owners, reaction.User.Username) && reaction.Name == cfg.ApproveEmoji {
if reaction.User.Username == cfg.MrAuthor && !cfg.Insecure {
log.Printf("MR author '%s' cannot approve their own MR", cfg.MrAuthor)
fmt.Printf("MR author '%s' cannot approve their own MR", cfg.MrAuthor)
continue
}
approvedBy = append(approvedBy, reaction.User.Username)
}
}

if len(approvedBy) > 0 {
log.Printf("Mandatory approval provided by: %v", approvedBy)
return true, nil
}

log.Println("Mandatory approval not found")
return false, nil
return approvedBy
}

// ProcessMR handles the MR processing, including approval checks.
// ProcessMR handles the overall MR processing workflow.
func ProcessMR(client GitlabClientInterface, cfg GitlabConfig) (bool, error) {
project, err := client.GetProject(fmt.Sprintf("%s/%s", cfg.BaseRepoOwner, cfg.BaseRepoName))
if err != nil {
return false, fmt.Errorf("failed to get project: %w", err)
}

branch := project.DefaultBranch
codeOwnersContent, err := client.GetFileContent(branch, cfg.CodeOwnersPath)
codeOwnersContent, err := fetchCodeOwnersContent(client, cfg, project)
if err != nil {
return false, fmt.Errorf("failed to fetch CODEOWNERS file: %w", err)
}

return CheckMandatoryApproval(client, cfg, codeOwnersContent)
return CheckMandatoryApproval(client, cfg, project.ID, codeOwnersContent)
}

// Run handles the main logic of the program and returns an exit code.
// Run handles the program's main logic.
func Run(client GitlabClientInterface, cfg GitlabConfig) int {
if cfg.Insecure {
log.Println("Insecure mode enabled: MR author can approve their own MR")
fmt.Println("Insecure mode enabled: MR author can approve their own MR if they are in CODEOWNERS")
}

approved, err := ProcessMR(client, cfg)
if err != nil {
log.Printf("Error processing MR: %v", err)
fmt.Printf("Error processing MR: %v", err)
return 1
}

if approved {
return 0
}
return 1
return map[bool]int{true: 0, false: 1}[approved]
}

func main() {
cfg, err := NewGitlabConfig()
if err != nil {
log.Fatalf("Error parsing GitLab config: %v", err)
panic(fmt.Sprintf("Error parsing GitLab config: %v", err))
}

client := NewGitlabClient(cfg.Url, cfg.Token)

// Run the application and use the returned exit code.
os.Exit(Run(client, cfg))
}
6 changes: 3 additions & 3 deletions cmd/emoji-gate/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ func (m *MockGitlabClient) GetProject(projectPath string) (*Project, error) {
return m.Project, m.ProjectErr
}

func (m *MockGitlabClient) ListAwardEmojis(mrID int) ([]*AwardEmoji, error) {
func (m *MockGitlabClient) ListAwardEmojis(projectID, mrID int) ([]*AwardEmoji, error) {
return m.AwardEmojis, m.EmojisErr
}

func (m *MockGitlabClient) GetFileContent(branch, filePath string) (string, error) {
func (m *MockGitlabClient) GetFileContent(projectID int, branch, filePath string) (string, error) {
return m.FileContent, m.FileContentErr
}

Expand Down Expand Up @@ -57,7 +57,7 @@ func TestCheckMandatoryApproval(t *testing.T) {

codeOwnersContent := "* @user1\n"

approved, err := CheckMandatoryApproval(mockClient, cfg, codeOwnersContent)
approved, err := CheckMandatoryApproval(mockClient, cfg, 1, codeOwnersContent)
assert.NoError(t, err)
assert.True(t, approved)
}
Expand Down

0 comments on commit 9b1ddee

Please sign in to comment.