import URL from 'url' ; import is from '@sindresorhus/is' ; import JSON5 from 'json5' ; import parseDiff from 'parse-diff' ; import { PlatformId } from '../../constants' ; import { REPOSITORY_NOT_FOUND } from '../../constants/error-messages' ; import { logger } from '../../logger' ; import { BranchStatus , PrState , VulnerabilityAlert } from '../../types' ; import * as git from '../../util/git' ; import * as hostRules from '../../util/host-rules' ; import { BitbucketHttp , setBaseUrl } from '../../util/http/bitbucket' ; import { regEx } from '../../util/regex' ; import { sanitize } from '../../util/sanitize' ; import type { BranchStatusConfig , CreatePRConfig , EnsureCommentConfig , EnsureCommentRemovalConfig , EnsureIssueConfig , EnsureIssueResult , FindPRConfig , Issue , MergePRConfig , PlatformParams , PlatformResult , Pr , RepoParams , RepoResult , UpdatePrConfig , } from '../types' ; import { smartTruncate } from '../utils/pr-body' ; import { readOnlyIssueBody } from '../utils/read-only-issue-body' ; import * as comments from './comments' ; import * as utils from './utils' ; import { PrResponse , PrReviewer , RepoInfoBody , UserResponse , mergeBodyTransformer , } from './utils' ; const bitbucketHttp = new BitbucketHttp ( ) ; const BITBUCKET_PROD_ENDPOINT = 'https://api.bitbucket.org/' ; let config = { } as any ; const defaults = { endpoint : BITBUCKET_PROD_ENDPOINT } ; const pathSeparator = '/' ; let renovateUserUuid ; const inactiveReviewersMessage = 'reviewers: Malformed reviewers list' ; const notMemberReviewersMessage = 'is not a member of this workspace and cannot be added to this pull request' ; export async function initPlatform ( { endpoint , username , password , } ) { if ( ! ( username && password ) ) { throw new Error ( 'Init: You must configure a Bitbucket username and password' ) ; } if ( endpoint && endpoint !== BITBUCKET_PROD_ENDPOINT ) { logger . warn ( `Init: Bitbucket Cloud endpoint should generally be ${ BITBUCKET_PROD_ENDPOINT } but is being configured to a different value. Did you mean to use Bitbucket Server?` ) ; defaults . endpoint = endpoint ; } setBaseUrl ( defaults . endpoint ) ; renovateUserUuid = null ; try { const { uuid } = ( await bitbucketHttp . getJson < { uuid : string ; } > ( '/2.0/user' , { username , password , useCache : false , } ) ) . body ; renovateUserUuid = uuid ; } catch ( err ) { if ( err . statusCode === 403 && err . body ?. error ?. detail ?. required ?. includes ( 'account' ) ) { logger . warn ( `Bitbucket: missing 'account' scope for password` ) ; } else { logger . debug ( { err } , 'Unknown error fetching Bitbucket user identity' ) ; } } const platformConfig = { endpoint : endpoint || BITBUCKET_PROD_ENDPOINT , } ; return Promise . resolve ( platformConfig ) ; } export async function getRepos ( ) { logger . debug ( 'Autodiscovering Bitbucket Cloud repositories' ) ; try { const repos = await utils . accumulateValues < { full_name : string ; } > ( `/2.0/repositories/?role=contributor` ) ; return repos . map ( ( repo ) => repo . full_name ) ; } catch ( err ) { logger . error ( { err } , `bitbucket getRepos error` ) ; throw err ; } } export async function getRawFile ( fileName , repoName ? , branchOrTag ? ) { const repo = repoName ?? config . repository ; const path = fileName ; let finalBranchOrTag = branchOrTag ; if ( branchOrTag ?. includes ( pathSeparator ) ) { finalBranchOrTag = await getBranchCommit ( branchOrTag ) ; } const url = `/2.0/repositories/${ repo }/src/` + ( finalBranchOrTag || `HEAD` ) + `/${ path }` ; const res = await bitbucketHttp . get ( url ) ; return res . body ; } export async function getJsonFile ( fileName , repoName ? , branchOrTag ? ) { const raw = await getRawFile ( fileName , repoName , branchOrTag ) ; if ( fileName . endsWith ( '.json5' ) ) { return JSON5 . parse ( raw ) ; } return JSON . parse ( raw ) ; } export async function initRepo ( { repository , cloneSubmodules , ignorePrAuthor , } ) { logger . debug ( `initRepo("${ repository }")` ) ; const opts = hostRules . find ( { hostType : PlatformId . Bitbucket , url : defaults . endpoint , } ) ; config = { repository , username : opts . username , ignorePrAuthor , } as utils . Config ; let info ; try { info = utils . repoInfoTransformer ( ( await bitbucketHttp . getJson < RepoInfoBody > ( `/2.0/repositories/${ repository }` ) ) . body ) ; config . defaultBranch = info . mainbranch ; config = { ... config , owner : info . owner , mergeMethod : info . mergeMethod , has_issues : info . has_issues , } ; logger . debug ( `${ repository } owner = ${ config . owner }` ) ; } catch ( err ) { if ( err . statusCode === 404 ) { throw new Error ( REPOSITORY_NOT_FOUND ) ; } logger . debug ( { err } , 'Unknown Bitbucket initRepo error' ) ; throw err ; } const { hostname } = URL . parse ( defaults . endpoint ) ; const hostnameWithoutApiPrefix = regEx ( /api[.|-](.+)/ ) . exec ( hostname ) [ 1 ] ; const url = git . getUrl ( { protocol : 'https' , auth : `${ opts . username }:${ opts . password }` , hostname : hostnameWithoutApiPrefix , repository , } ) ; await git . initRepo ( { ... config , url , cloneSubmodules , } ) ; const repoConfig = { defaultBranch : info . mainbranch , isFork : info . isFork , } ; return repoConfig ; } export function getRepoForceRebase ( ) { return Promise . resolve ( false ) ; } function matchesState ( state , desiredState ) { if ( desiredState === PrState . All ) { return true ; } if ( desiredState . startsWith ( '!' ) ) { return state !== desiredState . substring ( 1 ) ; } return state === desiredState ; } export async function getPrList ( ) { logger . debug ( 'getPrList()' ) ; if ( ! config . prList ) { logger . debug ( 'Retrieving PR list' ) ; let url = `/2.0/repositories/${ config . repository }/pullrequests?` ; url += utils . prStates . all . map ( ( state ) => 'state=' + state ) . join ( '&' ) ; if ( renovateUserUuid && ! config . ignorePrAuthor ) { url += `&q=author.uuid="${ renovateUserUuid }"` ; } const prs = await utils . accumulateValues ( url , undefined , undefined , 50 ) ; config . prList = prs . map ( utils . prInfo ) ; logger . debug ( { length : config . prList . length } , 'Retrieved Pull Requests' ) ; } return config . prList ; } export async function findPr ( { branchName , prTitle , state = PrState . All , } ) { logger . debug ( `findPr(${ branchName }, ${ prTitle }, ${ state })` ) ; const prList = await getPrList ( ) ; const pr = prList . find ( ( p ) => p . sourceBranch === branchName && ( ! prTitle || p . title === prTitle ) && matchesState ( p . state , state ) ) ; if ( pr ) { logger . debug ( `Found PR #${ pr . number }` ) ; } return pr ; } async function isPrConflicted ( prNo ) { const diff = ( await bitbucketHttp . get ( `/2.0/repositories/${ config . repository }/pullrequests/${ prNo }/diff` ) ) . body ; return utils . isConflicted ( parseDiff ( diff ) ) ; } export async function getPr ( prNo ) { const pr = ( await bitbucketHttp . getJson < PrResponse > ( `/2.0/repositories/${ config . repository }/pullrequests/${ prNo }` ) ) . body ; if ( ! pr ) { return null ; } const res = { displayNumber : `Pull Request #${ pr . id }` , ... utils . prInfo ( pr ) , } ; if ( utils . prStates . open . includes ( pr . state ) ) { res . isConflicted = await isPrConflicted ( prNo ) ; res . canMerge = ! res . isConflicted ; } res . hasReviewers = is . nonEmptyArray ( pr . reviewers ) ; return res ; } const escapeHash = ( input ) : string => input ? input . replace ( regEx ( /#/g ) , '%23' ) : input ; interface BranchResponse { target : { hash : string ; } ; } async function getBranchCommit ( branchName ) { try { const branch = ( await bitbucketHttp . getJson < BranchResponse > ( `/2.0/repositories/${ config . repository }/refs/branches/${ escapeHash ( branchName ) }` ) ) . body ; return branch . target . hash ; } catch ( err ) { logger . debug ( { err } , `getBranchCommit('${ branchName }') failed'` ) ; return null ; } } export async function getBranchPr ( branchName ) { logger . debug ( `getBranchPr(${ branchName })` ) ; const existingPr = await findPr ( { branchName , state : PrState . Open , } ) ; return existingPr ? getPr ( existingPr . number ) : null ; } async function getStatus ( branchName , useCache = true ) { const sha = await getBranchCommit ( branchName ) ; return utils . accumulateValues < utils . BitbucketStatus > ( `/2.0/repositories/${ config . repository }/commit/${ sha }/statuses` , 'get' , { useCache } ) ; } export async function getBranchStatus ( branchName ) { logger . debug ( `getBranchStatus(${ branchName })` ) ; const statuses = await getStatus ( branchName ) ; logger . debug ( { branch : branchName , statuses } , 'branch status check result' ) ; if ( ! statuses . length ) { logger . debug ( 'empty branch status check result = returning "pending"' ) ; return BranchStatus . yellow ; } const noOfFailures = statuses . filter ( ( status ) => status . state === 'FAILED' || status . state === 'STOPPED' ) . length ; if ( noOfFailures ) { return BranchStatus . red ; } const noOfPending = statuses . filter ( ( status ) => status . state === 'INPROGRESS' ) . length ; if ( noOfPending ) { return BranchStatus . yellow ; } return BranchStatus . green ; } const bbToRenovateStatusMapping = { SUCCESSFUL : BranchStatus . green , INPROGRESS : BranchStatus . yellow , FAILED : BranchStatus . red , } ; export async function getBranchStatusCheck ( branchName , context ) { const statuses = await getStatus ( branchName ) ; const bbState = ( statuses . find ( ( status ) => status . key === context ) || { } ) . state ; return bbToRenovateStatusMapping [ bbState ] || null ; } export async function setBranchStatus ( { branchName , context , description , state , url : targetUrl , } ) { const sha = await getBranchCommit ( branchName ) ; const url = targetUrl || 'https://bitbucket.org' ; const body = { name : context , state : utils . buildStates [ state ] , key : context , description , url , } ; await bitbucketHttp . postJson ( `/2.0/repositories/${ config . repository }/commit/${ sha }/statuses/build` , { body } ) ; await getStatus ( branchName , false ) ; } type BbIssue = { id : number ; title : string ; content ? : { raw : string ; } ; } ; async function findOpenIssues ( title ) { try { const filter = encodeURIComponent ( [ `title=${ JSON . stringify ( title ) }` , '(state = "new" OR state = "open")' , `reporter.username="${ config . username }"` , ] . join ( ' AND ' ) ) ; return ( ( await bitbucketHttp . getJson < { values : BbIssue [ ] ; } > ( `/2.0/repositories/${ config . repository }/issues?q=${ filter }` ) ) . body . values || [ ] ) ; } catch ( err ) { logger . warn ( { err } , 'Error finding issues' ) ; return [ ] ; } } export async function findIssue ( title ) { logger . debug ( `findIssue(${ title })` ) ; if ( ! config . has_issues ) { logger . debug ( 'Issues are disabled - cannot findIssue' ) ; return null ; } const issues = await findOpenIssues ( title ) ; if ( ! issues . length ) { return null ; } const [ issue ] = issues ; return { number : issue . id , body : issue . content ?. raw , } ; } async function closeIssue ( issueNumber ) { await bitbucketHttp . putJson ( `/2.0/repositories/${ config . repository }/issues/${ issueNumber }` , { body : { state : 'closed' } , } ) ; } export function massageMarkdown ( input ) { return smartTruncate ( input , 50000 ) . replace ( 'you tick the rebase/retry checkbox' , 'rename PR to start with "rebase!"' ) . replace ( regEx ( /<\/?summary>/g ) , '**' ) . replace ( regEx ( /<\/?details>/g ) , '' ) . replace ( regEx ( `\n---\n\n.*?<!-- rebase-check -->.*?\n` ) , '' ) . replace ( regEx ( /\]\(\.\.\/pull\//g ) , '](../../pull-requests/' ) ; } export async function ensureIssue ( { title , reuseTitle , body , } ) { logger . debug ( `ensureIssue()` ) ; const description = massageMarkdown ( sanitize ( body ) ) ; if ( ! config . has_issues ) { logger . warn ( 'Issues are disabled - cannot ensureIssue' ) ; logger . debug ( { title } , 'Failed to ensure Issue' ) ; return null ; } try { let issues = await findOpenIssues ( title ) ; if ( ! issues . length ) { issues = await findOpenIssues ( reuseTitle ) ; } if ( issues . length ) { for ( const issue of issues . slice ( 1 ) ) { await closeIssue ( issue . id ) ; } const [ issue ] = issues ; if ( issue . title !== title || String ( issue . content . raw ) . trim ( ) !== description . trim ( ) ) { logger . debug ( 'Issue updated' ) ; await bitbucketHttp . putJson ( `/2.0/repositories/${ config . repository }/issues/${ issue . id }` , { body : { content : { raw : readOnlyIssueBody ( description ) , markup : 'markdown' , } , } , } ) ; return 'updated' ; } } else { logger . info ( 'Issue created' ) ; await bitbucketHttp . postJson ( `/2.0/repositories/${ config . repository }/issues` , { body : { title , content : { raw : readOnlyIssueBody ( description ) , markup : 'markdown' , } , } , } ) ; return 'created' ; } } catch ( err ) { if ( err . message . startsWith ( 'Repository has no issue tracker.' ) ) { logger . debug ( `Issues are disabled, so could not create issue: ${ title }` ) ; } else { logger . warn ( { err } , 'Could not ensure issue' ) ; } } return null ; } export async function getIssueList ( ) { logger . debug ( `getIssueList()` ) ; if ( ! config . has_issues ) { logger . debug ( 'Issues are disabled - cannot getIssueList' ) ; return [ ] ; } try { const filter = encodeURIComponent ( [ '(state = "new" OR state = "open")' , `reporter.username="${ config . username }"` , ] . join ( ' AND ' ) ) ; return ( ( await bitbucketHttp . getJson < { values : Issue [ ] ; } > ( `/2.0/repositories/${ config . repository }/issues?q=${ filter }` ) ) . body . values || [ ] ) ; } catch ( err ) { logger . warn ( { err } , 'Error finding issues' ) ; return [ ] ; } } export async function ensureIssueClosing ( title ) { if ( ! config . has_issues ) { logger . debug ( 'Issues are disabled - cannot ensureIssueClosing' ) ; return ; } const issues = await findOpenIssues ( title ) ; for ( const issue of issues ) { await closeIssue ( issue . id ) ; } } export function addAssignees ( _prNr , _assignees ) { logger . warn ( 'Cannot add assignees' ) ; return Promise . resolve ( ) ; } export async function addReviewers ( prId , reviewers ) { logger . debug ( `Adding reviewers '${ reviewers . join ( ', ' ) }' to #${ prId }` ) ; const { title } = await getPr ( prId ) ; const body = { title , reviewers : reviewers . map ( ( username ) => ( { username } ) ) , } ; await bitbucketHttp . putJson ( `/2.0/repositories/${ config . repository }/pullrequests/${ prId }` , { body , } ) ; } export function deleteLabel ( ) { throw new Error ( 'deleteLabel not implemented' ) ; } export function ensureComment ( { number , topic , content , } ) { return comments . ensureComment ( { config , number , topic , content : sanitize ( content ) , } ) ; } export function ensureCommentRemoval ( { number : prNo , topic , content , } ) { return comments . ensureCommentRemoval ( config , prNo , topic , content ) ; } export async function createPr ( { sourceBranch , targetBranch , prTitle : title , prBody : description , platformOptions , } ) { const base = targetBranch ; logger . debug ( { repository : config . repository , title , base } , 'Creating PR' ) ; let reviewers = [ ] ; if ( platformOptions ?. bbUseDefaultReviewers ) { const reviewersResponse = ( await bitbucketHttp . getJson < utils . PagedResult < Reviewer > > ( `/2.0/repositories/${ config . repository }/default-reviewers` ) ) . body ; reviewers = reviewersResponse . values . map ( ( reviewer ) => ( { uuid : reviewer . uuid , } ) ) ; } const body = { title , description : sanitize ( description ) , source : { branch : { name : sourceBranch , } , } , destination : { branch : { name : base , } , } , close_source_branch : true , reviewers , } ; try { const prRes = ( await bitbucketHttp . postJson < PrResponse > ( `/2.0/repositories/${ config . repository }/pullrequests` , { body , } ) ) . body ; const pr = utils . prInfo ( prRes ) ; if ( config . prList ) { config . prList . push ( pr ) ; } return pr ; } catch ( err ) { logger . warn ( { err } , 'Error creating pull request' ) ; throw err ; } } interface Reviewer { uuid : { raw : string ; } ; } export async function updatePr ( { number : prNo , prTitle : title , prBody : description , state , } ) { logger . debug ( `updatePr(${ prNo }, ${ title }, body)` ) ; const pr = ( await bitbucketHttp . getJson < PrResponse > ( `/2.0/repositories/${ config . repository }/pullrequests/${ prNo }` ) ) . body ; try { await bitbucketHttp . putJson ( `/2.0/repositories/${ config . repository }/pullrequests/${ prNo }` , { body : { title , description : sanitize ( description ) , reviewers : pr . reviewers , } , } ) ; } catch ( err ) { if ( err . statusCode === 400 && [ inactiveReviewersMessage , notMemberReviewersMessage ] . some ( ( m ) => err . body ?. error ?. message . includes ( m ) ) ) { const sanitizedReviewers = [ ] ; if ( err . body ?. error ?. message . includes ( inactiveReviewersMessage ) ) { logger . warn ( { err } , 'PR contains inactive reviewer accounts.  Will try setting only active reviewers' ) ; for ( const reviewer of pr . reviewers ) { const reviewerUser = ( await bitbucketHttp . getJson < UserResponse > ( `/2.0/users/${ reviewer . account_id }` ) ) . body ; if ( reviewerUser . account_status === 'active' ) { sanitizedReviewers . push ( reviewer ) ; } } } if ( err . body ?. error ?. message . includes ( notMemberReviewersMessage ) ) { logger . warn ( { err } , 'PR contains reviewer accounts which are no longer member of this workspace.  Will try setting only member reviewers' ) ; const workspace = config . repository . split ( '/' ) [ 0 ] ; for ( const reviewer of pr . reviewers ) { try { await bitbucketHttp . head ( `/2.0/workspaces/${ workspace }/members/${ reviewer . account_id }` ) ; sanitizedReviewers . push ( reviewer ) ; } catch ( err ) { if ( err . response ?. statusCode !== 404 ) { throw err ; } } } } await bitbucketHttp . putJson ( `/2.0/repositories/${ config . repository }/pullrequests/${ prNo }` , { body : { title , description : sanitize ( description ) , reviewers : sanitizedReviewers , } , } ) ; } else { throw err ; } } if ( state === PrState . Closed && pr ) { await bitbucketHttp . postJson ( `/2.0/repositories/${ config . repository }/pullrequests/${ prNo }/decline` ) ; } } export async function mergePr ( { branchName , id : prNo , strategy : mergeStrategy , } ) { logger . debug ( `mergePr(${ prNo }, ${ branchName }, ${ mergeStrategy })` ) ; if ( mergeStrategy === 'rebase' ) { logger . warn ( 'Bitbucket Cloud does not support a "rebase" strategy.' ) ; return false ; } try { await bitbucketHttp . postJson ( `/2.0/repositories/${ config . repository }/pullrequests/${ prNo }/merge` , { body : mergeBodyTransformer ( mergeStrategy ) , } ) ; logger . debug ( 'Automerging succeeded' ) ; } catch ( err ) { logger . debug ( { err } , `PR merge error` ) ; logger . info ( { pr : prNo } , 'PR automerge failed' ) ; return false ; } return true ; } export function getVulnerabilityAlerts ( ) { return Promise . resolve ( [ ] ) ; }