Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge train #137

Merged
merged 55 commits into from
Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
c02de4b
Initial implementation of merge trains (#77)
rudymatela Jul 29, 2022
41f05ac
Replace use of getTrain by unfailingIntegratedPRs
rudymatela Aug 11, 2022
d9cd7b4
Logic.tryIntegratePullRequest: use explicit var
rudymatela Aug 11, 2022
a753484
Avoid the need for getTrain (and remove it)
rudymatela Aug 11, 2022
68756b3
Spec: add automated test for new commit
rudymatela Aug 12, 2022
506312f
Fix test title
rudymatela Aug 12, 2022
e33e56a
Add pending test of rebase failures
rudymatela Aug 12, 2022
2f4bd86
Register correct baseBranch for rebase failures
rudymatela Aug 12, 2022
6207205
Do not give feedback when speculative rebase fails
rudymatela Aug 12, 2022
b697ef1
Refactor integratedPullRequestsAfter
rudymatela Aug 12, 2022
30183cc
Report rebase failures after base promotion
rudymatela Aug 12, 2022
294676f
Add pending (failing) test involving rebase fails
rudymatela Aug 12, 2022
d1e23fb
Refactor pullRequestsAfterThat
rudymatela Aug 12, 2022
095a636
Handle rebase failures on merge trains
rudymatela Aug 12, 2022
8d6ae93
Spec: add a few stub tests
rudymatela Aug 12, 2022
4e2eefa
Spec: test success (1), success (2)
rudymatela Aug 12, 2022
a57d0ee
Spec: test success (2), success (1)
rudymatela Aug 12, 2022
85504b1
Spec: test failure (1), failure (2)
rudymatela Aug 12, 2022
06dc4d0
Spec: pending test failure (2), failure (1)
rudymatela Aug 12, 2022
142a53e
Fix behaviour when failure arrive in reverse order
rudymatela Aug 12, 2022
90e6fac
Add another test: success (1), failure (2)
rudymatela Aug 12, 2022
2e531f3
Spec: add a (pending) failing test.
rudymatela Aug 12, 2022
07f2a7d
fix test name
rudymatela Aug 12, 2022
1b5072a
Fix one buildfailure behaviour
rudymatela Aug 12, 2022
b3ad8a0
Spec: failure (1), success (2)
rudymatela Aug 12, 2022
6e077c2
Spec: failure (2), success (1)
rudymatela Aug 12, 2022
ab811ac
Test closing PRs early on the train
rudymatela Aug 12, 2022
6f1da5c
Add a proper PrStatusSpeculativeConflict
rudymatela Aug 12, 2022
596e122
Report waiting for which PRs on failed builds
rudymatela Aug 12, 2022
53b011d
Remove TODO item.
rudymatela Aug 12, 2022
321334b
Remove repeated code
rudymatela Aug 12, 2022
818270e
Spec: remove uneeded resultIntegrate values
rudymatela Aug 12, 2022
13bf8fe
Refactor unintegrateAfter
rudymatela Aug 12, 2022
9ca84da
Remove now-unused crufty code
rudymatela Aug 12, 2022
bfa11f3
Break down long line
rudymatela Aug 12, 2022
885bc33
Refactor branch handling
rudymatela Aug 12, 2022
0582816
Fix comment line break
rudymatela Aug 12, 2022
c0c900b
document the compose auxiliary function
rudymatela Aug 12, 2022
7804f5f
Remove unused function
rudymatela Aug 12, 2022
59cfe3e
Add comments describing two new functions
rudymatela Aug 12, 2022
6b0d8a9
Display build successes properly on the web iface
rudymatela Aug 16, 2022
4b21410
Add test with 4 PRs
rudymatela Aug 17, 2022
44e03dd
Remove uneeded comment
rudymatela Aug 17, 2022
a699fd2
refactor isUnfailingIntegrated
rudymatela Aug 17, 2022
75d9176
unintegrateAfter: document noop
rudymatela Aug 17, 2022
6b60bd7
Report WrongFixups right away.
rudymatela Aug 17, 2022
7886134
Fix grammar in message
rudymatela Aug 17, 2022
5751cb8
Add a comment about unused message
rudymatela Aug 17, 2022
7c6391f
Fix grammar in comment
rudymatela Aug 17, 2022
55d2365
test/Spec: update test title
rudymatela Aug 17, 2022
70306a5
rename unfailing to unfailed
rudymatela Aug 17, 2022
550c07d
use "First" instead of "Some"
rudymatela Aug 17, 2022
fe85220
isUnfailing -> isUnfailed
rudymatela Aug 17, 2022
6ce0638
split of a second -> split-second
rudymatela Aug 17, 2022
9497f8d
Merge #137: Merge train
OpsBotPrime Aug 17, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Git.hs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ module Git
runGitReadOnly,
tag,
tag',
toBaseBranch,
toRemoteBranch,
tryIntegrate,
)
where
Expand Down Expand Up @@ -90,6 +92,12 @@ newtype RemoteBranch = RemoteBranch Text deriving newtype (Show, Eq)
localBranch :: RemoteBranch -> Branch
localBranch (RemoteBranch name) = Branch name

toRemoteBranch :: Branch -> RemoteBranch
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
toRemoteBranch (Branch name) = RemoteBranch name

toBaseBranch :: Branch -> BaseBranch
toBaseBranch (Branch name) = BaseBranch name

-- | A commit hash is stored as its hexadecimal representation.
newtype Sha = Sha Text deriving newtype (Show, Eq)

Expand Down
210 changes: 164 additions & 46 deletions src/Logic.hs
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ where
import Control.Concurrent.STM.TBQueue (TBQueue, newTBQueue, readTBQueue, writeTBQueue)
import Control.Concurrent.STM.TMVar (TMVar, newTMVarIO, readTMVar, swapTMVar)
import Control.Exception (assert)
import Control.Monad (foldM, unless, void, when)
import Control.Monad (foldM, unless, void, when, (>=>))
import Control.Monad.Free (Free (..), foldFree, hoistFree, liftF)
import Control.Monad.STM (atomically)
import Data.Bifunctor (first)
import Data.Either.Extra (maybeToEither)
import Data.Functor.Sum (Sum (InL, InR))
import Data.IntSet (IntSet)
import Data.Maybe (fromJust, isJust, listToMaybe)
import Data.Maybe (fromJust, isJust, listToMaybe, fromMaybe)
import Data.Text (Text)
import Data.Text.Lazy (toStrict)
import GHC.Natural (Natural)
Expand Down Expand Up @@ -79,6 +79,7 @@ data ActionFree a
-- This is a record type, but the names are currently only used for documentation.
{ _mergeCommitMessage :: Text
, _integrationCandidate :: (PullRequestId, Branch, Sha)
, _train :: [PullRequestId]
, _alwaysAddMergeCommit :: Bool
, _cont :: Either IntegrationFailure Sha -> a
}
Expand All @@ -97,6 +98,7 @@ data ActionFree a
data PRCloseCause =
User -- ^ The user closed the PR.
| StopIntegration -- ^ We close and reopen the PR internally to stop its integration if it is approved.
deriving Show

type Action = Free ActionFree

Expand All @@ -118,8 +120,8 @@ doGit = hoistFree (InR . InL)
doGithub :: GithubOperation a -> Operation a
doGithub = hoistFree (InR . InR)

tryIntegrate :: Text -> (PullRequestId, Branch, Sha) -> Bool -> Action (Either IntegrationFailure Sha)
tryIntegrate mergeMessage candidate alwaysAddMergeCommit = liftF $ TryIntegrate mergeMessage candidate alwaysAddMergeCommit id
tryIntegrate :: Text -> (PullRequestId, Branch, Sha) -> [PullRequestId] -> Bool -> Action (Either IntegrationFailure Sha)
tryIntegrate mergeMessage candidate train alwaysAddMergeCommit = liftF $ TryIntegrate mergeMessage candidate train alwaysAddMergeCommit id

-- Try to fast-forward the remote target branch (usually master) to the new sha.
-- Before doing so, force-push that SHA to the pull request branch, and after
Expand Down Expand Up @@ -161,24 +163,26 @@ getDateTime = liftF $ GetDateTime id
-- Interpreter that translates high-level actions into more low-level ones.
runAction :: ProjectConfiguration -> Action a -> Operation a
runAction config = foldFree $ \case
TryIntegrate message (pr, ref, sha) alwaysAddMergeCommit cont -> do
TryIntegrate message (pr, ref, sha) train alwaysAddMergeCommit cont -> do
doGit $ ensureCloned config

-- Needed for backwards compatibility with existing repositories
-- as we now test at testing/<pr_id> instead of testing.
-- When no repositories have a testing branch, this can safely be removed.
_ <- doGit $ Git.deleteRemoteBranch $ Git.Branch $ Config.testBranch config

let targetBranch = fromMaybe (Git.Branch $ Config.branch config) (trainBranch train)

shaOrFailed <- doGit $ Git.tryIntegrate
message
ref
sha
(Git.RemoteBranch $ Config.branch config)
(Git.toRemoteBranch targetBranch)
(testBranch config pr)
alwaysAddMergeCommit

case shaOrFailed of
Left failure -> pure $ cont $ Left $ IntegrationFailure (BaseBranch $ Config.branch config) failure
Left failure -> pure $ cont $ Left $ IntegrationFailure (Git.toBaseBranch targetBranch) failure
Right integratedSha -> pure $ cont $ Right integratedSha

TryPromote prBranch sha cont -> do
Expand Down Expand Up @@ -223,14 +227,20 @@ runAction config = foldFree $ \case
openPrIds <- doGithub GithubApi.getOpenPullRequests
pure $ cont openPrIds

GetLatestVersion sha cont -> doGit $
GetLatestVersion sha cont -> doGit $ do
Git.fetchBranchWithTags $ Branch (Config.branch config)
cont . maybe (Right 0) (\t -> maybeToEither t $ parseVersion t) <$> Git.lastTag sha

GetChangelog prevTag curHead cont -> doGit $
cont <$> Git.shortlog (AsRefSpec prevTag) (AsRefSpec curHead)

GetDateTime cont -> doTime $ cont <$> Time.getDateTime

where
trainBranch :: [PullRequestId] -> Maybe Git.Branch
trainBranch [] = Nothing
trainBranch train = Just $ last [testBranch config pr | pr <- train]

ensureCloned :: ProjectConfiguration -> GitOperation ()
ensureCloned config =
let
Expand Down Expand Up @@ -370,9 +380,14 @@ handlePullRequestClosedByUser = handlePullRequestClosed User

handlePullRequestClosed :: PRCloseCause -> PullRequestId -> ProjectState -> Action ProjectState
handlePullRequestClosed closingReason pr state = do
when (pr `elem` Pr.integratedPullRequests state) $
when (pr `elem` Pr.unfailingIntegratedPullRequests state) $
leaveComment pr $ prClosingMessage closingReason
pure $ Pr.deletePullRequest pr state
-- actually delete the pull request
pure . Pr.deletePullRequest pr
$ case Pr.lookupPullRequest pr state of
Just (Pr.PullRequest{Pr.integrationStatus = Promoted}) -> state
-- we unintegrate PRs after it if it has not been promoted to master
_ -> unintegrateAfter pr $ state

handlePullRequestEdited :: PullRequestId -> Text -> BaseBranch -> ProjectState -> Action ProjectState
handlePullRequestEdited prId newTitle newBaseBranch state =
Expand Down Expand Up @@ -542,20 +557,42 @@ handleMergeRequested projectConfig prId author state pr approvalType = do
then pure $ Pr.setIntegrationStatus prId IncorrectBaseBranch state''
else pure state''

-- | Given a pull request id, mark all pull requests that follow from it
-- in the merge train as NotIntegrated
unintegrateAfter :: PullRequestId -> ProjectState -> ProjectState
unintegrateAfter pid state = case Pr.lookupPullRequest pid state of
Nothing -> state -- should not happen
Just pr -> unintegrateAfter' pr state
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
where
unintegrateAfter' :: PullRequest -> ProjectState -> ProjectState
unintegrateAfter' pr0 = Pr.updatePullRequests unintegrate
where
unintegrate pr | pr `Pr.approvedAfter` pr0 && Pr.isIntegratedOrSpeculativelyConflicted pr
= pr{Pr.integrationStatus = NotIntegrated}
| otherwise
= pr

-- | If there is an integration candidate, and its integration sha matches that of the build,
-- then update the build status for that pull request. Otherwise do nothing.
handleBuildStatusChanged :: Sha -> BuildStatus -> ProjectState -> Action ProjectState
handleBuildStatusChanged buildSha newStatus = pure . Pr.updatePullRequests setBuildStatus
handleBuildStatusChanged buildSha newStatus state = pure $
compose [ Pr.updatePullRequest pid setBuildStatus
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
. case newStatus of
BuildFailed _ -> unintegrateAfter pid
_ -> id
| pid <- Pr.filterPullRequestsBy shouldUpdate state
] state
where
setBuildStatus pr = case Pr.integrationStatus pr of
-- If there is an integration candidate, and its integration sha matches that of the build,
-- then update the build status for that pull request. Otherwise do nothing.
Integrated candidateSha oldStatus | candidateSha == buildSha && newStatus `supersedes` oldStatus ->
pr { Pr.integrationStatus = Integrated buildSha newStatus
, Pr.needsFeedback = case newStatus of
BuildStarted _ -> True
BuildFailed _ -> True
_ -> Pr.needsFeedback pr -- unchanged
}
_ -> pr
shouldUpdate pr = case Pr.integrationStatus pr of
Integrated candidateSha oldStatus -> candidateSha == buildSha && newStatus `supersedes` oldStatus
_ -> False
setBuildStatus pr = pr
{ Pr.integrationStatus = Integrated buildSha newStatus
, Pr.needsFeedback = case newStatus of
BuildStarted _ -> True
BuildFailed _ -> True
_ -> Pr.needsFeedback pr -- unchanged
}

-- | Does the first build status supersedes the second?
--
Expand Down Expand Up @@ -612,15 +649,19 @@ synchronizeState stateInitial =
-- should find a new candidate. Or after the pull request for which a build is
-- in progress is closed, we should find a new candidate.
proceed :: ProjectState -> Action ProjectState
proceed state = do
state' <- provideFeedback state
case (Pr.integratedPullRequests state', Pr.candidatePullRequests state') of
-- Proceed with an already integrated candidate
(candidate:_, _) -> proceedCandidate candidate state'
-- Found a new candidate, try to integrate it.
(_, pr:_) -> tryIntegratePullRequest pr state'
-- No pull requests eligible, do nothing.
(_, _) -> return state'
proceed = provideFeedback
>=> proceedSomeCandidate
>=> tryIntegrateSomePullRequest

proceedSomeCandidate :: ProjectState -> Action ProjectState
proceedSomeCandidate state = case Pr.unfailingIntegratedPullRequests state of
(candidate:_) -> proceedCandidate candidate state
_ -> pure state

tryIntegrateSomePullRequest :: ProjectState -> Action ProjectState
tryIntegrateSomePullRequest state = case Pr.candidatePullRequests state of
(pr:_) -> tryIntegratePullRequest pr state
_ -> pure state
rudymatela marked this conversation as resolved.
Show resolved Hide resolved

-- | Pushes the given integrated PR to be the new master if the build succeeded
proceedCandidate :: PullRequestId -> ProjectState -> Action ProjectState
Expand Down Expand Up @@ -656,14 +697,17 @@ tryIntegratePullRequest pr state =
, format "Auto-deploy: {}" [if approvalType == MergeAndDeploy then "true" else "false" :: Text]
]
mergeMessage = Text.unlines mergeMessageLines
-- the takeWhile here is needed in case of reintegrations after failing pushes
train = takeWhile (/= pr) $ Pr.unfailingIntegratedPullRequests state
in do
result <- tryIntegrate mergeMessage candidate $ Pr.alwaysAddMergeCommit approvalType
result <- tryIntegrate mergeMessage candidate train $ Pr.alwaysAddMergeCommit approvalType
case result of
Left (IntegrationFailure targetBranch reason) ->
-- If integrating failed, perform no further actions but do set the
-- state to conflicted.
-- If this is a speculative rebase, we wait before giving feedback.
pure $ Pr.setIntegrationStatus pr (Conflicted targetBranch reason) $
Pr.setNeedsFeedback pr True state
Pr.setNeedsFeedback pr (null train) state
rudymatela marked this conversation as resolved.
Show resolved Hide resolved

Right (Sha sha) -> do
-- If it succeeded, set the build to pending,
Expand Down Expand Up @@ -721,12 +765,46 @@ pushCandidate (pullRequestId, pullRequest) newHead state =
-- the integration candidate, so we proceed with the next pull request.
PushOk -> do
cleanupTestBranch pullRequestId
pure $ Pr.setIntegrationStatus pullRequestId Promoted state
pure $ Pr.updatePullRequests (unspeculateConflictsAfter pullRequest)
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
$ Pr.updatePullRequests (unspeculateFailuresAfter pullRequest)
$ Pr.setIntegrationStatus pullRequestId Promoted state
-- If something was pushed to the target branch while the candidate was
-- being tested, try to integrate again and hope that next time the push
-- succeeds.
PushRejected _why -> tryIntegratePullRequest pullRequestId state

-- | When a pull request has been promoted to master this means that any
-- conflicts (failed rebases) build on top of it are not speculative anymore:
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
-- they are real conflicts on top of the (new) master.
--
-- This function updates the conflicted bases for all pull requests that come
-- after the given PR and sets them to need feedback.
unspeculateConflictsAfter :: PullRequest -> PullRequest -> PullRequest
unspeculateConflictsAfter promotedPullRequest pr
| Pr.PullRequest{ Pr.integrationStatus = Conflicted specBase reason
, Pr.baseBranch = realBase
} <- pr
, specBase /= realBase && pr `Pr.approvedAfter` promotedPullRequest
= pr { Pr.integrationStatus = Conflicted realBase reason
, Pr.needsFeedback = True
}
| otherwise
= pr

-- | When a pull request has been promoted to master this means that any build
-- failures build on top of it are not speculative anymore: they are real build
-- failures on top of the (new) master.
--
-- This function simply sets them to be sent feedback again this time the build
-- failure will be reported as a real definitive failure.
unspeculateFailuresAfter :: PullRequest -> PullRequest -> PullRequest
unspeculateFailuresAfter promotedPullRequest pr
| Pr.PullRequest{Pr.integrationStatus = Integrated _ (BuildFailed _)} <- pr
, pr `Pr.approvedAfter` promotedPullRequest
= pr{Pr.needsFeedback = True}
| otherwise
= pr

-- Keep doing a proceed step until the state doesn't change any more. For this
-- to work properly, it is essential that "proceed" does not have any side
-- effects if it does not change the state.
Expand All @@ -749,8 +827,14 @@ describeStatus prId pr state = case Pr.classifyPullRequest pr of
0 -> format "Pull request approved for {} by @{}, rebasing now." [approvalCommand, approvedBy]
1 -> format "Pull request approved for {} by @{}, waiting for rebase behind one pull request." [approvalCommand, approvedBy]
n -> format "Pull request approved for {} by @{}, waiting for rebase behind {} pull requests." (approvalCommand, approvedBy, n)
PrStatusBuildPending -> let Sha sha = fromJust $ getIntegrationSha pr
in Text.concat ["Rebased as ", sha, ", waiting for CI …"]
PrStatusBuildPending -> let Sha sha = fromJust $ Pr.integrationSha pr
train = takeWhile (/= prId) $ Pr.unfailingIntegratedPullRequests state
in case train of
[] -> Text.concat ["Rebased as ", sha, ", waiting for CI …"]
(_:_) -> Text.concat [ "Speculatively rebased as ", sha
, " behind ", prettyPullRequestIds train
, ", waiting for CI …"
]
PrStatusBuildStarted url -> Text.concat ["[CI job](", url, ") started."]
PrStatusIntegrated -> "The build succeeded."
PrStatusIncorrectBaseBranch -> "Merge rejected: the target branch must be the integration branch."
Expand All @@ -769,16 +853,19 @@ describeStatus prId pr state = case Pr.classifyPullRequest pr of
, " "
, prBranchName
]
PrStatusFailedBuild url -> case url of
Just url' -> format "The build failed: {}\nIf this is the result of a flaky test, close and reopen the PR, then tag me again.\nOtherwise, push a new commit and tag me again." [url']
-- This should probably never happen
Nothing -> "The build failed, but GitHub did not provide an URL to the build failure."
where
getIntegrationSha :: PullRequest -> Maybe Sha
getIntegrationSha pullRequest =
case Pr.integrationStatus pullRequest of
Integrated sha _ -> Just sha
_ -> Nothing
PrStatusSpeculativeConflict -> "Failed to speculatively rebase. \
\ I will retry rebasing automatically when the queue clears."
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
PrStatusFailedBuild url -> case Pr.unfailingIntegratedPullRequestsBefore pr state of
[] -> case url of
Just url' -> format "The build failed: {}\n\
\If this is the result of a flaky test, \
\close and reopen the PR, then tag me again.\n\
\Otherwise, push a new commit and tag me again." [url']
-- This should probably never happen
Nothing -> "The build failed, but GitHub did not provide an URL to the build failure."
trainBefore -> format "Speculative build failed. \
\ I will automatically retry after {} build results."
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
[prettyPullRequestIds trainBefore]

-- Leave a comment with the feedback from 'describeStatus' and set the
-- 'needsFeedback' flag to 'False'.
Expand Down Expand Up @@ -829,3 +916,34 @@ pullRequestIdToText (PullRequestId prid) = Text.pack $ show prid

testBranch :: ProjectConfiguration -> PullRequestId -> Git.Branch
testBranch config pullRequestId = Git.Branch $ Config.testBranch config <> "/" <> pullRequestIdToText pullRequestId

-- | Textual rendering of a list of 'PullRequestId's
--
-- >>> prettyPullRequestIds [PullRequestId 12, PullRequestId 60, PullRequestId 1337]
-- "#12, #60 and #1337"
rudymatela marked this conversation as resolved.
Show resolved Hide resolved
prettyPullRequestIds :: [PullRequestId] -> Text
prettyPullRequestIds = commaAnd . map prettyPullRequestId
where
prettyPullRequestId (PullRequestId n) = "#" <> Text.pack (show n)

-- | Pretty printing of a list of Text with comma and and.
--
-- >>> commaAnd ["a", "b", "c" :: Text]
-- "a, b and c"
commaAnd :: [Text] -> Text
commaAnd [] = "none"
commaAnd ss = case init ss of
[] -> last ss
is -> Text.intercalate ", " is <> " and " <> last ss
rudymatela marked this conversation as resolved.
Show resolved Hide resolved

-- | Fold a list of unary functions by composition
--
-- Writing
--
-- > compose [f,g,h]
--
-- translates to
--
-- > f . g . h
compose :: [a -> a] -> a -> a
compose = foldr (.) id
Loading