patchrelay 0.38.2 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.38.2",
4
- "commit": "9cecc8b13d85",
5
- "builtAt": "2026-04-11T08:10:12.086Z"
3
+ "version": "0.39.0",
4
+ "commit": "80d285a7e9bd",
5
+ "builtAt": "2026-04-11T20:04:29.313Z"
6
6
  }
@@ -0,0 +1,18 @@
1
+ import { execCommand } from "./utils.js";
2
+ function shellSingleQuote(value) {
3
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
4
+ }
5
+ export function buildGitHubBotCredentialHelper(tokenFile) {
6
+ const quotedTokenFile = shellSingleQuote(tokenFile);
7
+ return `!f() { [ "$1" = get ] || exit 0; echo "username=x-access-token"; echo "password=$(cat ${quotedTokenFile})"; }; f`;
8
+ }
9
+ export async function configureGitHubBotAuthForWorktree(params) {
10
+ const helper = buildGitHubBotCredentialHelper(params.botIdentity.tokenFile);
11
+ const gitArgs = ["-C", params.worktreePath, "config"];
12
+ await execCommand(params.gitBin, [...gitArgs, "user.name", params.botIdentity.name], { timeoutMs: 5_000 });
13
+ await execCommand(params.gitBin, [...gitArgs, "user.email", params.botIdentity.email], { timeoutMs: 5_000 });
14
+ // Clear inherited GitHub-specific helpers such as `gh auth git-credential`
15
+ // so git HTTPS operations use the same bot token as the wrapped `gh` CLI.
16
+ await execCommand(params.gitBin, [...gitArgs, "--replace-all", "credential.https://github.com.helper", ""], { timeoutMs: 5_000 });
17
+ await execCommand(params.gitBin, [...gitArgs, "--add", "credential.https://github.com.helper", helper], { timeoutMs: 5_000 });
18
+ }
@@ -0,0 +1,170 @@
1
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
+ export function deriveLinearProgressFact(notification, issue) {
3
+ switch (notification.method) {
4
+ case "item/completed":
5
+ return deriveProgressFactFromCompletedItem(notification.params.item, issue);
6
+ case "turn/plan/updated":
7
+ return deriveProgressFactFromPlan(notification.params.plan, issue);
8
+ default:
9
+ return undefined;
10
+ }
11
+ }
12
+ function deriveProgressFactFromCompletedItem(rawItem, issue) {
13
+ void issue;
14
+ if (!rawItem || typeof rawItem !== "object") {
15
+ return undefined;
16
+ }
17
+ const item = rawItem;
18
+ if (item.type !== "agentMessage" || typeof item.text !== "string") {
19
+ return undefined;
20
+ }
21
+ const body = compactOperatorSentence(item.text);
22
+ if (!body) {
23
+ return undefined;
24
+ }
25
+ if (looksLikeVerification(body)) {
26
+ return {
27
+ kind: "verification_started",
28
+ meaningKey: `verification:${normalizeMeaningKey(body)}`,
29
+ content: { type: "thought", body },
30
+ };
31
+ }
32
+ if (looksLikePublishing(body)) {
33
+ return {
34
+ kind: "publishing_started",
35
+ meaningKey: `publishing:${normalizeMeaningKey(body)}`,
36
+ content: { type: "thought", body },
37
+ };
38
+ }
39
+ if (looksLikeRootCause(body)) {
40
+ return {
41
+ kind: "root_cause_found",
42
+ meaningKey: `finding:${normalizeMeaningKey(body)}`,
43
+ content: { type: "thought", body },
44
+ };
45
+ }
46
+ return undefined;
47
+ }
48
+ function deriveProgressFactFromPlan(rawPlan, issue) {
49
+ if (!Array.isArray(rawPlan)) {
50
+ return undefined;
51
+ }
52
+ const activeStep = rawPlan
53
+ .map((entry) => normalizePlanEntry(entry))
54
+ .find((entry) => entry && entry.status === "in_progress");
55
+ if (!activeStep) {
56
+ return undefined;
57
+ }
58
+ if (looksLikeVerification(activeStep.step)) {
59
+ return {
60
+ kind: "verification_started",
61
+ meaningKey: `verification:${normalizeMeaningKey(activeStep.step)}`,
62
+ content: {
63
+ type: "action",
64
+ action: "Verifying",
65
+ parameter: summarizePlanStep(activeStep.step, "latest changes before publishing"),
66
+ },
67
+ };
68
+ }
69
+ if (looksLikePublishing(activeStep.step)) {
70
+ const parameter = summarizePlanStep(activeStep.step, issue?.prNumber !== undefined ? `changes to PR #${issue.prNumber}` : "latest changes");
71
+ return {
72
+ kind: "publishing_started",
73
+ meaningKey: `publishing:${normalizeMeaningKey(activeStep.step)}`,
74
+ content: {
75
+ type: "action",
76
+ action: "Publishing",
77
+ parameter,
78
+ },
79
+ };
80
+ }
81
+ return undefined;
82
+ }
83
+ function normalizePlanEntry(rawEntry) {
84
+ if (!rawEntry || typeof rawEntry !== "object") {
85
+ return undefined;
86
+ }
87
+ const entry = rawEntry;
88
+ const rawStep = entry.step;
89
+ if (typeof rawStep !== "string" || !rawStep.trim()) {
90
+ return undefined;
91
+ }
92
+ const rawStatus = typeof entry.status === "string" ? entry.status : "pending";
93
+ return {
94
+ step: rawStep.trim(),
95
+ status: rawStatus === "inProgress" ? "in_progress"
96
+ : rawStatus === "completed" ? "completed"
97
+ : rawStatus === "pending" ? "pending"
98
+ : rawStatus === "in_progress" ? "in_progress"
99
+ : "pending",
100
+ };
101
+ }
102
+ function looksLikeRootCause(text) {
103
+ const normalized = text.toLowerCase();
104
+ return /\b(narrowed|isolated|root cause)\b/.test(normalized)
105
+ || normalized.startsWith("found that ")
106
+ || normalized.startsWith("the failure is isolated")
107
+ || normalized.startsWith("the issue is isolated");
108
+ }
109
+ function looksLikeVerification(text) {
110
+ const normalized = text.toLowerCase();
111
+ return /\b(verifying|verification|targeted verification|smoke)\b/.test(normalized);
112
+ }
113
+ function looksLikePublishing(text) {
114
+ const normalized = text.toLowerCase();
115
+ return /\b(publish|publishing|push|pushing)\b/.test(normalized)
116
+ || normalized.includes("opening pr")
117
+ || normalized.includes("opening the pr")
118
+ || normalized.includes("opening pull request");
119
+ }
120
+ function compactOperatorSentence(text, maxLength = 160) {
121
+ const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
122
+ if (!sanitized) {
123
+ return undefined;
124
+ }
125
+ if (sanitized.length <= maxLength) {
126
+ return sanitized;
127
+ }
128
+ const punctuated = lastBoundaryWithinLimit(sanitized, maxLength, /[.;!?]/g);
129
+ if (punctuated !== undefined) {
130
+ return sanitized.slice(0, punctuated + 1).trim();
131
+ }
132
+ const spaced = sanitized.lastIndexOf(" ", maxLength);
133
+ if (spaced > 0) {
134
+ return `${sanitized.slice(0, spaced).trimEnd()}...`;
135
+ }
136
+ return `${sanitized.slice(0, maxLength).trimEnd()}...`;
137
+ }
138
+ function summarizePlanStep(step, fallback) {
139
+ const sanitized = sanitizeOperatorFacingText(step)?.replace(/\s+/g, " ").trim();
140
+ if (!sanitized) {
141
+ return fallback;
142
+ }
143
+ const stripped = sanitized
144
+ .replace(/^(run|running|start|starting)\s+/i, "")
145
+ .replace(/^(verify|verifying|verification of)\s+/i, "")
146
+ .replace(/^(publish|publishing|push|pushing|open|opening)\s+/i, "")
147
+ .trim()
148
+ .replace(/[.]+$/, "");
149
+ return stripped || fallback;
150
+ }
151
+ function normalizeMeaningKey(text) {
152
+ return text
153
+ .toLowerCase()
154
+ .replace(/\s+/g, " ")
155
+ .trim();
156
+ }
157
+ function lastBoundaryWithinLimit(text, maxLength, pattern) {
158
+ let last = -1;
159
+ for (;;) {
160
+ const match = pattern.exec(text);
161
+ if (!match) {
162
+ break;
163
+ }
164
+ if (match.index >= maxLength) {
165
+ break;
166
+ }
167
+ last = match.index;
168
+ }
169
+ return last >= 0 ? last : undefined;
170
+ }
@@ -1,11 +1,38 @@
1
+ import { deriveLinearProgressFact } from "./linear-progress-facts.js";
1
2
  export class LinearProgressReporter {
2
- constructor(_db, _emitActivity) { }
3
- maybeEmitProgress(_notification, _run) {
4
- // Keep routine Codex progress in local/operator surfaces rather than
5
- // turning every planning or reasoning update into Linear thread chatter.
6
- return;
3
+ db;
4
+ emitActivity;
5
+ publicationsByRun = new Map();
6
+ constructor(db, emitActivity) {
7
+ this.db = db;
8
+ this.emitActivity = emitActivity;
7
9
  }
8
- clearProgress(_runId) {
9
- return;
10
+ maybeEmitProgress(notification, run) {
11
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
12
+ if (!issue) {
13
+ return;
14
+ }
15
+ const fact = deriveLinearProgressFact(notification, issue);
16
+ if (!fact) {
17
+ return;
18
+ }
19
+ const previous = this.publicationsByRun.get(run.id);
20
+ if (previous?.meaningKey === fact.meaningKey) {
21
+ return;
22
+ }
23
+ const publication = {
24
+ meaningKey: fact.meaningKey,
25
+ publishedAtMs: Date.now(),
26
+ };
27
+ this.publicationsByRun.set(run.id, publication);
28
+ void this.emitActivity(issue, fact.content, { ephemeral: true }).catch(() => {
29
+ const current = this.publicationsByRun.get(run.id);
30
+ if (current?.publishedAtMs === publication.publishedAtMs && current.meaningKey === publication.meaningKey) {
31
+ this.publicationsByRun.delete(run.id);
32
+ }
33
+ });
34
+ }
35
+ clearProgress(runId) {
36
+ this.publicationsByRun.delete(runId);
10
37
  }
11
38
  }
@@ -1,22 +1,24 @@
1
- import { resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
1
+ import { resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
2
+ import { isCompletedLinearState } from "./pr-state.js";
3
+ import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
2
4
  export async function syncActiveWorkflowState(params) {
3
5
  const { db, issue, linear, trackedIssue, options } = params;
4
- if (!shouldAutoAdvanceLinearState(issue)) {
5
- return;
6
- }
7
6
  const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
8
7
  if (!liveIssue)
9
8
  return;
9
+ const latestRun = db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
10
+ if (hasTrustedNoPrCompletion(issue, latestRun)) {
11
+ await syncCompletedLinearState({ db, issue, linear, liveIssue });
12
+ return;
13
+ }
14
+ if (!shouldAutoAdvanceLinearState(issue)) {
15
+ return;
16
+ }
10
17
  if (!shouldAutoAdvanceLinearState({
11
18
  currentLinearState: liveIssue.stateName,
12
19
  currentLinearStateType: liveIssue.stateType,
13
20
  })) {
14
- db.issues.upsertIssue({
15
- projectId: issue.projectId,
16
- linearIssueId: issue.linearIssueId,
17
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
18
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
19
- });
21
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
20
22
  return;
21
23
  }
22
24
  const targetState = resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue);
@@ -24,20 +26,37 @@ export async function syncActiveWorkflowState(params) {
24
26
  return;
25
27
  const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
26
28
  if (normalizedCurrent === targetState.trim().toLowerCase()) {
27
- db.issues.upsertIssue({
28
- projectId: issue.projectId,
29
- linearIssueId: issue.linearIssueId,
30
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
31
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
32
- });
29
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
33
30
  return;
34
31
  }
35
32
  const updated = await linear.setIssueState(issue.linearIssueId, targetState);
33
+ refreshCachedLinearState(db, issue, updated.stateName, updated.stateType);
34
+ }
35
+ async function syncCompletedLinearState(params) {
36
+ const { db, issue, linear, liveIssue } = params;
37
+ if (isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
38
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
39
+ return;
40
+ }
41
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
42
+ if (!targetState) {
43
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
44
+ return;
45
+ }
46
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
47
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
48
+ refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
49
+ return;
50
+ }
51
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
52
+ refreshCachedLinearState(db, issue, updated.stateName, updated.stateType);
53
+ }
54
+ function refreshCachedLinearState(db, issue, stateName, stateType) {
36
55
  db.issues.upsertIssue({
37
56
  projectId: issue.projectId,
38
57
  linearIssueId: issue.linearIssueId,
39
- ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
40
- ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
58
+ ...(stateName ? { currentLinearState: stateName } : {}),
59
+ ...(stateType ? { currentLinearStateType: stateType } : {}),
41
60
  });
42
61
  }
43
62
  function shouldAutoAdvanceLinearState(issue) {
@@ -1,4 +1,7 @@
1
+ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
1
2
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
+ import { isCompletedLinearState } from "./pr-state.js";
4
+ import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
2
5
  export class MergedLinearCompletionReconciler {
3
6
  db;
4
7
  linearProvider;
@@ -10,39 +13,116 @@ export class MergedLinearCompletionReconciler {
10
13
  }
11
14
  async reconcile() {
12
15
  for (const issue of this.db.issues.listIssues()) {
13
- if (issue.prState !== "merged")
14
- continue;
15
- if (issue.currentLinearStateType?.trim().toLowerCase() === "completed")
16
+ if (issue.factoryState !== "done" && issue.prState !== "merged") {
16
17
  continue;
18
+ }
17
19
  const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
18
- if (!linear)
20
+ if (!linear) {
19
21
  continue;
22
+ }
20
23
  try {
21
24
  const liveIssue = await linear.getIssue(issue.linearIssueId);
22
- const targetState = resolvePreferredCompletedLinearState(liveIssue);
23
- if (!targetState)
24
- continue;
25
- const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
26
- if (normalizedCurrent === targetState.trim().toLowerCase()) {
27
- this.db.issues.upsertIssue({
28
- projectId: issue.projectId,
29
- linearIssueId: issue.linearIssueId,
30
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
31
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
32
- });
33
- continue;
34
- }
35
- const updated = await linear.setIssueState(issue.linearIssueId, targetState);
36
- this.db.issues.upsertIssue({
25
+ this.db.issues.replaceIssueDependencies({
37
26
  projectId: issue.projectId,
38
27
  linearIssueId: issue.linearIssueId,
39
- ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
40
- ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
28
+ blockers: liveIssue.blockedBy.map((blocker) => ({
29
+ blockerLinearIssueId: blocker.id,
30
+ ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
31
+ ...(blocker.title ? { blockerTitle: blocker.title } : {}),
32
+ ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
33
+ ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
34
+ })),
41
35
  });
36
+ const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
37
+ const trustedNoPrDone = hasTrustedNoPrCompletion(issue, latestRun);
38
+ if (issue.prState === "merged" || trustedNoPrDone) {
39
+ await this.reconcileCompletedLinearState(issue, liveIssue, linear);
40
+ continue;
41
+ }
42
+ if (issue.factoryState === "done" && !isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
43
+ this.reopenStaleLocalDoneIssue(issue, liveIssue);
44
+ }
45
+ else {
46
+ this.refreshCachedLinearState(issue, liveIssue);
47
+ }
42
48
  }
43
49
  catch (error) {
44
- this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged issue to a completed Linear state");
50
+ this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged or stale completed issue state");
45
51
  }
46
52
  }
47
53
  }
54
+ async reconcileCompletedLinearState(issue, liveIssue, linear) {
55
+ if (isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
56
+ this.refreshCachedLinearState(issue, liveIssue);
57
+ return;
58
+ }
59
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
60
+ if (!targetState) {
61
+ this.refreshCachedLinearState(issue, liveIssue);
62
+ return;
63
+ }
64
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
65
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
66
+ this.refreshCachedLinearState(issue, liveIssue);
67
+ return;
68
+ }
69
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
70
+ this.db.issues.upsertIssue({
71
+ projectId: issue.projectId,
72
+ linearIssueId: issue.linearIssueId,
73
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
74
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
75
+ });
76
+ }
77
+ reopenStaleLocalDoneIssue(issue, liveIssue) {
78
+ const restored = resolveOpenWorkflowState(issue);
79
+ this.db.issues.upsertIssue({
80
+ projectId: issue.projectId,
81
+ linearIssueId: issue.linearIssueId,
82
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
83
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
84
+ ...(restored ? { factoryState: restored.factoryState } : {}),
85
+ ...(restored ? { pendingRunType: restored.pendingRunType } : {}),
86
+ });
87
+ this.logger.info({
88
+ issueKey: issue.issueKey,
89
+ previousFactoryState: issue.factoryState,
90
+ restoredFactoryState: restored?.factoryState,
91
+ liveLinearState: liveIssue.stateName,
92
+ }, "Reopened stale local done state from live Linear workflow");
93
+ }
94
+ refreshCachedLinearState(issue, liveIssue) {
95
+ this.db.issues.upsertIssue({
96
+ projectId: issue.projectId,
97
+ linearIssueId: issue.linearIssueId,
98
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
99
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
100
+ });
101
+ }
102
+ }
103
+ function resolveOpenWorkflowState(issue) {
104
+ const reactiveIntent = deriveIssueSessionReactiveIntent({
105
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
106
+ prNumber: issue.prNumber,
107
+ prState: issue.prState,
108
+ prReviewState: issue.prReviewState,
109
+ prCheckStatus: issue.prCheckStatus,
110
+ latestFailureSource: issue.lastGitHubFailureSource,
111
+ });
112
+ if (reactiveIntent) {
113
+ return {
114
+ factoryState: reactiveIntent.compatibilityFactoryState,
115
+ pendingRunType: reactiveIntent.runType,
116
+ };
117
+ }
118
+ if (issue.prNumber !== undefined && (issue.prState === undefined || issue.prState === "open")) {
119
+ if (issue.prReviewState === "approved" && (issue.prCheckStatus === "success" || issue.prCheckStatus === "passed")) {
120
+ return { factoryState: "awaiting_queue", pendingRunType: null };
121
+ }
122
+ return { factoryState: "pr_open", pendingRunType: null };
123
+ }
124
+ if (issue.delegatedToPatchRelay) {
125
+ return { factoryState: "delegated", pendingRunType: null };
126
+ }
127
+ return undefined;
48
128
  }
@@ -1,4 +1,11 @@
1
1
  import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
2
+ function shouldContinueForUnpublishedLocalChanges(message) {
3
+ const normalized = message.trim().toLowerCase();
4
+ if (!normalized)
5
+ return false;
6
+ return normalized.includes("worktree still has")
7
+ || (normalized.includes("local commit") && normalized.includes("ahead of origin/"));
8
+ }
2
9
  export async function handleNoPrCompletionCheck(params) {
3
10
  const completedRunUpdate = buildCompletedRunUpdate({
4
11
  threadId: params.threadId,
@@ -115,6 +122,51 @@ export async function handleNoPrCompletionCheck(params) {
115
122
  return;
116
123
  }
117
124
  if (completionCheck.outcome === "done") {
125
+ if (shouldContinueForUnpublishedLocalChanges(params.publishedOutcomeError)) {
126
+ const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
127
+ params.db.runs.finishRun(params.run.id, completedRunUpdate);
128
+ params.db.runs.saveCompletionCheck(params.run.id, {
129
+ ...completionCheck,
130
+ outcome: "continue",
131
+ summary: "PatchRelay changed files locally but has not published them yet; continuing automatically to finish publication.",
132
+ why: params.publishedOutcomeError,
133
+ });
134
+ params.db.issues.upsertIssue({
135
+ projectId: params.run.projectId,
136
+ linearIssueId: params.run.linearIssueId,
137
+ activeRunId: null,
138
+ factoryState: "delegated",
139
+ pendingRunType: null,
140
+ pendingRunContextJson: null,
141
+ });
142
+ return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
143
+ projectId: params.run.projectId,
144
+ linearIssueId: params.run.linearIssueId,
145
+ eventType: "completion_check_continue",
146
+ eventJson: JSON.stringify({
147
+ runType: params.run.runType,
148
+ summary: params.publishedOutcomeError,
149
+ }),
150
+ dedupeKey: `completion_check_continue:${params.run.id}`,
151
+ }));
152
+ });
153
+ if (!continued) {
154
+ params.logger.warn({ runId: params.run.id, issueId: params.run.linearIssueId }, "Skipping completion-check continue writes after losing issue-session lease");
155
+ params.clearProgressAndRelease(params.run);
156
+ return;
157
+ }
158
+ params.syncCompletionCheckOutcome({
159
+ run: params.run,
160
+ fallbackIssue: params.issue,
161
+ level: "info",
162
+ status: "completion_check_continue",
163
+ summary: "No PR found; continuing automatically to finish publication",
164
+ detail: params.publishedOutcomeError,
165
+ activity: buildCompletionCheckActivity("continue"),
166
+ enqueue: true,
167
+ });
168
+ return;
169
+ }
118
170
  const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
119
171
  params.db.runs.finishRun(params.run.id, completedRunUpdate);
120
172
  params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
@@ -2,7 +2,7 @@ import { buildHookEnv, runProjectHook } from "./hook-runner.js";
2
2
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
3
3
  import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
4
4
  import { buildRunPrompt as buildPatchRelayRunPrompt, findDisallowedPatchRelayPromptSectionIds, findUnknownPatchRelayPromptSectionIds, mergePromptCustomizationLayers, resolvePromptLayers, } from "./prompting/patchrelay.js";
5
- import { execCommand } from "./utils.js";
5
+ import { configureGitHubBotAuthForWorktree } from "./github-worktree-auth.js";
6
6
  function slugify(value) {
7
7
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
8
8
  }
@@ -116,11 +116,11 @@ export class RunLauncher {
116
116
  try {
117
117
  await this.worktreeManager.ensureIssueWorktree(params.project.repoPath, params.project.worktreeRoot, params.worktreePath, params.branchName, { allowExistingOutsideRoot: params.issue.branchName !== undefined });
118
118
  if (params.botIdentity) {
119
- const gitBin = this.config.runner.gitBin;
120
- await execCommand(gitBin, ["-C", params.worktreePath, "config", "user.name", params.botIdentity.name], { timeoutMs: 5_000 });
121
- await execCommand(gitBin, ["-C", params.worktreePath, "config", "user.email", params.botIdentity.email], { timeoutMs: 5_000 });
122
- const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=$(cat ${params.botIdentity.tokenFile})"; }; f`;
123
- await execCommand(gitBin, ["-C", params.worktreePath, "config", "credential.helper", credentialHelper], { timeoutMs: 5_000 });
119
+ await configureGitHubBotAuthForWorktree({
120
+ gitBin: this.config.runner.gitBin,
121
+ worktreePath: params.worktreePath,
122
+ botIdentity: params.botIdentity,
123
+ });
124
124
  }
125
125
  await this.worktreeManager.resetWorktreeToTrackedBranch(params.worktreePath, params.branchName, params.issue, this.logger);
126
126
  if (params.runType !== "queue_repair") {
@@ -0,0 +1,7 @@
1
+ export function hasTrustedNoPrCompletion(issue, latestRun) {
2
+ return issue.factoryState === "done"
3
+ && issue.prNumber === undefined
4
+ && !issue.prUrl
5
+ && latestRun?.status === "completed"
6
+ && latestRun.completionCheckOutcome === "done";
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.38.2",
3
+ "version": "0.39.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {