Skip to content

Commit

Permalink
[WIP] Implement BitBucketCloud API
Browse files Browse the repository at this point in the history
  • Loading branch information
HelloCore committed Jun 23, 2019
1 parent a38ab21 commit 6d8a026
Show file tree
Hide file tree
Showing 3 changed files with 628 additions and 0 deletions.
89 changes: 89 additions & 0 deletions source/dsl/BitBucketCloudDSL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { BitBucketServerPRDSL, RepoMetaData as BitBucketServerRepoMetaData } from "./BitBucketServerDSL"

export interface BitBucketCloudPagedResponse<T> {
pagelen: number
size: number
page: number
next: string | undefined
previous: string | undefined
values: T[]
}

export type BitBucketCloudPRDSL = BitBucketServerPRDSL
export type RepoMetaData = BitBucketServerRepoMetaData

export interface BitBucketCloudUser {
/** The uuid of the commit author */
uuid: string

/** The display name of the commit author */
display_name: string

/** The nick name of the commit author */
nickname: string

/** The acount id of the commit author */
account_id: string
}

/** A BitBucketServer specific implementation of a git commit. */
export interface BitBucketCloudCommit {
/** The SHA for the commit */
hash: string

/** The author of the commit, assumed to be the person who wrote the code. */
author: BitBucketCloudUser

/** When the commit was commited to the project, in ISO 8601 format */
date: string
/** The commit's message */
message: string
/** The commit's parents */
parents: {
/** The full SHA */
hash: string
}[]
}

export interface BitBucketCloudPRLink {
id: number
links: {
self: {
href: string
}
html: {
href: string
}
}
title: string
}

export interface BitBucketCloudContent {
raw: string
markup: string
html: string
type: "rendered"
}

export interface BitBucketCloudPRComment {
deleted: boolean
pullrequest: BitBucketCloudPRLink
content: BitBucketCloudContent

/** When the comment was created, in ISO 8601 format */
created_on: string
user: BitBucketCloudUser

/** When the comment was updated, in ISO 8601 format */
updated_on: string
type: string
id: number
}

export interface BitBucketCloudPRActivity {
comment?: BitBucketCloudPRComment
pull_request: {
id: number
title: string
}
}
270 changes: 270 additions & 0 deletions source/platforms/bitbucket_cloud/BitBucketCloudAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import { debug } from "../../debug"
import * as node_fetch from "node-fetch"
import { Agent } from "http"
import HttpsProxyAgent from "https-proxy-agent"

import { Env } from "../../ci_source/ci_source"
import { dangerIDToString } from "../../runner/templates/bitbucketServerTemplate"
import { api as fetch } from "../../api/fetch"
import {
BitBucketCloudPagedResponse,
BitBucketCloudPRDSL,
RepoMetaData,
BitBucketCloudCommit,
BitBucketCloudPRActivity,
BitBucketCloudPRComment,
} from "../../dsl/BitBucketCloudDSL"

export interface BitBucketCloudCredentials {
username: string
password: string
/** Unique ID for this user, must be wrapped with brackets */
uuid: string
}

export function bitbucketCloudCredentialsFromEnv(env: Env): BitBucketCloudCredentials {
if (!env["DANGER_BITBUCKETCLOUD_USERNAME"]) {
throw new Error(`DANGER_BITBUCKETCLOUD_USERNAME is not set`)
}
if (!env["DANGER_BITBUCKETCLOUD_PASSWORD"]) {
throw new Error(`DANGER_BITBUCKETCLOUD_PASSWORD is not set`)
}
if (!env["DANGER_BITBUCKETCLOUD_UUID"]) {
throw new Error(`DANGER_BITBUCKETCLOUD_UUID is not set`)
}
const uuid = `${env["DANGER_BITBUCKETCLOUD_UUID"]}`
if (!uuid.startsWith("{") || !uuid.endsWith("}")) {
throw new Error(`DANGER_BITBUCKETCLOUD_UUID must be wraped with brackets`)
}

return {
username: env["DANGER_BITBUCKETCLOUD_USERNAME"],
password: env["DANGER_BITBUCKETCLOUD_PASSWORD"],
uuid,
}
}

export class BitBucketCloudAPI {
fetch: typeof fetch
private readonly d = debug("BitBucketServerAPI")
private pr: BitBucketCloudPRDSL | undefined
private baseURL = "https://api.bitbucket.org/2.0"

constructor(public readonly repoMetadata: RepoMetaData, public readonly credentials: BitBucketCloudCredentials) {
// This allows Peril to DI in a new Fetch function
// which can handle unique API edge-cases around integrations
this.fetch = fetch
}

getBaseRepoURL() {
const { repoSlug } = this.repoMetadata
return `${this.baseURL}/repositories/${repoSlug}`
}

getPRURL() {
const { pullRequestID } = this.repoMetadata
return `${this.getBaseRepoURL()}/pullrequests/${pullRequestID}`
}

getPullRequestsFromBranch = async (branch: string): Promise<BitBucketCloudPRDSL[]> => {
// Need to encode URI here because it used special characters in query params.
// TODO: (HelloCore) Not sure if we need to use `source.branch.name` or `destination.branch.name` here.
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/pullrequests
let nextPageURL: string | undefined = encodeURI(
`${this.getBaseRepoURL()}/pullrequests?q=source.branch.name = "${branch}"`
)
let values: BitBucketCloudPRDSL[] = []

do {
const res = await this.get(nextPageURL)
throwIfNotOk(res)

const data = (await res.json()) as BitBucketCloudPagedResponse<BitBucketCloudPRDSL>

values = values.concat(data.values)

nextPageURL = data.next
} while (nextPageURL != null)

return values
}

getPullRequestInfo = async (): Promise<BitBucketCloudPRDSL> => {
if (this.pr) {
return this.pr
}
const res = await this.get(this.getPRURL())
throwIfNotOk(res)
const prDSL = (await res.json()) as BitBucketCloudPRDSL
this.pr = prDSL
return prDSL
}

getPullRequestCommits = async (): Promise<BitBucketCloudCommit[]> => {
let values: BitBucketCloudCommit[] = []

// https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/pullrequests/%7Bpull_request_id%7D/commits
let nextPageURL: string | undefined = `${this.getPRURL()}/commits`

do {
const res = await this.get(nextPageURL)
throwIfNotOk(res)

const data = (await res.json()) as BitBucketCloudPagedResponse<BitBucketCloudCommit>

values = values.concat(data.values)

nextPageURL = data.next
} while (nextPageURL != null)

return values
}

getPullRequestDiff = async () => {
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/pullrequests/%7Bpull_request_id%7D/diff
const res = await this.get(`${this.getPRURL()}/diff`)
return res.ok ? res.text() : ""
}

getPullRequestComments = async (): Promise<BitBucketCloudPRComment[]> => {
const activities = await this.getPullRequestActivities()
return activities
.map(activity => activity.comment)
.filter((comment): comment is BitBucketCloudPRComment => comment != null)
}

getPullRequestActivities = async (): Promise<BitBucketCloudPRActivity[]> => {
let values: BitBucketCloudPRActivity[] = []

// https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/pullrequests/%7Bpull_request_id%7D/activity
let nextPageURL: string | undefined = `${this.getPRURL()}/activity`

do {
const res = await this.get(nextPageURL)
throwIfNotOk(res)

const data = (await res.json()) as BitBucketCloudPagedResponse<BitBucketCloudPRActivity>

values = values.concat(data.values)

nextPageURL = data.next
} while (nextPageURL != null)

return values
}

getDangerComments = async (dangerID: string): Promise<BitBucketCloudPRComment[]> => {
const comments = await this.getPullRequestComments()
const dangerIDMessage = dangerIDToString(dangerID)

return comments
.filter(comment => comment.content.raw.includes(dangerIDMessage))
.filter(comment => comment.user.uuid === this.credentials.uuid)
.filter(comment => comment.content.raw.includes("Generated by"))
}

postBuildStatus = async (
commitId: string,
payload: {
state: "SUCCESSFUL" | "FAILED" | "INPROGRESS" | "STOPPED"
key: string
name: string
url: string
description: string
}
) => {
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/commit/%7Bnode%7D/statuses/build
const res = await this.post(`${this.getBaseRepoURL()}/commit/${commitId}/statuses/build`, {}, payload)
throwIfNotOk(res)

return await res.json()
}

postPRComment = async (comment: string) => {
const url = `${this.getPRURL()}/comments`
const res = await this.post(url, {}, { content: { raw: comment } })
return await res.json()
}

deleteComment = async (id: number) => {
const path = `${this.getPRURL()}/comments/${id}`
const res = await this.delete(path)

// TODO: (HelloCore) Have to handle nested comment case

if (!res.ok) {
throw new Error(`Failed to delete comment "${id}`)
}
}

updateComment = async (id: number, comment: string) => {
const path = `${this.getPRURL()}/comments/${id}`
const res = await this.put(
path,
{},
{
content: {
raw: comment,
},
}
)
if (res.ok) {
return res.json()
} else {
throw await res.json()
}
}
// API implementation
private api = (url: string, headers: any = {}, body: any = {}, method: string, suppressErrors?: boolean) => {
headers["Authorization"] = `Basic ${new Buffer(
this.credentials.username + ":" + this.credentials.password
).toString("base64")}`

this.d(`${method} ${url}`)

// Allow using a proxy configured through environmental variables
// Remember that to avoid the error "Error: self signed certificate in certificate chain"
// you should also do: "export NODE_TLS_REJECT_UNAUTHORIZED=0". See: https://github.com/request/request/issues/2061
let agent: Agent | undefined = undefined
let proxy = process.env.http_proxy || process.env.https_proxy
if (proxy) {
agent = new HttpsProxyAgent(proxy)
}

return this.fetch(
url,
{
method,
body,
headers: {
"Content-Type": "application/json",
...headers,
},
agent,
},
suppressErrors
)
}

get = (url: string, headers: any = {}, suppressErrors?: boolean): Promise<node_fetch.Response> =>
this.api(url, headers, null, "GET", suppressErrors)

post = (url: string, headers: any = {}, body: any = {}, suppressErrors?: boolean): Promise<node_fetch.Response> =>
this.api(url, headers, JSON.stringify(body), "POST", suppressErrors)

put = (url: string, headers: any = {}, body: any = {}): Promise<node_fetch.Response> =>
this.api(url, headers, JSON.stringify(body), "PUT")

delete = (url: string, headers: any = {}, body: any = {}): Promise<node_fetch.Response> =>
this.api(url, headers, JSON.stringify(body), "DELETE")
}

function throwIfNotOk(res: node_fetch.Response) {
if (!res.ok) {
let message = `${res.status} - ${res.statusText}`
if (res.status >= 400 && res.status < 500) {
message += ` (Have you set DANGER_BITBUCKETCLOUD_USERNAME and DANGER_BITBUCKETCLOUD_PASSWORD?)`
}
throw new Error(message)
}
}

0 comments on commit 6d8a026

Please sign in to comment.