-
Notifications
You must be signed in to change notification settings - Fork 1
AR-273: advance factory issues after PR merge #361
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -197,6 +197,7 @@ export class FactoryLoop implements Factory { | |
| readonly #dispatchAttempts = new Map<string, DispatchAttemptState>() | ||
| readonly #canonicalIssueStates = new Map<string, string>() | ||
| readonly #dispatchFailureReaperHandoffs = new Map<string, RegistryHandoffAgent>() | ||
| readonly #postMergeDoneAdvances = new Set<string>() | ||
| #slackDegraded = false | ||
| #slackDegradedReason: string | undefined | ||
| #slackWritebackFailureDegraded = false | ||
|
|
@@ -322,6 +323,10 @@ export class FactoryLoop implements Factory { | |
|
|
||
| await this.#backfillReadyIssues() | ||
| this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => { | ||
| if (isGithubPullFilePath(event.resource.path)) { | ||
| void this.#handlePrChange(event.resource.path) | ||
| return | ||
| } | ||
| if (isGithubIssueFilePath(event.resource.path)) { | ||
| void this.#handleGithubIssueChange(event.resource.path, { dryRun: this.#config.dryRun }) | ||
| return | ||
|
|
@@ -662,9 +667,7 @@ export class FactoryLoop implements Factory { | |
|
|
||
| #prepareLiveEventForDrain(event: ChangeEvent, seenIssueKeys: Set<string>): string | undefined { | ||
| const path = event.resource.path | ||
| // PR change events only matter when the babysitter is enabled; ignore them | ||
| // otherwise so the legacy completion path is untouched. | ||
| const isPullPath = this.#config.babysitter.enabled && isGithubPullFilePath(path) | ||
| const isPullPath = isGithubPullFilePath(path) | ||
| if (!isIssueFilePath(path) && !isGithubIssueFilePath(path) && !isPullPath) { | ||
| return undefined | ||
| } | ||
|
|
@@ -765,7 +768,7 @@ export class FactoryLoop implements Factory { | |
| } | ||
|
|
||
| async #handlePreparedLiveChange(path: string): Promise<void> { | ||
| if (this.#config.babysitter.enabled && isGithubPullFilePath(path)) { | ||
| if (isGithubPullFilePath(path)) { | ||
| await this.#handlePrChange(path) | ||
| return | ||
| } | ||
|
|
@@ -1516,7 +1519,9 @@ export class FactoryLoop implements Factory { | |
|
|
||
| #recordCanonicalIssueState(issue: Pick<LinearIssue, 'key' | 'stateId'>): void { | ||
| const previousStateId = this.#canonicalIssueStates.get(issue.key) | ||
| if (previousStateId === this.#config.stateIds.done && issue.stateId === this.#config.stateIds.readyForAgent) { | ||
| const reopenedFromTerminal = previousStateId === this.#config.stateIds.done || | ||
| previousStateId === this.#config.stateIds.humanReview | ||
| if (reopenedFromTerminal && issue.stateId === this.#config.stateIds.readyForAgent) { | ||
| const dispatchState = this.#dispatchAttempts.get(issue.key) | ||
| if (dispatchState?.terminal) { | ||
| dispatchState.attempts = 0 | ||
|
|
@@ -2367,13 +2372,21 @@ export class FactoryLoop implements Factory { | |
| const headRef = snapshot.headRef ?? '' | ||
| const record = this.#batch.inFlight.find((candidate) => | ||
| !candidate.dryRun && headRef && containsIssueKey(headRef, candidate.issue.key)) | ||
|
|
||
| if (prMetaShowsMerged(snapshot)) { | ||
| await this.#advanceMergedPrToDone(snapshot, record) | ||
| return | ||
| } | ||
|
|
||
| if (!this.#config.babysitter.enabled) { | ||
| return | ||
| } | ||
|
|
||
| if (!record) { | ||
| return | ||
| } | ||
|
|
||
| if (snapshot.state && snapshot.state.trim().toUpperCase() !== 'OPEN') { | ||
| // Closed/merged PR — completion of Human Review -> Done stays with the | ||
| // merge gate / the operator; nothing for the babysitter to spawn. | ||
| return | ||
| } | ||
| if (snapshot.draft) { | ||
|
|
@@ -2384,6 +2397,77 @@ export class FactoryLoop implements Factory { | |
| await this.#ensureBabysitter(record, { repo: `${parts.owner}/${parts.repo}`, prNumber: snapshot.number, url: snapshot.url, path }) | ||
| } | ||
|
|
||
| async #advanceMergedPrToDone(snapshot: PullSnapshot, record?: InFlightIssue): Promise<void> { | ||
| if (record) { | ||
| await this.#completeIssue(record, { targetState: 'done', runMergeGate: false }) | ||
| return | ||
| } | ||
|
|
||
| const issue = await this.#findMergeAdvanceIssueForPr(snapshot) | ||
| if (!issue) { | ||
| this.#increment('mergedPrAdvanceNoIssue') | ||
| return | ||
| } | ||
| const advanceKey = `${issue.key}:${snapshot.number}` | ||
| if (this.#postMergeDoneAdvances.has(advanceKey)) { | ||
| this.#increment('mergedPrAdvanceDuplicatesSuppressed') | ||
| return | ||
| } | ||
| this.#postMergeDoneAdvances.add(advanceKey) | ||
|
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. The this.#postMergeDoneAdvances.add(advanceKey)
if (this.#postMergeDoneAdvances.size > 5000) {
const oldest = this.#postMergeDoneAdvances.values().next().value
if (oldest !== undefined) {
this.#postMergeDoneAdvances.delete(oldest)
}
} |
||
|
|
||
| try { | ||
| await this.#linear.setState(issue, this.#config.stateIds.done) | ||
|
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 a merged PR's branch name does not contain the issue key but its title/body does, Useful? React with 👍 / 👎. |
||
| this.#recordCanonicalIssueState({ key: issue.key, stateId: this.#config.stateIds.done }) | ||
| this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path }) | ||
| this.#increment('mergedPrAdvancedDone') | ||
| this.#increment('done') | ||
| this.#logger.info?.('[factory] merged PR advanced issue to Done', { | ||
| issue: issue.key, | ||
| prNumber: snapshot.number, | ||
| url: snapshot.url, | ||
| }) | ||
|
|
||
| if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) { | ||
| try { | ||
| const root = await this.#slack.postThread({ | ||
| channel: this.#config.slack.channel, | ||
| text: `${issue.key}: PR merged; Linear state set to done.`, | ||
| }) | ||
| await this.#slack.reply(root.threadId, `${issue.key}: Linear state set to done.`) | ||
| this.#recordSlackWritebackSuccess('merge-done-thread') | ||
| } catch (error) { | ||
| this.#markSlackWritebackFailure('merge-done-thread', error) | ||
| } | ||
| } | ||
| } catch (error) { | ||
| this.#postMergeDoneAdvances.delete(advanceKey) | ||
| this.#error(error, issueRef(issue)) | ||
| } | ||
| } | ||
|
|
||
| async #findMergeAdvanceIssueForPr(snapshot: PullSnapshot): Promise<LinearIssue | undefined> { | ||
| const upstreamStates = new Set([ | ||
| this.#config.stateIds.agentImplementing, | ||
| this.#config.stateIds.humanReview, | ||
| ].filter((stateId): stateId is string => Boolean(stateId))) | ||
| for (const path of await this.#mount.listTree(ISSUE_ROOT)) { | ||
| if (!isIssueFilePath(path)) { | ||
| continue | ||
| } | ||
| const issue = await this.#readIssue(path) | ||
| if (!issue || !upstreamStates.has(issue.stateId)) { | ||
| continue | ||
| } | ||
| if (!isRealLinearIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) { | ||
| continue | ||
| } | ||
| if (prSnapshotReferencesIssue(snapshot, issue.key)) { | ||
|
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.
Because the live/default subscription watches all Useful? React with 👍 / 👎. |
||
| return issue | ||
| } | ||
| } | ||
|
Comment on lines
+2453
to
+2467
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. Currently, Since for (const path of await this.#mount.listTree(ISSUE_ROOT)) {
if (!isIssueFilePath(path)) {
continue
}
const issueKey = keyFromPath(path)
if (!prSnapshotReferencesIssue(snapshot, issueKey)) {
continue
}
const issue = await this.#readIssue(path)
if (!issue || !upstreamStates.has(issue.stateId)) {
continue
}
if (!isRealLinearIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
continue
}
return issue
} |
||
| return undefined | ||
| } | ||
|
|
||
| // Safety net for a missed PR-open mount event: resolve the PR via the existing | ||
| // probe resolver and spawn the babysitter. Triggered by an implementer exiting | ||
| // after opening its PR (an event, not a poll). | ||
|
|
@@ -2484,6 +2568,10 @@ export class FactoryLoop implements Factory { | |
| if (snapshot) { | ||
| const guard = prMetaAllowsHumanReview(snapshot) | ||
| if (!guard.ok) { | ||
| if (prMetaShowsMerged(snapshot)) { | ||
| await this.#advanceMergedPrToDone(snapshot, record) | ||
| return | ||
| } | ||
| this.#increment('babysitterReadinessGuardBlocked') | ||
| this.#logger.info?.('[factory] babysitter ready signal ignored; PR meta not eligible', { | ||
| issue: record.issue.key, | ||
|
|
@@ -2546,7 +2634,7 @@ export class FactoryLoop implements Factory { | |
| return found | ||
| } | ||
|
|
||
| async #completeIssue(record: InFlightIssue, opts: { humanReview?: boolean } = {}): Promise<void> { | ||
| async #completeIssue(record: InFlightIssue, opts: { humanReview?: boolean; targetState?: 'configured' | 'done'; runMergeGate?: boolean } = {}): Promise<void> { | ||
| const completionKey = issueKey(record.issue) | ||
| if (this.#completionInFlight.has(completionKey)) { | ||
| return | ||
|
|
@@ -2556,7 +2644,8 @@ export class FactoryLoop implements Factory { | |
| // state AND configured its UUID; otherwise fall back to `done` (the legacy | ||
| // terminal) so an operator who sets terminalState: 'done' keeps it and the | ||
| // issue never gets stuck waiting on an unconfigured state. | ||
| const humanReview = opts.humanReview === true && | ||
| const humanReview = opts.targetState !== 'done' && | ||
| opts.humanReview === true && | ||
| this.#config.terminalState === 'human-review' && | ||
| Boolean(this.#config.stateIds.humanReview) | ||
| const targetState = humanReview ? this.#config.stateIds.humanReview! : this.#config.stateIds.done | ||
|
|
@@ -2584,7 +2673,7 @@ export class FactoryLoop implements Factory { | |
| // Only auto-merge on the `done` terminal path. Human Review parks the PR | ||
| // for an operator — the merge gate (which requires an APPROVED review) | ||
| // would refuse anyway, and we must not merge before the human has looked. | ||
| if (issue && !humanReview) { | ||
| if (issue && !humanReview && opts.runMergeGate !== false) { | ||
| await this.#runCompletionMergeGate(issue) | ||
| } | ||
|
|
||
|
|
@@ -3671,7 +3760,7 @@ const githubPullPathParts = (path: string): { owner: string; repo: string; numbe | |
| const isGithubPullFilePath = (path: string): boolean => | ||
| githubPullPathParts(path) !== undefined | ||
|
|
||
| type PullSnapshot = { number: number; state?: string; headRef?: string; draft?: boolean; url?: string; title?: string; merged?: boolean } | ||
| type PullSnapshot = { number: number; state?: string; headRef?: string; draft?: boolean; url?: string; title?: string; body?: string; merged?: boolean } | ||
|
|
||
| const parsePullSnapshot = (content: unknown, fallbackNumber: number): PullSnapshot | undefined => { | ||
| const payload = wrappedPayload(content) | ||
|
|
@@ -3684,10 +3773,20 @@ const parsePullSnapshot = (content: unknown, fallbackNumber: number): PullSnapsh | |
| draft: booleanValue(payload.isDraft) ?? booleanValue(payload.draft), | ||
| url: stringValue(payload.url) ?? stringValue(payload.html_url), | ||
| title: stringValue(payload.title), | ||
| body: stringValue(payload.body), | ||
| merged: booleanValue(payload.merged), | ||
| } | ||
| } | ||
|
|
||
| const prSnapshotReferencesIssue = (snapshot: PullSnapshot, issueKey: string): boolean => | ||
| containsIssueKey(snapshot.headRef ?? '', issueKey) || | ||
| containsIssueKey(snapshot.title ?? '', issueKey) || | ||
| containsExplicitIssueReference(snapshot.body ?? '', issueKey) | ||
|
|
||
| const prMetaShowsMerged = (snapshot: PullSnapshot): boolean => | ||
| snapshot.merged === true || | ||
| snapshot.state?.trim().toUpperCase() === 'MERGED' | ||
|
|
||
| // Guard applied to the babysitter's ready signal: the PR's own webhook-fed meta | ||
| // must still be eligible for human review. A missing `state` is treated as open | ||
| // (older mount layouts omit it). The CI/conflict/review verdict is NOT re-derived | ||
|
|
||
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.
When an issue is ingested for the first time,
previousStateIdisundefined. Ifthis.#config.stateIds.humanReviewis alsoundefined(which is common if the operator has not configured it), the comparisonpreviousStateId === this.#config.stateIds.humanReviewwill evaluate totrue. This incorrectly flags the issue asreopenedFromTerminaland resets the dispatch attempts. Adding a guard to ensurepreviousStateId !== undefinedprevents this bug.