Skip to content

Commit b124363

Browse files
authored
Merge pull request #66 from elementsinteractive/archive-gh
feat: support issues on GitHub
2 parents a41c455 + 594cbfc commit b124363

File tree

3 files changed

+242
-48
lines changed

3 files changed

+242
-48
lines changed

internal/repository/github/github.go

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/elliotchance/pie/v2"
1515
"github.com/google/go-github/v68/github"
1616
"github.com/rs/zerolog/log"
17+
"golang.org/x/oauth2"
1718
"golang.org/x/sync/errgroup"
1819
)
1920

@@ -25,7 +26,11 @@ type githubService struct {
2526

2627
// newGithubRepo creates a new GitHub repository service
2728
func New(token string) githubService {
28-
client := github.NewClient(nil)
29+
ts := oauth2.StaticTokenSource(
30+
&oauth2.Token{AccessToken: token},
31+
)
32+
tc := oauth2.NewClient(context.Background(), ts)
33+
client := github.NewClient(tc)
2934
httpClient := &http.Client{
3035
Timeout: 30 * time.Second,
3136
}
@@ -70,12 +75,101 @@ func (s githubService) GetProjectList(paths []string) (projects []repository.Pro
7075

7176
// CloseVulnerabilityIssue closes the vulnerability issue for the given project
7277
func (s githubService) CloseVulnerabilityIssue(project repository.Project) (err error) {
73-
return errors.New("CloseVulnerabilityIssue not yet implemented") // TODO #9 Add github support
78+
issue, err := s.getVulnerabilityIssue(project.GroupOrOwner, project.Name)
79+
if err != nil {
80+
return fmt.Errorf("failed to fetch current list of issues: %w", err)
81+
}
82+
if issue == nil {
83+
log.Info().Str("project", project.Path).Msg("No issue to close, nothing to do")
84+
return nil
85+
}
86+
if issue.GetState() == "closed" {
87+
log.Info().Str("project", project.Path).Msg("Issue already closed")
88+
return nil
89+
}
90+
state := "closed"
91+
_, _, err = s.client.UpdateIssue(project.GroupOrOwner, project.Name, issue.GetNumber(), &github.IssueRequest{
92+
State: &state,
93+
})
94+
if err != nil {
95+
return fmt.Errorf("failed to update issue: %w", err)
96+
}
97+
log.Info().Str("project", project.Path).Msg("Issue closed")
98+
return nil
7499
}
75100

76101
// OpenVulnerabilityIssue opens or updates the vulnerability issue for the given project
77102
func (s githubService) OpenVulnerabilityIssue(project repository.Project, report string) (issue *repository.Issue, err error) {
78-
return nil, errors.New("OpenVulnerabilityIssue not yet implemented") // TODO #9 Add github support
103+
vulnTitle := repository.VulnerabilityIssueTitle
104+
ghIssue, err := s.getVulnerabilityIssue(project.GroupOrOwner, project.Name)
105+
if err != nil {
106+
return nil, fmt.Errorf("[%v] Failed to fetch current list of issues: %w", project.Path, err)
107+
}
108+
if ghIssue == nil {
109+
log.Info().Str("project", project.Path).Msg("Creating new issue")
110+
newIssue := &github.IssueRequest{
111+
Title: &vulnTitle,
112+
Body: &report,
113+
}
114+
created, _, err := s.client.CreateIssue(project.GroupOrOwner, project.Name, newIssue)
115+
if err != nil {
116+
return nil, fmt.Errorf("[%v] failed to create new issue: %w", project.Path, err)
117+
}
118+
return mapGithubIssuePtr(created), nil
119+
}
120+
log.Info().Str("project", project.Path).Int("issue", ghIssue.GetNumber()).Msg("Updating existing issue")
121+
state := "open"
122+
updatedIssue := &github.IssueRequest{
123+
Body: &report,
124+
State: &state,
125+
}
126+
edited, _, err := s.client.UpdateIssue(project.GroupOrOwner, project.Name, ghIssue.GetNumber(), updatedIssue)
127+
if err != nil {
128+
return nil, fmt.Errorf("[%v] Failed to update issue: %w", project.Path, err)
129+
}
130+
if edited.GetState() != "open" {
131+
return nil, errors.New("failed to reopen issue")
132+
}
133+
return mapGithubIssuePtr(edited), nil
134+
}
135+
136+
// getVulnerabilityIssue returns the vulnerability issue for the given repo (by title)
137+
func (s githubService) getVulnerabilityIssue(owner, repo string) (*github.Issue, error) {
138+
opts := &github.IssueListByRepoOptions{
139+
State: "all",
140+
ListOptions: github.ListOptions{PerPage: 100},
141+
}
142+
vulnTitle := repository.VulnerabilityIssueTitle
143+
for {
144+
issues, resp, err := s.client.ListRepositoryIssues(owner, repo, opts)
145+
if err != nil {
146+
return nil, err
147+
}
148+
for _, issue := range issues {
149+
if issue != nil && issue.GetTitle() == vulnTitle {
150+
return issue, nil
151+
}
152+
}
153+
if resp.NextPage == 0 {
154+
break
155+
}
156+
opts.Page = resp.NextPage
157+
}
158+
return nil, nil
159+
}
160+
func mapGithubIssue(i github.Issue) repository.Issue {
161+
return repository.Issue{
162+
Title: i.GetTitle(),
163+
WebURL: i.GetHTMLURL(),
164+
}
165+
}
166+
167+
func mapGithubIssuePtr(i *github.Issue) *repository.Issue {
168+
if i == nil {
169+
return nil
170+
}
171+
issue := mapGithubIssue(*i)
172+
return &issue
79173
}
80174

81175
func (s githubService) Download(project repository.Project, dir string) (err error) {

internal/repository/github/github_client.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,67 @@ package github
44
import (
55
"context"
66
"net/url"
7+
"time"
78

89
"github.com/google/go-github/v68/github"
910
)
1011

12+
const defaultTimeout = 30 * time.Second
13+
1114
// This client is a thin wrapper around the go-github library. It provides an interface to the GitHub client
1215
// The main purpose of this client is to provide an interface to the GitHub client which can be mocked in tests.
1316
// As such this MUST be as thin as possible and MUST not contain any business logic, since it is not testable.
14-
1517
type iGithubClient interface {
1618
GetRepository(owner string, repo string) (*github.Repository, *github.Response, error)
1719
GetOrganizationRepositories(org string, opts *github.RepositoryListByOrgOptions) ([]*github.Repository, *github.Response, error)
1820
GetUserRepositories(user string, opts *github.RepositoryListByUserOptions) ([]*github.Repository, *github.Response, error)
1921
GetArchiveLink(owner string, repo string, archiveFormat github.ArchiveFormat, opts *github.RepositoryContentGetOptions) (*url.URL, *github.Response, error)
22+
ListRepositoryIssues(owner string, repo string, opts *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error)
23+
CreateIssue(owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error)
24+
UpdateIssue(owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error)
2025
}
2126

2227
type githubClient struct {
2328
client *github.Client
2429
}
2530

31+
func (c *githubClient) ListRepositoryIssues(owner, repo string, opts *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) {
32+
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
33+
defer cancel()
34+
return c.client.Issues.ListByRepo(ctx, owner, repo, opts)
35+
}
36+
37+
func (c *githubClient) CreateIssue(owner, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
38+
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
39+
defer cancel()
40+
return c.client.Issues.Create(ctx, owner, repo, issue)
41+
}
42+
43+
func (c *githubClient) UpdateIssue(owner, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
44+
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
45+
defer cancel()
46+
return c.client.Issues.Edit(ctx, owner, repo, number, issue)
47+
}
2648
func (c *githubClient) GetRepository(owner string, repo string) (*github.Repository, *github.Response, error) {
27-
return c.client.Repositories.Get(context.Background(), owner, repo)
49+
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
50+
defer cancel()
51+
return c.client.Repositories.Get(ctx, owner, repo)
2852
}
2953

3054
func (c *githubClient) GetOrganizationRepositories(org string, opts *github.RepositoryListByOrgOptions) ([]*github.Repository, *github.Response, error) {
31-
return c.client.Repositories.ListByOrg(context.Background(), org, opts)
55+
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
56+
defer cancel()
57+
return c.client.Repositories.ListByOrg(ctx, org, opts)
3258
}
3359

3460
func (c *githubClient) GetUserRepositories(user string, opts *github.RepositoryListByUserOptions) ([]*github.Repository, *github.Response, error) {
35-
return c.client.Repositories.ListByUser(context.Background(), user, opts)
61+
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
62+
defer cancel()
63+
return c.client.Repositories.ListByUser(ctx, user, opts)
3664
}
3765

3866
func (c *githubClient) GetArchiveLink(owner string, repo string, archiveFormat github.ArchiveFormat, opts *github.RepositoryContentGetOptions) (*url.URL, *github.Response, error) {
39-
return c.client.Repositories.GetArchiveLink(context.Background(), owner, repo, archiveFormat, opts, 3)
67+
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
68+
defer cancel()
69+
return c.client.Repositories.GetArchiveLink(ctx, owner, repo, archiveFormat, opts, 3)
4070
}

internal/repository/github/github_test.go

Lines changed: 110 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -109,46 +109,6 @@ func TestGetProjectListWithNextPage(t *testing.T) {
109109
mockService.AssertExpectations(t)
110110
}
111111

112-
type mockService struct {
113-
mock.Mock
114-
}
115-
116-
func (c *mockService) GetRepository(owner string, repo string) (*github.Repository, *github.Response, error) {
117-
args := c.Called(owner, repo)
118-
var r *github.Response
119-
if resp := args.Get(1); resp != nil {
120-
r = args.Get(1).(*github.Response)
121-
}
122-
return args.Get(0).(*github.Repository), r, args.Error(2)
123-
}
124-
125-
func (c *mockService) GetOrganizationRepositories(org string, opts *github.RepositoryListByOrgOptions) ([]*github.Repository, *github.Response, error) {
126-
args := c.Called(org, opts)
127-
var r *github.Response
128-
if resp := args.Get(1); resp != nil {
129-
r = args.Get(1).(*github.Response)
130-
}
131-
return args.Get(0).([]*github.Repository), r, args.Error(2)
132-
}
133-
134-
func (c *mockService) GetUserRepositories(user string, opts *github.RepositoryListByUserOptions) ([]*github.Repository, *github.Response, error) {
135-
args := c.Called(user, opts)
136-
var r *github.Response
137-
if resp := args.Get(1); resp != nil {
138-
r = args.Get(1).(*github.Response)
139-
}
140-
return args.Get(0).([]*github.Repository), r, args.Error(2)
141-
}
142-
143-
func (c *mockService) GetArchiveLink(owner string, repo string, archiveFormat github.ArchiveFormat, opts *github.RepositoryContentGetOptions) (*url.URL, *github.Response, error) {
144-
args := c.Called(owner, repo, archiveFormat, opts)
145-
var r *github.Response
146-
if resp := args.Get(1); resp != nil {
147-
r = args.Get(1).(*github.Response)
148-
}
149-
return args.Get(0).(*url.URL), r, args.Error(2)
150-
}
151-
152112
func TestDownload(t *testing.T) {
153113
// Create temporary directory for testing
154114
tempDir, err := os.MkdirTemp("", "sheriff-clone-test-")
@@ -211,3 +171,113 @@ func TestDownload(t *testing.T) {
211171
_, err = os.Stat(filepath.Join(tempDir, "src"))
212172
assert.NoError(t, err, "src directory should exist")
213173
}
174+
175+
func TestOpenVulnerabilityIssue(t *testing.T) {
176+
title := repository.VulnerabilityIssueTitle
177+
mockClient := mockService{}
178+
mockClient.On("ListRepositoryIssues", mock.Anything, mock.Anything, mock.Anything).Return([]*github.Issue{}, &github.Response{}, nil)
179+
mockClient.On("CreateIssue", mock.Anything, mock.Anything, mock.Anything).Return(&github.Issue{Title: &title}, &github.Response{}, nil)
180+
181+
svc := githubService{client: &mockClient}
182+
183+
i, err := svc.OpenVulnerabilityIssue(repository.Project{GroupOrOwner: "group", Name: "repo"}, "report")
184+
assert.Nil(t, err)
185+
assert.NotNil(t, i)
186+
assert.Equal(t, repository.VulnerabilityIssueTitle, i.Title)
187+
mockClient.AssertExpectations(t)
188+
}
189+
190+
func TestCloseVulnerabilityIssue(t *testing.T) {
191+
title := repository.VulnerabilityIssueTitle
192+
state := "open"
193+
mockClient := mockService{}
194+
mockClient.On("ListRepositoryIssues", mock.Anything, mock.Anything, mock.Anything).Return([]*github.Issue{{Title: &title, State: &state}}, &github.Response{}, nil)
195+
mockClient.On("UpdateIssue", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&github.Issue{}, &github.Response{}, nil)
196+
197+
svc := githubService{client: &mockClient}
198+
199+
err := svc.CloseVulnerabilityIssue(repository.Project{GroupOrOwner: "group", Name: "repo"})
200+
assert.Nil(t, err)
201+
mockClient.AssertExpectations(t)
202+
}
203+
204+
func TestCloseVulnerabilityIssueNoIssue(t *testing.T) {
205+
mockClient := mockService{}
206+
mockClient.On("ListRepositoryIssues", mock.Anything, mock.Anything, mock.Anything).Return(nil, &github.Response{}, nil)
207+
208+
svc := githubService{client: &mockClient}
209+
210+
err := svc.CloseVulnerabilityIssue(repository.Project{GroupOrOwner: "group", Name: "repo"})
211+
assert.Nil(t, err)
212+
mockClient.AssertExpectations(t)
213+
}
214+
215+
type mockService struct {
216+
mock.Mock
217+
}
218+
219+
func (c *mockService) GetRepository(owner string, repo string) (*github.Repository, *github.Response, error) {
220+
args := c.Called(owner, repo)
221+
var r *github.Response
222+
if resp := args.Get(1); resp != nil {
223+
r = args.Get(1).(*github.Response)
224+
}
225+
return args.Get(0).(*github.Repository), r, args.Error(2)
226+
}
227+
228+
func (c *mockService) GetOrganizationRepositories(org string, opts *github.RepositoryListByOrgOptions) ([]*github.Repository, *github.Response, error) {
229+
args := c.Called(org, opts)
230+
var r *github.Response
231+
if resp := args.Get(1); resp != nil {
232+
r = args.Get(1).(*github.Response)
233+
}
234+
return args.Get(0).([]*github.Repository), r, args.Error(2)
235+
}
236+
237+
func (c *mockService) GetUserRepositories(user string, opts *github.RepositoryListByUserOptions) ([]*github.Repository, *github.Response, error) {
238+
args := c.Called(user, opts)
239+
var r *github.Response
240+
if resp := args.Get(1); resp != nil {
241+
r = args.Get(1).(*github.Response)
242+
}
243+
return args.Get(0).([]*github.Repository), r, args.Error(2)
244+
}
245+
246+
func (c *mockService) GetArchiveLink(owner string, repo string, archiveFormat github.ArchiveFormat, opts *github.RepositoryContentGetOptions) (*url.URL, *github.Response, error) {
247+
args := c.Called(owner, repo, archiveFormat, opts)
248+
var r *github.Response
249+
if resp := args.Get(1); resp != nil {
250+
r = args.Get(1).(*github.Response)
251+
}
252+
return args.Get(0).(*url.URL), r, args.Error(2)
253+
}
254+
255+
func (c *mockService) ListRepositoryIssues(owner string, repo string, opts *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) {
256+
args := c.Called(owner, repo, opts)
257+
var r *github.Response
258+
if resp := args.Get(1); resp != nil {
259+
r = args.Get(1).(*github.Response)
260+
}
261+
if args.Get(0) == nil {
262+
return nil, r, args.Error(2)
263+
}
264+
return args.Get(0).([]*github.Issue), r, args.Error(2)
265+
}
266+
267+
func (c *mockService) CreateIssue(owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
268+
args := c.Called(owner, repo, issue)
269+
var r *github.Response
270+
if resp := args.Get(1); resp != nil {
271+
r = args.Get(1).(*github.Response)
272+
}
273+
return args.Get(0).(*github.Issue), r, args.Error(2)
274+
}
275+
276+
func (c *mockService) UpdateIssue(owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
277+
args := c.Called(owner, repo, number, issue)
278+
var r *github.Response
279+
if resp := args.Get(1); resp != nil {
280+
r = args.Get(1).(*github.Response)
281+
}
282+
return args.Get(0).(*github.Issue), r, args.Error(2)
283+
}

0 commit comments

Comments
 (0)