Skip to content

Commit

Permalink
(feat) add azure webhook parsing, creation deletion & list
Browse files Browse the repository at this point in the history
  • Loading branch information
TP Honey committed Mar 30, 2022
1 parent 0c095d2 commit 7e9ed2b
Show file tree
Hide file tree
Showing 14 changed files with 1,201 additions and 32 deletions.
73 changes: 73 additions & 0 deletions scm/driver/azure/integration/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,76 @@ func TestListRepos(t *testing.T) {
t.Errorf("List should have at least 1 repo %d", len(references))
}
}

func TestListHooks(t *testing.T) {
if token == "" {
t.Skip("Skipping, Acceptance test")
}
client = azure.NewDefault(organization, project)
client.Client = &http.Client{
Transport: &transport.Custom{
Before: func(r *http.Request) {
r.Header.Set("Authorization", fmt.Sprintf("Basic %s", token))
},
},
}
hooks, response, listerr := client.Repositories.ListHooks(context.Background(), repoID, scm.ListOptions{})
if listerr != nil {
t.Errorf("List got an error %v", listerr)
}
if response.Status != http.StatusOK {
t.Errorf("List did not get a 200 back %v", response.Status)
}
if len(hooks) < 1 {
t.Errorf("List should have at least 1 hook %d", len(hooks))
}
}

func TestCreateDeleteHooks(t *testing.T) {
if token == "" {
t.Skip("Skipping, Acceptance test")
}
client = azure.NewDefault(organization, project)
client.Client = &http.Client{
Transport: &transport.Custom{
Before: func(r *http.Request) {
r.Header.Set("Authorization", fmt.Sprintf("Basic %s", token))
},
},
}
originalHooks, _, _ := client.Repositories.ListHooks(context.Background(), repoID, scm.ListOptions{})
// create a new hook
inputHook := &scm.HookInput{
Name: "web",
NativeEvents: []string{"git.push"},
Target: "http:https://www.example.com/webhook",
}
outHook, createResponse, createErr := client.Repositories.CreateHook(context.Background(), repoID, inputHook)
if createErr != nil {
t.Errorf("Create got an error %v", createErr)
}
if createResponse.Status != http.StatusOK {
t.Errorf("Create did not get a 200 back %v", createResponse.Status)
}
if len(outHook.Events) != 1 {
t.Errorf("New hook has one event %d", len(outHook.Events))
}
// get the hooks again, and make sure the new hook is there
afterCreate, _, _ := client.Repositories.ListHooks(context.Background(), repoID, scm.ListOptions{})
if len(afterCreate) != len(originalHooks)+1 {
t.Errorf("After create, the number of hooks is not correct %d. It should be %d", len(afterCreate), len(originalHooks)+1)
}
// delete the hook we created
deleteResponse, deleteErr := client.Repositories.DeleteHook(context.Background(), repoID, outHook.ID)
if deleteErr != nil {
t.Errorf("Delete got an error %v", deleteErr)
}
if deleteResponse.Status != http.StatusNoContent {
t.Errorf("Delete did not get a 204 back, got %v", deleteResponse.Status)
}
// get the hooks again, and make sure the new hook is gone
afterDelete, _, _ := client.Repositories.ListHooks(context.Background(), repoID, scm.ListOptions{})
if len(afterDelete) != len(originalHooks) {
t.Errorf("After Delete, the number of hooks is not correct %d. It should be %d", len(afterDelete), len(originalHooks))
}
}
152 changes: 149 additions & 3 deletions scm/driver/azure/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ func (s *RepositoryService) List(ctx context.Context, opts scm.ListOptions) ([]*

// ListHooks returns a list or repository hooks.
func (s *RepositoryService) ListHooks(ctx context.Context, repo string, opts scm.ListOptions) ([]*scm.Hook, *scm.Response, error) {
return nil, nil, scm.ErrNotSupported
// https://docs.microsoft.com/en-us/rest/api/azure/devops/hooks/subscriptions/list?view=azure-devops-rest-6.0
endpoint := fmt.Sprintf("%s/_apis/hooks/subscriptions?api-version=6.0", s.client.owner)
out := new(subscriptions)
res, err := s.client.do(ctx, "GET", endpoint, nil, &out)
return convertHookList(out.Value, repo), res, err
}

// ListStatus returns a list of commit statuses.
Expand All @@ -54,7 +58,37 @@ func (s *RepositoryService) ListStatus(ctx context.Context, repo, ref string, op

// CreateHook creates a new repository webhook.
func (s *RepositoryService) CreateHook(ctx context.Context, repo string, input *scm.HookInput) (*scm.Hook, *scm.Response, error) {
return nil, nil, scm.ErrNotSupported
// https://docs.microsoft.com/en-us/rest/api/azure/devops/hooks/subscriptions/create?view=azure-devops-rest-6.0
endpoint := fmt.Sprintf("%s/_apis/hooks/subscriptions?api-version=6.0", s.client.owner)
in := new(subscription)
in.Status = "enabled"
in.PublisherID = "tfs"
in.ResourceVersion = "1.0"
in.ConsumerID = "webHooks"
in.ConsumerActionID = "httpRequest"
// we do not support scm hookevents, only native events
if input.NativeEvents == nil {
return nil, nil, fmt.Errorf("CreateHook, You must pass at least one native event")
}
if len(input.NativeEvents) > 1 {
return nil, nil, fmt.Errorf("CreateHook, Azure only allows the creation of a single hook at a time %v", input.NativeEvents)
}
in.EventType = input.NativeEvents[0]
// publisher
projectID, projErr := s.getProjectIDFromProjectName(ctx, s.client.project)
if projErr != nil {
return nil, nil, fmt.Errorf("CreateHook was unable to look up the project's projectID, %s", projErr)
}
in.PublisherInputs.ProjectID = projectID
in.PublisherInputs.Repository = repo
// consumer
in.ConsumerInputs.URL = input.Target
if input.SkipVerify {
in.ConsumerInputs.AcceptUntrustedCerts = "enabled"
}
out := new(subscription)
res, err := s.client.do(ctx, "POST", endpoint, in, out)
return convertHook(out), res, err
}

// CreateStatus creates a new commit status.
Expand All @@ -74,7 +108,38 @@ func (s *RepositoryService) UpdateHook(ctx context.Context, repo, id string, inp

// DeleteHook deletes a repository webhook.
func (s *RepositoryService) DeleteHook(ctx context.Context, repo, id string) (*scm.Response, error) {
return nil, scm.ErrNotSupported
// https://docs.microsoft.com/en-us/rest/api/azure/devops/hooks/subscriptions/delete?view=azure-devops-rest-6.0
endpoint := fmt.Sprintf("%s/_apis/hooks/subscriptions/%s?api-version=6.0", s.client.owner, id)
return s.client.do(ctx, "DELETE", endpoint, nil, nil)
}

// helper function to return the projectID from the project name
func (s *RepositoryService) getProjectIDFromProjectName(ctx context.Context, projectName string) (string, error) {
// https://docs.microsoft.com/en-us/rest/api/azure/devops/core/projects/list?view=azure-devops-rest-6.0
endpoint := fmt.Sprintf("%s/_apis/projects?api-version=6.0", s.client.owner)
type projects struct {
Count int64 `json:"count"`
Value []struct {
Description string `json:"description"`
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
URL string `json:"url"`
} `json:"value"`
}

out := new(projects)
response, err := s.client.do(ctx, "GET", endpoint, nil, &out)
if err != nil {
fmt.Println(response)
return "", fmt.Errorf("failed to list projects: %s", err)
}
for _, v := range out.Value {
if v.Name == projectName {
return v.ID, nil
}
}
return "", fmt.Errorf("failed to find project id for %s", projectName)
}

type repositories struct {
Expand All @@ -96,6 +161,64 @@ type repository struct {
URL string `json:"url"`
}

type subscriptions struct {
Count int64 `json:"count"`
Value []*subscription `json:"value"`
}

type subscription struct {
ActionDescription string `json:"actionDescription"`
ConsumerActionID string `json:"consumerActionId"`
ConsumerID string `json:"consumerId"`
ConsumerInputs struct {
AccountName string `json:"accountName,omitempty"`
AcceptUntrustedCerts string `json:"acceptUntrustedCerts,omitempty"`
AddToTop string `json:"addToTop,omitempty"`
APIToken string `json:"apiToken,omitempty"`
BoardID string `json:"boardId,omitempty"`
BuildName string `json:"buildName,omitempty"`
BuildParameterized string `json:"buildParameterized,omitempty"`
FeedID string `json:"feedId,omitempty"`
ListID string `json:"listId,omitempty"`
PackageSourceID string `json:"packageSourceId,omitempty"`
Password string `json:"password,omitempty"`
ServerBaseURL string `json:"serverBaseUrl,omitempty"`
URL string `json:"url,omitempty"`
UserToken string `json:"userToken,omitempty"`
Username string `json:"username,omitempty"`
} `json:"consumerInputs"`
CreatedBy struct {
ID string `json:"id"`
} `json:"createdBy"`
CreatedDate string `json:"createdDate"`
EventDescription string `json:"eventDescription"`
EventType string `json:"eventType"`
ID string `json:"id"`
ModifiedBy struct {
ID string `json:"id"`
} `json:"modifiedBy"`
ModifiedDate string `json:"modifiedDate"`
ProbationRetries int64 `json:"probationRetries"`
PublisherID string `json:"publisherId"`
PublisherInputs struct {
AreaPath string `json:"areaPath,omitempty"`
Branch string `json:"branch,omitempty"`
BuildStatus string `json:"buildStatus,omitempty"`
ChangedFields string `json:"changedFields,omitempty"`
CommentPattern string `json:"commentPattern,omitempty"`
DefinitionName string `json:"definitionName,omitempty"`
HostID string `json:"hostId,omitempty"`
Path string `json:"path,omitempty"`
ProjectID string `json:"projectId,omitempty"`
Repository string `json:"repository,omitempty"`
TfsSubscriptionID string `json:"tfsSubscriptionId,omitempty"`
WorkItemType string `json:"workItemType,omitempty"`
} `json:"publisherInputs"`
ResourceVersion string `json:"resourceVersion"`
Status string `json:"status"`
URL string `json:"url"`
}

// helper function to convert from the gogs repository list to
// the common repository structure.
func convertRepositoryList(from *repositories) []*scm.Repository {
Expand All @@ -116,3 +239,26 @@ func convertRepository(from *repository) *scm.Repository {
Branch: from.DefaultBranch,
}
}

func convertHookList(from []*subscription, repositoryFilter string) []*scm.Hook {
to := []*scm.Hook{}
for _, v := range from {
if repositoryFilter != "" && repositoryFilter == v.PublisherInputs.Repository {
to = append(to, convertHook(v))
}
}
return to
}

func convertHook(from *subscription) *scm.Hook {
returnVal := &scm.Hook{
ID: from.ID,

Active: from.Status == "enabled",
Target: from.ConsumerInputs.URL,
Events: []string{from.EventType},
SkipVerify: from.ConsumerInputs.AcceptUntrustedCerts == "true",
}

return returnVal
}
59 changes: 59 additions & 0 deletions scm/driver/azure/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,62 @@ func TestRepositoryList(t *testing.T) {
t.Log(diff)
}
}

func TestRepositoryHookCreate(t *testing.T) {
defer gock.Off()

gock.New("https:/dev.azure.com/").
Get("/ORG/_apis/projects").
Reply(201).
Type("application/json").
File("testdata/projects.json")

gock.New("https:/dev.azure.com/").
Post("/ORG/_apis/hooks/subscriptions").
Reply(201).
Type("application/json").
File("testdata/hook.json")

in := &scm.HookInput{
Name: "web",
NativeEvents: []string{"git.push"},
Target: "http:https://www.example.com/webhook",
}

client := NewDefault("ORG", "test_project")
got, _, err := client.Repositories.CreateHook(context.Background(), "test_project", in)
if err != nil {
t.Error(err)
return
}

want := new(scm.Hook)
raw, _ := ioutil.ReadFile("testdata/hook.json.golden")
_ = json.Unmarshal(raw, want)

if diff := cmp.Diff(got, want); diff != "" {
t.Errorf("Unexpected Results")
t.Log(diff)
}
}

func TestRepositoryHookDelete(t *testing.T) {
defer gock.Off()

gock.New("https:/dev.azure.com/").
Delete("/ORG/_apis/hooks/subscriptions").
Reply(204).
Type("application/json")

client := NewDefault("ORG", "PROJ")
res, err := client.Repositories.DeleteHook(context.Background(), "", "test-project")
if err != nil {
t.Error(err)
return
}

if got, want := res.Status, 204; got != want {
t.Errorf("Want response status %d, got %d", want, got)
}

}
58 changes: 58 additions & 0 deletions scm/driver/azure/testdata/hook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"id": "d455cb11-20a0-4b15-b546-7e9fb9973cc6",
"url": "https://dev.azure.com/tphoney/_apis/hooks/subscriptions/d455cb11-20a0-4b15-b546-7e9fb9973cc6",
"status": "enabled",
"publisherId": "tfs",
"eventType": "git.pullrequest.created",
"subscriber": null,
"resourceVersion": "1.0",
"eventDescription": "Repository test_repo2",
"consumerId": "webHooks",
"consumerActionId": "httpRequest",
"actionDescription": "To host www.bla.com",
"probationRetries": 1,
"createdBy": {
"displayName": "tp",
"id": "3ff4a20f-306e-677e-8a01-57f35e71f109",
"uniqueName": "[email protected]",
"descriptor": "msa.M2ZmNGEyMGYtMzA2ZS03NzdlLThhMDEtNTdmMzVlNzFmMTA5"
},
"createdDate": "2022-03-25T13:28:12.39Z",
"modifiedBy": {
"displayName": "tp",
"id": "3ff4a20f-306e-677e-8a01-57f35e71f109",
"uniqueName": "[email protected]",
"descriptor": "msa.M2ZmNGEyMGYtMzA2ZS03NzdlLThhMDEtNTdmMzVlNzFmMTA5"
},
"modifiedDate": "2022-03-29T10:39:13.813Z",
"lastProbationRetryDate": "2022-03-28T10:44:51.093Z",
"publisherInputs": {
"branch": "",
"projectId": "d350c9c0-7749-4ff8-a78f-f9c1f0e56729",
"pullrequestCreatedBy": "",
"pullrequestReviewersContains": "",
"repository": "fde2d21f-13b9-4864-a995-83329045289a",
"tfsSubscriptionId": "4ce8d6c4-f655-418d-8eb6-9462dd01ff39"
},
"consumerInputs": {
"acceptUntrustedCerts": "true",
"url": "http:https://www.bla.com"
},
"_links": {
"self": {
"href": "https://dev.azure.com/tphoney/_apis/hooks/subscriptions/d455cb11-20a0-4b15-b546-7e9fb9973cc6"
},
"consumer": {
"href": "https://dev.azure.com/tphoney/_apis/hooks/consumers/webHooks"
},
"actions": {
"href": "https://dev.azure.com/tphoney/_apis/hooks/consumers/webHooks/actions"
},
"notifications": {
"href": "https://dev.azure.com/tphoney/_apis/hooks/subscriptions/d455cb11-20a0-4b15-b546-7e9fb9973cc6/notifications"
},
"publisher": {
"href": "https://dev.azure.com/tphoney/_apis/hooks/publishers/tfs"
}
}
}
10 changes: 10 additions & 0 deletions scm/driver/azure/testdata/hook.json.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"ID": "d455cb11-20a0-4b15-b546-7e9fb9973cc6",
"Name": "",
"Target": "http:https://www.bla.com",
"Events": [
"git.pullrequest.created"
],
"Active": true,
"SkipVerify": true
}
Loading

0 comments on commit 7e9ed2b

Please sign in to comment.