feat: [CODE-3239] add support to change target branch after PR is created (#3640)

* feat: [CODE-3239] lint
* feat: [CODE-3239] refactor promises
* feat: [CODE-3239] add dependency
* feat: [CODE-3239] address comments
* feat: [CODE-3239] address comments
* feat: [CODE-3239] add success toast
* feat: [CODE-3239] rebase
* feat: [CODE-3239] add support to change target branch after PR is created
This commit is contained in:
Ritik Kapoor 2025-04-09 19:57:28 +00:00 committed by Harness
parent ef5251a6b9
commit e5eafee82c
9 changed files with 451 additions and 36 deletions

View File

@ -879,10 +879,13 @@ export interface StringsMap {
'pr.statusLine': string
'pr.suggestedChange': string
'pr.suggestionApplied': string
'pr.targetBranchUpdated': string
'pr.titleAndBranchUpdated': string
'pr.titleChanged': string
'pr.titleChangedTable': string
'pr.titleIsRequired': string
'pr.titlePlaceHolder': string
'pr.titleUpdated': string
'pr.toggleComments': string
'pr.unified': string
'pr.updatedLine': string
@ -908,6 +911,7 @@ export interface StringsMap {
'prReview.requested': string
'prReview.selfAssigned': string
'prReview.selfRemoved': string
'prReview.targetBranchChange': string
prSourceAndTargetMustBeDifferent: string
'prState.draftDesc': string
'prState.draftHeading': string

View File

@ -368,6 +368,9 @@ pr:
reviewRequested: Review Requested
changesRequested: Changes Requested
myPRs: My Pull Requests
titleUpdated: Pull request title updated
targetBranchUpdated: Target branch updated to {{branch}}
titleAndBranchUpdated: 'Pull request updated: Title and target branch ({{branch}})'
prState:
draftHeading: This pull request is still a work in progress
draftDesc: Draft pull requests cannot be merged.
@ -391,6 +394,7 @@ prReview:
codeowners: '{author} requested review from {codeowners} as {count|1:code owner,code owners}'
defaultReviewers: '{author} requested review from {reviewers} as {count|1:default reviewer,default reviewers}'
labelsAssigned: '{count|0:label,labels}'
targetBranchChange: '{user} changed the target branch from {old} to {new}'
webhookListingContent: 'create,delete,deployment ...'
general: 'General'
webhooks: 'Webhooks'

View File

@ -31,6 +31,7 @@ import { CommitActions } from 'components/CommitActions/CommitActions'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { TimePopoverWithLocal } from 'utils/timePopoverLocal/TimePopoverWithLocal'
import { Label } from 'components/Label/Label'
import { GitRefLink } from 'components/GitRefLink/GitRefLink'
import { ActivityLabel, CommentType } from '../PullRequestUtils'
import css from './Conversation.module.scss'
@ -612,6 +613,50 @@ export const SystemComment: React.FC<SystemCommentProps> = ({ pullReqMetadata, c
)
}
case CommentType.TARGET_BRANCH_CHANGE: {
const vars = {
user: <strong>{payload?.author?.display_name}</strong>,
old: (
<GitRefLink
text={(payload?.payload as Unknown).old as string}
url={routes.toCODERepository({
repoPath: repoMetadataPath as string,
gitRef: (payload?.payload as Unknown).old
})}
showCopy
/>
),
new: (
<GitRefLink
text={(payload?.payload as Unknown).new as string}
url={routes.toCODERepository({
repoPath: repoMetadataPath as string,
gitRef: (payload?.payload as Unknown).new
})}
showCopy
/>
)
}
return (
<Container className={css.mergedBox}>
<Layout.Horizontal spacing="small" style={{ alignItems: 'center' }}>
<Avatar name={payload?.author?.display_name} size="small" hoverCard={false} />
<Text tag="div">
<StringSubstitute str={getString('prReview.targetBranchChange')} vars={vars} />
</Text>
<PipeSeparator height={9} />
<TimePopoverWithLocal
time={defaultTo(payload?.created as number, 0)}
inline
width={100}
font={{ variation: FontVariation.SMALL }}
color={Color.GREY_400}
/>
</Layout.Horizontal>
</Container>
)
}
default: {
// eslint-disable-next-line no-console
console.warn('Unable to render system type activity', commentItems)

View File

@ -62,6 +62,8 @@ export default function PullRequest() {
refetchPullReq,
retryOnErrorFunc
} = useGetPullRequestInfo()
const [edit, setEdit] = useState(false)
const [currentRef, setCurrentRef] = useState('')
const onAddDescriptionClick = useCallback(() => {
setShowEditDescription(true)
@ -98,6 +100,9 @@ export default function PullRequest() {
repoMetadata={repoMetadata}
{...pullReqMetadata}
onAddDescriptionClick={onAddDescriptionClick}
currentRef={currentRef as string}
edit={edit}
setEdit={setEdit}
/>
) : (
''
@ -118,7 +123,13 @@ export default function PullRequest() {
<Render when={repoMetadata && pullReqMetadata}>
<>
<PullRequestMetaLine repoMetadata={repoMetadata as RepoRepositoryOutput} {...pullReqMetadata} />
<PullRequestMetaLine
repoMetadata={repoMetadata as RepoRepositoryOutput}
{...pullReqMetadata}
edit={edit}
currentRef={currentRef as string}
setCurrentRef={setCurrentRef}
/>
<Container className={tabContainerCSS.tabsContainer}>
<Tabs

View File

@ -14,8 +14,8 @@
* limitations under the License.
*/
import React from 'react'
import { Container, Text, Layout, StringSubstitute } from '@harnessio/uicore'
import React, { useEffect } from 'react'
import { Container, Text, Layout, StringSubstitute, ButtonSize } from '@harnessio/uicore'
import { FontVariation } from '@harnessio/design-system'
import cx from 'classnames'
import { defaultTo } from 'lodash-es'
@ -27,9 +27,16 @@ import type { TypesPullReq } from 'services/code'
import { PullRequestStateLabel } from 'components/PullRequestStateLabel/PullRequestStateLabel'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { GitRefLink } from 'components/GitRefLink/GitRefLink'
import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect'
import css from './PullRequestMetaLine.module.scss'
export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, 'repoMetadata'>> = ({
interface PullRequestMetaLineProps extends TypesPullReq, Pick<GitInfoProps, 'repoMetadata'> {
edit: boolean
currentRef: string
setCurrentRef: React.Dispatch<React.SetStateAction<string>>
}
export const PullRequestMetaLine: React.FC<PullRequestMetaLineProps> = ({
repoMetadata,
target_branch,
source_branch,
@ -38,8 +45,14 @@ export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, 're
merged,
state,
is_draft,
stats
stats,
edit,
currentRef,
setCurrentRef
}) => {
useEffect(() => {
setCurrentRef(target_branch as string)
}, [target_branch, edit])
const { getString } = useStrings()
const { routes } = useAppContext()
const vars = {
@ -47,12 +60,23 @@ export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, 're
user: <strong>{author?.display_name || author?.email || ''}</strong>,
commits: <strong>{stats?.commits}</strong>,
commitsCount: stats?.commits,
target: (
target: !edit ? (
<GitRefLink
text={target_branch as string}
url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: target_branch })}
showCopy
/>
) : (
<BranchTagSelect
forBranchesOnly
disableBranchCreation
repoMetadata={repoMetadata}
gitRef={currentRef as string}
size={ButtonSize.SMALL}
onSelect={ref => {
setCurrentRef(ref)
}}
/>
),
source: (
<GitRefLink

View File

@ -19,7 +19,7 @@ import { Container, Text, Layout, Button, ButtonVariation, ButtonSize, TextInput
import { FontVariation } from '@harnessio/design-system'
import { useMutate } from 'restful-react'
import { Match, Truthy, Else } from 'react-jsx-match'
import { compact } from 'lodash-es'
import { compact, isEmpty } from 'lodash-es'
import { useStrings } from 'framework/strings'
import { ButtonRoleProps, getErrorMessage } from 'utils/Utils'
import type { GitInfoProps } from 'utils/GitUtils'
@ -29,6 +29,9 @@ import { useDocumentTitle } from 'hooks/useDocumentTitle'
import css from './PullRequest.module.scss'
interface PullRequestTitleProps extends TypesPullReq, Pick<GitInfoProps, 'repoMetadata'> {
edit: boolean
currentRef: string
setEdit: React.Dispatch<React.SetStateAction<boolean>>
onSaveDone?: (newTitle: string) => Promise<boolean>
onAddDescriptionClick: () => void
}
@ -38,28 +41,78 @@ export const PullRequestTitle: React.FC<PullRequestTitleProps> = ({
title,
number,
description,
currentRef,
target_branch,
edit,
setEdit,
onAddDescriptionClick
}) => {
const [original, setOriginal] = useState(title)
const [val, setVal] = useState(title)
const [edit, setEdit] = useState(false)
const { getString } = useStrings()
const { showError } = useToaster()
const { mutate } = useMutate({
const { showError, showSuccess } = useToaster()
const { mutate: updatePRTitle } = useMutate({
verb: 'PATCH',
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${number}`
path: `/api/v1/repos/${repoMetadata?.path}/+/pullreq/${number}`
})
const { mutate: updateTargetBranch } = useMutate({
verb: 'PUT',
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${number}/branch`
})
const submitChange = useCallback(() => {
mutate({
const titleChanged = title !== val
const targetBranchChanged = target_branch !== currentRef
if (!titleChanged && !targetBranchChanged) {
return
}
const promises = []
if (titleChanged) {
promises.push(
updatePRTitle({
title: val,
description
})
.then(() => {
setEdit(false)
setOriginal(val)
.then(() => ({ success: true, type: 'title' }))
.catch(exception => {
showError(getErrorMessage(exception), 1000)
return { success: false, type: 'title' }
})
.catch(exception => showError(getErrorMessage(exception), 0))
}, [description, val, mutate, showError])
)
}
if (targetBranchChanged) {
promises.push(
updateTargetBranch({ branch_name: currentRef })
.then(() => ({ success: true, type: 'branch' }))
.catch(exception => {
showError(getErrorMessage(exception), 1000)
return { success: false, type: 'branch' }
})
)
}
Promise.all(promises).then(results => {
setEdit(false)
if (titleChanged && results.some(r => r.type === 'title' && r.success)) {
setOriginal(val)
}
const successful = results.filter(result => result.success)
const titleSuccess = successful.some(r => r.type === 'title')
const branchSuccess = successful.some(r => r.type === 'branch')
if (titleSuccess && branchSuccess) {
showSuccess(getString('pr.titleAndBranchUpdated', { branch: currentRef }), 3000)
} else if (titleSuccess) {
showSuccess(getString('pr.titleUpdated'), 3000)
} else if (branchSuccess) {
showSuccess(getString('pr.targetBranchUpdated', { branch: currentRef }), 3000)
}
})
}, [description, val, title, target_branch, currentRef, updatePRTitle, updateTargetBranch, showError, showSuccess])
useEffect(() => {
setOriginal(title)
@ -99,7 +152,7 @@ export const PullRequestTitle: React.FC<PullRequestTitleProps> = ({
variation={ButtonVariation.PRIMARY}
text={getString('save')}
size={ButtonSize.MEDIUM}
disabled={(val || '').trim().length === 0 || title === val}
disabled={isEmpty(val?.trim()) || !(title !== val || target_branch !== currentRef)}
onClick={submitChange}
/>
<Button

View File

@ -57,7 +57,8 @@ export enum CommentType {
STATE_CHANGE = 'state-change',
LABEL_MODIFY = 'label-modify',
REVIEWER_ADD = 'reviewer-add',
REVIEWER_DELETE = 'reviewer-delete'
REVIEWER_DELETE = 'reviewer-delete',
TARGET_BRANCH_CHANGE = 'target-branch-change'
}
export function isCodeComment(commentItems: CommentItem<TypesPullReqActivity>[]) {

View File

@ -139,6 +139,7 @@ export type EnumPullReqActivityType =
| 'reviewer-add'
| 'reviewer-delete'
| 'state-change'
| 'target-branch-change'
| 'title-change'
export type EnumPullReqCommentStatus = 'active' | 'resolved'
@ -5193,6 +5194,7 @@ export interface ListPullReqActivitiesQueryParams {
| 'reviewer-add'
| 'reviewer-delete'
| 'state-change'
| 'target-branch-change'
| 'title-change'
)[]
/**
@ -5386,6 +5388,44 @@ export const useRestorePullReqSourceBranch = ({
{ base: getConfig('code/api/v1'), pathParams: { repo_ref, pullreq_number }, ...props }
)
export interface ChangeTargetBranchPathParams {
repo_ref: string
pullreq_number: number
}
export interface ChangeTargetBranchRequestBody {
branch_name?: string
}
export type ChangeTargetBranchProps = Omit<
MutateProps<TypesPullReq, UsererrorError, void, ChangeTargetBranchRequestBody, ChangeTargetBranchPathParams>,
'path' | 'verb'
> &
ChangeTargetBranchPathParams
export const ChangeTargetBranch = ({ repo_ref, pullreq_number, ...props }: ChangeTargetBranchProps) => (
<Mutate<TypesPullReq, UsererrorError, void, ChangeTargetBranchRequestBody, ChangeTargetBranchPathParams>
verb="PUT"
path={`/repos/${repo_ref}/pullreq/${pullreq_number}/branch`}
base={getConfig('code/api/v1')}
{...props}
/>
)
export type UseChangeTargetBranchProps = Omit<
UseMutateProps<TypesPullReq, UsererrorError, void, ChangeTargetBranchRequestBody, ChangeTargetBranchPathParams>,
'path' | 'verb'
> &
ChangeTargetBranchPathParams
export const useChangeTargetBranch = ({ repo_ref, pullreq_number, ...props }: UseChangeTargetBranchProps) =>
useMutate<TypesPullReq, UsererrorError, void, ChangeTargetBranchRequestBody, ChangeTargetBranchPathParams>(
'PUT',
(paramsInPath: ChangeTargetBranchPathParams) =>
`/repos/${paramsInPath.repo_ref}/pullreq/${paramsInPath.pullreq_number}/branch`,
{ base: getConfig('code/api/v1'), pathParams: { repo_ref, pullreq_number }, ...props }
)
export interface ChecksPullReqPathParams {
repo_ref: string
pullreq_number: number

View File

@ -4958,6 +4958,7 @@ paths:
- reviewer-add
- reviewer-delete
- state-change
- target-branch-change
- title-change
type: string
type: array
@ -5172,6 +5173,60 @@ paths:
description: Internal Server Error
tags:
- pullreq
put:
operationId: changeTargetBranch
parameters:
- in: path
name: repo_ref
required: true
schema:
type: string
- in: path
name: pullreq_number
required: true
schema:
type: integer
requestBody:
content:
application/json:
schema:
properties:
branch_name:
type: string
type: object
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPullReq'
description: OK
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Bad Request
'401':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Unauthorized
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Forbidden
'500':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Internal Server Error
tags:
- pullreq
/repos/{repo_ref}/pullreq/{pullreq_number}/checks:
get:
operationId: checksPullReq
@ -6581,10 +6636,6 @@ paths:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/OpenapiRawOutput'
description: OK
'401':
content:
@ -10306,6 +10357,191 @@ paths:
description: Internal Server Error
tags:
- space
/spaces/{space_ref}/pullreq/count:
get:
operationId: countSpacePullReq
parameters:
- description: The state of the pull requests to include in the result.
explode: true
in: query
name: state
required: false
schema:
items:
default: open
enum:
- closed
- merged
- open
type: string
type: array
style: form
- description: Source repository ref of the pull requests.
in: query
name: source_repo_ref
required: false
schema:
type: string
- description: Source branch of the pull requests.
in: query
name: source_branch
required: false
schema:
type: string
- description: Target branch of the pull requests.
in: query
name: target_branch
required: false
schema:
type: string
- description: The substring by which the pull requests are filtered.
in: query
name: query
required: false
schema:
type: string
- description: List of principal IDs who created pull requests.
explode: true
in: query
name: created_by
required: false
schema:
items:
type: integer
type: array
style: form
- description: The result should contain only entries created before this timestamp
(unix millis).
in: query
name: created_lt
required: false
schema:
minimum: 0
type: integer
- description: The result should contain only entries created after this timestamp
(unix millis).
in: query
name: created_gt
required: false
schema:
minimum: 0
type: integer
- description: The result should contain only entries updated before this timestamp
(unix millis).
in: query
name: updated_lt
required: false
schema:
minimum: 0
type: integer
- description: The result should contain entries from the desired space and
of its subspaces.
in: query
name: include_subspaces
required: false
schema:
default: false
type: boolean
- description: List of label ids used to filter pull requests.
explode: true
in: query
name: label_id
required: false
schema:
items:
type: integer
type: array
style: form
- description: List of label value ids used to filter pull requests.
explode: true
in: query
name: value_id
required: false
schema:
items:
type: integer
type: array
style: form
- description: Return only pull requests where this user is the author.
in: query
name: author_id
required: false
schema:
type: integer
- description: Return only pull requests where this user has created at least
one comment.
in: query
name: commenter_id
required: false
schema:
type: integer
- description: Return only pull requests where this user has been mentioned.
in: query
name: mentioned_id
required: false
schema:
type: integer
- description: Return only pull requests where this user has been added as a
reviewer.
in: query
name: reviewer_id
required: false
schema:
type: integer
- description: Require only this review decision of the reviewer. Requires reviewer_id
parameter.
explode: true
in: query
name: review_decision
required: false
schema:
items:
enum:
- approved
- changereq
- pending
- reviewed
type: string
type: array
style: form
- in: path
name: space_ref
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
type: integer
description: OK
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Bad Request
'401':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Unauthorized
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Forbidden
'500':
content:
application/json:
schema:
$ref: '#/components/schemas/UsererrorError'
description: Internal Server Error
tags:
- space
/spaces/{space_ref}/purge:
post:
operationId: purgeSpace
@ -12405,6 +12641,7 @@ components:
- reviewer-add
- reviewer-delete
- state-change
- target-branch-change
- title-change
type: string
EnumPullReqCommentStatus:
@ -12484,7 +12721,6 @@ components:
enum:
- artifact_created
- artifact_deleted
- artifact_updated
- branch_created
- branch_deleted
- branch_updated
@ -13277,6 +13513,9 @@ components:
type: string
OpenapiSecuritySettingsRequest:
properties:
principal_committer_match:
nullable: true
type: boolean
secret_scanning_enabled:
nullable: true
type: boolean
@ -13703,21 +13942,12 @@ components:
type: integer
encoding:
$ref: '#/components/schemas/EnumContentEncodingType'
size:
type: integer
lfs_object_id:
type: string
lfs_object_size:
type: integer
type: object
OpenapiRawOutput:
properties:
data:
type: string
size:
type: integer
sha:
type: string
type: object
RepoListPathsOutput:
properties:
@ -13830,6 +14060,9 @@ components:
type: object
ReposettingsSecuritySettings:
properties:
principal_committer_match:
nullable: true
type: boolean
secret_scanning_enabled:
nullable: true
type: boolean