-
Notifications
You must be signed in to change notification settings - Fork 1
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
Do not drop M-failed-staging-checks prematurely #46
base: master
Are you sure you want to change the base?
Changes from 8 commits
cbf5c91
b38e37f
94aeaf0
f7fcaa6
e380e54
cde4f45
9ec7908
051820a
3178095
bfc4d47
d11c29a
8c0aa95
b813cd9
fe1de92
7bf03bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -484,13 +484,24 @@ class PullRequest { | |
// GitHub statuses of the staged commit | ||
this._stagedStatuses = null; | ||
|
||
// GitHub statuses of an abandoned staged commit, if that commit is | ||
// equivalent to a staged commit that would have been created right now. | ||
// _stagedStatuses and _abandonedStagedStatuses are mutually exclusive | ||
this._abandonedStagedStatuses = null; | ||
|
||
// GitHub statuses of the PR branch head commit | ||
this._prStatuses = null; | ||
|
||
this._updated = false; // _update() has been called | ||
|
||
// truthy value contains a reason for disabling _pushLabelsToGitHub() | ||
this._labelPushBan = false; | ||
|
||
// Whether the PR has a relevant staged commit that failed its checks. | ||
// The relevant commit can be a fresh staged commit, a merged commit (for | ||
// still-open PRs) or an abandoned staged commit, if it is equivalent | ||
// to a staged commit that would have been created right now. | ||
this._stagingFailed = false; | ||
} | ||
|
||
// this PR will need to be reprocessed in this many milliseconds | ||
|
@@ -637,8 +648,10 @@ class PullRequest { | |
} | ||
|
||
// returns filled StatusChecks object | ||
async _getStagingStatuses() { | ||
const combinedStagingStatuses = await GH.getStatuses(this._stagedSha()); | ||
// recalculates this._stagingFailed value | ||
async _getStagingStatuses(stagedSha) { | ||
assert(stagedSha); | ||
const combinedStagingStatuses = await GH.getStatuses(stagedSha); | ||
const genuineStatuses = combinedStagingStatuses.statuses.filter(st => !st.description.endsWith(Config.copiedDescriptionSuffix())); | ||
assert(genuineStatuses.length <= Config.stagingChecks()); | ||
let statusChecks = new StatusChecks(Config.stagingChecks(), "Staging"); | ||
|
@@ -724,8 +737,10 @@ class PullRequest { | |
// already checked in _checkForHumanLabels() | ||
assert(!this._labels.has(Config.failedStagingOtherLabel())); | ||
|
||
if (this._labels.has(Config.failedStagingChecksLabel())) | ||
throw this._exObviousFailure("staged commit tests failed"); | ||
// Do not vainly recreate staged commit which will definitely fail again, | ||
// since the PR+base code is yet unchanged and the existing errors still persist | ||
if (this._abandonedStagedStatuses && this._abandonedStagedStatuses.failed()) | ||
throw this._exLabeledFailure("staged commit tests failed", Config.failedStagingChecksLabel()); | ||
|
||
if (this._wipPr()) | ||
throw this._exObviousFailure("work-in-progress"); | ||
|
@@ -954,12 +969,50 @@ class PullRequest { | |
return false; | ||
} | ||
|
||
async _getMergeCommit() { | ||
const mergeSha = await GH.getReference("pull/" + this._prNumber() + "/merge"); | ||
return await GH.getCommit(mergeSha); | ||
} | ||
|
||
// obtains abandoned staging checks | ||
async _getAbandonedStagingStatuses() { | ||
assert(!this._stagedSha()); | ||
rousskov marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
rousskov marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const allEvents = await GH.getIssueEvents(this._prNumber()); | ||
// staging events are PR events where the bot user created a commit referencing this PR | ||
let stagingEvents = allEvents.filter(ev => ev.event === "referenced" && ev.actor.login === Config.githubUserLogin()); | ||
if (!stagingEvents.length) | ||
return; | ||
|
||
// just in case: events should be already in chronological order already | ||
stagingEvents = stagingEvents.sort((ev1, ev2) => Date.parse(ev1.created_at) - Date.parse(ev2.created_at)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm.. If GitHub guarantees chronological order and created_at dates have a one second resolution, then we may be reordering same-second events by sorting them. Perhaps we should assert that the events returned by GitHub are sorted instead of reordering them? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That is how it works, but I could not find this guarantee in the documentation.
Do you think that sorting of equal items reorders them? I would not expect changes in case... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In many cases, it does. The question is whether this particular sort() is stable.
... starting with ES10. We are relying on ES6 support IIRC, but it is possible that the specific nodejs implementation we are using today is compliant with that specific ES10 requirement. Unfortunately, https://node.green/ does not detail/check this part AFAICT -- it only checks whether Array.sort() is available. An SO comment says "[stable sort] was implemented first in Node 11. sort() is not stable in Node 10 or below". What nodejs version are we using? Can we assert() somehow that it's sort() is stable or that it is compliant with ES10? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Our nodejs version is 10.19. It seems that sort stability starts from 11 version as you noted. We can update to 11 (and assert this requirement), but I could not find how to check sort() stability independently (or its compliancy with ES10). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest asserting that the events are sorted instead of sorting them, with a comment explaining that we should not sort because sort() is unstable until ES10. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done (7bf03bc). |
||
const lastStagingEvent = stagingEvents[stagingEvents.length - 1]; | ||
const mergeCommit = await this._getMergeCommit(); | ||
const mergeCommitCreatedAt = Date.parse(mergeCommit.author.date); // for merge commits author.date and committer.date should be the same | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is not clear/intuitive that author.date and committer.date should be the same for merge commits. We know for a fact that commits merged by Anubis have different author.date and committer.date. I understand that merge commits created by GitHub are different from merged commits created by Anubis, but it is not clear to me why we expect those two dates to be the same for merge commits. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Right - Anubis takes 'author' from the merge commit (i.e., author.date - when the merge commit was created), and committer date is the date when Anubis creates the staged commit (i.e., the current time).
I cannot refer to official documentation - I just checked this fact in few merge commits. However, is there any reason why they should different? For example, these two dates may differ after some action, performed on an existing commit, but merge commits are not subject of such actions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Just my personal (and possibly wrong) interpretation of what "merge commit" is. To me, that commit brings PR changes (authored some time ago) into the target repository branch. Thus, I would expect the author date to reflect something from the PR past rather than current time. However, I would not be surprised if somebody who knows a lot more about the mechanics of these merge commits would explain why the dates must be the same. It does not seem natural/intuitive to me, but sometimes the best approach is not natural or intuitive, especially from an outsider point of view. To avoid misunderstanding, the problem here is not what GitHub (currently) does.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't think that there is some special magic (or hidden mechanics) in this merge commits - except the fact that they are automatically generated by GitHub. For example, I see nothing special in the commit data (fetched via an API call) except the fact that it has two parent commits. If we create a new (ordinary) branch commit - we expect that author.date=committer.date, right? We create a new commit from scratch - as an atomic operation and both dates ought to be identical. Merge commit is created by GitHub, but the same reasoning should be applicable there. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When I create an ordinary commit, the dates are naturally the same because no good/reliable information is available about the changes I authored. Git could look at file timestamps instead of using current time, I guess. That would be natural too, but probably not worth the hassle in the default/common use case. When I create a merge commit, then my expectations would depend on the type of merge:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
FWIW, when I cherry-pick a commit, the dates differ:
To me, a merge commit is more similar to a cherry-picked commit than to an ordinary branch commit. |
||
const lastStagingEventCreatedAt = Date.parse(lastStagingEvent.created_at); | ||
assert(mergeCommitCreatedAt); | ||
rousskov marked this conversation as resolved.
Show resolved
Hide resolved
|
||
assert(lastStagingEventCreatedAt); | ||
// the staging event for merge commit X is always created after X so we do not | ||
// need to deal with the lastStagingEventCreatedAt == mergeCommitCreatedAt case | ||
assert(lastStagingEventCreatedAt != mergeCommitCreatedAt); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. GitHub event times have a second resolution. Could not the second-resolution timestamps be the same even though the two events happened at "slightly" different times, leading to an assertion here? P.S. Do we need to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That is possible, I think, because merge commits and staged commits are created independently by GitHub and Anubis. |
||
if (lastStagingEventCreatedAt < mergeCommitCreatedAt) | ||
return; // merge commit created after the last staging event | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is that a problem? In other words, we cannot we just use the lastStagingEvent even if there was a more recent merged commit? After all, we are not looking for a fresh/current staged commit here but an "abandoned" one... Why do we (implicitly) define "abandoned" this way? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We look for an abandoned staged commit (i.e., its statuses) only if there is no newer merge commit - that is how our abandonedStagedStatuses defined. A newer merge commit means that we do not need the abandonedStagedStatuses check at all in _checkStagingPreconditions() - there should be no livelocking scenario (which we try to avoid) in this case. However, the current approach with dates comparison is flawed, IMO, because staged commit uses merge commit data, but the merge commit itself can be already stale by that time - GitHub is free to create a new one anytime the base or issue branch changes. In other words, theoretically there can be a situation when lastStagingEventCreatedAt > mergeCommitCreatedAt, but merge commit is fresher that the staged commit. I think we need another approach here - the one that we already use in _stagedCommitIsFresh(), comparing tree SHAs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aha, so the answer to my question is: We cannot just always use the lastStagingEvent because we treat non-nil _abandonedStagedStatuses as a sign that "PR+base code is yet unchanged". Given that sign, we avoid restaging a doomed-to-fail PR. In a sense, "abandoned" implies "still fresh" here. I understand why we ended up here. "Abandoned" comes from the state of the "auto" branch that depends on other PRs. The abandoned staged commit in PR A was abandoned by Anubis when Anubis switched to stage PR B. From PR A point of view (which is how I read this PR-specific code), that commit represents the latest PR and target branch state. If we rebase I do not like negative names, so I am not suggesting to replace "abandoned" with "unstaged". We should document (proably where we declare_abandonedStagedStatuses) that "abandoned" means "removed from the staging branch or unstanged".
I am looking forward to moving away from dates. AFAICT, you want to define abandoned staged commit as a commit that would have been a fresh staged commit if the auto branch were to be modified to point at it. I would go a step further: // prevent staging if it is likely to fail again
async _checkStagingInVain()
{
// TODO: How to force Anubis to stage after fixing buggy _tests_ (assuming the
// fixed tests cannot be rerun to update the last staged PR commit statuses)?
const abandonedStagedCommit = await this._findAbandonedStagedCommit();
if (!abandonedStagedCommit)
return; // we have not staged this PR (in recent memory)
if (!isRestagingCandidate(abandonedStagedCommit))
return; // something has changed
// note that any unfinished tests are acceptable
const abandonedStagedCommitStatuses = await this._getStagingStatuses(abandonedStagedCommit's commit SHA);
if (abandonedStagedCommitStatuses.failed())
throw this._exLabeledFailure("restaging candidate failed its tests", Config.failedStagingChecksLabel());
// Expecting another successful staging attempt.
// Hopefully, the new staged commit will not be unstaged this time.
// TODO: Support restaging instead of creating a new staged commit (when possible).
return;
}
The above plan does not include any restaging code -- we do not have to add that feature in this PR. isRestagingCandidate() only checks whether restaging a previously staged commit is possible from git point of view (e.g., the abandoned staged commit is still a parent of the target branch) and would preserve all Anubis invariants (e.g., the PR title/description still match the commit message). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This field serves 2 purposes:
So we cannot simply drop this field - at least we need to store some indication (flag) instead (and check it in stagingFailed()). Also we need to set this field early (i.e., before _checkStagingPreconditions() from where your _checkStagingInVain() should be called) because we need to re-apply M-failed-staging-checks regardless of any other failing checks in _checkStagingPreconditions() coming prior to our call. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Understood. I suspected something like that -- that is why I added "(if possible)". If a simple flag can be stored instead, I would try doing that to avoid the complexity of indicating what "abandonedStagedCommitStatuses" (and their failure) really mean in other contexts. Clearly, the very concept of abandonment is rather convoluted here! The function can still compute abandonedStagedCommitStatuses locally as needed, similar to my sketch, but only store a simpler well-defined flag (based on abandonedStagedCommitStatuses.failed() and other function-local decisions). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I followed your refactoring sketch at fe1de92. |
||
|
||
// Merge commit created before the last staging event. | ||
// Thus, we can get statuses of that event commit SHA. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we say "we can use statuses..." instead of "we can get statuses..."? We can get statuses of any tested commit, right? We just do not want use the old/irrelevant statuses, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right that we can get statuses of any commit - as far as we have its SHA. Probably we can drop this comment at all - especially if we start comparing tree SHAs, as I suggested above. |
||
this._abandonedStagedStatuses = await this._getStagingStatuses(lastStagingEvent.commit_id); | ||
// TODO: If something made this (previously failed) commit succeed, then | ||
// we should use it further, if possible. | ||
} | ||
|
||
async _loadPrState() { | ||
if (!this._stagedSha()) { | ||
if (await this._mergedSomeTimeAgo()) | ||
await this._getAbandonedStagingStatuses(); | ||
if (await this._mergedSomeTimeAgo()) { | ||
this._enterMerged(); | ||
else | ||
await this._enterBrewing(); | ||
return; | ||
} | ||
await this._enterBrewing(); | ||
return; | ||
} | ||
|
||
|
@@ -979,11 +1032,10 @@ class PullRequest { | |
|
||
assert(this._stagedPosition.ahead()); | ||
|
||
const stagedStatuses = await this._getStagingStatuses(); | ||
// Do not vainly recreate staged commit which will definitely fail again, | ||
// since the PR+base code is yet unchanged and the existing errors still persist | ||
const stagedStatuses = await this._getStagingStatuses(this._stagedSha()); | ||
// if staging failed, enter the "brewing (with failed staging tests)" state | ||
if (stagedStatuses.failed()) { | ||
this._labels.add(Config.failedStagingChecksLabel()); | ||
this._abandonedStagedStatuses = stagedStatuses; | ||
await this._enterBrewing(); | ||
return; | ||
} | ||
|
@@ -1003,7 +1055,8 @@ class PullRequest { | |
if (stagedStatuses) | ||
this._stagedStatuses = stagedStatuses; | ||
else | ||
this._stagedStatuses = await this._getStagingStatuses(); | ||
this._stagedStatuses = await this._getStagingStatuses(this._stagedSha()); | ||
this._abandonedStagedStatuses = null; | ||
this._prStatuses = await this._getPrStatuses(); | ||
} | ||
|
||
|
@@ -1184,8 +1237,7 @@ class PullRequest { | |
|
||
async _createStaged() { | ||
const baseSha = await GH.getReference(this._prBaseBranchPath()); | ||
const mergeSha = await GH.getReference("pull/" + this._prNumber() + "/merge"); | ||
const mergeCommit = await GH.getCommit(mergeSha); | ||
const mergeCommit = await this._getMergeCommit(); | ||
if (!Config.githubUserName()) | ||
await this._acquireUserProperties(); | ||
let now = new Date(); | ||
|
@@ -1270,6 +1322,19 @@ class PullRequest { | |
await this._finalize(); | ||
} | ||
|
||
// Whether the PR has a relevant staged commit that failed its checks. | ||
// The relevant commit can be a fresh staged commit, a merged commit (for | ||
// still-open PRs) or an abandoned staged commit (if it is equivalent | ||
// to a staged commit that would have been created right now) | ||
stagingFailed() { | ||
assert(!(this._abandonedStagedStatuses && this._stagedStatuses)); | ||
if (this._abandonedStagedStatuses) | ||
return this._abandonedStagedStatuses.failed(); | ||
else if (this._stagedStatuses) | ||
return this._stagedStatuses.failed(); | ||
return false; | ||
} | ||
|
||
// Updates and, if possible, advances PR towards (and including) merging. | ||
// If reprocessing is needed in X milliseconds, returns X. | ||
// Otherwise, returns null. | ||
|
@@ -1290,11 +1355,17 @@ class PullRequest { | |
|
||
let result = new ProcessResult(); | ||
|
||
if (!this._labels) | ||
this._labels = new Labels([], this._prNumber()); | ||
|
||
if (this._prState && this._prState.staged() && suspended) | ||
result.setPrStaged(true); | ||
else | ||
this._removePositiveStagingLabels(); | ||
|
||
if (this.stagingFailed()) | ||
this._labels.add(Config.failedStagingChecksLabel()); // may be set already in the exception | ||
|
||
if (knownProblem) { | ||
result.setDelayMsIfAny(this._delayMs()); | ||
return result; | ||
|
@@ -1303,8 +1374,6 @@ class PullRequest { | |
// report this unknown but probably PR-specific problem on GitHub | ||
// XXX: We may keep redoing this PR every run() step forever, without any GitHub events. | ||
// TODO: Process Config.failedOtherLabel() PRs last and ignore their failures. | ||
if (!this._labels) | ||
this._labels = new Labels([], this._prNumber()); | ||
|
||
if (this._stagedSha()) { // the PR is staged now or was staged some time ago | ||
// avoid livelocking | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The above will need to be adjusted to use rateLimitedPromise(), right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I'll fix this.